314 lines
12 KiB
Fish
314 lines
12 KiB
Fish
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 [-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:<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_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
|