recover-clone.sh 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. #!/usr/bin/env bash
  2. # mac-ops :: recover-clone.sh
  3. # Safely image data off a failing drive using rsync with no retries.
  4. #
  5. # Cardinal rules (enforced):
  6. # 1. NEVER write to the source. Read-only operations only.
  7. # 2. NEVER use -y or --force on fsck against a failing drive.
  8. # 3. Default mode is DRY RUN — show what would be copied.
  9. #
  10. # Strategies (in order of safety):
  11. # --strategy=rsync Default. Resumable, skips errors, partial files OK.
  12. # --strategy=ditto macOS native. Preserves resource forks & xattrs.
  13. # --strategy=ddrescue Bit-level. Requires brew install gddrescue.
  14. set -u
  15. SOURCE=""
  16. DEST=""
  17. STRATEGY="rsync"
  18. APPLY=0
  19. EXCLUDES=()
  20. while [[ $# -gt 0 ]]; do
  21. case "$1" in
  22. -s|--source) SOURCE="$2"; shift 2 ;;
  23. -d|--destination) DEST="$2"; shift 2 ;;
  24. --strategy) STRATEGY="$2"; shift 2 ;;
  25. --exclude) EXCLUDES+=("$2"); shift 2 ;;
  26. --apply) APPLY=1; shift ;;
  27. --help|-h)
  28. cat <<EOF
  29. Usage: $0 -s <source> -d <destination> [options]
  30. -s, --source PATH Source path (file or directory on failing drive)
  31. -d, --destination PATH Destination path (healthy drive)
  32. --strategy NAME rsync (default) | ditto | ddrescue
  33. --exclude PATTERN Add exclusion (can repeat)
  34. --apply Actually perform the clone (default: dry-run)
  35. --json, --redact, --quiet, --verbose
  36. Examples:
  37. $0 -s /Volumes/Failing/work -d /Volumes/Rescue/work
  38. $0 -s ~/Documents -d /Volumes/Backup/Documents --apply
  39. $0 -s /Volumes/Failing -d /Volumes/Rescue --strategy=ditto --apply
  40. Strategy reference:
  41. rsync Best general-purpose. --partial --inplace --no-whole-file
  42. --append-verify. Skips errors, resumable.
  43. ditto macOS-native. Preserves metadata, xattrs, ACLs, resource forks.
  44. Use when source has Pro app libraries (Final Cut etc).
  45. ddrescue For drives with many bad sectors. Bit-level, resumable via map
  46. file. Requires: brew install gddrescue.
  47. EOF
  48. exit 0 ;;
  49. *) shift ;;
  50. esac
  51. done
  52. if [[ -z "$SOURCE" ]] || [[ -z "$DEST" ]]; then
  53. echo "Error: -s and -d required" >&2
  54. exit 2
  55. fi
  56. if [[ ! -e "$SOURCE" ]]; then
  57. echo "Error: source does not exist: $SOURCE" >&2
  58. exit 3
  59. fi
  60. source "$(dirname "$0")/_lib/common.sh"
  61. parse_common_flags "$@"
  62. maybe_filter_self "$@"
  63. note " Source: $SOURCE"
  64. note " Destination: $DEST"
  65. note " Strategy: $STRATEGY"
  66. note " Mode: $([[ "$APPLY" -eq 1 ]] && echo APPLY || echo DRY-RUN)"
  67. # ----------------------------------------------------------------------------
  68. section "1. PREFLIGHT"
  69. # ----------------------------------------------------------------------------
  70. # Source size (read-only)
  71. src_size=$(du -sh "$SOURCE" 2>/dev/null | awk '{print $1}')
  72. log_info "Source size (du)" "${src_size:-?}"
  73. # Destination free space
  74. dest_parent=$(dirname "$DEST")
  75. [[ -d "$dest_parent" ]] || { log_fail "Destination parent dir" "$dest_parent does not exist"; exit 3; }
  76. dest_free=$(df -h "$dest_parent" | awk 'NR==2{print $4}')
  77. log_info "Destination free space" "$dest_free"
  78. # Sanity: source and dest on different volumes?
  79. src_vol=$(df "$SOURCE" 2>/dev/null | awk 'NR==2{print $1}')
  80. dest_vol=$(df "$dest_parent" 2>/dev/null | awk 'NR==2{print $1}')
  81. if [[ "$src_vol" == "$dest_vol" ]]; then
  82. log_warn "Source/dest volume" "same volume — defeats purpose of cloning off failing drive"
  83. else
  84. log_pass "Source/dest volume" "different volumes"
  85. fi
  86. # Strategy availability check
  87. case "$STRATEGY" in
  88. rsync)
  89. command -v rsync >/dev/null || { log_fail "rsync" "not installed"; exit 5; }
  90. log_pass "rsync available" "$(rsync --version | head -1)"
  91. ;;
  92. ditto)
  93. command -v ditto >/dev/null || { log_fail "ditto" "not installed (built-in on macOS — shouldn't happen)"; exit 5; }
  94. log_pass "ditto available"
  95. ;;
  96. ddrescue)
  97. if ! command -v ddrescue >/dev/null; then
  98. log_fail "ddrescue" "not installed — run: brew install gddrescue"
  99. exit 5
  100. fi
  101. log_pass "ddrescue available"
  102. ;;
  103. *)
  104. log_fail "Strategy" "unknown: $STRATEGY"; exit 2 ;;
  105. esac
  106. # ----------------------------------------------------------------------------
  107. section "2. BUILD COMMAND"
  108. # ----------------------------------------------------------------------------
  109. case "$STRATEGY" in
  110. rsync)
  111. cmd=(rsync -avh
  112. --partial --inplace --no-whole-file --append-verify
  113. --no-perms --no-owner --no-group
  114. --human-readable --info=progress2,stats2
  115. --ignore-errors)
  116. for e in ${EXCLUDES[@]+"${EXCLUDES[@]}"}; do cmd+=("--exclude=$e"); done
  117. cmd+=("$SOURCE/" "$DEST/")
  118. ;;
  119. ditto)
  120. cmd=(ditto --rsrc --extattr "$SOURCE" "$DEST")
  121. ;;
  122. ddrescue)
  123. # ddrescue needs a map file for resumability
  124. mapfile="${DEST}.ddrescue.map"
  125. cmd=(ddrescue -n --idirect "$SOURCE" "$DEST" "$mapfile")
  126. note " ddrescue map file: $mapfile"
  127. ;;
  128. esac
  129. note " Command:"
  130. note " ${cmd[*]}"
  131. # ----------------------------------------------------------------------------
  132. section "3. EXECUTE"
  133. # ----------------------------------------------------------------------------
  134. if [[ "$APPLY" -eq 0 ]]; then
  135. note " (dry-run — pass --apply to actually clone)"
  136. if [[ "$STRATEGY" == "rsync" ]]; then
  137. # rsync has its own --dry-run that previews actions
  138. rsync --dry-run -ah "$SOURCE/" "$DEST/" 2>&1 | tail -10 | sed 's/^/ /'
  139. fi
  140. emit_summary
  141. exit 0
  142. fi
  143. # Apply mode
  144. mkdir -p "$DEST" || { log_fail "mkdir $DEST" "failed"; exit 1; }
  145. log_info "Starting clone" "$STRATEGY"
  146. "${cmd[@]}"
  147. rc=$?
  148. if [[ "$rc" -eq 0 ]]; then
  149. log_pass "Clone finished" "exit 0"
  150. elif [[ "$rc" -le 24 ]] && [[ "$STRATEGY" == "rsync" ]]; then
  151. # rsync 23-24 = partial transfer (some files failed); acceptable for failing drive
  152. log_warn "Clone finished with rsync exit $rc" "some files unreadable — expected on failing drive"
  153. else
  154. log_fail "Clone exit code" "$rc"
  155. fi
  156. emit_summary