fix: rscp can now handle *wildcards and remote to remote
This commit is contained in:
+265
-26
@@ -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/<dataset> are transferred directly as
|
||||
# NAS:/mnt/tank/<dataset>, 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 <host> ... 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:<path>. 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
|
||||
|
||||
Reference in New Issue
Block a user