#!/usr/bin/perl
# License: Public Domain or CC0
# See https://creativecommons.org/publicdomain/zero/1.0/
# The author, Jim Avera (jim.avera at gmail) has waived all copyright and
# related or neighboring rights to the content of this file.
# Attribution is requested but is not required.
use strict; use warnings FATAL => 'all';
use feature qw(state say current_sub signatures);
use version 0.77; our $VERSION = version->declare(sprintf "v%s", q$Id: safe_rsync,v 1.11 2026/06/24 19:35:23 jima Exp jima $ =~ /(\d[.\d]+)/);
use utf8; { use open ':std', IO => ':encoding(UTF-8)'; }
STDOUT->autoflush(); STDERR->autoflush();

sub USAGE() {
  return (<<'EOF' . "VERSION = $VERSION\n");

safe_rsync [OPTIONS] [RSYNC-OPTIONS] SOURCES...  DESTDIR/

Relative symlinks can be freely used in local SOURCES including links pointing 
outside of SOURCES; symlinks will be preserved in the destiantion where possible 
(i.e. when they point within the transfer) but otherwise converted into copies.

OPTIONS:

  -V or --Verbose to see the generated rsync commands
  -Debug          to see internal trace messages

  (silent by default)

This wrapper works around a problem with rsync --copy-unsafe-links where the
result can nevertheless contain wrong or broken symlinks.  For example, given this:

 /tmp/tdir
 ├── distant
 │   ├── file1.txt
 │   └── foodir
 │       └── link_to_file1.txt -> ../file1.txt
 └── src
     └── a
         └── b
             └── link_to_foodir -> /tmp/tdir/distant/foodir

rsync -r -l --copy-unsafe-links /tmp/tdir/src/a/ /tmp/tdir/dst/a/
produces this:

 /tmp/tdir/dst
 └── a
     └── b
         └── link_to_foodir
             └── link_to_file1.txt -> ../file1.txt  # BROKEN LINK

The "bug" is that rsync thinks 'link_to_file1.txt' is safe because it points up 
only one level and it will exist three levels down in the implied result tree;
however since it is included only via a link to "foodir", even a single-level uplink
e.g '../file1.txt' refers to something which will not exist in the transfer,
rendering the result unusable (to rsync "safe" only means a link can not point 
outside the *result* tree and so can not access possibly-sensitve data;
not that a link is safe from beciming broken when copied to the destination.)

HOW IT WORKS

This wrapper pre-scans the inputs (which must be local) to find symlinks rsync 
will wrongly treat as "safe"; an exclude list is built and used in an initial 
rsync run with --copy-unsafe-links to transfer everything else including symlinks
which do refer within the transfer; then a second rsync run is done to transfer 
just the problem items using --copy-links (which forces copying as real files/dirs
and not as symlinks).

EOF
}

use Getopt::Long qw(GetOptions);
use Path::Tiny qw/path/;
use File::Find ();
use Guard qw/scope_guard/;
use Data::Dumper::Interp qw/:DEFAULT dvisO/;
visnew
  ->Sortkeys(sub ($h) {
    my %haskey = (map{ $_ => 1 } keys %$h);
    my @keys;
    #for my $k(qw/src dst excludes sub_rsyncs/) {
    #  push @keys, $k if delete $haskey{$k};
    #}
    return [@keys, sort keys %haskey];
  })
#  ->Refaddr(1)
  ->set_defaults;
use Cwd qw/getcwd abs_path/;
use Spreadsheet::Edit::Log qw/:btw oops/;


sub _regen_getopt_code() {
  my %namelists;
  foreach (qx/man rsync/) {
    pos=0;
    last if /run.*daemon.*the following.*are.*accept/;
    if (/^\h*(?=-)/gc) {
      my (@names, $hasarg);
      while (/\G(?:--?)([a-zA-Z0][-a-zA-Z0]*)/gc) {
        last if $1 eq "no-OPTION";
        last if $1 eq "help";
        next if $1 eq "V";  # still handle --verbose
        push @names, $1;
        if (/\G=/gc) { $hasarg = 1; }
        last unless /\G\S*,\h*/gc;
      }
      next unless @names > 0;
      foreach my $n (@names) { $namelists{$n} = "IGN"; }
      # Always put the long name first, which is the canonical name used
      # as a key in %rsync_opts
      my $namelist = join('|', sort { length($b) <=> length($a) } @names);
      $namelists{$namelist} = $hasarg // 0;
    }
  }
  print "    # Generated by $0 --regen ",scalar(localtime),"\n";
  for my $namelist (sort keys %namelists) {
    my $hasarg = $namelists{$namelist};
    next if $hasarg eq "IGN";
    my $lhs = vis( 
        $namelist . ($hasarg ? "=s" 
                             : $namelist =~ /^no-/ ? "" : "!") );
    # The "!" makes Getopt::Long recognize --noXXX args.
    # Unfortunately rsync uses a different convention: --no-XXX
    # This is handled by a pre-scan of ARGV
    my $rhs = $hasarg ? "\\&_save_rsync_argopt" : "\\&_save_rsync_boolopt";
    printf "    %-22s => %s,\n", $lhs, $rhs;
  }
  print "    # (End generated specs)\n";
}

sub cleaned :prototype(_) ($path) {  # rm dup slashes, trailing slash
  return path($path)->canonpath;
}

my ($Debug, $Verbose, $Colorize, @rsync_opts, %rsync_opts);
$Colorize = 1;

my $tmp_file; # Path::Tint tempfile, deleted when last ref is gone


sub _save_rsync_boolopt($argname, $val, @rest) {
  $rsync_opts{$argname} = $val;
  if ($val) {
    push @rsync_opts, (length($argname)==1 ? "-" : "--").$argname;
  } else {
    # @rsync_opts = grep{ ! /^--?${argname}$/ } @rsync_opts;
    push @rsync_opts, "--no-".$argname;
  }
}
sub _save_rsync_argopt($argname, $val, @rest) {
  # The hash is only for our checks, concatenating values if multiple
  ($rsync_opts{$argname}//="") .= $val;
  # But each instance is passed through to rsync
  push @rsync_opts, (length($argname)==1 ? "-" : "--").$argname;
  push @rsync_opts, $val;
}

sub rev_sort :prototype(&\@) ($cmp, $aref) {
  $cmp ? (sort { -1 * ($cmp->()) } @$aref)
       : (sort { $b cmp $a } @$aref)
}

# * * * * * MAIN * * * * * *

# pre-convert rsync arg forms not handled by our generated code
foreach (@ARGV) {
  s/^--no-ic\w*$/--iconv=-/;
}

Getopt::Long::Configure qw(gnu_getopt no_ignore_case);
GetOptions(
  "Colorize!"         => \$Colorize,
  "Debug"             => \$Debug,  # to not clash with rsync's --debug
  "Tmpfile=s"         => sub{ $tmp_file = path($_[1]) },
  "V|Verbose"         => \$Verbose,
  "help"              => sub{ print USAGE; exit 0; },
  "regen-getopt-code" => sub{ _regen_getopt_code; exit; },
    # Generated by /home/jima/bin/safe_rsync --regen Wed Jun 24 12:31:07 2026
    "D!"                   => \&_save_rsync_boolopt,
    "F!"                   => \&_save_rsync_boolopt,
    "P!"                   => \&_save_rsync_boolopt,
    "acls|A!"              => \&_save_rsync_boolopt,
    "address=s"            => \&_save_rsync_argopt,
    "append!"              => \&_save_rsync_boolopt,
    "append-verify!"       => \&_save_rsync_boolopt,
    "archive|a!"           => \&_save_rsync_boolopt,
    "atimes|U!"            => \&_save_rsync_boolopt,
    "backup-dir=s"         => \&_save_rsync_argopt,
    "backup|b!"            => \&_save_rsync_boolopt,
    "block-size|B=s"       => \&_save_rsync_argopt,
    "blocking-io!"         => \&_save_rsync_boolopt,
    "bwlimit=s"            => \&_save_rsync_argopt,
    "checksum-choice=s"    => \&_save_rsync_argopt,
    "checksum-seed=s"      => \&_save_rsync_argopt,
    "checksum|c!"          => \&_save_rsync_boolopt,
    "chmod=s"              => \&_save_rsync_argopt,
    "chown=s"              => \&_save_rsync_argopt,
    "compare-dest=s"       => \&_save_rsync_argopt,
    "compress-choice=s"    => \&_save_rsync_argopt,
    "compress-level=s"     => \&_save_rsync_argopt,
    "compress|z!"          => \&_save_rsync_boolopt,
    "contimeout=s"         => \&_save_rsync_argopt,
    "copy-as=s"            => \&_save_rsync_argopt,
    "copy-dest=s"          => \&_save_rsync_argopt,
    "copy-devices!"        => \&_save_rsync_boolopt,
    "copy-dirlinks|k!"     => \&_save_rsync_boolopt,
    "copy-links|L!"        => \&_save_rsync_boolopt,
    "copy-unsafe-links!"   => \&_save_rsync_boolopt,
    "crtimes|N!"           => \&_save_rsync_boolopt,
    "cvs-exclude|C!"       => \&_save_rsync_boolopt,
    "debug=s"              => \&_save_rsync_argopt,
    "del!"                 => \&_save_rsync_boolopt,
    "delay-updates!"       => \&_save_rsync_boolopt,
    "delete!"              => \&_save_rsync_boolopt,
    "delete-after!"        => \&_save_rsync_boolopt,
    "delete-before!"       => \&_save_rsync_boolopt,
    "delete-delay!"        => \&_save_rsync_boolopt,
    "delete-during!"       => \&_save_rsync_boolopt,
    "delete-excluded!"     => \&_save_rsync_boolopt,
    "delete-missing-args!" => \&_save_rsync_boolopt,
    "devices!"             => \&_save_rsync_boolopt,
    "dirs|d!"              => \&_save_rsync_boolopt,
    "dry-run|n!"           => \&_save_rsync_boolopt,
    "early-input=s"        => \&_save_rsync_argopt,
    "exclude=s"            => \&_save_rsync_argopt,
    "exclude-from=s"       => \&_save_rsync_argopt,
    "executability|E!"     => \&_save_rsync_boolopt,
    "existing!"            => \&_save_rsync_boolopt,
    "fake-super!"          => \&_save_rsync_boolopt,
    "files-from=s"         => \&_save_rsync_argopt,
    "filter|f=s"           => \&_save_rsync_argopt,
    "force!"               => \&_save_rsync_boolopt,
    "from0|0!"             => \&_save_rsync_boolopt,
    "fsync!"               => \&_save_rsync_boolopt,
    "fuzzy|y!"             => \&_save_rsync_boolopt,
    "groupmap=s"           => \&_save_rsync_argopt,
    "group|g!"             => \&_save_rsync_boolopt,
    "hard-links|H!"        => \&_save_rsync_boolopt,
    "human-readable|h!"    => \&_save_rsync_boolopt,
    "iconv=s"              => \&_save_rsync_argopt,
    "ignore-errors!"       => \&_save_rsync_boolopt,
    "ignore-existing!"     => \&_save_rsync_boolopt,
    "ignore-missing-args!" => \&_save_rsync_boolopt,
    "ignore-times|I!"      => \&_save_rsync_boolopt,
    "include=s"            => \&_save_rsync_argopt,
    "include-from=s"       => \&_save_rsync_argopt,
    "info=s"               => \&_save_rsync_argopt,
    "inplace!"             => \&_save_rsync_boolopt,
    "ipv!"                 => \&_save_rsync_boolopt,
    "itemize-changes|i!"   => \&_save_rsync_boolopt,
    "keep-dirlinks|K!"     => \&_save_rsync_boolopt,
    "link-dest=s"          => \&_save_rsync_argopt,
    "links|l!"             => \&_save_rsync_boolopt,
    "list-only!"           => \&_save_rsync_boolopt,
    "log-file=s"           => \&_save_rsync_argopt,
    "log-file-format=s"    => \&_save_rsync_argopt,
    "max-alloc=s"          => \&_save_rsync_argopt,
    "max-delete=s"         => \&_save_rsync_argopt,
    "max-size=s"           => \&_save_rsync_argopt,
    "min-size=s"           => \&_save_rsync_argopt,
    "mkpath!"              => \&_save_rsync_boolopt,
    "modify-window=s"      => \&_save_rsync_argopt,
    "munge-links!"         => \&_save_rsync_boolopt,
    "no-implied-dirs"      => \&_save_rsync_boolopt,
    "no-motd"              => \&_save_rsync_boolopt,
    "numeric-ids!"         => \&_save_rsync_boolopt,
    "old-args!"            => \&_save_rsync_boolopt,
    "old-dirs|old-d!"      => \&_save_rsync_boolopt,
    "omit-dir-times|O!"    => \&_save_rsync_boolopt,
    "omit-link-times|J!"   => \&_save_rsync_boolopt,
    "one-file-system|x!"   => \&_save_rsync_boolopt,
    "only-write-batch=s"   => \&_save_rsync_argopt,
    "open-noatime!"        => \&_save_rsync_boolopt,
    "out-format=s"         => \&_save_rsync_argopt,
    "outbuf=s"             => \&_save_rsync_argopt,
    "owner|o!"             => \&_save_rsync_boolopt,
    "partial!"             => \&_save_rsync_boolopt,
    "partial-dir=s"        => \&_save_rsync_argopt,
    "password-file=s"      => \&_save_rsync_argopt,
    "perms|p!"             => \&_save_rsync_boolopt,
    "port=s"               => \&_save_rsync_argopt,
    "preallocate!"         => \&_save_rsync_boolopt,
    "progress!"            => \&_save_rsync_boolopt,
    "protocol=s"           => \&_save_rsync_argopt,
    "prune-empty-dirs|m!"  => \&_save_rsync_boolopt,
    "quiet|q!"             => \&_save_rsync_boolopt,
    "read-batch=s"         => \&_save_rsync_argopt,
    "recursive|r!"         => \&_save_rsync_boolopt,
    "relative|R!"          => \&_save_rsync_boolopt,
    "remote-option|M=s"    => \&_save_rsync_argopt,
    "remove-source-files!" => \&_save_rsync_boolopt,
    "rsh|e=s"              => \&_save_rsync_argopt,
    "rsync-path=s"         => \&_save_rsync_argopt,
    "safe-links!"          => \&_save_rsync_boolopt,
    "secluded-args|s!"     => \&_save_rsync_boolopt,
    "size-only!"           => \&_save_rsync_boolopt,
    "skip-compress=s"      => \&_save_rsync_argopt,
    "sockopts=s"           => \&_save_rsync_argopt,
    "sparse|S!"            => \&_save_rsync_boolopt,
    "specials!"            => \&_save_rsync_boolopt,
    "stats!"               => \&_save_rsync_boolopt,
    "stderr=s"             => \&_save_rsync_argopt,
    "stop-after=s"         => \&_save_rsync_argopt,
    "stop-at=s"            => \&_save_rsync_argopt,
    "suffix=s"             => \&_save_rsync_argopt,
    "super!"               => \&_save_rsync_boolopt,
    "temp-dir|T=s"         => \&_save_rsync_argopt,
    "timeout=s"            => \&_save_rsync_argopt,
    "times|t!"             => \&_save_rsync_boolopt,
    "trust-sender!"        => \&_save_rsync_boolopt,
    "update|u!"            => \&_save_rsync_boolopt,
    "usermap=s"            => \&_save_rsync_argopt,
    "verbose|v!"           => \&_save_rsync_boolopt,
    "version!"             => \&_save_rsync_boolopt,
    "whole-file|W!"        => \&_save_rsync_boolopt,
    "write-batch=s"        => \&_save_rsync_argopt,
    "write-devices!"       => \&_save_rsync_boolopt,
    "xattrs|X!"            => \&_save_rsync_boolopt,
    # (End generated specs)
) or die "died";

my $tree_cmd = "tree -F";
if (!$Colorize) { $ENV{NO_COLOR} = 1; } # for btw
else { $tree_cmd .= " -C"; }

if ($rsync_opts{version}) {
  say "\n$0 version $VERSION;  License: Public Domain or CC0.";
  say "\n> rsync --version";
  system("rsync", "--version");
  exit 0;
}

die USAGE if @ARGV < 2 && !$Debug;
my $top_dst = pop @ARGV;
my @top_srcs = @ARGV;

# Rsync allows renaming a single file or empty directory by specifying a
# non-existent destination path without a trailing slash.  
# We don't handle that.
die "DESTDIR must have a trailing slash (not ${\visq($top_dst)})\n"
  unless $top_dst =~ /\/$/;

if ($Debug || $rsync_opts{debug}) {
  unshift @rsync_opts, "-i" unless $rsync_opts{'itemize-changes'};
  unshift @rsync_opts, "--debug=FILTER" unless ($rsync_opts{'debug'}//"") =~ /FILTER/;
} else {
  unless ($rsync_opts{recursive} or $rsync_opts{archive}) {
    die "Using $0 is pointless without -r or -a\n"
  }
}
for my $argname (qw/links copy-unsafe-links/) {
  if (defined (my $val = $rsync_opts{$argname})) {
    die "Using $0 is pointless with --no-$argname" if !$val;
  } else {
    push @rsync_opts, "--$argname";
  }
}

$Verbose = 1
  if $Debug || $rsync_opts{verbose} || $rsync_opts{'itemize-changes'};


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

sub process_a_src($top_src) {
  my @excluded_links;
  my %devino_seen;
  my $cleaned_top_src = cleaned( $top_src );
  # In rsync args:
  # SRCDIR/ means copy the contents (but not SRCDIR itself) into DESTDIR/ ;
  # [anything/]SRCDIR means copy the directory into DESTDIR/SRCDIR except
  # if SRCDIR is . or .. (and hence not named) then it is treated as if there
  # were a trailing slash i.e. it's contents are copied directly into DESTDIR/.
  #
  # For us, DESTDIR/ always has a trailing slash (we don't handle the special
  # rsync mode to rename a single object where DEST is without a slash).
  my $topsrc_is_included = 
    $top_src !~ /\/$/ && path($cleaned_top_src)->basename !~ /^\.\.?$/;
  my $top2 = $topsrc_is_included ? path($cleaned_top_src)->parent."/" : $cleaned_top_src."/";
  # $parentslash must be the first part of every path(...)->children
  # and child paths will include the parent unless the parent is ".".
  ##my $parentslash = $cleaned_top_src eq "." ? "" : $top2;
  my $parentslash = $top2 eq "./" ? "" : $top2;
  my @stack = ([$top_src, $topsrc_is_included ? 0 : -1]);
  warn dvis '### $top_src  $topsrc_is_included   $parentslash  $top2\n' if $Debug;
  while (@stack) {
    my ($p, $max_uplinks) = @{ pop(@stack) };
    warn btwpad,"visiting -",
         (-l $p ? "l":""), (-d $p ? "d":""), (-f $p ? "f":""),
         " $p [$max_uplinks]\n",
         btwpad,dvisO '@stack\n'  if $Debug;

    my $lstat = path($p)->lstat;
    my $isdir;
    if (-l $lstat) {
      my $rawlnk = readlink($p) // die $!;
      my $clnk = cleaned( $rawlnk ); # remove extraneous slashes
      if ($clnk !~ /^\//) { # relative
        my $skip = 1;
        my $nup = 0;
        foreach (split m{/}, $clnk) {
          if ($_ eq "..") { $skip=0,last if ++$nup > $max_uplinks } else { --$nup }
        }
        warn btwpad, "  : RELATIVE symlink ($p -> $clnk): ", dvis '$skip\n' if $Debug;
        if ($skip) {
          # relative symlink safely points within it's subtree
          next;
        }
        warn btwpad, "  : EXCLUDING $p\n" if $Debug;
        push @excluded_links, $p;
      }
      $isdir = (-d $p);  # stat, not lstat (follows symlinks)
      warn btwpad, "  : symlink ($p): ", dvis '$isdir\n' if $Debug;
      if ($isdir) {
         # symlink to directory
         if ($devino_seen{ $lstat->dev ."|". $lstat->ino }++) {
           #die "Reached DIR symlink ",qsh($p)," a second time";  #TODO silently ignore?
           warn btwpad, "  : Skipping 2nd visitation of DIR symlink $p\n" if $Debug;
           next;
         }
         $max_uplinks = -1;  # incremented below
      }
    } else {
      $isdir = (-d $lstat);
      warn btwpad, "  : NOT symlink ($p): ", dvis '$isdir\n' if $Debug;
    }
    if ($isdir) {
      # Visit children in alphabetical order.  Can't use Path::Tiny because we
      # need to preserve the original top_src including leading ./ if present
      # WRONG...?
      my @child_paths =  path($p)->children;
      push @stack, map{ [$_, $max_uplinks+1] }
                   reverse sort @child_paths;
    }
  }

  btw dvisO '@excluded_links' if $Debug;

  my @excl_opts;
  if (@excluded_links) {
    $tmp_file //= Path::Tiny->tempfile; # deleted when $tmp_file is destroyed
    my @lines = map{
                     m{^\Q${parentslash}\E(.*)} or die dvis 'BUG ${parentslash}\n    $_';
                     "/$1\n"
                } @excluded_links;
    $tmp_file->spew_raw(@lines);
    print "> exclude-from file contains ",scalar(@lines)," entries\n"
      if $Verbose;
    print map{ "   $_" } @lines if $Debug;
    @excl_opts = ("--exclude-from", $tmp_file);
  }

  # *FIRST RSYNC* copies everything except unsafe symlinks
  { # N.B. --exclude-from must go before user options which might use --include
    my @cmd = ('rsync', @excl_opts, @rsync_opts, $top_src, $top_dst);
    warn "> ",qshlist(@cmd), "\n" if $Verbose;
    system(@cmd) == 0 or die "rsync failed";
  }

  if (@excluded_links) {
    # *SECOND RSYNC* copies only the previously-excluded unsafe symlinks (as files).
    # --files-from is used to copy only the explicitly listed items;
    #
    # The "src" will be the parent of the transfer root for the main rsync ($top2)
    @excl_opts = ("--files-from", $tmp_file, "--recursive");
    @rsync_opts = grep{ !/^--(exclude-unsafe-links|copy-dirlinks)/ }  @rsync_opts;
    my @cmd = ('rsync', @excl_opts, @rsync_opts, "--copy-links", $top2, $top_dst);
    warn "> ",qshlist(@cmd), "\n" if $Verbose;
    system(@cmd) == 0 or die "rsync failed";
  }

  if ($Debug && $top_dst !~ /^[-.\w]*:/ && ! $rsync_opts{'dry-run'}) {
    system "set -x; $tree_cmd ".qsh(cleaned $top_dst);
  }

}#process_a_src

foreach (@top_srcs) {
  /:/ && die "Sorry, ${\(path($0)->basename)} can only work with local sources.\n";
  process_a_src($_);
}

