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
|
if test (count $argv) -lt 2
|
||||||
echo "Usage: rscp SOURCE DESTINATION"
|
echo "Usage: rscp [-y|--yes] [--] SOURCE... DESTINATION" >&2
|
||||||
return 1
|
return 1
|
||||||
end
|
end
|
||||||
|
|
||||||
if not command -q rsync
|
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
|
return 1
|
||||||
end
|
end
|
||||||
|
|
||||||
set -l src $argv[1]
|
|
||||||
set -l dst $argv[2]
|
|
||||||
|
|
||||||
# Mount <-> NAS dataset mapping (keep both lists in the same order)
|
# 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 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 remotes NAS:/mnt/tank/Media NAS:/mnt/tank/Data NAS:/mnt/tank/Ripping NAS:/mnt/tank/Apps
|
||||||
|
|
||||||
set -l src_remote 0
|
# Only the last argument is the destination; everything before it is a
|
||||||
set -l dst_remote 0
|
# 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))
|
# Translate sources: local mount path -> NAS:<path>. For each source keep
|
||||||
if string match -q -- $mounts[$i] $src; or string match -q -- "$mounts[$i]/*" $src
|
# the translated path, a 1/0 remote flag, the remote host and the path
|
||||||
set src (string replace -f -- $mounts[$i] $remotes[$i] -- $src)
|
# as seen on the remote side (without the "HOST:" prefix).
|
||||||
set src_remote 1
|
set -l srcs
|
||||||
break
|
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
|
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
|
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))
|
for i in (seq (count $mounts))
|
||||||
if string match -q -- $mounts[$i] $dst; or string match -q -- "$mounts[$i]/*" $dst
|
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
|
set dst_remote 1
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
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
|
set -l n_src_remote 0
|
||||||
echo "cp -av -- $src $dst"
|
for f in $src_remote_flags
|
||||||
command cp -av -- $src $dst
|
if test $f -eq 1
|
||||||
return $status
|
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
|
end
|
||||||
|
|
||||||
# --no-perms: required - TrueNAS ZFS datasets use SMB ACLs and reject unix perms preservation
|
# --no-perms: required - TrueNAS ZFS datasets use SMB ACLs and reject unix perms preservation
|
||||||
set -l opts -avh --no-perms --info=progress2 --partial
|
set -l base_opts -avh --no-perms --info=progress2 --partial
|
||||||
if test $dst_remote -eq 1
|
# Flags of the "to remote" direction; remote-to-remote via ssh on the NAS
|
||||||
set -a opts --rsync-path="sudo -n rsync" --chown=1000:0
|
# keeps these same flags (sudo for the chown, which needs root).
|
||||||
end
|
set -l to_remote_opts --rsync-path="sudo -n rsync" --chown=1000:0
|
||||||
if test $src_remote -eq 1
|
|
||||||
set -a opts --rsync-path="sudo -n rsync"
|
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
|
end
|
||||||
|
|
||||||
echo rsync $opts -- $src $dst
|
if test $n_src_remote -eq (count $srcs); and test $dst_remote -eq 1
|
||||||
command rsync $opts -- $src $dst
|
# 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
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user