drive-dependencies.ps1 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  1. <#
  2. .SYNOPSIS
  3. Find every system mechanism referencing a target drive letter or
  4. disk number. The "is it safe to disconnect?" check.
  5. .DESCRIPTION
  6. Before physically removing a failing drive (or setting it Offline),
  7. audit what's pointing at it: pagefile location, Windows Search index,
  8. scheduled tasks, services, user-profile symlinks/junctions, startup
  9. folder shortcuts, mounted volume mount points, and any drive
  10. references in the Windows Run keys.
  11. Default output is a human-readable table. -Json emits structured.
  12. Exit codes (reflect whether the audit RAN, not what it found):
  13. 0 success — audit completed (verdict reported via panel + JSON)
  14. 2 usage
  15. 3 not found (no such drive)
  16. The verdict (SAFE TO DISCONNECT / WARNINGS / DO NOT DISCONNECT) is
  17. in the panel output and JSON 'verdict' field, not $LASTEXITCODE.
  18. .PARAMETER DriveLetter
  19. Single drive letter (e.g. 'Y'). Case-insensitive.
  20. .PARAMETER DiskNumber
  21. Physical disk number (from Get-Disk). The script resolves all drive
  22. letters mounted on that disk and checks each.
  23. .PARAMETER Json
  24. Machine-readable JSON output.
  25. .EXAMPLE
  26. scripts/drive-dependencies.ps1 -DriveLetter Y
  27. Audit all system references to Y: drive.
  28. .EXAMPLE
  29. scripts/drive-dependencies.ps1 -DiskNumber 1
  30. Audit all references to drive letters on physical disk 1.
  31. .EXAMPLE
  32. scripts/drive-dependencies.ps1 -DriveLetter Y -Json | jq '.dependencies[]'
  33. Machine-readable output for downstream tooling.
  34. .NOTES
  35. Output verdict at end:
  36. SAFE TO DISCONNECT — no critical references found
  37. WARNINGS — some references found but none boot-critical
  38. DO NOT DISCONNECT — boot-critical reference (pagefile, system, etc.)
  39. #>
  40. [CmdletBinding(DefaultParameterSetName='Letter')]
  41. param(
  42. [Parameter(Mandatory, ParameterSetName='Letter', Position=0)]
  43. [ValidatePattern('^[A-Za-z]$')]
  44. [string]$DriveLetter,
  45. [Parameter(Mandatory, ParameterSetName='Number')]
  46. [ValidateRange(0, 99)]
  47. [int]$DiskNumber,
  48. [switch]$Json
  49. )
  50. $ErrorActionPreference = 'Stop'
  51. . "$PSScriptRoot\_lib\common.ps1"
  52. . (Join-Path $PSScriptRoot '..\..\_lib\term.ps1')
  53. Initialize-Term
  54. # Resolve target drive letter(s)
  55. if ($PSCmdlet.ParameterSetName -eq 'Number') {
  56. $parts = Get-Partition -DiskNumber $DiskNumber -ErrorAction SilentlyContinue
  57. if (-not $parts) {
  58. Write-Log -Level FAIL -Message "No partitions found on disk $DiskNumber"
  59. exit $script:EXIT_NOT_FOUND
  60. }
  61. $targetLetters = @($parts | Where-Object { $_.DriveLetter } | ForEach-Object { "$($_.DriveLetter)" })
  62. if (-not $targetLetters) {
  63. Write-Log -Level WARN -Message "Disk $DiskNumber has no mounted drive letters (still audit-worthy for system-volume refs)"
  64. $targetLetters = @()
  65. }
  66. } else {
  67. $targetLetters = @($DriveLetter.ToUpper())
  68. # Verify the drive exists
  69. if (-not (Get-PSDrive -PSProvider FileSystem -Name $DriveLetter.ToUpper() -ErrorAction SilentlyContinue)) {
  70. Write-Log -Level WARN -Message "Drive ${DriveLetter}: not currently mounted — auditing references anyway"
  71. }
  72. }
  73. # Build a drive-letter regex that doesn't false-positive on URL schemes
  74. # (e.g. the 'e:' in 'file:'). Require the letter to be either at string
  75. # start, or preceded by a non-alpha character, and followed by `:\` or `:/`.
  76. $letterPattern = if ($targetLetters) {
  77. $letters = ($targetLetters | ForEach-Object { [regex]::Escape($_) }) -join '|'
  78. "(?:^|[^A-Za-z])($letters):[\\/]"
  79. } else { '__NOMATCH__' }
  80. # Force case-sensitive match so lowercase 'e' inside 'file:' won't match 'E:'
  81. function Test-DrivePath {
  82. param([string]$Text)
  83. if (-not $Text) { return $false }
  84. return [regex]::IsMatch($Text, $letterPattern)
  85. }
  86. $findings = New-Object System.Collections.Generic.List[hashtable]
  87. function Add-Dependency {
  88. param(
  89. [Parameter(Mandatory)][string]$Category,
  90. [Parameter(Mandatory)][string]$Name,
  91. [Parameter(Mandatory)][string]$Target,
  92. [Parameter(Mandatory)][ValidateSet('critical','warn','info')]$Severity
  93. )
  94. $findings.Add(@{ category=$Category; name=$Name; target=$Target; severity=$Severity })
  95. }
  96. # (panel header rendered after dependency collection — see end of script)
  97. # ─────────────────────────────────────────────────────────────────────
  98. # 1. Pagefile location
  99. # ─────────────────────────────────────────────────────────────────────
  100. try {
  101. $pagefiles = Get-CimInstance Win32_PageFileSetting -ErrorAction SilentlyContinue
  102. foreach ($pf in $pagefiles) {
  103. if (Test-DrivePath $pf.Name) {
  104. Add-Dependency -Category 'pagefile' -Name $pf.Name -Target $pf.Name -Severity 'critical'
  105. }
  106. }
  107. } catch {}
  108. # ─────────────────────────────────────────────────────────────────────
  109. # 2. Windows Search index data directory
  110. # ─────────────────────────────────────────────────────────────────────
  111. try {
  112. $idxDir = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows Search' -Name DataDirectory -ErrorAction SilentlyContinue).DataDirectory
  113. if (Test-DrivePath $idxDir) {
  114. Add-Dependency -Category 'search-index' -Name 'Windows.edb' -Target $idxDir -Severity 'warn'
  115. }
  116. } catch {}
  117. # ─────────────────────────────────────────────────────────────────────
  118. # 3. Windows Search indexed scopes (paths in the crawl scope)
  119. # ─────────────────────────────────────────────────────────────────────
  120. try {
  121. $scopeKey = 'HKLM:\SOFTWARE\Microsoft\Windows Search\CrawlScopeManager\Windows\SystemIndex\WorkingSetRules'
  122. if (Test-Path $scopeKey) {
  123. Get-ChildItem $scopeKey -ErrorAction SilentlyContinue | ForEach-Object {
  124. $url = (Get-ItemProperty $_.PSPath -Name URL -ErrorAction SilentlyContinue).URL
  125. if (Test-DrivePath $url) {
  126. Add-Dependency -Category 'search-scope' -Name 'Indexed path' -Target $url -Severity 'warn'
  127. }
  128. }
  129. }
  130. } catch {}
  131. # ─────────────────────────────────────────────────────────────────────
  132. # 4. Scheduled tasks
  133. # ─────────────────────────────────────────────────────────────────────
  134. try {
  135. Get-ScheduledTask -ErrorAction SilentlyContinue | ForEach-Object {
  136. $task = $_
  137. foreach ($action in $task.Actions) {
  138. $strs = @($action.Execute, $action.Arguments, $action.WorkingDirectory) -join ' '
  139. if (Test-DrivePath $strs) {
  140. Add-Dependency -Category 'scheduled-task' -Name $task.TaskName -Target ($strs.Trim()) -Severity 'warn'
  141. break
  142. }
  143. }
  144. }
  145. } catch {}
  146. # ─────────────────────────────────────────────────────────────────────
  147. # 5. Services with binary path on target drive
  148. # ─────────────────────────────────────────────────────────────────────
  149. try {
  150. Get-CimInstance Win32_Service -ErrorAction SilentlyContinue | ForEach-Object {
  151. if (Test-DrivePath $_.PathName) {
  152. $sev = if ($_.StartMode -eq 'Auto') { 'critical' } else { 'warn' }
  153. Add-Dependency -Category 'service' -Name $_.Name -Target $_.PathName -Severity $sev
  154. }
  155. }
  156. } catch {}
  157. # ─────────────────────────────────────────────────────────────────────
  158. # 6. User profile symlinks/junctions pointing at target
  159. # ─────────────────────────────────────────────────────────────────────
  160. try {
  161. Get-ChildItem $env:USERPROFILE -Force -ErrorAction SilentlyContinue |
  162. Where-Object { $_.Attributes -band [System.IO.FileAttributes]::ReparsePoint } |
  163. ForEach-Object {
  164. if ($_.Target -and (Test-DrivePath ($_.Target -join ' '))) {
  165. Add-Dependency -Category 'profile-symlink' -Name $_.Name -Target ($_.Target -join '; ') -Severity 'warn'
  166. }
  167. }
  168. } catch {}
  169. # ─────────────────────────────────────────────────────────────────────
  170. # 7. Startup folder shortcuts targeting drive
  171. # ─────────────────────────────────────────────────────────────────────
  172. try {
  173. $shell = New-Object -ComObject WScript.Shell
  174. foreach ($d in @("$env:APPDATA\Microsoft\Windows\Start Menu\Programs\Startup",
  175. "$env:ALLUSERSPROFILE\Microsoft\Windows\Start Menu\Programs\StartUp")) {
  176. if (Test-Path $d) {
  177. Get-ChildItem $d -Filter *.lnk -ErrorAction SilentlyContinue | ForEach-Object {
  178. $sc = $shell.CreateShortcut($_.FullName)
  179. $combined = @($sc.TargetPath, $sc.WorkingDirectory, $sc.Arguments) -join ' '
  180. if (Test-DrivePath $combined) {
  181. Add-Dependency -Category 'startup-shortcut' -Name $_.Name -Target $sc.TargetPath -Severity 'warn'
  182. }
  183. }
  184. }
  185. }
  186. } catch {}
  187. # ─────────────────────────────────────────────────────────────────────
  188. # 8. Registry Run-key entries pointing at drive
  189. # ─────────────────────────────────────────────────────────────────────
  190. $runPaths = @(
  191. 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run',
  192. 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run',
  193. 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run'
  194. )
  195. foreach ($p in $runPaths) {
  196. if (Test-Path $p) {
  197. (Get-ItemProperty $p -ErrorAction SilentlyContinue).PSObject.Properties |
  198. Where-Object { $_.Name -notmatch '^PS' -and (Test-DrivePath $_.Value) } |
  199. ForEach-Object {
  200. Add-Dependency -Category 'run-key' -Name $_.Name -Target $_.Value -Severity 'warn'
  201. }
  202. }
  203. }
  204. # ─────────────────────────────────────────────────────────────────────
  205. # 9. Volume mount points (a folder on C: that mounts the target volume)
  206. # ─────────────────────────────────────────────────────────────────────
  207. try {
  208. $partitions = Get-Partition -ErrorAction SilentlyContinue | Where-Object {
  209. $_.DriveLetter -and $targetLetters -contains "$($_.DriveLetter)"
  210. }
  211. foreach ($p in $partitions) {
  212. $vol = Get-Volume -Partition $p -ErrorAction SilentlyContinue
  213. if ($vol -and $vol.AccessPaths) {
  214. foreach ($path in $vol.AccessPaths) {
  215. if ($path -match '^[A-Z]:\\' -and $path -notmatch "^${($p.DriveLetter)}:") {
  216. Add-Dependency -Category 'mount-point' -Name "$($p.DriveLetter): mounted at" -Target $path -Severity 'warn'
  217. }
  218. }
  219. }
  220. }
  221. } catch {}
  222. # ─────────────────────────────────────────────────────────────────────
  223. # Output
  224. # ─────────────────────────────────────────────────────────────────────
  225. $criticalCount = ($findings | Where-Object { $_.severity -eq 'critical' }).Count
  226. $warnCount = ($findings | Where-Object { $_.severity -eq 'warn' }).Count
  227. $infoCount = ($findings | Where-Object { $_.severity -eq 'info' }).Count
  228. $verdict = if ($criticalCount -gt 0) {
  229. 'DO NOT DISCONNECT — boot-critical references found'
  230. } elseif ($warnCount -gt 0) {
  231. 'WARNINGS — some references found; review before disconnecting'
  232. } else {
  233. 'SAFE TO DISCONNECT — no system dependencies on this drive'
  234. }
  235. if ($Json) {
  236. @{
  237. targetLetters = $targetLetters
  238. dependencies = $findings
  239. critical = $criticalCount
  240. warnings = $warnCount
  241. verdict = $verdict
  242. } | ConvertTo-Json -Depth 5 | ForEach-Object { [Console]::Out.WriteLine($_) }
  243. } else {
  244. $indicator = ($targetLetters -join ',')
  245. Write-TermLine (New-TermPanelOpen -Brand 'windows-ops' -Name 'windows-ops' -Subtitle 'drive-dependencies' -Indicator $indicator)
  246. Write-TermLine (New-TermPanelVert)
  247. $totalRefs = $findings.Count
  248. $summary = if ($criticalCount -gt 0) {
  249. "$totalRefs references · DO NOT DISCONNECT"
  250. } elseif ($warnCount -gt 0) {
  251. "$totalRefs references · review before disconnect"
  252. } else {
  253. "0 system references · safe to disconnect"
  254. }
  255. Write-TermLine (New-TermSummary -Text $summary)
  256. Write-TermLine (New-TermPanelVert)
  257. # CRITICAL section first — highest severity
  258. $criticals = $findings | Where-Object { $_.severity -eq 'critical' }
  259. if ($criticals) {
  260. Write-TermLine (New-TermSection -State 'CRITICAL' -Label 'CRITICAL' -Count $criticals.Count)
  261. $last = $criticals[-1]
  262. foreach ($f in $criticals) {
  263. $name = "$($f.category)"
  264. $target = Get-TermTruncated -Text $f.target -MaxCols 50
  265. Write-TermLine (New-TermLeaf -Name $name -Meta $f.name -Age '' -IsLast:($f -eq $last) -NameColWidth 20 -RailColWidth 0)
  266. }
  267. Write-TermLine (New-TermAlert -Severity critical -Text 'system-critical references — disconnecting will break the OS')
  268. Write-TermLine (New-TermPanelVert)
  269. }
  270. # WARN section — condense if very large
  271. $warns = $findings | Where-Object { $_.severity -eq 'warn' }
  272. if ($warns) {
  273. Write-TermLine (New-TermSection -State 'WARN' -Label 'WARN' -Count $warns.Count)
  274. $showCount = if ($warns.Count -gt 20) { 8 } else { $warns.Count }
  275. $visible = $warns | Select-Object -First $showCount
  276. $last = $visible[-1]
  277. foreach ($f in $visible) {
  278. $target = Get-TermTruncated -Text $f.target -MaxCols 50
  279. Write-TermLine (New-TermLeaf -Name $f.category -Meta $f.name -Age $target -IsLast:($f -eq $last -and $warns.Count -le 20) -NameColWidth 20 -RailColWidth 0 -MetaColWidth 30)
  280. }
  281. if ($warns.Count -gt 20) {
  282. Write-TermLine (New-TermLeaf -Name "($($warns.Count - $showCount) more)" -IsLast -NameColWidth 20 -RailColWidth 0)
  283. Write-TermLine (New-TermAlert -Severity warning -Text "run with -Json to see the full list")
  284. }
  285. Write-TermLine (New-TermPanelVert)
  286. }
  287. if (-not $findings) {
  288. Write-TermLine (New-TermHint -Text 'no system mechanism references this drive')
  289. Write-TermLine (New-TermPanelVert)
  290. }
  291. # Footer
  292. $health = if ($criticalCount -gt 0) {
  293. New-TermHealth -State 'busted' -Text 'blocked'
  294. } elseif ($warnCount -gt 0) {
  295. New-TermHealth -State 'warning' -Text 'warnings'
  296. } else {
  297. New-TermHealth -State 'healthy' -Text 'safe'
  298. }
  299. $hk = @(
  300. (New-TermHotkey -Key 'B' -Verb 'back')
  301. (New-TermHotkey -Key '?' -Verb 'help')
  302. ) | Join-TermHotkeys
  303. Write-TermLine (New-TermPanelClose -Hotkeys $hk -Healths $health)
  304. }
  305. # Verdict is in the panel and JSON output; exit 0 means the audit ran.
  306. exit $script:EXIT_OK