diff --git a/functions/rscp.fish b/functions/rscp.fish index adde257..dc43ab0 100644 --- a/functions/rscp.fish +++ b/functions/rscp.fish @@ -1,55 +1,294 @@ -function rscp +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 SOURCE DESTINATION" + 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." + echo "rscp: 'rsync' is not installed. Install it via your package manager." >&2 return 1 end - set -l src $argv[1] - set -l dst $argv[2] - # 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 - set -l src_remote 0 - set -l dst_remote 0 + # 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] - for i in (seq (count $mounts)) - if string match -q -- $mounts[$i] $src; or string match -q -- "$mounts[$i]/*" $src - set src (string replace -f -- $mounts[$i] $remotes[$i] -- $src) - set src_remote 1 - break + # 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 (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 - if test $src_remote -eq 0 -a $dst_remote -eq 0 - echo "cp -av -- $src $dst" - command cp -av -- $src $dst - return $status + 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, ask before overwriting. + 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 + 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 + 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 + end + end + end + if test $dst_exists -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. Re-run interactively or pass -y/--yes to overwrite." >&2 + return 1 + end + if not read -l -P "rscp: destination '$dst_disp' already exists. 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 opts -avh --no-perms --info=progress2 --partial - if test $dst_remote -eq 1 - set -a opts --rsync-path="sudo -n rsync" --chown=1000:0 - end - if test $src_remote -eq 1 - set -a opts --rsync-path="sudo -n rsync" + 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 - echo rsync $opts -- $src $dst - command rsync $opts -- $src $dst + 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