diff --git a/bin/ssr-mackup b/bin/ssr-mackup index 1c8dfb4..d4ad6a4 100755 --- a/bin/ssr-mackup +++ b/bin/ssr-mackup @@ -71,7 +71,7 @@ cmd_scan() { done <<< "$declared" return 1 } - _in_backup() { [[ -n "$mackup_dir" && -e "$mackup_dir/$1" ]]; } + _in_backup() { [[ -n "$mackup_dir" && -e "$mackup_dir/mackup/$1" ]]; } # ----------------------------------------------------------------------- # Collect candidate items — same logic as generate_custom_cfg. diff --git a/bin/ssr-restore b/bin/ssr-restore index 5c5b73e..54e448b 100755 --- a/bin/ssr-restore +++ b/bin/ssr-restore @@ -90,7 +90,10 @@ ssr::icloud::prefetch "$mackup_dir" # ssr::mackup::restore already calls ssr::icloud::wait internally. _ssr_mackup_restore_step() { ssr::mackup::ensure_installed - ssr::mackup::configure "$mackup_dir" + ssr::mackup::configure "$mackup_dir" || { + ssr::warn "Skipping mackup restore — ~/.mackup.cfg was not updated (see messages above)." + return 1 + } ssr::mackup::restore } diff --git a/bin/ssr-sync b/bin/ssr-sync index 2ec03f1..f8157ef 100755 --- a/bin/ssr-sync +++ b/bin/ssr-sync @@ -39,7 +39,8 @@ ssr::icloud::prefetch "$mackup_dir" ssr::brew::ensure_installed ssr::mackup::ensure_installed -ssr::mackup::configure "$mackup_dir" +_mackup_cfg_ok=true +ssr::mackup::configure "$mackup_dir" || _mackup_cfg_ok=false ssr::brew::update ssr::brew::upgrade @@ -48,7 +49,11 @@ ssr::brew::upgrade # Timeout (seconds) is configurable; default 180s. ssr::icloud::wait "$mackup_dir" "${SSR_ICLOUD_WAIT_TIMEOUT:-180}" -ssr::mackup::backup +if [[ "$_mackup_cfg_ok" == true ]]; then + ssr::mackup::backup +else + ssr::warn "Skipping mackup backup — ~/.mackup.cfg was not updated (see messages above)." +fi ssr::brew::dump "$backup_dir" ssr::brew::cleanup diff --git a/config/post-restore.sh.example b/config/post-restore.sh.example index a06152c..74cc859 100644 --- a/config/post-restore.sh.example +++ b/config/post-restore.sh.example @@ -13,7 +13,7 @@ # Available variables: # SSR_ROOT — path to the ssr repository # backup_dir — cloud backup directory for this host -# mackup_dir — cloud Mackup directory +# mackup_dir — cloud path parent for Mackup (~/.mackup.cfg path=…; files under …/mackup/) # # EXAMPLES # ------------------------------------------------------------------------- diff --git a/config/ssr.conf.example b/config/ssr.conf.example index 4100fb2..1ad7a89 100644 --- a/config/ssr.conf.example +++ b/config/ssr.conf.example @@ -12,14 +12,18 @@ SSR_CLOUD_PROVIDER=icloud # Only used when SSR_CLOUD_PROVIDER=custom. Absolute path. # SSR_CLOUD_PATH=/Volumes/MyNAS/macos-sync -# Subfolders created inside the cloud root. +# Subfolder inside the cloud root for SSR data: per-host backups live under +# // - /. Mackup storage uses the +# same parent path; ~/.mackup.cfg sets directory=mackup (see ssr::mackup::configure). SSR_BACKUP_SUBDIR=BACKUP -SSR_MACKUP_SUBDIR=Mackup # SSR uses `mackup backup` / `mackup restore` (copy, not symlinks) for reliability # on newer macOS. If you still have Mackup symlinks from an older setup, run: # ssr mackup unlink +# SSR_ALLOW_CONF_OVERWRITE=yes — apply ~/.mackup.cfg updates without a TTY prompt +# (launchd / CI). Interactive runs show a diff and ask before replacing. + # Subdirs (relative to $HOME) to delete before each mackup backup attempt. # Handles transient dirs with dangling symlinks or sockets (debug sessions, # log dirs) that should not be copied to cloud. diff --git a/lib/cloud.sh b/lib/cloud.sh index 1131577..36852d7 100644 --- a/lib/cloud.sh +++ b/lib/cloud.sh @@ -47,12 +47,14 @@ ssr::cloud::backup_dir() { printf '%s/%s/%s - %s\n' "$cloud" "$subdir" "$host" "$uuid" } -# Mackup data lives at the cloud root in a fixed folder. Mackup itself -# resolves its engine via ~/.mackup.cfg which the install routine seeds. +# Parent directory for Mackup file_system storage: same cloud subfolder as +# per-host backups (SSR_BACKUP_SUBDIR). ~/.mackup.cfg sets path= and +# directory=mackup — Mackup places files under /mackup/ (not SSR-controlled). ssr::cloud::mackup_dir() { - local cloud + local cloud subdir cloud="$(ssr::cloud::resolve_dir)" - printf '%s/%s\n' "$cloud" "${SSR_MACKUP_SUBDIR:-Mackup}" + subdir="${SSR_BACKUP_SUBDIR:-BACKUP}" + printf '%s/%s\n' "$cloud" "$subdir" } ssr::cloud::ensure_available() { diff --git a/lib/common.sh b/lib/common.sh index 1fff511..77d8f86 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -52,6 +52,66 @@ ssr::confirm() { case "$reply" in y|Y) return 0 ;; *) return 1 ;; esac } +# Install or replace $1 with the contents of regular file $2 (staging path). +# - Missing path or dangling symlink: writes immediately (no overwrite). +# - Same bytes as staging: no-op, return 0. +# - Differs: prints unified diff (existing → proposed) on stderr, then either +# prompts on a TTY or requires SSR_ALLOW_CONF_OVERWRITE=yes (non-interactive). +# Returns 0 after a successful write or no-op; 1 if the user declined or +# non-interactive without SSR_ALLOW_CONF_OVERWRITE. +ssr::replace_file_with_confirm() { + local target="$1" staging="$2" + ssr::validate_path "$target" + ssr::validate_path "$staging" + [[ -f "$staging" ]] || ssr::die "Staging file not found: $staging" + [[ -d "$target" ]] && ssr::die "Refusing to replace a directory: $target" + + # Nothing there yet (or dangling symlink) — create without prompt. + if [[ ! -e "$target" ]]; then + rm -f "$target" + cat "$staging" > "$target" + ssr::ok "Created $target" + return 0 + fi + + if ! [[ -f "$target" ]]; then + ssr::die "Refusing to replace non-regular file: $target" + fi + + if cmp -s "$target" "$staging" 2>/dev/null; then + return 0 + fi + + printf '\n' >&2 + ssr::log "Proposed change to $target (unified diff: existing → proposed)" + diff -u "$target" "$staging" >&2 || true + printf '\n' >&2 + + case "${SSR_ALLOW_CONF_OVERWRITE:-}" in + yes|1|true|on) + ssr::log "SSR_ALLOW_CONF_OVERWRITE set — applying change to $target" + ;; + *) + if ! [[ -t 0 ]]; then + ssr::warn "Refusing to overwrite $target (stdin is not a TTY)." + ssr::warn "Review the diff above, merge manually, or run once with:" + ssr::warn " SSR_ALLOW_CONF_OVERWRITE=yes ssr sync # or ssr restore" + return 1 + fi + ssr::confirm "Replace $target as shown in the diff above?" 0 || { + ssr::warn "Leaving $target unchanged." + return 1 + } + ;; + esac + + cp -f "$target" "$target.bak.$(date +%s)" 2>/dev/null || true + rm -f "$target" + cat "$staging" > "$target" + ssr::ok "Updated $target (previous saved as ${target}.bak.*)" + return 0 +} + # Validate a path is safe (no newlines, no `..` traversal). Used before any FS op. # Note: bash strings cannot hold NUL bytes, so no separate NUL check needed. ssr::validate_path() { @@ -69,12 +129,81 @@ ssr::ensure_dir() { [[ -d "$d" ]] || mkdir -p "$d" } +# If ssr.conf lists SSR_CLOUD_PROVIDER more than once, rewrite +# the file to a single assignment: same value repeated is merged silently; +# different values prompt on a TTY (stores choice) or abort without TTY. +ssr::config::resolve_cloud_provider_duplicates() { + local cfg="$1" + local -a vals=() + local line + while IFS= read -r line; do + [[ "$line" =~ ^SSR_CLOUD_PROVIDER=(.*)$ ]] || continue + vals+=("${BASH_REMATCH[1]}") + done < <(grep '^SSR_CLOUD_PROVIDER=' "$cfg" 2>/dev/null || true) + [[ ${#vals[@]} -le 1 ]] && return 0 + + local -a uniq=() + local v seen u + for v in "${vals[@]}"; do + seen=0 + for u in "${uniq[@]:-}"; do [[ "$u" == "$v" ]] && { seen=1; break; }; done + [[ "$seen" -eq 0 ]] && uniq+=("$v") + done + + local choice="" + if [[ ${#uniq[@]} -eq 1 ]]; then + choice="${uniq[0]}" + ssr::log "Consolidating ${#vals[@]} SSR_CLOUD_PROVIDER line(s) → $choice" + elif [[ -t 0 ]]; then + printf '\n' >&2 + ssr::warn "Config $cfg defines multiple different SSR_CLOUD_PROVIDER values." + local i=1 + for v in "${uniq[@]}"; do + printf ' %d) %s\n' "$i" "$v" >&2 + i=$((i + 1)) + done + local pick + while true; do + read -r -p "Select provider to keep [1-${#uniq[@]}]: " pick || pick="" + [[ "$pick" =~ ^[0-9]+$ ]] || { ssr::warn "Enter a number between 1 and ${#uniq[@]}."; continue; } + (( pick >= 1 && pick <= ${#uniq[@]} )) || { ssr::warn "Out of range."; continue; } + choice="${uniq[$((pick - 1))]}" + break + done + ssr::ok "SSR_CLOUD_PROVIDER=$choice will be written to $cfg" + else + ssr::die "Config $cfg has conflicting SSR_CLOUD_PROVIDER lines (${uniq[*]}). Keep one value or run ssr from a terminal to choose." + fi + + local tmp newtmp bak + tmp="$(mktemp "${TMPDIR:-/tmp}/ssr-cfg.XXXXXX")" + newtmp="$(mktemp "${TMPDIR:-/tmp}/ssr-cfg.XXXXXX")" + bak="${cfg}.bak.$(date +%s)" + cp -p "$cfg" "$bak" 2>/dev/null || cp "$cfg" "$bak" + grep -v '^SSR_CLOUD_PROVIDER=' "$cfg" > "$tmp" + local ins=0 + while IFS= read -r line || [[ -n "$line" ]]; do + if [[ "$ins" -eq 0 && "$line" == '# Provider:'* ]]; then + printf '%s\n' "$line" + printf 'SSR_CLOUD_PROVIDER=%s\n' "$choice" + ins=1 + continue + fi + printf '%s\n' "$line" + done < "$tmp" > "$newtmp" + [[ "$ins" -eq 1 ]] || printf 'SSR_CLOUD_PROVIDER=%s\n' "$choice" >> "$newtmp" + mv "$newtmp" "$cfg" + rm -f "$tmp" + ssr::log "Config updated (previous copy: $bak)" +} + # Source config file if it exists. Validates that only known KEY=VALUE lines # are present to avoid arbitrary code execution from a tampered config. ssr::load_config() { local cfg="${1:-${SSR_CONFIG:-$HOME/.config/ssr/ssr.conf}}" export SSR_CONFIG="$cfg" [[ -f "$cfg" ]] || return 0 + ssr::config::resolve_cloud_provider_duplicates "$cfg" if grep -Eq -v '^[[:space:]]*(#|$|[A-Z_][A-Z0-9_]*=)' "$cfg"; then ssr::die "Config $cfg contains invalid lines (only KEY=VALUE allowed)" fi diff --git a/lib/mackup.sh b/lib/mackup.sh index ba66591..e1358e9 100644 --- a/lib/mackup.sh +++ b/lib/mackup.sh @@ -238,7 +238,8 @@ ssr::mackup::generate_custom_cfg() { return 1 } # Return true when is already present in the Mackup cloud backup. - _in_backup() { [[ -e "$mackup_dir/$1" ]]; } + # SSR seeds ~/.mackup.cfg with directory=mackup — data lives under that subdir. + _in_backup() { [[ -e "$mackup_dir/mackup/$1" ]]; } local -a items=() @@ -288,23 +289,21 @@ ssr::mackup::ensure_installed() { } # Writes ~/.mackup.cfg pointing the engine at the cloud directory. -# Idempotent: rewrites only if content differs. +# Never overwrites an existing different file without confirmation: shows a +# unified diff, then prompts (TTY) or requires SSR_ALLOW_CONF_OVERWRITE=yes. ssr::mackup::configure() { local mackup_dir="$1" local cfg="$HOME/.mackup.cfg" - local desired + local desired tmp desired="$(printf '[storage]\nengine = file_system\npath = %s\ndirectory = mackup\n' "$mackup_dir")" - - if [[ -f "$cfg" ]] && [[ "$(cat "$cfg")" == "$desired" ]]; then + tmp="$(mktemp "${TMPDIR:-/tmp}/ssr-mackup-cfg.XXXXXX")" + printf '%s\n' "$desired" > "$tmp" + if ssr::replace_file_with_confirm "$cfg" "$tmp"; then + rm -f "$tmp" return 0 fi - - if [[ -L "$cfg" || -f "$cfg" ]]; then - cp -f "$cfg" "$cfg.bak.$(date +%s)" 2>/dev/null || true - rm -f "$cfg" - fi - printf '%s\n' "$desired" > "$cfg" - ssr::ok "Wrote $cfg" + rm -f "$tmp" + return 1 } # Force iCloud Drive to materialize placeholder (.icloud) files under the