#!/usr/bin/perl

# this software is licensed for use under the Free Software Foundation's GPL v3.0 license, as retrieved
# from http://www.gnu.org/licenses/gpl-3.0.html on 2014-11-17.  A copy should also be available in this
# project's Git repository at https://github.com/jimsalterjrs/sanoid/blob/master/LICENSE.

$::VERSION = '2.3.0';

use strict;
use warnings;
use Data::Dumper;
use Getopt::Long qw(:config auto_version auto_help);
use Pod::Usage;
use Time::Local;
use Sys::Hostname;
use Capture::Tiny ':all';

my $mbuffer_size = "16M";
my $pvoptions = "-p -t -e -r -b";

# Blank defaults to use ssh client's default
# TODO: Merge into a single "sshflags" option?
my %args = ('sshconfig' => '', 'sshkey' => '', 'sshport' => '', 'sshcipher' => '', 'sshoption' => [], 'target-bwlimit' => '', 'source-bwlimit' => '');
GetOptions(\%args, "no-command-checks", "monitor-version", "compress=s", "dumpsnaps", "recursive|r", "sendoptions=s", "recvoptions=s",
                   "source-bwlimit=s", "target-bwlimit=s", "sshconfig=s", "sshkey=s", "sshport=i", "sshcipher|c=s", "sshoption|o=s@",
                   "debug", "quiet", "no-stream", "no-sync-snap", "no-resume", "exclude=s@", "skip-parent", "identifier=s",
                   "no-clone-handling", "no-privilege-elevation", "force-delete", "no-rollback", "create-bookmark", "use-hold",
                   "pv-options=s" => \$pvoptions, "keep-sync-snap", "preserve-recordsize", "mbuffer-size=s" => \$mbuffer_size,
                   "delete-target-snapshots", "insecure-direct-connection=s", "preserve-properties",
                   "include-snaps=s@", "exclude-snaps=s@", "exclude-datasets=s@")
                   or pod2usage(2);

my %compressargs = %{compressargset($args{'compress'} || 'default')}; # Can't be done with GetOptions arg, as default still needs to be set

if (defined($args{'exclude'})) {
	writelog('WARN', 'The --exclude option is deprecated, please use --exclude-datasets instead');

	# If both --exclude and --exclude-datasets are provided, then ignore
	# --exclude
	if (!defined($args{'exclude-datasets'})) {
		$args{'exclude-datasets'} = $args{'exclude'};
	}
}

my @sendoptions = ();
if (length $args{'sendoptions'}) {
	@sendoptions = parsespecialoptions($args{'sendoptions'});
	if (! defined($sendoptions[0])) {
		writelog('WARN', "invalid send options!");
		pod2usage(2);
		exit 127;
	}

	if (defined $args{'recursive'}) {
		foreach my $option(@sendoptions) {
			if ($option->{option} eq 'R') {
				writelog('WARN', "invalid argument combination, zfs send -R and --recursive aren't compatible!");
				pod2usage(2);
				exit 127;
			}
		}
	}
}

my @recvoptions = ();
if (length $args{'recvoptions'}) {
	@recvoptions = parsespecialoptions($args{'recvoptions'});
	if (! defined($recvoptions[0])) {
		writelog('WARN', "invalid receive options!");
		pod2usage(2);
		exit 127;
	}
}


# TODO Expand to accept multiple sources?
if (scalar(@ARGV) != 2) {
	writelog('WARN', "Source or target not found!");
	pod2usage(2);
	exit 127;
} else {
	$args{'source'} = $ARGV[0];
	$args{'target'} = $ARGV[1];
}

# Could possibly merge these into an options function
if (length $args{'source-bwlimit'}) {
	$args{'source-bwlimit'} = "-R $args{'source-bwlimit'}";
}
if (length $args{'target-bwlimit'}) {
	$args{'target-bwlimit'} = "-r $args{'target-bwlimit'}";
}
$args{'streamarg'} = (defined $args{'no-stream'} ? '-i' : '-I');

my $rawsourcefs = $args{'source'};
my $rawtargetfs = $args{'target'};
my $debug = $args{'debug'};
my $quiet = $args{'quiet'};
my $resume = !$args{'no-resume'};

# for compatibility reasons, older versions used hardcoded command paths
$ENV{'PATH'} = $ENV{'PATH'} . ":/bin:/usr/bin:/sbin";

my $zfscmd = 'zfs';
my $zpoolcmd = 'zpool';
my $sshcmd = 'ssh';
my $pscmd = 'ps';

my $pvcmd = 'pv';
my $mbuffercmd = 'mbuffer';
my $socatcmd = 'socat';
my $sudocmd = 'sudo';
my $mbufferoptions = "-q -s 128k -m $mbuffer_size";
# currently using POSIX compatible command to check for program existence because we aren't depending on perl
# being present on remote machines.
my $checkcmd = 'command -v';

if (length $args{'sshcipher'}) {
	$args{'sshcipher'} = "-c $args{'sshcipher'}";
}
if (length $args{'sshport'}) {
  $args{'sshport'} = "-p $args{'sshport'}";
}
if (length $args{'sshconfig'}) {
	$args{'sshconfig'} = "-F $args{'sshconfig'}";
}
if (length $args{'sshkey'}) {
	$args{'sshkey'} = "-i $args{'sshkey'}";
}
my $sshoptions = join " ", map { "-o " . $_ } @{$args{'sshoption'}}; # deref required

my $identifier = "";
if (length $args{'identifier'}) {
	if ($args{'identifier'} !~ /^[a-zA-Z0-9-_:.]+$/) {
		# invalid extra identifier
		writelog('WARN', "extra identifier contains invalid chars!");
		pod2usage(2);
		exit 127;
	}
	$identifier = "$args{'identifier'}_";
}

# figure out if source and/or target are remote.
$sshcmd = "$sshcmd $args{'sshconfig'} $args{'sshcipher'} $sshoptions $args{'sshport'} $args{'sshkey'}";
writelog('DEBUG', "SSHCMD: $sshcmd");
my ($sourcehost,$sourcefs,$sourceisroot) = getssh($rawsourcefs);
my ($targethost,$targetfs,$targetisroot) = getssh($rawtargetfs);

my $sourcesudocmd = $sourceisroot ? '' : $sudocmd;
my $targetsudocmd = $targetisroot ? '' : $sudocmd;

if (!defined $sourcehost) { $sourcehost = ''; }
if (!defined $targethost) { $targethost = ''; }

# handle insecure direct connection arguments
my $directconnect = "";
my $directlisten = "";
my $directtimeout = 60;
my $directmbuffer = 0;

if (length $args{'insecure-direct-connection'}) {
	if ($sourcehost ne '' && $targethost ne '') {
		print("CRITICAL: relaying between remote hosts is not supported with insecure direct connection!\n");
		pod2usage(2);
		exit 127;
	}

	my @parts = split(',', $args{'insecure-direct-connection'});
	if (scalar @parts > 4) {
		print("CRITICAL: invalid insecure-direct-connection argument!\n");
		pod2usage(2);
		exit 127;
	} elsif (scalar @parts >= 2) {
		$directconnect = $parts[0];
		$directlisten = $parts[1];
	} else {
		$directconnect = $args{'insecure-direct-connection'};
		$directlisten = $args{'insecure-direct-connection'};
	}

	if (scalar @parts == 3) {
		$directtimeout = $parts[2];
	}

	if (scalar @parts == 4) {
		if ($parts[3] eq "mbuffer") {
			$directmbuffer = 1;
		}
	}
}

# figure out whether compression, mbuffering, pv
# are available on source, target, local machines.
# warn user of anything missing, then continue with sync.
my %avail = checkcommands();

my %snaps;
my $exitcode = 0;

my $replicationCount = 0;

## break here to call replication individually so that we ##
## can loop across children separately, for recursive     ##
## replication                                            ##

if (!defined $args{'recursive'}) {
	syncdataset($sourcehost, $sourcefs, $targethost, $targetfs, undef);
} else {
	writelog('DEBUG', "recursive sync of $sourcefs.");
	my @datasets = getchilddatasets($sourcehost, $sourcefs, $sourceisroot);

	if (!@datasets) {
		writelog('CRITICAL', "no datasets found");
		@datasets = ();
		$exitcode = 2;
	}

	my @deferred;

	foreach my $datasetProperties(@datasets) {
		my $dataset = $datasetProperties->{'name'};
		my $origin = $datasetProperties->{'origin'};
		if ($origin eq "-" || defined $args{'no-clone-handling'}) {
			$origin = undef;
		} else {
			# check if clone source is replicated too
			my @values = split(/@/, $origin, 2);
			my $srcdataset = $values[0];

			my $found = 0;
			foreach my $datasetProperties(@datasets) {
				if ($datasetProperties->{'name'} eq $srcdataset) {
					$found = 1;
					last;
				}
			}

			if ($found == 0) {
				# clone source is not replicated, do a full replication
				$origin = undef;
			} else {
				# clone source is replicated, defer until all non clones are replicated
				push @deferred, $datasetProperties;
				next;
			}
		}

		$dataset =~ s/\Q$sourcefs\E//;
		chomp $dataset;
		my $childsourcefs = $sourcefs . $dataset;
		my $childtargetfs = $targetfs . $dataset;
		syncdataset($sourcehost, $childsourcefs, $targethost, $childtargetfs, $origin);
	}

	# replicate cloned datasets and if this is the initial run, recreate them on the target
	foreach my $datasetProperties(@deferred) {
		my $dataset = $datasetProperties->{'name'};
		my $origin = $datasetProperties->{'origin'};

		$dataset =~ s/\Q$sourcefs\E//;
		chomp $dataset;
		my $childsourcefs = $sourcefs . $dataset;
		my $childtargetfs = $targetfs . $dataset;
		syncdataset($sourcehost, $childsourcefs, $targethost, $childtargetfs, $origin);
	}
}

# close SSH sockets for master connections as applicable
if ($sourcehost ne '') {
	open FH, "$sshcmd $sourcehost -O exit 2>&1 |";
	close FH;
}
if ($targethost ne '') {
	open FH, "$sshcmd $targethost -O exit 2>&1 |";
	close FH;
}

exit $exitcode;

##############################################################################
##############################################################################
##############################################################################
##############################################################################

sub getchilddatasets {
	my ($rhost,$fs,$isroot,%snaps) = @_;
	my $mysudocmd;
	my $fsescaped = escapeshellparam($fs);

	if ($isroot) { $mysudocmd = ''; } else { $mysudocmd = $sudocmd; }
	if ($rhost ne '') {
		$rhost = "$sshcmd $rhost";
		# double escaping needed
		$fsescaped = escapeshellparam($fsescaped);
	}

	my $getchildrencmd = "$rhost $mysudocmd $zfscmd list -o name,origin -t filesystem,volume -Hr $fsescaped |";
	writelog('DEBUG', "getting list of child datasets on $fs using $getchildrencmd...");
	if (! open FH, $getchildrencmd) {
		die "ERROR: list command failed!\n";
	}

	my @children;
	my $first = 1;

	DATASETS: while(<FH>) {
		chomp;

		if (defined $args{'skip-parent'} && $first eq 1) {
			# parent dataset is the first element
			$first = 0;
			next;
		}

		my ($dataset, $origin) = /^([^\t]+)\t([^\t]+)/;

		if (defined $args{'exclude-datasets'}) {
			my $excludes = $args{'exclude-datasets'};
			foreach (@$excludes) {
				if ($dataset =~ /$_/) {
					writelog('DEBUG', "excluded $dataset because of $_");
					next DATASETS;
				}
			}
		}

		my %properties;
		$properties{'name'} = $dataset;
		$properties{'origin'} = $origin;

		push @children, \%properties;
	}
	close FH;

	return @children;
}

sub syncdataset {

	my ($sourcehost, $sourcefs, $targethost, $targetfs, $origin, $skipsnapshot) = @_;

	my $stdout;
	my $exit;

	my $sourcefsescaped = escapeshellparam($sourcefs);
	my $targetfsescaped = escapeshellparam($targetfs);

	# if no rollbacks are allowed, disable forced receive
	my $forcedrecv = "-F";
	if (defined $args{'no-rollback'}) {
		$forcedrecv = "";
	}

	writelog('DEBUG', "syncing source $sourcefs to target $targetfs.");

	my ($sync, $error) = getzfsvalue($sourcehost,$sourcefs,$sourceisroot,'syncoid:sync');

	if (!defined $sync) {
		# zfs already printed the corresponding error
		if ($error =~ /\bdataset does not exist\b/ && $replicationCount > 0) {
			writelog('WARN', "Skipping dataset (dataset no longer exists): $sourcefs...");
			return 0;
		}
		else {
			# print the error out and set exit code
			writelog('CRITICAL', "$error");
			if ($exitcode < 2) { $exitcode = 2 }
		}

		return 0;
	}

	if ($sync eq 'true' || $sync eq '-' || $sync eq '') {
		# empty is handled the same as unset (aka: '-')
		# definitely sync this dataset - if a host is called 'true' or '-', then you're special
	} elsif ($sync eq 'false') {
		writelog('INFO', "Skipping dataset (syncoid:sync=false): $sourcefs...");
		return 0;
	} else {
		my $hostid = hostname();
		my @hosts = split(/,/,$sync);
		if (!(grep $hostid eq $_, @hosts)) {
			writelog('INFO', "Skipping dataset (syncoid:sync doesn't include $hostid): $sourcefs...");
			return 0;
		}
	}

	# make sure target is not currently in receive.
	if (iszfsbusy($targethost,$targetfs,$targetisroot)) {
		writelog('WARN', "Cannot sync now: $targetfs is already target of a zfs receive process.");
		if ($exitcode < 1) { $exitcode = 1; }
		return 0;
	}

	# does the target filesystem exist yet?
	my $targetexists = targetexists($targethost,$targetfs,$targetisroot);

	my $receivetoken;

	if ($resume) {
		if ($targetexists) {
			# check remote dataset for receive resume token (interrupted receive)
			$receivetoken = getreceivetoken($targethost,$targetfs,$targetisroot);

			if (defined($receivetoken)) {
				writelog('DEBUG', "got receive resume token: $receivetoken: ");
			}
		}
	}

	my $newsyncsnap;
	my $matchingsnap;

	# skip snapshot checking/creation in case of resumed receive
	if (!defined($receivetoken)) {
		# build hashes of the snaps on the source and target filesystems.

		%snaps = getsnaps('source',$sourcehost,$sourcefs,$sourceisroot);

		if ($targetexists) {
		    my %targetsnaps = getsnaps('target',$targethost,$targetfs,$targetisroot);
		    my %sourcesnaps = %snaps;
		    %snaps = (%sourcesnaps, %targetsnaps);
		}

		if (defined $args{'dumpsnaps'}) {
		    writelog('INFO', "merged snapshot list of $targetfs: ");
		    dumphash(\%snaps);
		}

		if (!defined $args{'no-sync-snap'} && !defined $skipsnapshot) {
			# create a new syncoid snapshot on the source filesystem.
			$newsyncsnap = newsyncsnap($sourcehost,$sourcefs,$sourceisroot);
			if (!$newsyncsnap) {
				# we already whined about the error
				return 0;
			}
			# Don't send the sync snap if it's filtered out by --exclude-snaps or
			# --include-snaps
			if (!snapisincluded($newsyncsnap)) {
				$newsyncsnap = getnewestsnapshot($sourcehost,$sourcefs,$sourceisroot);
				if ($newsyncsnap eq 0) {
					writelog('WARN', "CRITICAL: no snapshots exist on source $sourcefs, and you asked for --no-sync-snap.");
					if ($exitcode < 1) { $exitcode = 1; }
					return 0;
				}
			}
		} else {
			# we don't want sync snapshots created, so use the newest snapshot we can find.
			$newsyncsnap = getnewestsnapshot($sourcehost,$sourcefs,$sourceisroot);
			if ($newsyncsnap eq 0) {
				writelog('WARN', "CRITICAL: no snapshots exist on source $sourcefs, and you asked for --no-sync-snap.");
				if ($exitcode < 1) { $exitcode = 1; }
				return 0;
			}
		}
	}
	my $newsyncsnapescaped = escapeshellparam($newsyncsnap);

	# there is currently (2014-09-01) a bug in ZFS on Linux
	# that causes readonly to always show on if it's EVER
	# been turned on... even when it's off... unless and
	# until the filesystem is zfs umounted and zfs remounted.
	# we're going to do the right thing anyway.
		# dyking this functionality out for the time being due to buggy mount/unmount behavior
		# with ZFS on Linux (possibly OpenZFS in general) when setting/unsetting readonly.
	#my $originaltargetreadonly;

	# sync 'em up.
	if (! $targetexists) {
		# do an initial sync from the oldest source snapshot
		# THEN do an -I to the newest
		if (!defined ($args{'no-stream'}) ) {
			writelog('DEBUG', "target $targetfs does not exist.  Finding oldest available snapshot on source $sourcefs ...");
		} else {
			writelog('DEBUG', "target $targetfs does not exist, and --no-stream selected.  Finding newest available snapshot on source $sourcefs ...");
		}
		my $oldestsnap = getoldestsnapshot(\%snaps);
		if (! $oldestsnap) {
			if (defined ($args{'no-sync-snap'}) ) {
				# we already whined about the missing snapshots
				return 0;
			}

			# getoldestsnapshot() returned false, so use new sync snapshot
			writelog('DEBUG', "getoldestsnapshot() returned false, so using $newsyncsnap.");
			$oldestsnap = $newsyncsnap;
		}

		# if --no-stream is specified, our full needs to be the newest snapshot, not the oldest.
		if (defined $args{'no-stream'}) {
			if (defined ($args{'no-sync-snap'}) ) {
				$oldestsnap = getnewestsnapshot(\%snaps);
			} else {
				$oldestsnap = $newsyncsnap;
			}
		}

		my $ret;
		if (defined $origin) {
			($ret, $stdout) = syncclone($sourcehost, $sourcefs, $origin, $targethost, $targetfs, $oldestsnap);
			if ($ret) {
				writelog('INFO', "clone creation failed, trying ordinary replication as fallback");
				syncdataset($sourcehost, $sourcefs, $targethost, $targetfs, undef, 1);
				return 0;
			}
		} else {
			($ret, $stdout) = syncfull($sourcehost, $sourcefs, $targethost, $targetfs, $oldestsnap);
		}

		if ($ret) {
			if ($exitcode < 2) { $exitcode = 2; }
			return 0;
		}

		# now do an -I to the new sync snapshot, assuming there were any snapshots
		# other than the new sync snapshot to begin with, of course - and that we
		# aren't invoked with --no-stream, in which case a full of the newest snap
		# available was all we needed to do
		if (!defined ($args{'no-stream'}) && ($oldestsnap ne $newsyncsnap) ) {

			# get current readonly status of target, then set it to on during sync
				# dyking this functionality out for the time being due to buggy mount/unmount behavior
				# with ZFS on Linux (possibly OpenZFS in general) when setting/unsetting readonly.
			# $originaltargetreadonly = getzfsvalue($targethost,$targetfs,$targetisroot,'readonly');
			# setzfsvalue($targethost,$targetfs,$targetisroot,'readonly','on');

			(my $ret, $stdout) = syncincremental($sourcehost, $sourcefs, $targethost, $targetfs, $oldestsnap, $newsyncsnap, 0);

			if ($ret != 0) {
				if ($exitcode < 1) { $exitcode = 1; }
				return 0;
			}

			# restore original readonly value to target after sync complete
				# dyking this functionality out for the time being due to buggy mount/unmount behavior
				# with ZFS on Linux (possibly OpenZFS in general) when setting/unsetting readonly.
			# setzfsvalue($targethost,$targetfs,$targetisroot,'readonly',$originaltargetreadonly);
		}
	} else {
		# resume interrupted receive if there is a valid resume $token
		# and because this will ony resume the receive to the next
		# snapshot, do a normal sync after that
		if (defined($receivetoken)) {
			($exit, $stdout) = syncresume($sourcehost, $sourcefs, $targethost, $targetfs, $receivetoken);

			$exit == 0 or do {
				if (
					$stdout =~ /\Qused in the initial send no longer exists\E/ ||
					$stdout =~ /incremental source [0-9xa-f]+ no longer exists/
				) {
					writelog('WARN', "resetting partially receive state because the snapshot source no longer exists");
					resetreceivestate($targethost,$targetfs,$targetisroot);
					# do an normal sync cycle
					return syncdataset($sourcehost, $sourcefs, $targethost, $targetfs, $origin);
				} else {
					if ($exitcode < 2) { $exitcode = 2; }
					return 0;
				}
			};

			# a resumed transfer will only be done to the next snapshot,
			# so do an normal sync cycle
			return syncdataset($sourcehost, $sourcefs, $targethost, $targetfs, undef);
		}

		# find most recent matching snapshot and do an -I
		# to the new snapshot

		# get current readonly status of target, then set it to on during sync
			# dyking this functionality out for the time being due to buggy mount/unmount behavior
			# with ZFS on Linux (possibly OpenZFS in general) when setting/unsetting readonly.
		# $originaltargetreadonly = getzfsvalue($targethost,$targetfs,$targetisroot,'readonly');
		# setzfsvalue($targethost,$targetfs,$targetisroot,'readonly','on');

		my $targetsize = getzfsvalue($targethost,$targetfs,$targetisroot,'-p used');

		my $bookmark = 0;
		my $bookmarkcreation = 0;

		$matchingsnap = getmatchingsnapshot($sourcefs, $targetfs, \%snaps);
		if (! $matchingsnap) {
			# no matching snapshots, check for bookmarks as fallback
			my %bookmarks = getbookmarks($sourcehost,$sourcefs,$sourceisroot);

			# check for matching guid of source bookmark and target snapshot (oldest first)
			foreach my $snap ( sort { $snaps{'target'}{$b}{'creation'}<=>$snaps{'target'}{$a}{'creation'} } keys %{ $snaps{'target'} }) {
				my $guid = $snaps{'target'}{$snap}{'guid'};

				if (defined $bookmarks{$guid}) {
					# found a match
					$bookmark = $bookmarks{$guid}{'name'};
					$bookmarkcreation = $bookmarks{$guid}{'creation'};
					$matchingsnap = $snap;
					last;
				}
			}

			if (! $bookmark) {
				# force delete is not possible for the root dataset
				if ($args{'force-delete'} && index($targetfs, '/') != -1) {
					writelog('INFO', "Removing $targetfs because no matching snapshots were found");

					my $rcommand = '';
					my $mysudocmd = '';
					my $targetfsescaped = escapeshellparam($targetfs);

					if ($targethost ne '') { $rcommand = "$sshcmd $targethost"; }
					if (!$targetisroot) { $mysudocmd = $sudocmd; }

					my $prunecmd = "$mysudocmd $zfscmd destroy -r $targetfsescaped; ";
					if ($targethost ne '') {
						$prunecmd = escapeshellparam($prunecmd);
					}

					my $ret = system("$rcommand $prunecmd");
					if ($ret != 0) {
						writelog('WARN', "$rcommand $prunecmd failed: $?");
					} else {
						# redo sync and skip snapshot creation (already taken)
						return syncdataset($sourcehost, $sourcefs, $targethost, $targetfs, undef, 1);
					}
				}

				# if we got this far, we failed to find a matching snapshot/bookmark.
				if ($exitcode < 2) { $exitcode = 2; }

				my $msg = <<~"EOT";

					Target $targetfs exists but has no snapshots matching with $sourcefs!
					Replication to target would require destroying existing
					target. Cowardly refusing to destroy your existing target.

				EOT

				writelog('CRITICAL', $msg);

				# experience tells me we need a mollyguard for people who try to
				# zfs create targetpool/targetsnap ; syncoid sourcepool/sourcesnap targetpool/targetsnap ...

				if ( $targetsize < (64*1024*1024) ) {
					$msg = <<~"EOT";
						NOTE: Target $targetfs dataset is < 64MB used - did you mistakenly run
						`zfs create $args{'target'}` on the target? ZFS initial
						replication must be to a NON EXISTENT DATASET, which will
						then be CREATED BY the initial replication process.

					EOT
				}

				# return false now in case more child datasets need replication.
				return 0;
			}
		}

		# make sure target is (still) not currently in receive.
		if (iszfsbusy($targethost,$targetfs,$targetisroot)) {
			writelog('WARN', "Cannot sync now: $targetfs is already target of a zfs receive process.");
			if ($exitcode < 1) { $exitcode = 1; }
			return 0;
		}

		if ($matchingsnap eq $newsyncsnap) {
			# barf some text but don't touch the filesystem
			writelog('INFO', "no snapshots on source newer than $newsyncsnap on target. Nothing to do, not syncing.");
			return 0;
		} else {
			my $matchingsnapescaped = escapeshellparam($matchingsnap);

			my $nextsnapshot = 0;

			if ($bookmark) {
				my $bookmarkescaped = escapeshellparam($bookmark);

				if (!defined $args{'no-stream'}) {
					# if intermediate snapshots are needed we need to find the next oldest snapshot,
					# do an replication to it and replicate as always from oldest to newest
					# because bookmark sends doesn't support intermediates directly
					foreach my $snap ( sort { $snaps{'source'}{$a}{'creation'}<=>$snaps{'source'}{$b}{'creation'} } keys %{ $snaps{'source'} }) {
						if ($snaps{'source'}{$snap}{'creation'} >= $bookmarkcreation) {
							$nextsnapshot = $snap;
							last;
						}
					}
				}

				if ($nextsnapshot) {
					($exit, $stdout) = syncbookmark($sourcehost, $sourcefs, $targethost, $targetfs, $bookmark, $nextsnapshot);

					$exit == 0 or do {
						if (!$resume && $stdout =~ /\Qcontains partially-complete state\E/) {
							writelog('WARN', "resetting partially receive state");
							resetreceivestate($targethost,$targetfs,$targetisroot);
							(my $ret) = syncbookmark($sourcehost, $sourcefs, $targethost, $targetfs, $bookmark, $nextsnapshot);
							$ret == 0 or do {
								if ($exitcode < 2) { $exitcode = 2; }
								return 0;
							}
						} else {
							if ($exitcode < 2) { $exitcode = 2; }
							return 0;
						}
					};

					$matchingsnap = $nextsnapshot;
					$matchingsnapescaped = escapeshellparam($matchingsnap);
				} else {
					($exit, $stdout) = syncbookmark($sourcehost, $sourcefs, $targethost, $targetfs, $bookmark, $newsyncsnap);

					$exit == 0 or do {
						if (!$resume && $stdout =~ /\Qcontains partially-complete state\E/) {
							writelog('WARN', "resetting partially receive state");
							resetreceivestate($targethost,$targetfs,$targetisroot);
							(my $ret) = syncbookmark($sourcehost, $sourcefs, $targethost, $targetfs, $bookmark, $newsyncsnap);
							$ret == 0 or do {
								if ($exitcode < 2) { $exitcode = 2; }
								return 0;
							}
						} else {
							if ($exitcode < 2) { $exitcode = 2; }
							return 0;
						}
					};
				}
			}

			# do a normal replication if bookmarks aren't used or if previous
			# bookmark replication was only done to the next oldest snapshot
			if (!$bookmark || $nextsnapshot) {
				if ($matchingsnap eq $newsyncsnap) {
					# edge case: bookmark replication used the latest snapshot
					return 0;
				}

				($exit, $stdout) = syncincremental($sourcehost, $sourcefs, $targethost, $targetfs, $matchingsnap, $newsyncsnap, defined($args{'no-stream'}));

				$exit == 0 or do {
					# FreeBSD reports "dataset is busy" instead of "contains partially-complete state"
					if (!$resume && ($stdout =~ /\Qcontains partially-complete state\E/ || $stdout =~ /\Qdataset is busy\E/)) {
						writelog('WARN', "resetting partially receive state");
						resetreceivestate($targethost,$targetfs,$targetisroot);
						(my $ret) = syncincremental($sourcehost, $sourcefs, $targethost, $targetfs, $matchingsnap, $newsyncsnap, defined($args{'no-stream'}));
						$ret == 0 or do {
							if ($exitcode < 2) { $exitcode = 2; }
							return 0;
						}
					} elsif ($args{'force-delete'} && $stdout =~ /\Qdestination already exists\E/) {
						(my $existing) = $stdout =~ m/^cannot restore to ([^:]*): destination already exists$/g;
						if ($existing eq "") {
							if ($exitcode < 2) { $exitcode = 2; }
							return 0;
						}

						writelog('WARN', "removing existing destination: $existing");
						my $rcommand = '';
						my $mysudocmd = '';
						my $existingescaped = escapeshellparam($existing);

						if ($targethost ne '') { $rcommand = "$sshcmd $targethost"; }
						if (!$targetisroot) { $mysudocmd = $sudocmd; }

						my $prunecmd = "$mysudocmd $zfscmd destroy $existingescaped; ";
						if ($targethost ne '') {
							$prunecmd = escapeshellparam($prunecmd);
						}

						my $ret = system("$rcommand $prunecmd");
						if ($ret != 0) {
							warn "CRITICAL ERROR: $rcommand $prunecmd failed: $?";
							if ($exitcode < 2) { $exitcode = 2; }
							return 0;
						} else {
							# redo sync and skip snapshot creation (already taken)
							return syncdataset($sourcehost, $sourcefs, $targethost, $targetfs, undef, 1);
						}
					} else {
						if ($exitcode < 2) { $exitcode = 2; }
						return 0;
					}
				};
			}

			# restore original readonly value to target after sync complete
				# dyking this functionality out for the time being due to buggy mount/unmount behavior
				# with ZFS on Linux (possibly OpenZFS in general) when setting/unsetting readonly.
			#setzfsvalue($targethost,$targetfs,$targetisroot,'readonly',$originaltargetreadonly);
		}
	}

	$replicationCount++;

	# if "--use-hold" parameter is used set hold on newsync snapshot and remove hold on matching snapshot both on source and target
	# hold name: "syncoid" + identifier + hostname -> in case of replication to multiple targets separate holds can be set for each target by assinging different identifiers to each target. Only if all targets have been replicated all syncoid holds are removed from the matching snapshot and it can be removed
	if (defined $args{'use-hold'}) {
		my $holdcmd;
		my $holdreleasecmd;
		my $hostid = hostname();
		my $matchingsnapescaped = escapeshellparam($matchingsnap);
		my $holdname = "syncoid\_$identifier$hostid";
		if ($sourcehost ne '') {
			$holdcmd = "$sshcmd $sourcehost " . escapeshellparam("$sourcesudocmd $zfscmd hold $holdname $sourcefsescaped\@$newsyncsnapescaped");
			$holdreleasecmd = "$sshcmd $sourcehost " . escapeshellparam("$sourcesudocmd $zfscmd release $holdname $sourcefsescaped\@$matchingsnapescaped");
		} else {
			$holdcmd = "$sourcesudocmd $zfscmd hold $holdname $sourcefsescaped\@$newsyncsnapescaped";
			$holdreleasecmd = "$sourcesudocmd $zfscmd release $holdname $sourcefsescaped\@$matchingsnapescaped";
		}
		writelog('DEBUG', "Set new hold on source: $holdcmd");
		system($holdcmd) == 0 or warn "WARNING: $holdcmd failed: $?";
		# Do hold release only if matchingsnap exists
		if ($matchingsnap) {
			writelog('DEBUG', "Release old hold on source: $holdreleasecmd");
			system($holdreleasecmd) == 0 or warn "WARNING: $holdreleasecmd failed: $?";
		}
		if ($targethost ne '') {
			$holdcmd = "$sshcmd $targethost " . escapeshellparam("$targetsudocmd $zfscmd hold $holdname $targetfsescaped\@$newsyncsnapescaped");
			$holdreleasecmd = "$sshcmd $targethost " . escapeshellparam("$targetsudocmd $zfscmd release $holdname $targetfsescaped\@$matchingsnapescaped");
		} else {
			$holdcmd = "$targetsudocmd $zfscmd hold $holdname $targetfsescaped\@$newsyncsnapescaped";				$holdreleasecmd = "$targetsudocmd $zfscmd release $holdname $targetfsescaped\@$matchingsnapescaped";
		}
		writelog('DEBUG', "Set new hold on target: $holdcmd");
		system($holdcmd) == 0 or warn "WARNING: $holdcmd failed: $?";
		# Do hold release only if matchingsnap exists
		if ($matchingsnap) {
			writelog('DEBUG', "Release old hold on target: $holdreleasecmd");
			system($holdreleasecmd) == 0 or warn "WARNING: $holdreleasecmd failed: $?";
		}
	}
	if (defined $args{'no-sync-snap'}) {
		if (defined $args{'create-bookmark'}) {
			my $ret = createbookmark($sourcehost, $sourcefs, $newsyncsnap, $newsyncsnap);
			$ret == 0 or do {
				# fallback: assume naming conflict and try again with guid based suffix
				my $guid = $snaps{'source'}{$newsyncsnap}{'guid'};
				$guid = substr($guid, 0, 6);

				writelog('INFO', "bookmark creation failed, retrying with guid based suffix ($guid)...");

				my $ret = createbookmark($sourcehost, $sourcefs, $newsyncsnap, "$newsyncsnap$guid");
				$ret == 0 or do {
					if ($exitcode < 2) { $exitcode = 2; }
					return 0;
				}
			};
		}
	} else {
		if (!defined $args{'keep-sync-snap'}) {
			# prune obsolete sync snaps on source and target (only if this run created ones).
			pruneoldsyncsnaps($sourcehost,$sourcefs,$newsyncsnap,$sourceisroot,keys %{ $snaps{'source'}});
			pruneoldsyncsnaps($targethost,$targetfs,$newsyncsnap,$targetisroot,keys %{ $snaps{'target'}});
		}
	}

	if (defined $args{'delete-target-snapshots'}) {
		# Find the snapshots that exist on the target, filter with
		# those that exist on the source. Remaining are the snapshots
		# that are only on the target. Then sort to remove the oldest
		# snapshots first.

		# regather snapshots on source and target
		%snaps = getsnaps('source',$sourcehost,$sourcefs,$sourceisroot);

		if ($targetexists) {
			my %targetsnaps = getsnaps('target',$targethost,$targetfs,$targetisroot);
			my %sourcesnaps = %snaps;
			%snaps = (%sourcesnaps, %targetsnaps);
		}

		my @to_delete = sort { $snaps{'target'}{$a}{'creation'}<=>$snaps{'target'}{$b}{'creation'} } grep {!exists $snaps{'source'}{$_}} keys %{ $snaps{'target'} };
		while (@to_delete) {
			# Create batch of snapshots to remove
			my $snaps = join ',', splice(@to_delete, 0, 50);
			my $command;
			if ($targethost ne '') {
				$command = "$sshcmd $targethost " . escapeshellparam("$targetsudocmd $zfscmd destroy $targetfsescaped\@$snaps");
			} else {
				$command = "$targetsudocmd $zfscmd destroy $targetfsescaped\@$snaps";
			}
			writelog('DEBUG', "$command");
			my ($stdout, $stderr, $result) = capture { system $command; };
			if ($result != 0 && !$quiet) {
				warn "$command failed: $stderr";
			}
		}
	}

} # end syncdataset()

# Return codes:
#  0 - ZFS send/receive completed without errors
#  1 - ZFS target is currently in receive
#  2 - Critical error encountered when running the ZFS send/receive command
sub runsynccmd {
	my ($sourcehost, $sourcefs, $sendsource, $targethost, $targetfs, $pvsize) = @_;

	my $sourcefsescaped = escapeshellparam($sourcefs);
	my $targetfsescaped = escapeshellparam($targetfs);

	my $disp_pvsize = $pvsize == 0 ? 'UNKNOWN' : readablebytes($pvsize);
	my $sendoptions;
	if ($sendsource =~ /^-t /) {
		$sendoptions = getoptionsline(\@sendoptions, ('P','V','e','v'));
	} elsif ($sendsource =~ /#/) {
		$sendoptions = getoptionsline(\@sendoptions, ('L','V','c','e','w'));
	} else {
		$sendoptions = getoptionsline(\@sendoptions, ('L','P','V','R','X','b','c','e','h','p','s','v','w'));
	}

	my $recvoptions = getoptionsline(\@recvoptions, ('h','o','x','u','v'));

	# save state of interrupted receive stream
	if ($resume) { $recvoptions .= ' -s'; }
	# if no rollbacks are allowed, disable forced receive
	if (!defined $args{'no-rollback'}) { $recvoptions .= ' -F'; }

	if (defined $args{'preserve-properties'}) {
		my %properties = getlocalzfsvalues($sourcehost,$sourcefs,$sourceisroot);

		foreach my $key (keys %properties) {
			my $value = $properties{$key};
			writelog('DEBUG', "will set $key to $value ...");
			my $pair = escapeshellparam("$key=$value");
			$recvoptions .= " -o $pair";
		}
	} elsif (defined $args{'preserve-recordsize'}) {
		my $type = getzfsvalue($sourcehost,$sourcefs,$sourceisroot,'type');
		if ($type eq "filesystem") {
			my $recordsize = getzfsvalue($sourcehost,$sourcefs,$sourceisroot,'recordsize');
			$recvoptions .= " -o recordsize=$recordsize"
		}
	}

	my $sendcmd = "$sourcesudocmd $zfscmd send $sendoptions $sendsource";
	my $recvcmd = "$targetsudocmd $zfscmd receive $recvoptions $targetfsescaped 2>&1";

	my $synccmd = buildsynccmd($sendcmd,$recvcmd,$pvsize,$sourceisroot,$targetisroot);
	writelog('DEBUG', "sync size: ~$disp_pvsize");
	writelog('DEBUG', "$synccmd");

	# make sure target is (still) not currently in receive.
	if (iszfsbusy($targethost,$targetfs,$targetisroot)) {
		my $targetname = buildnicename($targethost, $targetfs);
		writelog('WARN', "Cannot sync now: $targetname is already target of a zfs receive process.");
		return (1, '');
	}

	my $stdout;
	my $ret;
	if ($pvsize == 0) {
		($stdout, $ret) = tee_stderr {
			system("$synccmd");
		};
	} else {
		($stdout, $ret) = tee_stdout {
			system("$synccmd");
		};
	}

	if ($ret != 0) {
		writelog('CRITICAL', "$synccmd failed: $?");
		return (2, $stdout);
	} else {
		return 0;
	}
} # end runsendcmd()

sub syncfull {
	my ($sourcehost, $sourcefs, $targethost, $targetfs, $snapname) = @_;

	my $sourcefsescaped = escapeshellparam($sourcefs);
	my $snapescaped = escapeshellparam($snapname);
	my $sendsource = "$sourcefsescaped\@$snapescaped";
	my $pvsize = getsendsize($sourcehost,"$sourcefs\@$snapname",0,$sourceisroot);

	my $srcname = buildnicename($sourcehost, $sourcefs, $snapname);
	my $targetname = buildnicename($targethost, $targetfs);
	my $disp_pvsize = $pvsize == 0 ? 'UNKNOWN' : readablebytes($pvsize);

	if (!defined ($args{'no-stream'}) ) {
		writelog('INFO', "Sending oldest full snapshot $srcname to new target filesystem $targetname (~ $disp_pvsize):");
	} else {
		writelog('INFO', "--no-stream selected; sending newest full snapshot $srcname to new target filesystem $targetname: (~ $disp_pvsize)");
	}

	return runsynccmd($sourcehost, $sourcefs, $sendsource, $targethost, $targetfs, $pvsize);
} # end syncfull()

sub syncincremental {
	my ($sourcehost, $sourcefs, $targethost, $targetfs, $fromsnap, $tosnap, $skipintermediate) = @_;

	my $streamarg = '-I';

	if ($skipintermediate) {
		$streamarg = '-i';
	}

	# If this is an -I sync but we're filtering snaps, then we should do a series
	# of -i syncs instead.
	if (!$skipintermediate) {
		if (defined($args{'exclude-snaps'}) || defined($args{'include-snaps'})) {
			writelog('INFO', '--no-stream is omitted but snaps are filtered. Simulating -I with filtered snaps');

			# Get the snap names between $fromsnap and $tosnap
			my @intsnaps = ();
			my $inrange = 0;
			foreach my $testsnap (sort { $snaps{'source'}{$a}{'creation'}<=>$snaps{'source'}{$b}{'creation'} } keys %{ $snaps{'source'} }) {
				if ($testsnap eq $fromsnap) { $inrange = 1; }

				if ($inrange) { push(@intsnaps, $testsnap); }

				if ($testsnap eq $tosnap) { last; }
			}

			# If we created a new sync snap, it won't be in @intsnaps yet
			if ($intsnaps[-1] ne $tosnap) {
				# Make sure that the sync snap isn't filtered out by --include-snaps or --exclude-snaps
				if (snapisincluded($tosnap)) {
					push(@intsnaps, $tosnap);
				}
			}

			foreach my $i (0..(scalar(@intsnaps) - 2)) {
				my $snapa = $intsnaps[$i];
				my $snapb = $intsnaps[$i + 1];
				(my $ret, my $stdout) = syncincremental($sourcehost, $sourcefs, $targethost, $targetfs, $snapa, $snapb, 1);

				if ($ret != 0) {
					return ($ret, $stdout);
				}
			}

			# Return after finishing the -i syncs so that we don't try to do another -I
			return 0;
		}
	}

	my $sourcefsescaped = escapeshellparam($sourcefs);
	my $fromsnapescaped = escapeshellparam($fromsnap);
	my $tosnapescaped = escapeshellparam($tosnap);
	my $sendsource = "$streamarg $sourcefsescaped\@$fromsnapescaped $sourcefsescaped\@$tosnapescaped";
	my $pvsize = getsendsize($sourcehost,"$sourcefs\@$fromsnap","$sourcefs\@$tosnap",$sourceisroot);

	my $srcname = buildnicename($sourcehost, $sourcefs, $fromsnap);
	my $targetname = buildnicename($targethost, $targetfs);
	my $disp_pvsize = $pvsize == 0 ? 'UNKNOWN' : readablebytes($pvsize);

	writelog('INFO', "Sending incremental $srcname ... $tosnap to $targetname (~ $disp_pvsize):");

	return runsynccmd($sourcehost, $sourcefs, $sendsource, $targethost, $targetfs, $pvsize);
} # end syncincremental()

sub syncclone {
	my ($sourcehost, $sourcefs, $origin, $targethost, $targetfs, $tosnap) = @_;

	my $sourcefsescaped = escapeshellparam($sourcefs);
	my $originescaped = escapeshellparam($origin);
	my $tosnapescaped = escapeshellparam($tosnap);
	my $sendsource = "-i $originescaped $sourcefsescaped\@$tosnapescaped";
	my $pvsize = getsendsize($sourcehost,$origin,"$sourcefs\@$tosnap",$sourceisroot);

	my $srcname = buildnicename($sourcehost, $origin);
	my $targetname = buildnicename($targethost, $targetfs);
	my $disp_pvsize = $pvsize == 0 ? 'UNKNOWN' : readablebytes($pvsize);

	writelog('INFO', "Clone is recreated on target $targetname based on $srcname (~ $disp_pvsize):");

	return runsynccmd($sourcehost, $sourcefs, $sendsource, $targethost, $targetfs, $pvsize);
} # end syncclone()

sub syncresume {
	my ($sourcehost, $sourcefs, $targethost, $targetfs, $receivetoken) = @_;

	my $sendsource = "-t $receivetoken";
	my $pvsize = getsendsize($sourcehost,"","",$sourceisroot,$receivetoken);

	my $srcname = buildnicename($sourcehost, $sourcefs);
	my $targetname = buildnicename($targethost, $targetfs);
	my $disp_pvsize = $pvsize == 0 ? 'UNKNOWN' : readablebytes($pvsize);

	writelog('INFO', "Resuming interrupted zfs send/receive from $srcname to $targetname (~ $disp_pvsize remaining):");

	return runsynccmd($sourcehost, $sourcefs, $sendsource, $targethost, $targetfs, $pvsize);
} # end syncresume()

sub syncbookmark {
	my ($sourcehost, $sourcefs, $targethost, $targetfs, $bookmark, $tosnap) = @_;

	my $sourcefsescaped = escapeshellparam($sourcefs);
	my $bookmarkescaped = escapeshellparam($bookmark);
	my $tosnapescaped = escapeshellparam($tosnap);
	my $sendsource = "-i $sourcefsescaped#$bookmarkescaped $sourcefsescaped\@$tosnapescaped";

	my $srcname = buildnicename($sourcehost, $sourcefs, '', $bookmark);
	my $targetname = buildnicename($targethost, $targetfs);

	writelog('INFO', "Sending incremental $srcname ... $tosnap to $targetname:");

	return runsynccmd($sourcehost, $sourcefs, $sendsource, $targethost, $targetfs, 0);
} # end syncbookmark

sub createbookmark {
	my ($sourcehost, $sourcefs, $snapname, $bookmark) = @_;

	my $sourcefsescaped = escapeshellparam($sourcefs);
	my $bookmarkescaped = escapeshellparam($bookmark);
	my $snapnameescaped = escapeshellparam($snapname);
	my $cmd = "$sourcesudocmd $zfscmd bookmark $sourcefsescaped\@$snapname $sourcefsescaped\#$bookmark";
	if ($sourcehost ne '') {
		$cmd = "$sshcmd $sourcehost " . escapeshellparam($cmd);
	}

	writelog('DEBUG', "$cmd");
	return system($cmd);
} # end createbookmark()

sub compressargset {
	my ($value) = @_;
	my $DEFAULT_COMPRESSION = 'lzo';
	my %COMPRESS_ARGS = (
		'none' => {
			rawcmd      => '',
			args        => '',
			decomrawcmd => '',
			decomargs   => '',
		},
		'gzip' => {
			rawcmd      => 'gzip',
			args        => '-3',
			decomrawcmd => 'zcat',
			decomargs   => '',
		},
		'pigz-fast' => {
			rawcmd      => 'pigz',
			args        => '-3',
			decomrawcmd => 'pigz',
			decomargs   => '-dc',
		},
		'pigz-slow' => {
			rawcmd      => 'pigz',
			args        => '-9',
			decomrawcmd => 'pigz',
			decomargs   => '-dc',
		},
		'zstd-fast' => {
			rawcmd      => 'zstd',
			args        => '-3',
			decomrawcmd => 'zstd',
			decomargs   => '-dc',
		},
		'zstdmt-fast' => {
			rawcmd      => 'zstdmt',
			args        => '-3',
			decomrawcmd => 'zstdmt',
			decomargs   => '-dc',
		},
		'zstd-slow' => {
			rawcmd      => 'zstd',
			args        => '-19',
			decomrawcmd => 'zstd',
			decomargs   => '-dc',
		},
		'zstdmt-slow' => {
			rawcmd      => 'zstdmt',
			args        => '-19',
			decomrawcmd => 'zstdmt',
			decomargs   => '-dc',
		},
		'xz' => {
			rawcmd      => 'xz',
			args        => '',
			decomrawcmd => 'xz',
			decomargs   => '-d',
		},
		'lzo' => {
			rawcmd      => 'lzop',
			args        => '',
			decomrawcmd => 'lzop',
			decomargs   => '-dfc',
		},
		'lz4' => {
			rawcmd      => 'lz4',
			args        => '',
			decomrawcmd => 'lz4',
			decomargs   => '-dc',
		},
	);

	if ($value eq 'default') {
		$value = $DEFAULT_COMPRESSION;
	} elsif (!(grep $value eq $_, ('gzip', 'pigz-fast', 'pigz-slow', 'zstd-fast', 'zstdmt-fast', 'zstd-slow', 'zstdmt-slow', 'lz4', 'xz', 'lzo', 'default', 'none'))) {
		writelog('WARN', "Unrecognised compression value $value, defaulting to $DEFAULT_COMPRESSION");
		$value = $DEFAULT_COMPRESSION;
	}

	my %comargs = %{$COMPRESS_ARGS{$value}}; # copy
	$comargs{'compress'} = $value;
	$comargs{'cmd'}      = "$comargs{'rawcmd'} $comargs{'args'}";
	$comargs{'decomcmd'} = "$comargs{'decomrawcmd'} $comargs{'decomargs'}";
	return \%comargs;
}

sub checkcommands {
	# make sure compression, mbuffer, and pv are available on
	# source, target, and local hosts as appropriate.

	my %avail;
	my $sourcessh;
	my $targetssh;

	# if --nocommandchecks then assume everything's available and return
	if ($args{'nocommandchecks'}) {
		writelog('DEBUG', "not checking for command availability due to --nocommandchecks switch.");
		$avail{'compress'} = 1;
		$avail{'localpv'} = 1;
		$avail{'localmbuffer'} = 1;
		$avail{'sourcembuffer'} = 1;
		$avail{'targetmbuffer'} = 1;
		$avail{'sourceresume'} = 1;
		$avail{'targetresume'} = 1;
		return %avail;
	}

	if ($sourcehost ne '') { $sourcessh = "$sshcmd $sourcehost"; } else { $sourcessh = ''; }
	if ($targethost ne '') { $targetssh = "$sshcmd $targethost"; } else { $targetssh = ''; }

	# if raw compress command is null, we must have specified no compression. otherwise,
	# make sure that compression is available everywhere we need it
	if ($compressargs{'compress'} eq 'none') {
		writelog('DEBUG', "compression forced off from command line arguments.");
	} else {
		writelog('DEBUG', "checking availability of $compressargs{'rawcmd'} on source...");
		$avail{'sourcecompress'} = `$sourcessh $checkcmd $compressargs{'rawcmd'} 2>/dev/null`;
		writelog('DEBUG', "checking availability of $compressargs{'rawcmd'} on target...");
		$avail{'targetcompress'} = `$targetssh $checkcmd $compressargs{'rawcmd'} 2>/dev/null`;
		writelog('DEBUG', "checking availability of $compressargs{'rawcmd'} on local machine...");
		$avail{'localcompress'} = `$checkcmd $compressargs{'rawcmd'} 2>/dev/null`;
	}

	my ($s,$t);
	if ($sourcehost eq '') {
		$s = '[local machine]'
	} else {
		$s = $sourcehost;
		$s =~ s/^\S*\@//;
		$s = "ssh:$s";
	}
	if ($targethost eq '') {
		$t = '[local machine]'
	} else {
		$t = $targethost;
		$t =~ s/^\S*\@//;
		$t = "ssh:$t";
	}

	if (!defined $avail{'sourcecompress'}) { $avail{'sourcecompress'} = ''; }
	if (!defined $avail{'targetcompress'}) { $avail{'targetcompress'} = ''; }
	if (!defined $avail{'localcompress'}) { $avail{'localcompress'} = ''; }
	if (!defined $avail{'sourcembuffer'}) { $avail{'sourcembuffer'} = ''; }
	if (!defined $avail{'targetmbuffer'}) { $avail{'targetmbuffer'} = ''; }


	if ($avail{'sourcecompress'} eq '') {
		if ($compressargs{'rawcmd'} ne '') {
			writelog('WARN', "$compressargs{'rawcmd'} not available on source $s- sync will continue without compression.");
		}
		$avail{'compress'} = 0;
	}
	if ($avail{'targetcompress'} eq '') {
		if ($compressargs{'rawcmd'} ne '') {
			writelog('WARN', "$compressargs{'rawcmd'} not available on target $t - sync will continue without compression.");
		}
		$avail{'compress'} = 0;
	}
	if ($avail{'targetcompress'} ne '' && $avail{'sourcecompress'} ne '') {
		# compression available - unless source and target are both remote, which we'll check
		# for in the next block and respond to accordingly.
		$avail{'compress'} = 1;
	}

	# corner case - if source AND target are BOTH remote, we have to check for local compress too
	if ($sourcehost ne '' && $targethost ne '' && $avail{'localcompress'} eq '') {
		if ($compressargs{'rawcmd'} ne '') {
			writelog('WARN', "$compressargs{'rawcmd'} not available on local machine - sync will continue without compression.");
		}
		$avail{'compress'} = 0;
	}

	if (length $args{'insecure-direct-connection'}) {
		writelog('DEBUG', "checking availability of $socatcmd on source...");
		my $socatAvailable = `$sourcessh $checkcmd $socatcmd 2>/dev/null`;
		if ($socatAvailable eq '') {
			die "CRIT: $socatcmd is needed on source for insecure direct connection!\n";
		}

		if (!$directmbuffer) {
			writelog('DEBUG', "checking availability of busybox (for nc) on target...");
			my $busyboxAvailable = `$targetssh $checkcmd busybox 2>/dev/null`;
			if ($busyboxAvailable eq '') {
				die "CRIT: busybox is needed on target for insecure direct connection!\n";
			}
		}
	}

	writelog('DEBUG', "checking availability of $mbuffercmd on source...");
	$avail{'sourcembuffer'} = `$sourcessh $checkcmd $mbuffercmd 2>/dev/null`;
	if ($avail{'sourcembuffer'} eq '') {
		writelog('WARN', "$mbuffercmd not available on source $s - sync will continue without source buffering.");
		$avail{'sourcembuffer'} = 0;
	} else {
		$avail{'sourcembuffer'} = 1;
	}

	writelog('DEBUG', "checking availability of $mbuffercmd on target...");
	$avail{'targetmbuffer'} = `$targetssh $checkcmd $mbuffercmd 2>/dev/null`;
	if ($avail{'targetmbuffer'} eq '') {
		if ($directmbuffer) {
			die "CRIT: $mbuffercmd is needed on target for insecure direct connection!\n";
		}
		writelog('WARN', "$mbuffercmd not available on target $t - sync will continue without target buffering.");
		$avail{'targetmbuffer'} = 0;
	} else {
		$avail{'targetmbuffer'} = 1;
	}

	# if we're doing remote source AND remote target, check for local mbuffer as well
	if ($sourcehost ne '' && $targethost ne '') {
		writelog('DEBUG', "checking availability of $mbuffercmd on local machine...");
		$avail{'localmbuffer'} = `$checkcmd $mbuffercmd 2>/dev/null`;
		if ($avail{'localmbuffer'} eq '') {
			$avail{'localmbuffer'} = 0;
			writelog('WARN', "$mbuffercmd not available on local machine - sync will continue without local buffering.");
		}
	}

	writelog('DEBUG', "checking availability of $pvcmd on local machine...");
	$avail{'localpv'} = `$checkcmd $pvcmd 2>/dev/null`;
	if ($avail{'localpv'} eq '') {
		writelog('WARN', "$pvcmd not available on local machine - sync will continue without progress bar.");
		$avail{'localpv'} = 0;
	} else {
		$avail{'localpv'} = 1;
	}

	# check for ZFS resume feature support
	if ($resume) {
		my @parts = split ('/', $sourcefs);
		my $srcpool = $parts[0];
		@parts = split ('/', $targetfs);
		my $dstpool = $parts[0];

		$srcpool = escapeshellparam($srcpool);
		$dstpool = escapeshellparam($dstpool);

		if ($sourcehost ne '') {
			# double escaping needed
			$srcpool = escapeshellparam($srcpool);
		}

		if ($targethost ne '') {
			# double escaping needed
			$dstpool = escapeshellparam($dstpool);
		}

		my $resumechkcmd = "$zpoolcmd get -o value -H feature\@extensible_dataset";

		writelog('DEBUG', "checking availability of zfs resume feature on source...");
		$avail{'sourceresume'} = system("$sourcessh $sourcesudocmd $resumechkcmd $srcpool 2>/dev/null | grep '\\(active\\|enabled\\)' >/dev/null 2>&1");
		$avail{'sourceresume'} = $avail{'sourceresume'} == 0 ? 1 : 0;

		writelog('DEBUG', "checking availability of zfs resume feature on target...");
		$avail{'targetresume'} = system("$targetssh $targetsudocmd $resumechkcmd $dstpool 2>/dev/null | grep '\\(active\\|enabled\\)' >/dev/null 2>&1");
		$avail{'targetresume'} = $avail{'targetresume'} == 0 ? 1 : 0;

		if ($avail{'sourceresume'} == 0 || $avail{'targetresume'} == 0) {
			# disable resume
			$resume = '';

			my @hosts = ();
			if ($avail{'sourceresume'} == 0) {
				push @hosts, 'source';
			}
			if ($avail{'targetresume'} == 0) {
				push @hosts, 'target';
			}
			my $affected = join(" and ", @hosts);
			writelog('WARN', "ZFS resume feature not available on $affected machine - sync will continue without resume support.");
		}
	} else {
		$avail{'sourceresume'} = 0;
		$avail{'targetresume'} = 0;
	}

	return %avail;
}

sub iszfsbusy {
	my ($rhost,$fs,$isroot) = @_;
	if ($rhost ne '') { $rhost = "$sshcmd $rhost"; }
	writelog('DEBUG', "checking to see if $fs on $rhost is already in zfs receive using $rhost $pscmd -Ao args= ...");

	open PL, "$rhost $pscmd -Ao args= |";
	my @processes = <PL>;
	close PL;

	foreach my $process (@processes) {
		if ($process =~ /zfs *(receive|recv)[^\/]*\Q$fs\E\Z/) {
			# there's already a zfs receive process for our target filesystem - return true
			writelog('DEBUG', "process $process matches target $fs!");
			return 1;
		}
	}

	# no zfs receive processes for our target filesystem found - return false
	return 0;
}

sub setzfsvalue {
	my ($rhost,$fs,$isroot,$property,$value) = @_;

	my $fsescaped = escapeshellparam($fs);

	if ($rhost ne '') {
		$rhost = "$sshcmd $rhost";
		# double escaping needed
		$fsescaped = escapeshellparam($fsescaped);
	}

	writelog('DEBUG', "setting $property to $value on $fs...");
	my $mysudocmd;
	if ($isroot) { $mysudocmd = ''; } else { $mysudocmd = $sudocmd; }
	writelog('DEBUG', "$rhost $mysudocmd $zfscmd set $property=$value $fsescaped");
	system("$rhost $mysudocmd $zfscmd set $property=$value $fsescaped") == 0
		or writelog('WARN', "$rhost $mysudocmd $zfscmd set $property=$value $fsescaped died: $?, proceeding anyway.");
	return;
}

sub getzfsvalue {
	my ($rhost,$fs,$isroot,$property) = @_;

	my $fsescaped = escapeshellparam($fs);

	if ($rhost ne '') {
		$rhost = "$sshcmd $rhost";
		# double escaping needed
		$fsescaped = escapeshellparam($fsescaped);
	}

	writelog('DEBUG', "getting current value of $property on $fs...");
	my $mysudocmd;
	if ($isroot) { $mysudocmd = ''; } else { $mysudocmd = $sudocmd; }
	writelog('DEBUG', "$rhost $mysudocmd $zfscmd get -H $property $fsescaped");
	my ($value, $error, $exit) = capture {
		system("$rhost $mysudocmd $zfscmd get -H $property $fsescaped");
	};

	my @values = split(/\t/,$value);
	$value = $values[2];

	my $wantarray = wantarray || 0;

	# If we are in scalar context and there is an error, print it out.
	# Otherwise we assume the caller will deal with it.
	if (!$wantarray and $error) {
		writelog('CRITICAL', "getzfsvalue $fs $property: $error");
	}

	return $wantarray ? ($value, $error) : $value;
}

sub getlocalzfsvalues {
	my ($rhost,$fs,$isroot) = @_;

	my $fsescaped = escapeshellparam($fs);

	if ($rhost ne '') {
		$rhost = "$sshcmd $rhost";
		# double escaping needed
		$fsescaped = escapeshellparam($fsescaped);
	}

	writelog('DEBUG', "getting locally set values of properties on $fs...");
	my $mysudocmd;
	if ($isroot) { $mysudocmd = ''; } else { $mysudocmd = $sudocmd; }
	writelog('DEBUG', "$rhost $mysudocmd $zfscmd get -s local -H all $fsescaped");
	my ($values, $error, $exit) = capture {
		system("$rhost $mysudocmd $zfscmd get -s local -H all $fsescaped");
	};

	my %properties=();

	if ($exit != 0) {
		warn "WARNING: getlocalzfsvalues failed for $fs: $error";
		if ($exitcode < 1) { $exitcode = 1; }
		return %properties;
	}

	my @blacklist = (
		"available", "compressratio", "createtxg", "creation", "clones",
		"defer_destroy", "encryptionroot", "filesystem_count", "keystatus", "guid",
		"logicalreferenced", "logicalused", "mounted", "objsetid", "origin",
		"receive_resume_token", "redact_snaps", "referenced", "refcompressratio", "snapshot_count",
		"type", "used", "usedbychildren", "usedbydataset", "usedbyrefreservation",
		"usedbysnapshots", "userrefs", "snapshots_changed", "volblocksize", "written",
		"version", "volsize", "casesensitivity", "normalization", "utf8only",
		"encryption"
	);
	my %blacklisthash = map {$_ => 1} @blacklist;

	foreach (split(/\n/,$values)) {
		my @parts = split(/\t/, $_);
		if (exists $blacklisthash{$parts[1]}) {
			next;
		}
		$properties{$parts[1]} = $parts[2];
	}

	return %properties;
}

sub readablebytes {
	my $bytes = shift;
	my $disp;

	if ($bytes > 1024*1024*1024) {
		$disp = sprintf("%.1f",$bytes/1024/1024/1024) . ' GB';
	} elsif ($bytes > 1024*1024) {
		$disp = sprintf("%.1f",$bytes/1024/1024) . ' MB';
	} else {
		$disp = sprintf("%d",$bytes/1024) . ' KB';
	}
	return $disp;
}

sub getoldestsnapshot {
	my $snaps = shift;
	foreach my $snap ( sort { $snaps{'source'}{$a}{'creation'}<=>$snaps{'source'}{$b}{'creation'} } keys %{ $snaps{'source'} }) {
		# return on first snap found - it's the oldest
		return $snap;
	}
	# must not have had any snapshots on source - luckily, we already made one, amirite?
	if (defined ($args{'no-sync-snap'}) ) {
		# well, actually we set --no-sync-snap, so no we *didn't* already make one. Whoops.
		writelog('CRITICAL', "--no-sync-snap is set, and getoldestsnapshot() could not find any snapshots on source!");
	}
	return 0;
}

sub getnewestsnapshot {
	my $snaps = shift;
	foreach my $snap ( sort { $snaps{'source'}{$b}{'creation'}<=>$snaps{'source'}{$a}{'creation'} } keys %{ $snaps{'source'} }) {
		# return on first snap found - it's the newest
		writelog('DEBUG', "NEWEST SNAPSHOT: $snap");
		return $snap;
	}
	# must not have had any snapshots on source - looks like we'd better create one!
	if (defined ($args{'no-sync-snap'}) ) {
		if (!defined ($args{'recursive'}) ) {
			# well, actually we set --no-sync-snap and we're not recursive, so no we *can't* make one. Whoops.
			die "CRIT: --no-sync-snap is set, and getnewestsnapshot() could not find any snapshots on source!\n";
		}
		# fixme: we need to output WHAT the current dataset IS if we encounter this WARN condition.
		#        we also probably need an argument to mute this WARN, for people who deliberately exclude
		#        datasets from recursive replication this way.
		writelog('WARN', "--no-sync-snap is set, and getnewestsnapshot() could not find any snapshots on source for current dataset. Continuing.");
		if ($exitcode < 2) { $exitcode = 2; }
	}
	return 0;
}

sub buildsynccmd {
	my ($sendcmd,$recvcmd,$pvsize,$sourceisroot,$targetisroot) = @_;
	# here's where it gets fun: figuring out when to compress and decompress.
	# to make this work for all possible combinations, you may have to decompress
	# AND recompress across the pipe viewer. FUN.
	my $synccmd;

	if ($sourcehost eq '' && $targethost eq '') {
		# both sides local. don't compress. do mbuffer, once, on the source side.
		# $synccmd = "$sendcmd | $mbuffercmd | $pvcmd | $recvcmd";
		$synccmd = "$sendcmd |";
		# avoid confusion - accept either source-bwlimit or target-bwlimit as the bandwidth limiting option here
		my $bwlimit = '';
		if (length $args{'source-bwlimit'}) {
			$bwlimit = $args{'source-bwlimit'};
		} elsif (length $args{'target-bwlimit'}) {
			$bwlimit = $args{'target-bwlimit'};
		}

		if ($avail{'sourcembuffer'}) { $synccmd .= " $mbuffercmd $bwlimit $mbufferoptions |"; }
		if ($avail{'localpv'} && !$quiet) { $synccmd .= " $pvcmd $pvoptions -s $pvsize |"; }
		$synccmd .= " $recvcmd";
	} elsif ($sourcehost eq '') {
		# local source, remote target.
		#$synccmd = "$sendcmd | $pvcmd | $compressargs{'cmd'} | $mbuffercmd | $sshcmd $targethost '$compressargs{'decomcmd'} | $mbuffercmd | $recvcmd'";
		$synccmd = "$sendcmd |";
		if ($avail{'localpv'} && !$quiet) { $synccmd .= " $pvcmd $pvoptions -s $pvsize |"; }
		if ($avail{'compress'}) { $synccmd .= " $compressargs{'cmd'} |"; }
		if ($avail{'sourcembuffer'}) { $synccmd .= " $mbuffercmd $args{'source-bwlimit'} $mbufferoptions |"; }
		if (length $directconnect) {
			$synccmd .= " $socatcmd - TCP:" . $directconnect . ",retry=$directtimeout,interval=1 |";
		}
		$synccmd .= " $sshcmd $targethost ";

		my $remotecmd = "";
		if ($directmbuffer) {
			$remotecmd .= " $mbuffercmd $args{'target-bwlimit'} -W $directtimeout -I " . $directlisten . " $mbufferoptions |";
		} elsif (length $directlisten) {
			$remotecmd .= " busybox nc -l " . $directlisten . " -w $directtimeout |";
		}

		if ($avail{'targetmbuffer'} && !$directmbuffer) { $remotecmd .= " $mbuffercmd $args{'target-bwlimit'} $mbufferoptions |"; }
		if ($avail{'compress'}) { $remotecmd .= " $compressargs{'decomcmd'} |"; }
		$remotecmd .= " $recvcmd";

		$synccmd .= escapeshellparam($remotecmd);
	} elsif ($targethost eq '') {
		# remote source, local target.
		#$synccmd = "$sshcmd $sourcehost '$sendcmd | $compressargs{'cmd'} | $mbuffercmd' | $compressargs{'decomcmd'} | $mbuffercmd | $pvcmd | $recvcmd";

		my $remotecmd = $sendcmd;
		if ($avail{'compress'}) { $remotecmd .= " | $compressargs{'cmd'}"; }
		if ($avail{'sourcembuffer'}) { $remotecmd .= " | $mbuffercmd $args{'source-bwlimit'} $mbufferoptions"; }
		if (length $directconnect) {
			$remotecmd .= " | $socatcmd - TCP:" . $directconnect . ",retry=$directtimeout,interval=1";
		}

		$synccmd = "$sshcmd $sourcehost " . escapeshellparam($remotecmd);
		$synccmd .= " | ";
		if ($directmbuffer) {
			$synccmd .= "$mbuffercmd $args{'target-bwlimit'} -W $directtimeout -I " . $directlisten . " $mbufferoptions | ";
		} elsif (length $directlisten) {
			$synccmd .= " busybox nc -l " . $directlisten . " -w $directtimeout | ";
		}

		if ($avail{'targetmbuffer'} && !$directmbuffer) { $synccmd .= "$mbuffercmd $args{'target-bwlimit'} $mbufferoptions | "; }
		if ($avail{'compress'}) { $synccmd .= "$compressargs{'decomcmd'} | "; }
		if ($avail{'localpv'} && !$quiet) { $synccmd .= "$pvcmd $pvoptions -s $pvsize | "; }
		$synccmd .= "$recvcmd";
	} else {
		#remote source, remote target... weird, but whatever, I'm not here to judge you.
		#$synccmd = "$sshcmd $sourcehost '$sendcmd | $compressargs{'cmd'} | $mbuffercmd' | $compressargs{'decomcmd'} | $pvcmd | $compressargs{'cmd'} | $mbuffercmd | $sshcmd $targethost '$compressargs{'decomcmd'} | $mbuffercmd | $recvcmd'";

		my $remotecmd = $sendcmd;
		if ($avail{'compress'}) { $remotecmd .= " | $compressargs{'cmd'}"; }
		if ($avail{'sourcembuffer'}) { $remotecmd .= " | $mbuffercmd $args{'source-bwlimit'} $mbufferoptions"; }

		$synccmd = "$sshcmd $sourcehost " . escapeshellparam($remotecmd);
		$synccmd .= " | ";

		if ($avail{'compress'}) { $synccmd .= "$compressargs{'decomcmd'} | "; }
		if ($avail{'localpv'} && !$quiet) { $synccmd .= "$pvcmd $pvoptions -s $pvsize | "; }
		if ($avail{'compress'}) { $synccmd .= "$compressargs{'cmd'} | "; }
		if ($avail{'localmbuffer'}) { $synccmd .= "$mbuffercmd $mbufferoptions | "; }
		$synccmd .= "$sshcmd $targethost ";

		$remotecmd = "";
		if ($avail{'targetmbuffer'}) { $remotecmd .= " $mbuffercmd $args{'target-bwlimit'} $mbufferoptions |"; }
		if ($avail{'compress'}) { $remotecmd .= " $compressargs{'decomcmd'} |"; }
		$remotecmd .= " $recvcmd";

		$synccmd .= escapeshellparam($remotecmd);
	}
	return $synccmd;
}

sub pruneoldsyncsnaps {
	my ($rhost,$fs,$newsyncsnap,$isroot,@snaps) = @_;

	my $fsescaped = escapeshellparam($fs);

	if ($rhost ne '') { $rhost = "$sshcmd $rhost"; }

	my $hostid = hostname();

	my $mysudocmd;
	if ($isroot) { $mysudocmd=''; } else { $mysudocmd = $sudocmd; }

	my @prunesnaps;

	# only prune snaps beginning with syncoid and our own hostname
	foreach my $snap(@snaps) {
		if ($snap =~ /^syncoid_\Q$identifier$hostid\E/) {
			# no matter what, we categorically refuse to
			# prune the new sync snap we created for this run
			if ($snap ne $newsyncsnap) {
				push (@prunesnaps,$snap);
			}
		}
	}

	# concatenate pruning commands to ten per line, to cut down
	# auth times for any remote hosts that must be operated via SSH
	my $counter;
	my $maxsnapspercmd = 10;
	my $prunecmd;
	foreach my $snap(@prunesnaps) {
		$counter ++;
		$prunecmd .= "$mysudocmd $zfscmd destroy $fsescaped\@$snap; ";
		if ($counter > $maxsnapspercmd) {
			$prunecmd =~ s/\; $//;
			writelog('DEBUG', "pruning up to $maxsnapspercmd obsolete sync snapshots...");
			writelog('DEBUG', "$rhost $prunecmd");
			if ($rhost ne '') {
				$prunecmd = escapeshellparam($prunecmd);
			}
			system("$rhost $prunecmd") == 0
				or writelog('WARN', "$rhost $prunecmd failed: $?");
			$prunecmd = '';
			$counter = 0;
		}
	}
	# if we still have some prune commands stacked up after finishing
	# the loop, commit 'em now
	if ($counter) {
		$prunecmd =~ s/\; $//;
		writelog('DEBUG', "pruning up to $maxsnapspercmd obsolete sync snapshots...");
		writelog('DEBUG', "$rhost $prunecmd");
		if ($rhost ne '') {
			$prunecmd = escapeshellparam($prunecmd);
		}
		system("$rhost $prunecmd") == 0
			or writelog('WARN', "$rhost $prunecmd failed: $?");
	}
	return;
}

sub getmatchingsnapshot {
	my ($sourcefs, $targetfs, $snaps) = @_;
	foreach my $snap ( sort { $snaps{'source'}{$b}{'creation'}<=>$snaps{'source'}{$a}{'creation'} } keys %{ $snaps{'source'} }) {
		if (defined $snaps{'target'}{$snap}) {
			if ($snaps{'source'}{$snap}{'guid'} == $snaps{'target'}{$snap}{'guid'}) {
				return $snap;
			}
		}
	}

	return 0;
}

sub newsyncsnap {
	my ($rhost,$fs,$isroot) = @_;
	my $fsescaped = escapeshellparam($fs);
	if ($rhost ne '') {
		$rhost = "$sshcmd $rhost";
		# double escaping needed
		$fsescaped = escapeshellparam($fsescaped);
	}
	my $mysudocmd;
	if ($isroot) { $mysudocmd = ''; } else { $mysudocmd = $sudocmd; }
	my $hostid = hostname();
	my %date = getdate();
	my $snapname = "syncoid\_$identifier$hostid\_$date{'stamp'}";
	my $snapcmd = "$rhost $mysudocmd $zfscmd snapshot $fsescaped\@$snapname\n";
	writelog('DEBUG', "creating sync snapshot using \"$snapcmd\"...");
	system($snapcmd) == 0 or do {
		writelog('CRITICAL', "$snapcmd failed: $?");
		if ($exitcode < 2) { $exitcode = 2; }
		return 0;
	};

	return $snapname;
}

sub targetexists {
	my ($rhost,$fs,$isroot) = @_;
	my $fsescaped = escapeshellparam($fs);
	if ($rhost ne '') {
		$rhost = "$sshcmd $rhost";
		# double escaping needed
		$fsescaped = escapeshellparam($fsescaped);
	}
	my $mysudocmd;
	if ($isroot) { $mysudocmd = ''; } else { $mysudocmd = $sudocmd; }
	my $checktargetcmd = "$rhost $mysudocmd $zfscmd get -H name $fsescaped";
	writelog('DEBUG', "checking to see if target filesystem exists using \"$checktargetcmd 2>&1 |\"...");
	open FH, "$checktargetcmd 2>&1 |";
	my $targetexists = <FH>;
	close FH;
	my $exit = $?;
	$targetexists = ( $targetexists =~ /^\Q$fs\E/ && $exit == 0 );
	return $targetexists;
}

sub getssh {
	my $fs = shift;

	my $rhost = "";
	my $isroot;
	my $socket;
	my $remoteuser = "";

	# if we got passed something with an @ in it, we assume it's an ssh connection, eg root@myotherbox
	if ($fs =~ /\@/) {
		$rhost = $fs;
		$fs =~ s/^[^\@:]*\@[^\@:]*://;
		$rhost =~ s/:\Q$fs\E$//;
		$remoteuser = $rhost;
		$remoteuser =~ s/\@.*$//;
		# do not require a username to be specified
		$rhost =~ s/^@//;
	} elsif ($fs =~ m{^[^/]*:}) {
		# if we got passed something with an : in it, BEFORE any forward slash
		# (i.e., not in a dataset name) it MAY be an ssh connection
		# but we need to check if there is a pool with that name
		my $pool = $fs;
		$pool =~ s%/.*$%%;
		my ($pools, $error, $exit) = capture {
			system("$zfscmd list -d0 -H -oname");
		};
		$rhost = $fs;
		if ($exit != 0) {
			writelog('WARN', "Unable to enumerate pools (is zfs available?)");
		} else {
			foreach (split(/\n/,$pools)) {
				if ($_ eq $pool) {
					# there's a pool with this name.
					$rhost = "";
					last;
				}
			}
		}
		if ($rhost ne "") {
			# there's no pool that might conflict with this
			$rhost =~ s/:.*$//;
			$fs =~ s/\Q$rhost\E://;
		}
	}

	if ($rhost ne "") {
		if ($remoteuser eq 'root' || $args{'no-privilege-elevation'}) { $isroot = 1; } else { $isroot = 0; }

		my $sanitizedrhost = $rhost;
		$sanitizedrhost =~ s/[^a-zA-Z0-9-]//g;
		# unix socket path have a length limit of about 104 characters so make sure it's not exceeded
		$sanitizedrhost = substr($sanitizedrhost, 0, 50);

		# now we need to establish a persistent master SSH connection
		$socket = "/tmp/syncoid-$sanitizedrhost-" . time() . "-" . $$ . "-" . int(rand(10000));


		open FH, "$sshcmd -M -S $socket -o ControlPersist=1m $args{'sshport'} $rhost exit |";
		close FH;

		system("$sshcmd -S $socket $rhost echo -n") == 0 or do {
			my $code = $? >> 8;
			writelog('CRITICAL', "ssh connection echo test failed for $rhost with exit code $code");
			exit(2);
		};

		$rhost = "-S $socket $rhost";
	} else {
		my $localuid = $<;
		if ($localuid == 0 || $args{'no-privilege-elevation'}) { $isroot = 1; } else { $isroot = 0; }
	}
	return ($rhost,$fs,$isroot);
}

sub dumphash() {
	my $hash = shift;
	$Data::Dumper::Sortkeys = 1;
	writelog('INFO', Dumper($hash));
}

sub getsnaps() {
	my ($type,$rhost,$fs,$isroot,%snaps) = @_;
	my $mysudocmd;
	my $fsescaped = escapeshellparam($fs);
	if ($isroot) { $mysudocmd = ''; } else { $mysudocmd = $sudocmd; }

	my $rhostOriginal = $rhost;

	if ($rhost ne '') {
		$rhost = "$sshcmd $rhost";
		# double escaping needed
		$fsescaped = escapeshellparam($fsescaped);
	}

	my $getsnapcmd = "$rhost $mysudocmd $zfscmd get -Hpd 1 -t snapshot guid,creation $fsescaped";
	if ($debug) {
		$getsnapcmd = "$getsnapcmd |";
		writelog('DEBUG', "getting list of snapshots on $fs using $getsnapcmd...");
	} else {
		$getsnapcmd = "$getsnapcmd 2>/dev/null |";
	}
	open FH, $getsnapcmd;
	my @rawsnaps = <FH>;
	close FH or do {
		# fallback (solaris for example doesn't support the -t option)
		return getsnapsfallback($type,$rhostOriginal,$fs,$isroot,%snaps);
	};

	# this is a little obnoxious. get guid,creation returns guid,creation on two separate lines
	# as though each were an entirely separate get command.

	my %creationtimes=();

	foreach my $line (@rawsnaps) {
		$line =~ /\Q$fs\E\@(\S*)/;
		my $snapname = $1;

		if (!snapisincluded($snapname)) { next; }

		# only import snap guids from the specified filesystem
		if ($line =~ /\Q$fs\E\@.*\tguid/) {
			chomp $line;
			my $guid = $line;
			$guid =~ s/^.*\tguid\t*(\d*).*/$1/;
			my $snap = $line;
			$snap =~ s/^.*\@(.*)\tguid.*$/$1/;
			$snaps{$type}{$snap}{'guid'}=$guid;
		}
		# only import snap creations from the specified filesystem
		elsif ($line =~ /\Q$fs\E\@.*\tcreation/) {
			chomp $line;
			my $creation = $line;
			$creation =~ s/^.*\tcreation\t*(\d*).*/$1/;
			my $snap = $line;
			$snap =~ s/^.*\@(.*)\tcreation.*$/$1/;

			# the accuracy of the creation timestamp is only for a second, but
			# snapshots in the same second are highly likely. The list command
			# has an ordered output so we append another three digit running number
			# to the creation timestamp and make sure those are ordered correctly
			# for snapshot with the same creation timestamp
			my $counter = 0;
			my $creationsuffix;
			while ($counter < 999) {
				$creationsuffix = sprintf("%s%03d", $creation, $counter);
				if (!defined $creationtimes{$creationsuffix}) {
					$creationtimes{$creationsuffix} = 1;
					last;
				}
				$counter += 1;
			}

			$snaps{$type}{$snap}{'creation'}=$creationsuffix;
		}
	}

	return %snaps;
}

sub getsnapsfallback() {
	# fallback (solaris for example doesn't support the -t option)
	my ($type,$rhost,$fs,$isroot,%snaps) = @_;
	my $mysudocmd;
	my $fsescaped = escapeshellparam($fs);
	if ($isroot) { $mysudocmd = ''; } else { $mysudocmd = $sudocmd; }

	if ($rhost ne '') {
		$rhost = "$sshcmd $rhost";
		# double escaping needed
		$fsescaped = escapeshellparam($fsescaped);
	}

	my $getsnapcmd = "$rhost $mysudocmd $zfscmd get -Hpd 1 type,guid,creation $fsescaped |";
	writelog('WARN', "snapshot listing failed, trying fallback command");
	writelog('DEBUG', "FALLBACK, getting list of snapshots on $fs using $getsnapcmd...");
	open FH, $getsnapcmd;
	my @rawsnaps = <FH>;
	close FH or die "CRITICAL ERROR: snapshots couldn't be listed for $fs (exit code $?)";

	my %creationtimes=();

	my $state = 0;
	foreach my $line (@rawsnaps) {
		if ($state < 0) {
			$state++;
			next;
		}

		if ($state eq 0) {
			if ($line !~ /\Q$fs\E\@.*\ttype\s*snapshot/) {
				# skip non snapshot type object
				$state = -2;
				next;
			}
		} elsif ($state eq 1) {
			if ($line !~ /\Q$fs\E\@.*\tguid/) {
				die "CRITICAL ERROR: snapshots couldn't be listed for $fs (guid parser error)";
			}

			chomp $line;
			my $guid = $line;
			$guid =~ s/^.*\tguid\t*(\d*).*/$1/;
			my $snap = $line;
			$snap =~ s/^.*\@(.*)\tguid.*$/$1/;
			if (!snapisincluded($snap)) { next; }
			$snaps{$type}{$snap}{'guid'}=$guid;
		} elsif ($state eq 2) {
			if ($line !~ /\Q$fs\E\@.*\tcreation/) {
				die "CRITICAL ERROR: snapshots couldn't be listed for $fs (creation parser error)";
			}

			chomp $line;
			my $creation = $line;
			$creation =~ s/^.*\tcreation\t*(\d*).*/$1/;
			my $snap = $line;
			$snap =~ s/^.*\@(.*)\tcreation.*$/$1/;
			if (!snapisincluded($snap)) { next; }

			# the accuracy of the creation timestamp is only for a second, but
			# snapshots in the same second are highly likely. The list command
			# has an ordered output so we append another three digit running number
			# to the creation timestamp and make sure those are ordered correctly
			# for snapshot with the same creation timestamp
			my $counter = 0;
			my $creationsuffix;
			while ($counter < 999) {
				$creationsuffix = sprintf("%s%03d", $creation, $counter);
				if (!defined $creationtimes{$creationsuffix}) {
					$creationtimes{$creationsuffix} = 1;
					last;
				}
				$counter += 1;
			}

			$snaps{$type}{$snap}{'creation'}=$creationsuffix;
			$state = -1;
		}

		$state++;
	}

	return %snaps;
}

sub getbookmarks() {
	my ($rhost,$fs,$isroot,%bookmarks) = @_;
	my $mysudocmd;
	my $fsescaped = escapeshellparam($fs);
	if ($isroot) { $mysudocmd = ''; } else { $mysudocmd = $sudocmd; }

	if ($rhost ne '') {
		$rhost = "$sshcmd $rhost";
		# double escaping needed
		$fsescaped = escapeshellparam($fsescaped);
	}

	my $error = 0;
	my $getbookmarkcmd = "$rhost $mysudocmd $zfscmd get -Hpd 1 -t bookmark guid,creation $fsescaped 2>&1 |";
	writelog('DEBUG', "getting list of bookmarks on $fs using $getbookmarkcmd...");
	open FH, $getbookmarkcmd;
	my @rawbookmarks = <FH>;
	close FH or $error = 1;

	if ($error == 1) {
		if ($rawbookmarks[0] =~ /invalid type/ or $rawbookmarks[0] =~ /operation not applicable to datasets of this type/) {
			# no support for zfs bookmarks, return empty hash
			return %bookmarks;
		}

		die "CRITICAL ERROR: bookmarks couldn't be listed for $fs (exit code $?)";
	}

	# this is a little obnoxious. get guid,creation returns guid,creation on two separate lines
	# as though each were an entirely separate get command.

	my $lastguid;
	my %creationtimes=();

	foreach my $line (@rawbookmarks) {
		# only import bookmark guids, creation from the specified filesystem
		if ($line =~ /\Q$fs\E\#.*\tguid/) {
			chomp $line;
			$lastguid = $line;
			$lastguid =~ s/^.*\tguid\t*(\d*).*/$1/;
			my $bookmark = $line;
			$bookmark =~ s/^.*\#(.*)\tguid.*$/$1/;
			$bookmarks{$lastguid}{'name'}=$bookmark;
		} elsif ($line =~ /\Q$fs\E\#.*\tcreation/) {
			chomp $line;
			my $creation = $line;
			$creation =~ s/^.*\tcreation\t*(\d*).*/$1/;
			my $bookmark = $line;
			$bookmark =~ s/^.*\#(.*)\tcreation.*$/$1/;

			# the accuracy of the creation timestamp is only for a second, but
			# bookmarks in the same second are possible. The list command
			# has an ordered output so we append another three digit running number
			# to the creation timestamp and make sure those are ordered correctly
			# for bookmarks with the same creation timestamp
			my $counter = 0;
			my $creationsuffix;
			while ($counter < 999) {
				$creationsuffix = sprintf("%s%03d", $creation, $counter);
				if (!defined $creationtimes{$creationsuffix}) {
					$creationtimes{$creationsuffix} = 1;
					last;
				}
				$counter += 1;
			}

			$bookmarks{$lastguid}{'creation'}=$creationsuffix;
		}
	}

	return %bookmarks;
}

sub getsendsize {
	my ($sourcehost,$snap1,$snap2,$isroot,$receivetoken) = @_;

	my $snap1escaped = escapeshellparam($snap1);
	my $snap2escaped = escapeshellparam($snap2);

	my $mysudocmd;
	if ($isroot) { $mysudocmd = ''; } else { $mysudocmd = $sudocmd; }

	my $sourcessh;
	if ($sourcehost ne '') {
		$sourcessh = "$sshcmd $sourcehost";
		$snap1escaped = escapeshellparam($snap1escaped);
		$snap2escaped = escapeshellparam($snap2escaped);
	} else {
		$sourcessh = '';
	}

	my $snaps;
	if ($snap2) {
		# if we got a $snap2 argument, we want an incremental send estimate from $snap1 to $snap2.
		$snaps = "$args{'streamarg'} $snap1escaped $snap2escaped";
	} else {
		# if we didn't get a $snap2 arg, we want a full send estimate for $snap1.
		$snaps = "$snap1escaped";
	}

	# in case of a resumed receive, get the remaining
	# size based on the resume token
	if (defined($receivetoken)) {
		$snaps = "-t $receivetoken";
	}

	my $sendoptions;
	if (defined($receivetoken)) {
		$sendoptions = getoptionsline(\@sendoptions, ('V','e'));
	} else {
		$sendoptions = getoptionsline(\@sendoptions, ('L','V','R','X','b','c','e','h','p','s','w'));
	}
	my $getsendsizecmd = "$sourcessh $mysudocmd $zfscmd send $sendoptions -nvP $snaps";
	writelog('DEBUG', "getting estimated transfer size from source $sourcehost using \"$getsendsizecmd 2>&1 |\"...");

	open FH, "$getsendsizecmd 2>&1 |";
	my @rawsize = <FH>;
	close FH;
	my $exit = $?;

	# process sendsize: last line of multi-line output is
	# size of proposed xfer in bytes, but we need to remove
	# human-readable crap from it
	my $sendsize = pop(@rawsize);
	# the output format is different in case of
	# a resumed receive
	if (defined($receivetoken)) {
		$sendsize =~ s/.*\t([0-9]+)$/$1/;
	} else {
		$sendsize =~ s/^size\t*//;
	}
	chomp $sendsize;

	# check for valid value
	if ($sendsize !~ /^\d+$/) {
		$sendsize = '';
	}

	# to avoid confusion with a zero size pv, give sendsize
	# a minimum 4K value - or if empty, make sure it reads UNKNOWN
	writelog('DEBUG', "sendsize = $sendsize");
	if ($sendsize eq '' || $exit != 0) {
		$sendsize = '0';
	} elsif ($sendsize < 4096) {
		$sendsize = 4096;
	}
	return $sendsize;
}

sub getdate {
	my @time = localtime(time);

	# get timezone info
	my $offset = timegm(@time) - timelocal(@time);
	my $sign = ''; # + is not allowed in a snapshot name
	if ($offset < 0) {
		$sign = '-';
		$offset = abs($offset);
	}
	my $hours = int($offset / 3600);
	my $minutes = int($offset / 60) - $hours * 60;

	my ($sec,$min,$hour,$mday,$mon,$year,$wday,$yday,$isdst) = @time;
	$year += 1900;
	my %date;
	$date{'unix'} = (((((((($year - 1971) * 365) + $yday) * 24) + $hour) * 60) + $min) * 60) + $sec;
	$date{'year'} = $year;
	$date{'sec'} = sprintf ("%02u", $sec);
	$date{'min'} = sprintf ("%02u", $min);
	$date{'hour'} = sprintf ("%02u", $hour);
	$date{'mday'} = sprintf ("%02u", $mday);
	$date{'mon'} = sprintf ("%02u", ($mon + 1));
	$date{'tzoffset'} = sprintf ("GMT%s%02d:%02u", $sign, $hours, $minutes);
	$date{'stamp'} = "$date{'year'}-$date{'mon'}-$date{'mday'}:$date{'hour'}:$date{'min'}:$date{'sec'}-$date{'tzoffset'}";
	return %date;
}

sub escapeshellparam {
	my ($par) = @_;
	# avoid use of uninitialized string in regex
	if (length($par)) {
		# "escape" all single quotes
		$par =~ s/'/'"'"'/g;
	} else {
		# avoid use of uninitialized string in concatenation below
		$par = '';
	}
	# single-quote entire string
	return "'$par'";
}

sub getreceivetoken() {
	my ($rhost,$fs,$isroot) = @_;
	my $token = getzfsvalue($rhost,$fs,$isroot,"receive_resume_token");

	if (defined $token && $token ne '-' && $token ne '') {
		return $token;
	}

	writelog('DEBUG', "no receive token found");

	return
}

sub parsespecialoptions {
	my ($line) = @_;

	my @options = ();

	my @values = split(/ /, $line);

	my $optionValue = 0;
	my $lastOption;

	foreach my $value (@values) {
		if ($optionValue ne 0) {
			my %item = (
				"option"  => $lastOption,
				"line" => "-$lastOption $value",
			);

			push @options, \%item;
			$optionValue = 0;
			next;
		}

		for my $char (split //, $value) {
			if ($optionValue ne 0) {
				return undef;
			}

			if ($char eq 'o' || $char eq 'x' || $char eq 'X') {
				$lastOption = $char;
				$optionValue = 1;
			} else {
				my %item = (
					"option"  => $char,
					"line" => "-$char",
				);

				push @options, \%item;
			}
		}
	}

	return @options;
}

sub getoptionsline {
	my ($options_ref, @allowed) = @_;

	my $line = '';

	foreach my $value (@{ $options_ref }) {
		if (@allowed) {
			if (!grep( /^$$value{'option'}$/, @allowed) ) {
				next;
			}
		}

		$line = "$line$$value{'line'} ";
	}

	return $line;
}

sub resetreceivestate {	my ($rhost,$fs,$isroot) = @_;

	my $fsescaped = escapeshellparam($fs);

	if ($rhost ne '') {
		$rhost = "$sshcmd $rhost";
		# double escaping needed
		$fsescaped = escapeshellparam($fsescaped);
	}

	writelog('DEBUG', "reset partial receive state of $fs...");
	my $mysudocmd;
	if ($isroot) { $mysudocmd = ''; } else { $mysudocmd = $sudocmd; }
	my $resetcmd = "$rhost $mysudocmd $zfscmd receive -A $fsescaped";
	writelog('DEBUG', "$resetcmd");
	system("$resetcmd") == 0
		or die "CRITICAL ERROR: $resetcmd failed: $?";
}

# $loglevel can be one of:
#  - CRITICAL
#  - WARN
#  - INFO
#  - DEBUG
sub writelog {
	my ($loglevel, $msg) = @_;

	my $header;
	chomp($msg);

	if ($loglevel eq 'CRITICAL') {
		warn("CRITICAL ERROR: $msg\n");
	} elsif ($loglevel eq 'WARN') {
		if (!$quiet) { warn("WARNING: $msg\n"); }
	} elsif ($loglevel eq 'INFO') {
		if (!$quiet) { print("INFO: $msg\n"); }
	} elsif ($loglevel eq 'DEBUG') {
		if ($debug) { print("DEBUG: $msg\n"); }
	}
}

sub snapisincluded {
	my ($snapname) = @_;

	# Return false if the snapshot matches an exclude-snaps pattern
	if (defined $args{'exclude-snaps'}) {
		my $excludes = $args{'exclude-snaps'};
		foreach (@$excludes) {
			if ($snapname =~ /$_/) {
				writelog('DEBUG', "excluded $snapname because of exclude pattern /$_/");
				return 0;
			}
		}
	}

	# Return true if the snapshot matches an include-snaps pattern
	if (defined $args{'include-snaps'}) {
		my $includes = $args{'include-snaps'};
		foreach (@$includes) {
			if ($snapname =~ /$_/) {
				writelog('DEBUG', "included $snapname because of include pattern /$_/");
				return 1;
			}
		}

		# Return false if the snapshot didn't match any inclusion patterns
		return 0;
	}

	return 1;
}

sub buildnicename {
	my ($host,$fs,$snapname,$bookmarkname) = @_;

	my $name;
	if ($host) {
		$host =~ s/-S \/tmp\/syncoid[a-zA-Z0-9-@]+ //g;
		$name = "$host:$fs";
	} else {
		$name = "$fs";
	}

	if ($snapname) {
		$name = "$name\@$snapname";
	} elsif ($bookmarkname) {
		$name = "$name#$bookmarkname";
	}

	return $name;
}

__END__

=head1 NAME

syncoid - ZFS snapshot replication tool

=head1 SYNOPSIS

 syncoid [options]... SOURCE TARGET
 or   syncoid [options]... SOURCE [[USER]@]HOST:TARGET
 or   syncoid [options]... [[USER]@]HOST:SOURCE TARGET
 or   syncoid [options]... [[USER]@]HOST:SOURCE [[USER]@]HOST:TARGET

 SOURCE                Source ZFS dataset. Can be either local or remote
 TARGET                Target ZFS dataset. Can be either local or remote

Options:

  --compress=FORMAT     Compresses data during transfer. Currently accepted options are gzip, pigz-fast, pigz-slow, zstd-fast, zstdmt-fast, zstd-slow, zstdmt-slow, lz4, xz, lzo (default) & none
  --identifier=EXTRA    Extra identifier which is included in the snapshot name. Can be used for replicating to multiple targets.
  --recursive|r         Also transfers child datasets
  --skip-parent         Skips syncing of the parent dataset. Does nothing without '--recursive' option.
  --source-bwlimit=<limit k|m|g|t>  Bandwidth limit in bytes/kbytes/etc per second on the source transfer
  --target-bwlimit=<limit k|m|g|t>  Bandwidth limit in bytes/kbytes/etc per second on the target transfer
  --mbuffer-size=VALUE  Specify the mbuffer size (default: 16M), please refer to mbuffer(1) manual page.
  --pv-options=OPTIONS  Configure how pv displays the progress bar, default '-p -t -e -r -b'
  --no-stream           Replicates using newest snapshot instead of intermediates
  --no-sync-snap        Does not create new snapshot, only transfers existing
  --keep-sync-snap      Don't destroy created sync snapshots
  --create-bookmark     Creates a zfs bookmark for the newest snapshot on the source after replication succeeds (only works with --no-sync-snap)
  --use-hold            Adds a hold to the newest snapshot on the source and target after replication succeeds and removes the hold after the next successful replication. The hold name includes the identifier if set. This allows for separate holds in case of multiple targets
  --preserve-recordsize Preserves the recordsize on initial sends to the target
  --preserve-properties Preserves locally set dataset properties similar to the zfs send -p flag but this one will also work for encrypted datasets in non raw sends
  --no-rollback         Does not rollback snapshots on target (it probably requires a readonly target)
  --delete-target-snapshots With this argument snapshots which are missing on the source will be destroyed on the target. Use this if you only want to handle snapshots on the source.
  --exclude=REGEX       DEPRECATED. Equivalent to --exclude-datasets, but will be removed in a future release. Ignored if --exclude-datasets is also provided.
  --exclude-datasets=REGEX Exclude specific datasets which match the given regular expression. Can be specified multiple times
  --exclude-snaps=REGEX Exclude specific snapshots that match the given regular expression. Can be specified multiple times. If a snapshot matches both the exclude-snaps and include-snaps patterns, then it will be excluded.
  --include-snaps=REGEX Only include snapshots that match the given regular expression. Can be specified multiple times. If a snapshot matches both the exclude-snaps and include-snaps patterns, then it will be excluded.
  --sendoptions=OPTIONS Use advanced options for zfs send (the arguments are filtered as needed), e.g. syncoid --sendoptions="Lc e" sets zfs send -L -c -e ...
  --recvoptions=OPTIONS Use advanced options for zfs receive (the arguments are filtered as needed), e.g. syncoid --recvoptions="ux recordsize o compression=lz4" sets zfs receive -u -x recordsize -o compression=lz4 ...
  --sshconfig=FILE      Specifies an ssh_config(5) file to be used
  --sshkey=FILE         Specifies a ssh key to use to connect
  --sshport=PORT        Connects to remote on a particular port
  --sshcipher|c=CIPHER  Passes CIPHER to ssh to use a particular cipher set
  --sshoption|o=OPTION  Passes OPTION to ssh for remote usage. Can be specified multiple times
  --insecure-direct-connection=IP:PORT[,IP:PORT]  WARNING: DATA IS NOT ENCRYPTED. First address pair is for connecting to the target and the second for listening at the target

  --help                Prints this helptext
  --version             Prints the version number
  --debug               Prints out a lot of additional information during a syncoid run
  --monitor-version     Currently does nothing
  --quiet               Suppresses non-error output
  --dumpsnaps           Dumps a list of snapshots during the run
  --no-command-checks   Do not check command existence before attempting transfer. Not recommended
  --no-resume           Don't use the ZFS resume feature if available
  --no-clone-handling   Don't try to recreate clones on target
  --no-privilege-elevation  Bypass the root check, for use with ZFS permission delegation

  --force-delete        Remove target datasets recursively, if there are no matching snapshots/bookmarks (also overwrites conflicting named snapshots)
