| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175 |
- #!/usr/bin/env bash
- # mac-ops :: recover-clone.sh
- # Safely image data off a failing drive using rsync with no retries.
- #
- # Cardinal rules (enforced):
- # 1. NEVER write to the source. Read-only operations only.
- # 2. NEVER use -y or --force on fsck against a failing drive.
- # 3. Default mode is DRY RUN — show what would be copied.
- #
- # Strategies (in order of safety):
- # --strategy=rsync Default. Resumable, skips errors, partial files OK.
- # --strategy=ditto macOS native. Preserves resource forks & xattrs.
- # --strategy=ddrescue Bit-level. Requires brew install gddrescue.
- set -u
- SOURCE=""
- DEST=""
- STRATEGY="rsync"
- APPLY=0
- EXCLUDES=()
- while [[ $# -gt 0 ]]; do
- case "$1" in
- -s|--source) SOURCE="$2"; shift 2 ;;
- -d|--destination) DEST="$2"; shift 2 ;;
- --strategy) STRATEGY="$2"; shift 2 ;;
- --exclude) EXCLUDES+=("$2"); shift 2 ;;
- --apply) APPLY=1; shift ;;
- --help|-h)
- cat <<EOF
- Usage: $0 -s <source> -d <destination> [options]
- -s, --source PATH Source path (file or directory on failing drive)
- -d, --destination PATH Destination path (healthy drive)
- --strategy NAME rsync (default) | ditto | ddrescue
- --exclude PATTERN Add exclusion (can repeat)
- --apply Actually perform the clone (default: dry-run)
- --json, --redact, --quiet, --verbose
- Examples:
- $0 -s /Volumes/Failing/work -d /Volumes/Rescue/work
- $0 -s ~/Documents -d /Volumes/Backup/Documents --apply
- $0 -s /Volumes/Failing -d /Volumes/Rescue --strategy=ditto --apply
- Strategy reference:
- rsync Best general-purpose. --partial --inplace --no-whole-file
- --append-verify. Skips errors, resumable.
- ditto macOS-native. Preserves metadata, xattrs, ACLs, resource forks.
- Use when source has Pro app libraries (Final Cut etc).
- ddrescue For drives with many bad sectors. Bit-level, resumable via map
- file. Requires: brew install gddrescue.
- EOF
- exit 0 ;;
- *) shift ;;
- esac
- done
- if [[ -z "$SOURCE" ]] || [[ -z "$DEST" ]]; then
- echo "Error: -s and -d required" >&2
- exit 2
- fi
- if [[ ! -e "$SOURCE" ]]; then
- echo "Error: source does not exist: $SOURCE" >&2
- exit 3
- fi
- source "$(dirname "$0")/_lib/common.sh"
- parse_common_flags "$@"
- maybe_filter_self "$@"
- note " Source: $SOURCE"
- note " Destination: $DEST"
- note " Strategy: $STRATEGY"
- note " Mode: $([[ "$APPLY" -eq 1 ]] && echo APPLY || echo DRY-RUN)"
- # ----------------------------------------------------------------------------
- section "1. PREFLIGHT"
- # ----------------------------------------------------------------------------
- # Source size (read-only)
- src_size=$(du -sh "$SOURCE" 2>/dev/null | awk '{print $1}')
- log_info "Source size (du)" "${src_size:-?}"
- # Destination free space
- dest_parent=$(dirname "$DEST")
- [[ -d "$dest_parent" ]] || { log_fail "Destination parent dir" "$dest_parent does not exist"; exit 3; }
- dest_free=$(df -h "$dest_parent" | awk 'NR==2{print $4}')
- log_info "Destination free space" "$dest_free"
- # Sanity: source and dest on different volumes?
- src_vol=$(df "$SOURCE" 2>/dev/null | awk 'NR==2{print $1}')
- dest_vol=$(df "$dest_parent" 2>/dev/null | awk 'NR==2{print $1}')
- if [[ "$src_vol" == "$dest_vol" ]]; then
- log_warn "Source/dest volume" "same volume — defeats purpose of cloning off failing drive"
- else
- log_pass "Source/dest volume" "different volumes"
- fi
- # Strategy availability check
- case "$STRATEGY" in
- rsync)
- command -v rsync >/dev/null || { log_fail "rsync" "not installed"; exit 5; }
- log_pass "rsync available" "$(rsync --version | head -1)"
- ;;
- ditto)
- command -v ditto >/dev/null || { log_fail "ditto" "not installed (built-in on macOS — shouldn't happen)"; exit 5; }
- log_pass "ditto available"
- ;;
- ddrescue)
- if ! command -v ddrescue >/dev/null; then
- log_fail "ddrescue" "not installed — run: brew install gddrescue"
- exit 5
- fi
- log_pass "ddrescue available"
- ;;
- *)
- log_fail "Strategy" "unknown: $STRATEGY"; exit 2 ;;
- esac
- # ----------------------------------------------------------------------------
- section "2. BUILD COMMAND"
- # ----------------------------------------------------------------------------
- case "$STRATEGY" in
- rsync)
- cmd=(rsync -avh
- --partial --inplace --no-whole-file --append-verify
- --no-perms --no-owner --no-group
- --human-readable --info=progress2,stats2
- --ignore-errors)
- for e in ${EXCLUDES[@]+"${EXCLUDES[@]}"}; do cmd+=("--exclude=$e"); done
- cmd+=("$SOURCE/" "$DEST/")
- ;;
- ditto)
- cmd=(ditto --rsrc --extattr "$SOURCE" "$DEST")
- ;;
- ddrescue)
- # ddrescue needs a map file for resumability
- mapfile="${DEST}.ddrescue.map"
- cmd=(ddrescue -n --idirect "$SOURCE" "$DEST" "$mapfile")
- note " ddrescue map file: $mapfile"
- ;;
- esac
- note " Command:"
- note " ${cmd[*]}"
- # ----------------------------------------------------------------------------
- section "3. EXECUTE"
- # ----------------------------------------------------------------------------
- if [[ "$APPLY" -eq 0 ]]; then
- note " (dry-run — pass --apply to actually clone)"
- if [[ "$STRATEGY" == "rsync" ]]; then
- # rsync has its own --dry-run that previews actions
- rsync --dry-run -ah "$SOURCE/" "$DEST/" 2>&1 | tail -10 | sed 's/^/ /'
- fi
- emit_summary
- exit 0
- fi
- # Apply mode
- mkdir -p "$DEST" || { log_fail "mkdir $DEST" "failed"; exit 1; }
- log_info "Starting clone" "$STRATEGY"
- "${cmd[@]}"
- rc=$?
- if [[ "$rc" -eq 0 ]]; then
- log_pass "Clone finished" "exit 0"
- elif [[ "$rc" -le 24 ]] && [[ "$STRATEGY" == "rsync" ]]; then
- # rsync 23-24 = partial transfer (some files failed); acceptable for failing drive
- log_warn "Clone finished with rsync exit $rc" "some files unreadable — expected on failing drive"
- else
- log_fail "Clone exit code" "$rc"
- fi
- emit_summary
|