function rscp --description "Copy SOURCE... to DESTINATION via rsync (local or NAS)" # rscp: rsync-based copy that understands the local NAS mounts. # # Usage: rscp [-y|--yes] [--] SOURCE... DESTINATION # # Only the LAST argument is the destination; everything before it is a # source. That way wildcards just work: `rscp *.mkv /dest/` copies every # match instead of silently dropping all but the first one. # # Paths under /home/honney/mount/ are transferred directly as # NAS:/mnt/tank/, so rsync talks to the NAS instead of going # through the local mount. Purely local (PC -> PC) copies use rsync with # the same flags as rcp (-avh --progress). # NAS -> NAS copies run on the NAS itself via `ssh ... rsync`, # keeping the flags of the "to remote" direction (sudo + chown). set -l force 0 # Only leading flags are options; everything else (even a source starting # with "-") is a path. Use `--` to end option parsing explicitly. while test (count $argv) -gt 0 switch $argv[1] case -y --yes set force 1 set -e argv[1] case -h --help echo "Usage: rscp [-y|--yes] [--] SOURCE... DESTINATION" echo "" echo "Copy one or more SOURCEs to DESTINATION with rsync." echo "Only the last argument is the destination; everything before it is a source," echo "so wildcards at the source (e.g. rscp *.mkv /dest/) work as expected." echo "" echo "Options:" echo " -y, --yes skip the overwrite confirmation prompt" echo " -h, --help show this help" return 0 case -- set -e argv[1] break case '-*' echo "rscp: unknown option: $argv[1]" >&2 echo "Usage: rscp [-y|--yes] [--] SOURCE... DESTINATION" >&2 return 1 case '*' break end end if test (count $argv) -lt 2 echo "Usage: rscp [-y|--yes] [--] SOURCE... DESTINATION" >&2 return 1 end if not command -q rsync echo "rscp: 'rsync' is not installed. Install it via your package manager." >&2 return 1 end # Mount <-> NAS dataset mapping (keep both lists in the same order) set -l mounts /home/honney/mount/Media /home/honney/mount/Data /home/honney/mount/Ripping /home/honney/mount/Apps set -l remotes NAS:/mnt/tank/Media NAS:/mnt/tank/Data NAS:/mnt/tank/Ripping NAS:/mnt/tank/Apps # Only the last argument is the destination; everything before it is a # source, so any number of (wildcard-expanded) sources is supported. set -l n (count $argv) set -l last_src (math $n - 1) set -l dst_raw $argv[$n] set -l srcs_raw $argv[1..$last_src] # Translate sources: local mount path -> NAS:. For each source keep # the translated path, a 1/0 remote flag, the remote host and the path # as seen on the remote side (without the "HOST:" prefix). set -l srcs set -l src_remote_flags set -l src_hosts set -l src_naslocals set -l hosts for raw in $srcs_raw set -l s $raw set -l is_remote 0 for i in (seq (count $mounts)) if string match -q -- $mounts[$i] $s; or string match -q -- "$mounts[$i]/*" $s set s (string replace -f -- $mounts[$i] $remotes[$i] $s) set is_remote 1 break end end # Also accept an explicit HOST:/path given directly. if test $is_remote -eq 0 if string match -qr '^[A-Za-z0-9_.@-]+:/' -- $s set is_remote 1 end end set -l host "" set -l naslocal $s if test $is_remote -eq 1 set -l parts (string split -m1 : -- $s) set host $parts[1] set naslocal (string join : -- $parts[2..-1]) if not contains -- $host $hosts set -a hosts $host end end set -a srcs $s set -a src_remote_flags $is_remote set -a src_hosts $host set -a src_naslocals $naslocal end # Translate the destination the same way (it is always $argv[-1]). set -l dst $dst_raw set -l dst_remote 0 for i in (seq (count $mounts)) if string match -q -- $mounts[$i] $dst; or string match -q -- "$mounts[$i]/*" $dst set dst (string replace -f -- $mounts[$i] $remotes[$i] $dst) set dst_remote 1 break end end if test $dst_remote -eq 0 if string match -qr '^[A-Za-z0-9_.@-]+:/' -- $dst set dst_remote 1 end end set -l dst_host "" set -l dst_naslocal $dst if test $dst_remote -eq 1 set -l dparts (string split -m1 : -- $dst) set dst_host $dparts[1] set dst_naslocal (string join : -- $dparts[2..-1]) if not contains -- $dst_host $hosts set -a hosts $dst_host end end set -l n_src_remote 0 for f in $src_remote_flags if test $f -eq 1 set n_src_remote (math $n_src_remote + 1) end end set -l any_remote 0 if test $dst_remote -eq 1; or test $n_src_remote -gt 0 set any_remote 1 end # Failsafe: when a remote side is involved, verify ssh actually knows the # host (e.g. via ~/.ssh/config) before copying anything. The probe is # non-interactive; the real transfer below still uses your normal ssh # config/auth (password prompt included). if test $any_remote -eq 1 if not command -q ssh echo "rscp: 'ssh' is not installed, but a remote copy was requested." >&2 return 1 end if test (count $hosts) -gt 1 echo "rscp: copies between different remote hosts are not supported: $hosts" >&2 return 1 end for h in $hosts set -l probe_err (ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null $h true 2>&1) set -l probe_st $status if test $probe_st -ne 0 if string match -qi -- "*could not resolve*" $probe_err; or string match -qi -- "*name or service not known*" $probe_err; or string match -qi -- "*no address associated*" $probe_err; or string match -qi -- "*nodename nor servname*" $probe_err echo "rscp: ssh host '$h' is unknown (check the 'Host $h' entry in ~/.ssh/config)." >&2 return 1 else if string match -qi -- "*permission denied*" $probe_err; or string match -qi -- "*authentication fail*" $probe_err echo "rscp: note: key auth for '$h' failed; continuing, ssh may ask for a password." >&2 else echo "rscp: cannot reach ssh host '$h': $probe_err" >&2 return 1 end end end end # Failsafe: if the destination already exists *and has content*, ask # before overwriting. An existing but empty directory is not worth # asking about - there is nothing that could be lost. set -l dst_disp $dst_raw if test "$dst" != "$dst_raw" set dst_disp "$dst_raw ($dst)" end set -l dst_exists 0 set -l dst_is_dir 0 set -l dst_nonempty 0 if test $dst_remote -eq 1 set -l qdst (__rscp_shquote $dst_naslocal) if ssh $dst_host test -e $qdst set dst_exists 1 else if ssh $dst_host test -L $qdst set dst_exists 1 end if test $dst_exists -eq 1 if ssh $dst_host test -d $qdst set dst_is_dir 1 set -l listing (ssh $dst_host ls -A $qdst 2>/dev/null) set -l ls_st $status if test $ls_st -ne 0; or test (count $listing) -gt 0 # Unlistable -> stay safe and treat it as non-empty. set dst_nonempty 1 end else # File (or symlink): overwriting would replace content. set dst_nonempty 1 end end else if test -e "$dst"; or test -L "$dst" set dst_exists 1 end if test $dst_exists -eq 1 if test -d "$dst" set dst_is_dir 1 set -l listing (command ls -A -- "$dst" 2>/dev/null) set -l ls_st $status if test $ls_st -ne 0; or test (count $listing) -gt 0 set dst_nonempty 1 end else set dst_nonempty 1 end end end if test $dst_nonempty -eq 1 if test (count $srcs) -gt 1; and test $dst_is_dir -eq 0 echo "rscp: destination '$dst_disp' exists and is not a directory, but multiple sources were given." >&2 return 1 end if test $force -eq 0 if not isatty stdin echo "rscp: destination '$dst_disp' already exists and is not empty. Re-run interactively or pass -y/--yes to overwrite." >&2 return 1 end if not read -l -P "rscp: destination '$dst_disp' already exists and is not empty. Overwrite/merge? [y/N] " confirm_raw echo "rscp: aborted." >&2 return 1 end switch (string lower -- (string trim -- "$confirm_raw")) case y yes # proceed case '*' echo "rscp: aborted." return 1 end end end # --no-perms: required - TrueNAS ZFS datasets use SMB ACLs and reject unix perms preservation set -l base_opts -avh --no-perms --info=progress2 --partial # Flags of the "to remote" direction; remote-to-remote via ssh on the NAS # keeps these same flags (sudo for the chown, which needs root). set -l to_remote_opts --rsync-path="sudo -n rsync" --chown=1000:0 if test $any_remote -eq 0 # PC -> PC goes through rsync with the same flags as rcp (no NAS-only # --no-perms/--chown permission workarounds needed locally). set -l local_opts -avh --progress echo rsync $local_opts -- $srcs $dst command rsync $local_opts -- $srcs $dst return $status end if test $n_src_remote -eq (count $srcs); and test $dst_remote -eq 1 # NAS -> NAS: run rsync directly on the NAS itself. Only one remote # host is possible here (checked above), so $hosts[1] is that host. set -l rsrcs for nl in $src_naslocals set -a rsrcs (__rscp_shquote $nl) end set -l rdst (__rscp_shquote $dst_naslocal) set -l ssh_tty if isatty stdin set ssh_tty -t end echo ssh $ssh_tty $hosts[1] sudo -n rsync $base_opts --chown=1000:0 -- $rsrcs $rdst command ssh $ssh_tty $hosts[1] sudo -n rsync $base_opts --chown=1000:0 -- $rsrcs $rdst return $status end if test $n_src_remote -gt 0; and test $n_src_remote -lt (count $srcs) # Mixed local and remote sources cannot go into a single rsync call, # so copy source by source. The overwrite question above was already # asked once, hence -y here (each call still re-checks everything else). for k in (seq (count $srcs)) rscp -y -- $srcs_raw[$k] $dst_raw set -l st $status if test $st -ne 0 return $st end end return 0 end # Single-sided copy: local -> NAS, NAS -> local, or one source of any kind. set -l call_opts $base_opts if test $dst_remote -eq 1 set -a call_opts $to_remote_opts else if test $n_src_remote -gt 0 set -a call_opts --rsync-path="sudo -n rsync" end echo rsync $call_opts -- $srcs $dst command rsync $call_opts -- $srcs $dst return $status end function __rscp_shquote --description "Single-quote strings for a POSIX remote shell" # Usage: __rscp_shquote STRING... ; prints each string single-quoted. for s in $argv set -l e (string replace -a -- "'" "'\"'\"'" $s) echo "'$e'" end end