Files
gniza4cp/lib/transfer.sh
shuki 1f68ea1058 Security hardening, static analysis fixes, and expanded test coverage
- Fix CRITICAL: safe config parser replacing shell source, sshpass -e,
  CSRF with /dev/urandom, symlink-safe file I/O
- Fix HIGH: input validation for timestamps/accounts, path traversal
  prevention in Runner.pm, AJAX CSRF on all endpoints
- Fix MEDIUM: umask 077, chmod 700 on config dirs, Config.pm TOCTOU lock,
  rsync exit code capture bug, RSYNC_EXTRA_OPTS character validation
- ShellCheck: fix word-splitting in notify.sh, safe rm in pkgacct.sh,
  suppress cross-file SC2034 false positives
- Perl::Critic: return undef→bare return, return (sort), unpack @_,
  explicit return on void subs, rename Config::write→save
- Remove dead code: enforce_retention_all(), rsync_dry_run()
- Add require_cmd checks for rsync/ssh/hostname/gzip at startup
- Escape $hint/$tip in CGI helper functions for defense-in-depth
- Expand tests from 17→40: validate_timestamp, validate_account_name,
  _safe_source_config (including malicious input), numeric validation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:57:26 +02:00

147 lines
4.4 KiB
Bash

#!/usr/bin/env bash
# gniza/lib/transfer.sh — rsync --link-dest to remote, .partial atomicity, retries
rsync_to_remote() {
local source_dir="$1"
local remote_dest="$2"
local link_dest="${3:-}"
local attempt=0
local max_retries="${SSH_RETRIES:-$DEFAULT_SSH_RETRIES}"
local rsync_ssh; rsync_ssh=$(build_rsync_ssh_cmd)
local rsync_opts=(-aHAX --numeric-ids --delete --rsync-path="rsync --fake-super")
if [[ -n "$link_dest" ]]; then
rsync_opts+=(--link-dest="$link_dest")
fi
if [[ "${BWLIMIT:-0}" -gt 0 ]]; then
rsync_opts+=(--bwlimit="$BWLIMIT")
fi
if [[ -n "${RSYNC_EXTRA_OPTS:-}" ]]; then
# shellcheck disable=SC2206
rsync_opts+=($RSYNC_EXTRA_OPTS)
fi
rsync_opts+=(-e "$rsync_ssh")
# Ensure source ends with /
[[ "$source_dir" != */ ]] && source_dir="$source_dir/"
while (( attempt < max_retries )); do
((attempt++)) || true
log_debug "rsync attempt $attempt/$max_retries: $source_dir -> $remote_dest"
log_debug "CMD: rsync ${rsync_opts[*]} $source_dir ${REMOTE_USER}@${REMOTE_HOST}:${remote_dest}"
local rsync_cmd=(rsync "${rsync_opts[@]}" "$source_dir" "${REMOTE_USER}@${REMOTE_HOST}:${remote_dest}")
if _is_password_mode; then
export SSHPASS="$REMOTE_PASSWORD"
rsync_cmd=(sshpass -e "${rsync_cmd[@]}")
fi
local rc=0
"${rsync_cmd[@]}" || rc=$?
if (( rc == 0 )); then
log_debug "rsync succeeded on attempt $attempt"
return 0
fi
log_warn "rsync failed (exit $rc), attempt $attempt/$max_retries"
if (( attempt < max_retries )); then
local backoff=$(( attempt * 10 ))
log_info "Retrying in ${backoff}s..."
sleep "$backoff"
fi
done
log_error "rsync failed after $max_retries attempts"
return 1
}
transfer_pkgacct() {
local user="$1"
local timestamp="$2"
local prev_snapshot="${3:-}"
local temp_dir="${TEMP_DIR:-$DEFAULT_TEMP_DIR}"
local source="$temp_dir/$user"
if _is_rclone_mode; then
local snap_subpath="accounts/${user}/snapshots/${timestamp}"
log_info "Transferring pkgacct data for $user (rclone)..."
rclone_to_remote "$source" "$snap_subpath"
return
fi
local snap_dir; snap_dir=$(get_snapshot_dir "$user")
local dest="$snap_dir/${timestamp}.partial/"
local link_dest=""
if [[ -n "$prev_snapshot" ]]; then
# Detect old format (pkgacct/ subdir) vs new format (content at root)
if remote_exec "test -d '$snap_dir/$prev_snapshot/pkgacct'" 2>/dev/null; then
link_dest="$snap_dir/$prev_snapshot/pkgacct"
else
link_dest="$snap_dir/$prev_snapshot"
fi
fi
ensure_remote_dir "$dest" || return 1
log_info "Transferring pkgacct data for $user..."
rsync_to_remote "$source" "$dest" "$link_dest"
}
transfer_homedir() {
local user="$1"
local timestamp="$2"
local prev_snapshot="${3:-}"
local homedir; homedir=$(get_account_homedir "$user")
if [[ ! -d "$homedir" ]]; then
log_warn "Home directory not found for $user: $homedir"
return 1
fi
if _is_rclone_mode; then
local snap_subpath="accounts/${user}/snapshots/${timestamp}/homedir"
log_info "Transferring homedir for $user ($homedir) (rclone)..."
rclone_to_remote "$homedir" "$snap_subpath"
return
fi
local snap_dir; snap_dir=$(get_snapshot_dir "$user")
local dest="$snap_dir/${timestamp}.partial/homedir/"
local link_dest=""
if [[ -n "$prev_snapshot" ]]; then
link_dest="$snap_dir/$prev_snapshot/homedir"
fi
ensure_remote_dir "$dest" || return 1
log_info "Transferring homedir for $user ($homedir)..."
rsync_to_remote "$homedir" "$dest" "$link_dest"
}
finalize_snapshot() {
local user="$1"
local timestamp="$2"
if _is_rclone_mode; then
log_info "Finalizing snapshot for $user: $timestamp (rclone)"
rclone_finalize_snapshot "$user" "$timestamp"
return
fi
local snap_dir; snap_dir=$(get_snapshot_dir "$user")
log_info "Finalizing snapshot for $user: $timestamp"
remote_exec "mv '$snap_dir/${timestamp}.partial' '$snap_dir/$timestamp'" || {
log_error "Failed to finalize snapshot for $user: $timestamp"
return 1
}
update_latest_symlink "$user" "$timestamp"
}