drive-dependencies.sh 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. #!/usr/bin/env bash
  2. # mac-ops :: drive-dependencies.sh
  3. # "Is it safe to eject this volume?" — find every reference to a volume
  4. # before you yank the cable / unmount / destroy a snapshot.
  5. #
  6. # Checks:
  7. # - Open files via lsof
  8. # - Spotlight index state
  9. # - Time Machine destination
  10. # - Photos / Music / TV library locations
  11. # - Helper-tool security-scoped bookmarks (best-effort)
  12. # - Symlinks pointing into the volume from common locations
  13. # - Background processes with cwd inside the volume
  14. set -u
  15. TARGET=""
  16. while [[ $# -gt 0 ]]; do
  17. case "$1" in
  18. -v|--volume) TARGET="$2"; shift 2 ;;
  19. --help|-h)
  20. cat <<EOF
  21. Usage: $0 -v <mount-point> [options]
  22. -v, --volume PATH Volume to check (e.g. /Volumes/Backup, /)
  23. --json, --redact, --quiet, --verbose Standard flags
  24. Verdict: "safe to eject" requires PASS on every check. Any FAIL/WARN means
  25. something would break or lose state on disconnect.
  26. EOF
  27. exit 0 ;;
  28. *) shift ;;
  29. esac
  30. done
  31. if [[ -z "$TARGET" ]]; then
  32. echo "Error: -v <mount-point> required (e.g. -v /Volumes/Backup)" >&2
  33. exit 2
  34. fi
  35. if [[ ! -d "$TARGET" ]]; then
  36. echo "Error: $TARGET is not a directory / not mounted" >&2
  37. exit 3
  38. fi
  39. source "$(dirname "$0")/_lib/common.sh"
  40. parse_common_flags "$@"
  41. maybe_filter_self "$@"
  42. note " Volume: $TARGET"
  43. # ----------------------------------------------------------------------------
  44. section "1. OPEN FILES (lsof)"
  45. # ----------------------------------------------------------------------------
  46. # `lsof +D` is recursive and VERY slow on large volumes (especially $HOME).
  47. # Use `lsof` without +D and grep by mount point — much faster, equivalent
  48. # accuracy for "is anything open under this path".
  49. target_real=$(cd "$TARGET" 2>/dev/null && pwd -P || echo "$TARGET")
  50. open_lines=$(lsof -F n 2>/dev/null | grep "^n${target_real}/" 2>/dev/null || true)
  51. open_count=$(printf '%s\n' "$open_lines" | grep -c . 2>/dev/null || echo 0)
  52. if [[ "$open_count" -gt 0 ]]; then
  53. log_fail "Open file handles" "$open_count — unmount will fail or corrupt"
  54. note " Top processes holding files (sample):"
  55. # lsof -F format is column-based; use plain lsof for the process listing
  56. lsof 2>/dev/null | awk -v t="$target_real" '$NF ~ "^"t"/"{print $1, $2}' | sort -u | head -10 | sed 's/^/ /'
  57. else
  58. log_pass "Open file handles" "0"
  59. fi
  60. # ----------------------------------------------------------------------------
  61. section "2. PROCESSES WITH CWD INSIDE VOLUME"
  62. # ----------------------------------------------------------------------------
  63. # Use lsof -c with -d cwd for current working directories
  64. cwd_procs=$(lsof -d cwd 2>/dev/null | awk -v t="$TARGET" '$NF ~ t {print $1, $2}' | sort -u)
  65. if [[ -n "$cwd_procs" ]]; then
  66. cwd_count=$(echo "$cwd_procs" | wc -l | tr -d ' ')
  67. log_warn "Processes with cwd inside" "$cwd_count"
  68. echo "$cwd_procs" | head -5 | sed 's/^/ /'
  69. else
  70. log_pass "Processes with cwd inside" "0"
  71. fi
  72. # ----------------------------------------------------------------------------
  73. section "3. SPOTLIGHT INDEX STATE"
  74. # ----------------------------------------------------------------------------
  75. spotlight_status=$(mdutil -s "$TARGET" 2>/dev/null | tail -1 | sed 's/^[[:space:]]*//')
  76. note " $spotlight_status"
  77. case "$spotlight_status" in
  78. *"Indexing enabled"*) log_warn "Spotlight indexing" "enabled on this volume — eject may corrupt index" ;;
  79. *"Indexing disabled"*) log_pass "Spotlight indexing" "disabled" ;;
  80. *"unknown"*) log_pass "Spotlight indexing" "(no user index — system or read-only volume)" ;;
  81. *) log_info "Spotlight indexing" "${spotlight_status:-(no response)}" ;;
  82. esac
  83. # ----------------------------------------------------------------------------
  84. section "4. TIME MACHINE DESTINATION CHECK"
  85. # ----------------------------------------------------------------------------
  86. tm_dest=$(tmutil destinationinfo 2>/dev/null | awk -F': *' '/Mount Point/{print $2}')
  87. # Empty tm_dest matches /tmp via prefix logic if not careful; require non-empty + exact prefix
  88. if [[ -n "$tm_dest" ]] && { [[ "$tm_dest" == "$TARGET" ]] || [[ "$TARGET" == "$tm_dest"/* ]]; }; then
  89. log_fail "Time Machine destination" "this volume IS the TM target — eject will fail current/next backup"
  90. elif [[ -n "$tm_dest" ]]; then
  91. log_pass "Time Machine destination" "different volume ($tm_dest)"
  92. else
  93. log_pass "Time Machine destination" "none configured"
  94. fi
  95. # Recent TM activity touching this volume
  96. tm_active=$(tmutil currentphase 2>/dev/null)
  97. if [[ "$tm_active" != "BackupNotRunning" ]] && [[ -n "$tm_active" ]]; then
  98. log_warn "Time Machine current phase" "$tm_active — wait before eject"
  99. fi
  100. # ----------------------------------------------------------------------------
  101. section "5. MEDIA LIBRARY LOCATIONS"
  102. # ----------------------------------------------------------------------------
  103. # Photos library
  104. photos_lib=$(defaults read com.apple.Photos UserLibrarySelectionMethod 2>/dev/null || true)
  105. # Best-effort: check common Photos library paths under this volume
  106. photos_libs=$(find "$TARGET" -maxdepth 3 -name "Photos Library.photoslibrary" -type d 2>/dev/null | head -3)
  107. if [[ -n "$photos_libs" ]]; then
  108. log_warn "Photos library detected on volume" "$(echo "$photos_libs" | head -1)"
  109. fi
  110. # Music library
  111. music_libs=$(find "$TARGET" -maxdepth 3 -name "*.musiclibrary" -type d 2>/dev/null | head -3)
  112. if [[ -n "$music_libs" ]]; then
  113. log_warn "Music library detected on volume" "$(echo "$music_libs" | head -1)"
  114. fi
  115. # Final Cut / Logic / iMovie libraries
  116. fcp_libs=$(find "$TARGET" -maxdepth 3 \( -name "*.fcpbundle" -o -name "*.logicx" -o -name "*.imovielibrary" \) -type d 2>/dev/null | head -3)
  117. if [[ -n "$fcp_libs" ]]; then
  118. log_warn "Pro app library detected on volume" "$(echo "$fcp_libs" | head -1)"
  119. fi
  120. # ----------------------------------------------------------------------------
  121. section "6. SYMLINKS POINTING INTO VOLUME"
  122. # ----------------------------------------------------------------------------
  123. # Common places where symlinks land
  124. declare -a check_dirs=(
  125. "$HOME/Documents"
  126. "$HOME/Desktop"
  127. "$HOME/Movies"
  128. "$HOME/Music"
  129. "$HOME/Pictures"
  130. "$HOME/Library/Mobile Documents"
  131. )
  132. symlink_count=0
  133. for d in "${check_dirs[@]}"; do
  134. [[ -d "$d" ]] || continue
  135. found=$(find "$d" -maxdepth 2 -type l 2>/dev/null | while read -r link; do
  136. dest=$(readlink "$link")
  137. [[ "$dest" == "$TARGET"/* ]] && echo "$link -> $dest"
  138. done)
  139. if [[ -n "$found" ]]; then
  140. n=$(echo "$found" | wc -l | tr -d ' ')
  141. symlink_count=$((symlink_count + n))
  142. echo "$found" | head -3 | sed 's/^/ /'
  143. fi
  144. done
  145. if [[ "$symlink_count" -gt 0 ]]; then
  146. log_warn "Symlinks pointing into volume" "$symlink_count — they'll dangle on eject"
  147. else
  148. log_pass "Symlinks pointing into volume" "0"
  149. fi
  150. # ----------------------------------------------------------------------------
  151. section "7. PRIVILEGED HELPER / LAUNCH ITEMS REFERENCING VOLUME"
  152. # ----------------------------------------------------------------------------
  153. # Grep launchd plists for paths inside the target
  154. helper_refs=0
  155. for d in "$HOME/Library/LaunchAgents" /Library/LaunchAgents /Library/LaunchDaemons; do
  156. [[ -d "$d" ]] || continue
  157. matches=$(grep -l "$TARGET" "$d"/*.plist 2>/dev/null || true)
  158. if [[ -n "$matches" ]]; then
  159. helper_refs=$((helper_refs + $(echo "$matches" | wc -l | tr -d ' ')))
  160. echo "$matches" | head -3 | sed 's|^| |'
  161. fi
  162. done
  163. if [[ "$helper_refs" -gt 0 ]]; then
  164. log_warn "Launchd plists referencing volume" "$helper_refs — daemons will fail on eject"
  165. else
  166. log_pass "Launchd plists referencing volume" "0"
  167. fi
  168. # ----------------------------------------------------------------------------
  169. section "8. APP BOOKMARKS / RECENTS"
  170. # ----------------------------------------------------------------------------
  171. # Sandboxed apps store security-scoped bookmarks; we can't decode them without
  172. # the app, but we can list which apps have recents pointing at this volume.
  173. note " (App security-scoped bookmarks aren't directly inspectable — this is informational)"
  174. # ----------------------------------------------------------------------------
  175. emit_summary
  176. if [[ "$JSON_MODE" -eq 0 ]]; then
  177. echo
  178. if [[ "$FAIL_COUNT" -eq 0 ]] && [[ "$WARN_COUNT" -eq 0 ]]; then
  179. echo " ✓ Safe to eject $TARGET — no system references detected."
  180. elif [[ "$FAIL_COUNT" -gt 0 ]]; then
  181. echo " ✗ NOT safe to eject $TARGET — eject will fail or break the items above."
  182. else
  183. echo " ⚠ Ejecting will work, but the items above will dangle or stop working until remount."
  184. fi
  185. fi