windowsweep 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/AI-INTEGRATION-GUIDE.md +15 -11
  2. package/CHANGELOG.md +56 -0
  3. package/LICENSE +21 -21
  4. package/README.md +70 -42
  5. package/VERSION +1 -1
  6. package/lib/actions.ps1 +212 -212
  7. package/lib/config.ps1 +108 -99
  8. package/lib/constants.ps1 +103 -93
  9. package/lib/fs.ps1 +189 -180
  10. package/lib/log.ps1 +140 -140
  11. package/lib/safety.ps1 +541 -493
  12. package/lib/scan.ps1 +109 -100
  13. package/lib/ui.ps1 +275 -275
  14. package/modules/android_avd.ps1 +36 -36
  15. package/modules/app_caches.ps1 +58 -58
  16. package/modules/browsers.ps1 +33 -33
  17. package/modules/build_tools.ps1 +35 -35
  18. package/modules/crash_trap.ps1 +89 -89
  19. package/modules/disk_usage.ps1 +49 -49
  20. package/modules/docker.ps1 +93 -93
  21. package/modules/editors.ps1 +150 -150
  22. package/modules/globals_audit.ps1 +236 -236
  23. package/modules/health.ps1 +101 -101
  24. package/modules/installed_programs.ps1 +118 -118
  25. package/modules/menu.ps1 +63 -63
  26. package/modules/notify.ps1 +60 -60
  27. package/modules/orphaned_appdata.ps1 +189 -189
  28. package/modules/personal.ps1 +126 -126
  29. package/modules/pkg_managers.ps1 +73 -73
  30. package/modules/projects.ps1 +160 -160
  31. package/modules/release_helpers.ps1 +438 -438
  32. package/modules/reports.ps1 +212 -212
  33. package/modules/runner.ps1 +242 -239
  34. package/modules/self_test_extra.ps1 +537 -360
  35. package/modules/startup_audit.ps1 +126 -126
  36. package/modules/system_admin.ps1 +220 -220
  37. package/modules/test_runners.ps1 +24 -24
  38. package/modules/walkthrough.ps1 +80 -79
  39. package/modules/windows_user.ps1 +118 -118
  40. package/package.json +2 -2
  41. package/windowsweep.ps1 +322 -316
package/lib/safety.ps1 CHANGED
@@ -1,493 +1,541 @@
1
- # safety.ps1 - the deletion chokepoint and its guards.
2
- #
3
- # Every byte this tool removes passes through Remove-PathSafe (or Send-ToRecycleBin), which:
4
- # 1. resolves the path and refuses relative segments, UNC paths, drive roots and system roots;
5
- # 2. refuses every protected subtree / pattern / basename (Test-ProtectedPath);
6
- # 3. asserts the path lies strictly inside the target root the caller declared (-Within);
7
- # 4. short-circuits in --dry-run, printing what it would do and tallying an estimate;
8
- # 5. removes reparse points as links, never following them; skips locked files, never fails a run.
9
- # No flag bypasses steps 1-3.
10
-
11
- function Initialize-Safety {
12
- <# .SYNOPSIS Build the protected-path tables from the environment. Called once at startup. #>
13
- $U = Get-FullPath $env:USERPROFILE
14
- $L = Get-FullPath $env:LOCALAPPDATA
15
- $A = Get-FullPath $env:APPDATA
16
- $LL = Get-FullPath (Join-Path (Split-Path -Parent $L) 'LocalLow')
17
- $SR = Get-FullPath $env:SystemRoot
18
- $PD = Get-FullPath $env:ProgramData
19
- $SD = $env:SystemDrive
20
- $PF = Get-FullPath $env:ProgramFiles
21
- $PF86 = Get-FullPath ${env:ProgramFiles(x86)}
22
-
23
- $exact = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
24
- foreach ($d in [IO.DriveInfo]::GetDrives()) { $null = $exact.Add($d.Name.TrimEnd('\')); $null = $exact.Add($d.Name) }
25
- foreach ($p in @($SR, "$SR\System32", "$SR\SysWOW64", $PF, $PF86, $PD, "$SD\Users", "$SD\Users\Default", "$SD\Users\Public", $env:PUBLIC, $U, "$U\AppData", $A, $L, $LL)) {
26
- if (-not $p) { continue }
27
- $fp = Get-FullPath $p
28
- if ($fp) { $null = $exact.Add($fp) }
29
- }
30
-
31
- $subtrees = @(
32
- "$U\Documents", "$U\Pictures", "$U\Music", "$U\Videos", "$U\Desktop", "$U\Contacts", "$U\Favorites", "$U\Links",
33
- "$U\Saved Games", "$U\Searches", "$U\3D Objects", "$U\Dropbox", "$U\Google Drive", "$U\iCloudDrive",
34
- "$U\.ssh", "$U\.gnupg", "$U\.secrets", "$U\.aws", "$U\.azure", "$U\.kube", "$U\.gcloud", "$U\.docker", "$U\.config",
35
- "$U\.local", "$U\.claude", "$U\.codex", "$U\.agents", "$U\.gemini", "$U\.copilot", "$U\.antigravity", "$U\.ollama",
36
- "$U\.vscode-server", "$U\.cursor-server", "$U\.password-store",
37
- "$A\npm", "$A\nvm", "$L\nvm", "$SD\nvm4w", "$L\Volta", "$L\fnm", "$L\fnm_multishells", "$L\node\corepack",
38
- "$L\pnpm\global", "$U\.bun\bin", "$U\.bun\install\global", "$U\.deno\bin", "$U\.cargo\bin", "$U\.rustup", "$U\go\bin",
39
- "$L\Programs", "$L\Microsoft\WindowsApps", "$L\Android\Sdk",
40
- "$SR\Prefetch", "$SR\Installer", "$SR\WinSxS", "$SR\System32\config", "$SR\servicing", "$SR\Boot", "$SR\Fonts",
41
- "$PD\Package Cache", "$PD\Microsoft\Windows\Start Menu",
42
- "$SD\`$Recycle.Bin", "$SD\System Volume Information", "$SD\Recovery", "$SD\Boot", "$SD\EFI"
43
- )
44
- # Declared exceptions: paths inside a protected subtree that ARE regenerable caches.
45
- $exceptions = @("$L\Android\Sdk\.temp", "$L\Android\Sdk\.downloadIntermediates")
46
-
47
- # Wildcards matched against the full path. Browser/editor state that must never be mistaken for cache.
48
- $patterns = @(
49
- "$U\OneDrive*", "*\Packages\*\LocalState*", "*\Packages\*\Settings*", "*\Packages\*\RoamingState*",
50
- '*chrome-for-testing*', '*\Chrome for Testing*',
51
- '*\User Data\Default', '*\User Data\Guest Profile', '*\User Data\System Profile', '*\User Data\Profile ?', '*\User Data\Profile ??',
52
- '*\User Data\*\Local Storage*', '*\User Data\*\Session Storage*', '*\User Data\*\IndexedDB*', '*\User Data\*\Cookies*',
53
- '*\User Data\*\Login Data*', '*\User Data\*\Web Data*', '*\User Data\*\Bookmarks*', '*\User Data\*\History*',
54
- '*\User Data\*\Sync Data*', '*\User Data\*\Preferences', '*\User Data\*\Secure Preferences', '*\User Data\*\Network\*',
55
- '*\User Data\*\File System*', '*\User Data\*\databases*', '*\User Data\*\Sessions*', '*\User Data\*\Extension State*',
56
- '*\User Data\*\Local Extension Settings*', '*\User Data\*\Sync Extension Settings*', '*\User Data\*\Extensions\*',
57
- '*\User Data\*\Service Worker\Database*', '*\User Data\*\Service Worker\CacheStorage*', '*\User Data\*\shared_proto_db*',
58
- '*\User\globalStorage*', '*\User\History*', '*\User\settings.json', '*\User\keybindings.json', '*\User\snippets*',
59
- "$U\.cache\claude*", "$U\.cache\codex*", "$U\.cache\gemini*", "$U\.cache\copilot*", "$L\JetBrains\Toolbox*",
60
- '*\Profiles\*\places.sqlite*', '*\Profiles\*\key4.db', '*\Profiles\*\logins.json', '*\Profiles\*\cookies.sqlite*',
61
- '*\Profiles\*\storage\*', '*\Profiles\*\prefs.js', '*\Profiles\*\extensions\*'
62
- )
63
- $basenames = @('NTUSER.DAT', 'NTUSER.DAT.LOG1', 'NTUSER.DAT.LOG2', 'ntuser.ini', 'UsrClass.dat', 'UsrClass.dat.LOG1',
64
- 'UsrClass.dat.LOG2', 'hiberfil.sys', 'pagefile.sys', 'swapfile.sys', 'bootmgr', 'BOOTNXT', 'DumpStack.log.tmp')
65
-
66
- # Pre-normalized once: Get-ProtectionReason runs once per file during a prune (400k+ files on a big yarn
67
- # cache), so it must be plain string tests - no per-call path resolution, no per-call wildcard compile.
68
- $subtreesNorm = @($subtrees | ForEach-Object { Get-FullPath $_ } | Where-Object { $_ })
69
- $exceptionsNorm = @($exceptions | ForEach-Object { Get-FullPath $_ } | Where-Object { $_ })
70
- $wildcardIgnoreCase = [System.Management.Automation.WildcardOptions]::IgnoreCase
71
- $Script:WS_PROTECT = @{
72
- Exact = $exact
73
- Subtrees = $subtreesNorm
74
- SubtreePrefixes = @($subtreesNorm | ForEach-Object { $_ + '\' })
75
- Exceptions = $exceptionsNorm
76
- ExceptionPrefixes = @($exceptionsNorm | ForEach-Object { $_ + '\' })
77
- Patterns = $patterns
78
- PatternObjects = @($patterns | ForEach-Object { [System.Management.Automation.WildcardPattern]::Get($_, $wildcardIgnoreCase) })
79
- Basenames = $basenames
80
- HomePrefix = $null
81
- }
82
- }
83
-
84
- function Test-PathWithin {
85
- <# .SYNOPSIS True when Path equals Within or lies inside it (case-insensitive, normalized). #>
86
- param([string] $Path, [string] $Within)
87
- $p = Get-FullPath $Path; $w = Get-FullPath $Within
88
- if (-not $p -or -not $w) { return $false }
89
- if ($p.Equals($w, [StringComparison]::OrdinalIgnoreCase)) { return $true }
90
- $prefix = $w
91
- if (-not $prefix.EndsWith('\')) { $prefix = $prefix + '\' }
92
- return $p.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)
93
- }
94
-
95
- function Get-ProtectionReason {
96
- <# .SYNOPSIS Why a path may not be deleted, or $null when it is allowed.
97
- Hot path: called once per file during a prune, so it uses only the tables Initialize-Safety pre-normalized.
98
- "$p\" starts with "$root\" is exactly Test-PathWithin (equal, or inside) without the per-call resolution. #>
99
- param([string] $Path)
100
- if ([string]::IsNullOrWhiteSpace($Path)) { return 'empty path' }
101
- $raw = $Path.Trim()
102
- if ($raw -match '(^|[\\/])\.\.([\\/]|$)') { return 'relative ".." segment' }
103
- if ($raw.StartsWith('\\') -and -not $raw.StartsWith('\\?\')) { return 'UNC path' }
104
- $p = Get-FullPath $raw
105
- if (-not $p) { return 'unresolvable path' }
106
- if ($p.StartsWith('\\')) { return 'UNC path' }
107
- if ($p -match '^[A-Za-z]:\\?$') { return 'drive root' }
108
- $pr = $Script:WS_PROTECT
109
- if ($pr.Exact.Contains($p)) { return "protected root: $p" }
110
- $ic = [StringComparison]::OrdinalIgnoreCase
111
- $pSlash = $p + '\'
112
- foreach ($e in $pr.ExceptionPrefixes) { if ($pSlash.StartsWith($e, $ic)) { return $null } }
113
- for ($i = 0; $i -lt $pr.SubtreePrefixes.Count; $i++) {
114
- if ($pSlash.StartsWith($pr.SubtreePrefixes[$i], $ic)) { return "inside protected: $($pr.Subtrees[$i])" }
115
- }
116
- for ($i = 0; $i -lt $pr.PatternObjects.Count; $i++) {
117
- if ($pr.PatternObjects[$i].IsMatch($p)) { return "matches protected pattern: $($pr.Patterns[$i])" }
118
- }
119
- $base = [IO.Path]::GetFileName($p)
120
- foreach ($b in $pr.Basenames) { if ($base.Equals($b, $ic)) { return "protected file name: $b" } }
121
- if ($Script:WS.Home -and -not $Script:WS.AllowOwnData) {
122
- if (-not $pr.HomePrefix) { $pr.HomePrefix = (Get-FullPath $Script:WS.Home) + '\' }
123
- if ($pSlash.StartsWith($pr.HomePrefix, $ic)) { return 'the tool''s own data directory' }
124
- }
125
- return $null
126
- }
127
-
128
- function Test-ProtectedPath {
129
- param([string] $Path)
130
- return ($null -ne (Get-ProtectionReason $Path))
131
- }
132
-
133
- function New-RemoveResult {
134
- return [pscustomobject]@{ Removed = $false; Bytes = [long]0; Files = 0; Skipped = 0; SkippedBytes = [long]0; Reason = $null }
135
- }
136
-
137
- function Remove-ReadOnlyAttribute {
138
- param([string] $LongPath)
139
- try {
140
- $a = [IO.File]::GetAttributes($LongPath)
141
- if (($a -band [IO.FileAttributes]::ReadOnly) -ne 0) { [IO.File]::SetAttributes($LongPath, ($a -bxor [IO.FileAttributes]::ReadOnly)) }
142
- } catch { $null = $_ }
143
- }
144
-
145
- function Remove-TreeInternal {
146
- <# .SYNOPSIS Recursive delete that removes nested reparse points as links and skips locked files. Internal: guards already ran. #>
147
- param([string] $Path, [pscustomobject] $Result)
148
- $lp = Get-LongPath $Path
149
- $entries = @()
150
- try { $entries = (New-Object IO.DirectoryInfo($lp)).EnumerateFileSystemInfos() } catch { $Result.Skipped++; return $false }
151
- $allClear = $true
152
- foreach ($e in $entries) {
153
- $isLink = (($e.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)
154
- $isDir = (($e.Attributes -band [IO.FileAttributes]::Directory) -ne 0)
155
- try {
156
- if ($isLink) {
157
- if ($isDir) { [IO.Directory]::Delete($e.FullName, $false) } else { [IO.File]::Delete($e.FullName) }
158
- Write-LogLine ("removed link (not followed): " + (Remove-LongPrefix $e.FullName))
159
- } elseif ($isDir) {
160
- if (-not (Remove-TreeInternal -Path $e.FullName -Result $Result)) { $allClear = $false }
161
- } else {
162
- Remove-ReadOnlyAttribute $e.FullName
163
- [IO.File]::Delete($e.FullName)
164
- $Result.Files++
165
- }
166
- } catch {
167
- $allClear = $false
168
- $Result.Skipped++
169
- try { if (-not $isDir) { $Result.SkippedBytes += [long]$e.Length } } catch { $null = $_ }
170
- }
171
- }
172
- if ($allClear) {
173
- try { [IO.Directory]::Delete($lp, $false) } catch { $allClear = $false; $Result.Skipped++ }
174
- }
175
- return $allClear
176
- }
177
-
178
- function Remove-PathSafe {
179
- <# .SYNOPSIS The chokepoint. Removes one file, directory or link after every guard passes. Honours --dry-run. #>
180
- param([Parameter(Mandatory = $true)][string] $Path, [Parameter(Mandatory = $true)][string] $Within, [string] $Label = '')
181
- $ws = $Script:WS
182
- $r = New-RemoveResult
183
- $tag = ''
184
- if ($Label) { $tag = " [$Label]" }
185
- $p = Get-FullPath $Path
186
- $w = Get-FullPath $Within
187
- if (-not $p -or -not $w) { $r.Reason = 'unresolvable path'; Write-Err "REFUSE ($($r.Reason)): $Path"; return $r }
188
- $why = Get-ProtectionReason $p
189
- if ($why) { $r.Reason = $why; Write-Err "REFUSE ($why): $p"; return $r }
190
- if ($p.Equals($w, [StringComparison]::OrdinalIgnoreCase)) { $r.Reason = 'target is its own declared root'; Write-Err "REFUSE ($($r.Reason)): $p"; return $r }
191
- if (-not (Test-PathWithin -Path $p -Within $w)) { $r.Reason = "outside declared root $w"; Write-Err "REFUSE ($($r.Reason)): $p"; return $r }
192
- $info = Get-ItemInfo $p
193
- if ($null -eq $info) { $r.Removed = $true; return $r }
194
- $isDir = ($info -is [IO.DirectoryInfo])
195
- $isLink = Test-ReparsePoint $info
196
- $bytes = [long]0
197
- if ($isLink) { $bytes = 0 } elseif ($isDir) { $bytes = (Get-DirectoryStats $p).Bytes } else { $bytes = [long]$info.Length }
198
- if ($ws.DryRun) {
199
- Write-DryRun ("would remove {0,10} {1}" -f (Format-Bytes $bytes), $p)
200
- $r.Removed = $true; $r.Bytes = $bytes
201
- Add-Freed $bytes
202
- return $r
203
- }
204
- $lp = Get-LongPath $p
205
- try {
206
- if ($isLink) {
207
- if ($isDir) { [IO.Directory]::Delete($lp, $false) } else { [IO.File]::Delete($lp) }
208
- $r.Removed = $true
209
- Write-LogLine "removed link (not followed): $p$tag"
210
- } elseif ($isDir) {
211
- $r.Removed = Remove-TreeInternal -Path $p -Result $r
212
- $r.Bytes = [math]::Max([long]0, $bytes - $r.SkippedBytes)
213
- if ($r.Skipped -gt 0) { Write-Note "$($r.Skipped) item(s) in use or inaccessible, left in place under $p" }
214
- Write-LogLine ("removed dir: $p ($($r.Bytes) bytes, skipped $($r.Skipped))$tag")
215
- } else {
216
- Remove-ReadOnlyAttribute $lp
217
- [IO.File]::Delete($lp)
218
- $r.Removed = $true; $r.Bytes = $bytes; $r.Files = 1
219
- Write-LogLine "removed file: $p ($bytes bytes)$tag"
220
- }
221
- Add-Freed $r.Bytes
222
- } catch {
223
- $r.Skipped++
224
- $r.Reason = 'in use or access denied'
225
- Write-Note "in use or inaccessible, skipped: $p"
226
- Write-LogLine "skip (locked): $p - $($_.Exception.Message)"
227
- }
228
- return $r
229
- }
230
-
231
- function Remove-StaleFiles {
232
- <# .SYNOPSIS Prune files idle >= Days under Root (both timestamps rule), then sweep empty directories. #>
233
- param([Parameter(Mandatory = $true)][string] $Root, [Parameter(Mandatory = $true)][string] $Within, [int] $Days, [string] $Label = '')
234
- $ws = $Script:WS
235
- $out = [pscustomobject]@{ Freed = [long]0; Files = 0; Skipped = 0; Exists = $false }
236
- if (-not $Label) { $Label = $Root }
237
- $root = Get-FullPath $Root
238
- if (-not (Test-DirPresent $root)) { Write-Info "$Label - absent"; return $out }
239
- if (Test-ReparsePoint (Get-ItemInfo $root)) { Write-Note "$Label - is a link, not entered"; return $out }
240
- $out.Exists = $true
241
- $why = Get-ProtectionReason $root
242
- if ($why) { Write-Err "REFUSE ($why): $root"; return $out }
243
- if (-not (Test-PathWithin -Path $root -Within $Within)) { Write-Err "REFUSE (outside declared root): $root"; return $out }
244
- $scan = Get-StaleFiles -Root $root -Days $Days
245
- $total = [long]0
246
- foreach ($f in $scan.Files) { $total += $f.Bytes }
247
- if ($scan.Files.Count -eq 0) { Write-Info "$Label - nothing idle for $Days+ days"; return $out }
248
- if ($ws.DryRun) {
249
- Write-DryRun ("would prune {0} in {1} files idle {2}+ days from {3}" -f (Format-Bytes $total), $scan.Files.Count, $Days, $root)
250
- $out.Freed = $total; $out.Files = $scan.Files.Count
251
- Add-Freed $total
252
- return $out
253
- }
254
- foreach ($f in $scan.Files) {
255
- $why = Get-ProtectionReason $f.Path
256
- if ($why) { $out.Skipped++; continue }
257
- try {
258
- Remove-ReadOnlyAttribute $f.Path
259
- [IO.File]::Delete($f.Path)
260
- $out.Freed += $f.Bytes; $out.Files++
261
- } catch { $out.Skipped++ }
262
- }
263
- # Sweep directories that became empty, deepest first; never the root itself.
264
- $dirs = @($scan.Dirs | Sort-Object { $_.Length } -Descending)
265
- foreach ($d in $dirs) {
266
- try {
267
- $di = New-Object IO.DirectoryInfo($d)
268
- if (($di.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { continue }
269
- $any = $false
270
- foreach ($x in $di.EnumerateFileSystemInfos()) { $any = $true; break }
271
- if (-not $any) { [IO.Directory]::Delete($d, $false) }
272
- } catch { $null = $_ }
273
- }
274
- Add-Freed $out.Freed
275
- Write-LogLine ("pruned $($out.Files) files ($($out.Freed) bytes) idle $Days+ days from $root; skipped $($out.Skipped)")
276
- Write-Ok ("$Label - pruned {0} in {1} files idle {2}+ days ({3} skipped in use)" -f (Format-Bytes $out.Freed), $out.Files, $Days, $out.Skipped)
277
- return $out
278
- }
279
-
280
- function Remove-StaleUnits {
281
- <# .SYNOPSIS Treat each top-level child as one unit (a tool version, an AVD). Delete units idle >= Days;
282
- -KeepNewest never removes the freshest unit of each group (-GroupBy maps a name to its group; default: one group). #>
283
- param([Parameter(Mandatory = $true)][string] $Root, [Parameter(Mandatory = $true)][string] $Within, [int] $Days,
284
- [string] $Pattern = '*', [switch] $KeepNewest, [switch] $DirectoriesOnly, [scriptblock] $GroupBy = $null,
285
- [string[]] $ExcludeNames = @(), [string] $Label = '')
286
- $out = [pscustomobject]@{ Inspected = 0; Pruned = 0; Kept = 0; Freed = [long]0; Exists = $false }
287
- if (-not $Label) { $Label = $Root }
288
- $root = Get-FullPath $Root
289
- if (-not (Test-DirPresent $root)) { Write-Info "$Label - absent"; return $out }
290
- if (Test-ReparsePoint (Get-ItemInfo $root)) { Write-Note "$Label - is a link, not entered"; return $out }
291
- $out.Exists = $true
292
- $why = Get-ProtectionReason $root
293
- if ($why) { Write-Err "REFUSE ($why): $root"; return $out }
294
- if (-not (Test-PathWithin -Path $root -Within $Within)) { Write-Err "REFUSE (outside declared root): $root"; return $out }
295
- $units = @()
296
- foreach ($e in (Get-ChildEntries $root)) {
297
- if ($DirectoriesOnly -and -not ($e -is [IO.DirectoryInfo])) { continue }
298
- if ($e.Name -notlike $Pattern) { continue }
299
- if ($ExcludeNames -contains $e.Name) { continue }
300
- if (Test-ReparsePoint $e) { continue }
301
- $full = Remove-LongPrefix $e.FullName
302
- $group = 'all'
303
- if ($GroupBy) { $group = [string](& $GroupBy $e.Name) }
304
- $units += [pscustomobject]@{ Name = $e.Name; Path = $full; Idle = (Get-IdleDays $full); IsDir = ($e -is [IO.DirectoryInfo]); Group = $group }
305
- }
306
- if ($units.Count -eq 0) { Write-Info "$Label - nothing to inspect"; return $out }
307
- $keep = @{}
308
- if ($KeepNewest) {
309
- foreach ($g in ($units | Group-Object Group)) {
310
- $freshest = $g.Group | Sort-Object Idle | Select-Object -First 1
311
- $keep[$freshest.Path] = $true
312
- }
313
- }
314
- foreach ($u in ($units | Sort-Object Name)) {
315
- $out.Inspected++
316
- if ($KeepNewest -and $keep.ContainsKey($u.Path)) {
317
- Write-Note ("kept {0} (newest of its kind, {1}d idle)" -f $u.Name, $u.Idle)
318
- $out.Kept++
319
- continue
320
- }
321
- if ($u.Idle -ge $Days) {
322
- $r = Remove-PathSafe -Path $u.Path -Within $Within -Label $u.Name
323
- if ($r.Removed) {
324
- $out.Pruned++; $out.Freed += $r.Bytes
325
- if (-not $Script:WS.DryRun) { Write-Ok ("pruned {0} ({1}d idle, {2})" -f $u.Name, $u.Idle, (Format-Bytes $r.Bytes)) }
326
- }
327
- } else {
328
- Write-Note ("kept {0} ({1}d idle, within the {2}-day window)" -f $u.Name, $u.Idle, $Days)
329
- $out.Kept++
330
- }
331
- }
332
- return $out
333
- }
334
-
335
- function Clear-DirectoryContents {
336
- <# .SYNOPSIS Remove everything inside Path (never Path itself). -OlderThanDays limits it to idle entries. #>
337
- param([Parameter(Mandatory = $true)][string] $Path, [Parameter(Mandatory = $true)][string] $Within, [int] $OlderThanDays = 0, [string] $Label = '')
338
- $out = [pscustomobject]@{ Freed = [long]0; Items = 0; Skipped = 0; Exists = $false }
339
- if (-not $Label) { $Label = $Path }
340
- $p = Get-FullPath $Path
341
- if (-not (Test-DirPresent $p)) { Write-Info "$Label - absent"; return $out }
342
- if (Test-ReparsePoint (Get-ItemInfo $p)) { Write-Note "$Label - is a link, not entered"; return $out }
343
- $out.Exists = $true
344
- $why = Get-ProtectionReason $p
345
- if ($why) { Write-Err "REFUSE ($why): $p"; return $out }
346
- if (-not (Test-PathWithin -Path $p -Within $Within)) { Write-Err "REFUSE (outside declared root $Within): $p"; return $out }
347
- $entries = @(Get-ChildEntries $p)
348
- if ($entries.Count -eq 0) { Write-Info "$Label - already empty"; return $out }
349
- if ($Script:WS.DryRun) {
350
- # One line per folder in dry-run: the estimate is the sum of what each eligible child would free.
351
- $est = [long]0; $n = 0
352
- foreach ($e in $entries) {
353
- $full = Remove-LongPrefix $e.FullName
354
- if ($OlderThanDays -gt 0 -and (Get-IdleDays $full) -lt $OlderThanDays) { continue }
355
- if (Get-ProtectionReason $full) { continue }
356
- if (Test-ReparsePoint $e) { $n++; continue }
357
- if ($e -is [IO.DirectoryInfo]) { $est += (Get-DirectoryStats $full).Bytes } else { $est += [long]$e.Length }
358
- $n++
359
- }
360
- if ($n -eq 0) { Write-Info "$Label - nothing eligible"; return $out }
361
- $age = ''
362
- if ($OlderThanDays -gt 0) { $age = " idle $OlderThanDays+ days" }
363
- Write-DryRun ("would clear {0} in {1} items{2} from {3}" -f (Format-Bytes $est), $n, $age, $p)
364
- Add-Freed $est
365
- $out.Items = $n; $out.Freed = $est
366
- return $out
367
- }
368
- foreach ($e in $entries) {
369
- $full = Remove-LongPrefix $e.FullName
370
- if ($OlderThanDays -gt 0 -and (Get-IdleDays $full) -lt $OlderThanDays) { continue }
371
- $r = Remove-PathSafe -Path $full -Within $p -Label $Label
372
- if ($r.Removed) { $out.Items++; $out.Freed += $r.Bytes }
373
- $out.Skipped += $r.Skipped
374
- }
375
- if (-not $Script:WS.DryRun -and $out.Items -gt 0) {
376
- Write-Ok ("$Label - cleared {0} ({1} items, {2} skipped in use)" -f (Format-Bytes $out.Freed), $out.Items, $out.Skipped)
377
- } elseif (-not $Script:WS.DryRun) {
378
- Write-Info "$Label - nothing eligible"
379
- }
380
- return $out
381
- }
382
-
383
- function Send-ToRecycleBin {
384
- <# .SYNOPSIS Move a personal file/folder to the Recycle Bin (or delete when --permanent). Same guards as Remove-PathSafe. #>
385
- param([Parameter(Mandatory = $true)][string] $Path, [Parameter(Mandatory = $true)][string] $Within)
386
- $ws = $Script:WS
387
- if ($ws.Permanent) { return (Remove-PathSafe -Path $Path -Within $Within) }
388
- $r = New-RemoveResult
389
- $p = Get-FullPath $Path
390
- $why = Get-ProtectionReason $p
391
- if ($why) { $r.Reason = $why; Write-Err "REFUSE ($why): $p"; return $r }
392
- if (-not (Test-PathWithin -Path $p -Within $Within) -or $p.Equals((Get-FullPath $Within), [StringComparison]::OrdinalIgnoreCase)) {
393
- $r.Reason = "outside declared root $Within"; Write-Err "REFUSE ($($r.Reason)): $p"; return $r
394
- }
395
- $info = Get-ItemInfo $p
396
- if ($null -eq $info) { $r.Removed = $true; return $r }
397
- $bytes = [long](Get-DirectoryStats $p).Bytes
398
- if ($ws.DryRun) { Write-DryRun ("would move to Recycle Bin {0,10} {1}" -f (Format-Bytes $bytes), $p); $r.Removed = $true; $r.Bytes = $bytes; Add-Freed $bytes; return $r }
399
- try {
400
- Add-Type -AssemblyName Microsoft.VisualBasic -ErrorAction Stop
401
- if ($info -is [IO.DirectoryInfo]) {
402
- [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory($p, [Microsoft.VisualBasic.FileIO.UIOption]::OnlyErrorDialogs, [Microsoft.VisualBasic.FileIO.RecycleOption]::SendToRecycleBin)
403
- } else {
404
- [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile($p, [Microsoft.VisualBasic.FileIO.UIOption]::OnlyErrorDialogs, [Microsoft.VisualBasic.FileIO.RecycleOption]::SendToRecycleBin)
405
- }
406
- $r.Removed = $true; $r.Bytes = $bytes
407
- Add-Freed $bytes
408
- Write-LogLine "recycled: $p ($bytes bytes)"
409
- Write-Ok ("moved to Recycle Bin: {0} ({1})" -f $p, (Format-Bytes $bytes))
410
- } catch {
411
- $r.Skipped++; $r.Reason = $_.Exception.Message
412
- Write-Warn "could not recycle $p - $($_.Exception.Message)"
413
- }
414
- return $r
415
- }
416
-
417
- function Invoke-External {
418
- <# .SYNOPSIS Run an external command, streaming its output to the log. -Destructive commands are skipped in --dry-run. #>
419
- param([Parameter(Mandatory = $true)][string] $FilePath, [string[]] $ArgumentList = @(), [switch] $Destructive, [switch] $Quiet, [string] $Label = '')
420
- $display = "$FilePath $($ArgumentList -join ' ')"
421
- $tag = ''
422
- if ($Label) { $tag = " [$Label]" }
423
- if ($Destructive -and $Script:WS.DryRun) {
424
- Write-DryRun "would run: $display"
425
- return [pscustomobject]@{ Ran = $false; ExitCode = 0; Output = @() }
426
- }
427
- Write-LogLine "run${tag}: $display"
428
- $lines = @()
429
- try {
430
- $raw = & $FilePath @ArgumentList 2>&1
431
- foreach ($l in $raw) {
432
- $s = [string]$l
433
- $lines += $s
434
- if (-not $Quiet) { Write-Note $s } else { Write-LogLine $s }
435
- }
436
- $code = $LASTEXITCODE
437
- if ($null -eq $code) { $code = 0 }
438
- } catch {
439
- $lines += $_.Exception.Message
440
- $code = 1
441
- }
442
- Write-LogLine "exit $code$tag : $display"
443
- return [pscustomobject]@{ Ran = $true; ExitCode = $code; Output = $lines }
444
- }
445
-
446
- function Test-CommandPresent {
447
- param([string] $Name)
448
- return ($null -ne (Get-Command $Name -ErrorAction SilentlyContinue))
449
- }
450
-
451
- function Test-ProcessRunning {
452
- <# .SYNOPSIS True when any process with one of the given names is running. #>
453
- param([string[]] $Names)
454
- foreach ($n in $Names) {
455
- if (@(Get-Process -Name $n -ErrorAction SilentlyContinue).Count -gt 0) { return $true }
456
- }
457
- return $false
458
- }
459
-
460
- function Test-IsAdmin {
461
- try {
462
- $id = [Security.Principal.WindowsIdentity]::GetCurrent()
463
- return (New-Object Security.Principal.WindowsPrincipal($id)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
464
- } catch { return $false }
465
- }
466
-
467
- function Test-CanElevate {
468
- <# .SYNOPSIS True when the account belongs to Administrators (even in a filtered, non-elevated token). #>
469
- try {
470
- $out = & "$env:SystemRoot\System32\whoami.exe" /groups /fo csv 2>$null
471
- foreach ($l in $out) { if ($l -match 'S-1-5-32-544') { return $true } }
472
- } catch { $null = $_ }
473
- return $false
474
- }
475
-
476
- function Invoke-Elevated {
477
- <# .SYNOPSIS Relaunch this run elevated (UAC prompt) and wait for it. Returns the child's exit code. #>
478
- $ws = $Script:WS
479
- $childArgs = @('-NoProfile', '-NoLogo', '-ExecutionPolicy', 'Bypass', '-File', ('"' + $ws.ScriptPath + '"'))
480
- foreach ($a in $ws.RawArgs) {
481
- if ($a -eq '--elevate') { continue }
482
- if ($a -match '[\s"]') { $childArgs += ('"' + ($a -replace '"', '\"') + '"') } else { $childArgs += $a }
483
- }
484
- $childArgs += '--elevated-child'
485
- Write-Info 'Requesting elevation (a UAC prompt appears). The elevated run opens in a new window and writes its own log.'
486
- try {
487
- $proc = Start-Process -FilePath 'powershell.exe' -ArgumentList $childArgs -Verb RunAs -Wait -PassThru
488
- return $proc.ExitCode
489
- } catch {
490
- Write-Err "elevation refused or failed: $($_.Exception.Message)"
491
- return $Script:WS_EXIT_REFUSED
492
- }
493
- }
1
+ # safety.ps1 - the deletion chokepoint and its guards.
2
+ #
3
+ # Every byte this tool removes passes through Remove-PathSafe (or Send-ToRecycleBin), which:
4
+ # 1. resolves the path and refuses relative segments, UNC paths, drive roots and system roots;
5
+ # 2. refuses every protected subtree / pattern / basename (Test-ProtectedPath);
6
+ # 3. asserts the path lies strictly inside the target root the caller declared (-Within);
7
+ # 4. short-circuits in --dry-run, printing what it would do and tallying an estimate;
8
+ # 5. removes reparse points as links, never following them; skips locked files, never fails a run.
9
+ # No flag bypasses steps 1-3.
10
+
11
+ function Initialize-Safety {
12
+ <# .SYNOPSIS Build the protected-path tables from the environment. Called once at startup. #>
13
+ $U = Get-FullPath $env:USERPROFILE
14
+ $L = Get-FullPath $env:LOCALAPPDATA
15
+ $A = Get-FullPath $env:APPDATA
16
+ $LL = Get-FullPath (Join-Path (Split-Path -Parent $L) 'LocalLow')
17
+ $SR = Get-FullPath $env:SystemRoot
18
+ $PD = Get-FullPath $env:ProgramData
19
+ $SD = $env:SystemDrive
20
+ $PF = Get-FullPath $env:ProgramFiles
21
+ $PF86 = Get-FullPath ${env:ProgramFiles(x86)}
22
+
23
+ $exact = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
24
+ foreach ($d in [IO.DriveInfo]::GetDrives()) { $null = $exact.Add($d.Name.TrimEnd('\')); $null = $exact.Add($d.Name) }
25
+ foreach ($p in @($SR, "$SR\System32", "$SR\SysWOW64", $PF, $PF86, $PD, "$SD\Users", "$SD\Users\Default", "$SD\Users\Public", $env:PUBLIC, $U, "$U\AppData", $A, $L, $LL)) {
26
+ if (-not $p) { continue }
27
+ $fp = Get-FullPath $p
28
+ if ($fp) { $null = $exact.Add($fp) }
29
+ }
30
+
31
+ $subtrees = @(
32
+ "$U\Documents", "$U\Pictures", "$U\Music", "$U\Videos", "$U\Desktop", "$U\Contacts", "$U\Favorites", "$U\Links",
33
+ "$U\Saved Games", "$U\Searches", "$U\3D Objects", "$U\Dropbox", "$U\Google Drive", "$U\iCloudDrive",
34
+ "$U\.ssh", "$U\.gnupg", "$U\.secrets", "$U\.aws", "$U\.azure", "$U\.kube", "$U\.gcloud", "$U\.docker", "$U\.config",
35
+ "$U\.local", "$U\.claude", "$U\.codex", "$U\.agents", "$U\.gemini", "$U\.copilot", "$U\.antigravity", "$U\.ollama",
36
+ "$U\.vscode-server", "$U\.cursor-server", "$U\.password-store",
37
+ "$A\npm", "$A\nvm", "$L\nvm", "$SD\nvm4w", "$L\Volta", "$L\fnm", "$L\fnm_multishells", "$L\node\corepack",
38
+ "$L\pnpm\global", "$U\.bun\bin", "$U\.bun\install\global", "$U\.deno\bin", "$U\.cargo\bin", "$U\.rustup", "$U\go\bin",
39
+ "$L\Programs", "$L\Microsoft\WindowsApps", "$L\Android\Sdk",
40
+ "$SR\Prefetch", "$SR\Installer", "$SR\WinSxS", "$SR\System32\config", "$SR\servicing", "$SR\Boot", "$SR\Fonts",
41
+ "$PD\Package Cache", "$PD\Microsoft\Windows\Start Menu",
42
+ "$SD\`$Recycle.Bin", "$SD\System Volume Information", "$SD\Recovery", "$SD\Boot", "$SD\EFI"
43
+ )
44
+ # Declared exceptions: paths inside a protected subtree that ARE regenerable caches.
45
+ $exceptions = @("$L\Android\Sdk\.temp", "$L\Android\Sdk\.downloadIntermediates")
46
+
47
+ # Wildcards matched against the full path. Browser/editor state that must never be mistaken for cache.
48
+ $patterns = @(
49
+ "$U\OneDrive*", "*\Packages\*\LocalState*", "*\Packages\*\Settings*", "*\Packages\*\RoamingState*",
50
+ '*chrome-for-testing*', '*\Chrome for Testing*',
51
+ '*\User Data\Default', '*\User Data\Guest Profile', '*\User Data\System Profile', '*\User Data\Profile ?', '*\User Data\Profile ??',
52
+ '*\User Data\*\Local Storage*', '*\User Data\*\Session Storage*', '*\User Data\*\IndexedDB*', '*\User Data\*\Cookies*',
53
+ '*\User Data\*\Login Data*', '*\User Data\*\Web Data*', '*\User Data\*\Bookmarks*', '*\User Data\*\History*',
54
+ '*\User Data\*\Sync Data*', '*\User Data\*\Preferences', '*\User Data\*\Secure Preferences', '*\User Data\*\Network\*',
55
+ '*\User Data\*\File System*', '*\User Data\*\databases*', '*\User Data\*\Sessions*', '*\User Data\*\Extension State*',
56
+ '*\User Data\*\Local Extension Settings*', '*\User Data\*\Sync Extension Settings*', '*\User Data\*\Extensions\*',
57
+ '*\User Data\*\Service Worker\Database*', '*\User Data\*\Service Worker\CacheStorage*', '*\User Data\*\shared_proto_db*',
58
+ '*\User\globalStorage*', '*\User\History*', '*\User\settings.json', '*\User\keybindings.json', '*\User\snippets*',
59
+ "$U\.cache\claude*", "$U\.cache\codex*", "$U\.cache\gemini*", "$U\.cache\copilot*", "$L\JetBrains\Toolbox*",
60
+ '*\Profiles\*\places.sqlite*', '*\Profiles\*\key4.db', '*\Profiles\*\logins.json', '*\Profiles\*\cookies.sqlite*',
61
+ '*\Profiles\*\storage\*', '*\Profiles\*\prefs.js', '*\Profiles\*\extensions\*'
62
+ )
63
+ $basenames = @('NTUSER.DAT', 'NTUSER.DAT.LOG1', 'NTUSER.DAT.LOG2', 'ntuser.ini', 'UsrClass.dat', 'UsrClass.dat.LOG1',
64
+ 'UsrClass.dat.LOG2', 'hiberfil.sys', 'pagefile.sys', 'swapfile.sys', 'bootmgr', 'BOOTNXT', 'DumpStack.log.tmp')
65
+
66
+ # Pre-normalized once: Get-ProtectionReason runs once per file during a prune (400k+ files on a big yarn
67
+ # cache), so it must be plain string tests - no per-call path resolution, no per-call wildcard compile.
68
+ $subtreesNorm = @($subtrees | ForEach-Object { Get-FullPath $_ } | Where-Object { $_ })
69
+ $exceptionsNorm = @($exceptions | ForEach-Object { Get-FullPath $_ } | Where-Object { $_ })
70
+ $wildcardIgnoreCase = [System.Management.Automation.WildcardOptions]::IgnoreCase
71
+ $Script:WS_PROTECT = @{
72
+ Exact = $exact
73
+ Subtrees = $subtreesNorm
74
+ SubtreePrefixes = @($subtreesNorm | ForEach-Object { $_ + '\' })
75
+ Exceptions = $exceptionsNorm
76
+ ExceptionPrefixes = @($exceptionsNorm | ForEach-Object { $_ + '\' })
77
+ Patterns = $patterns
78
+ PatternObjects = @($patterns | ForEach-Object { [System.Management.Automation.WildcardPattern]::Get($_, $wildcardIgnoreCase) })
79
+ Basenames = $basenames
80
+ HomePrefix = $null
81
+ }
82
+ }
83
+
84
+ function Test-PathWithin {
85
+ <# .SYNOPSIS True when Path equals Within or lies inside it (case-insensitive, normalized). #>
86
+ param([string] $Path, [string] $Within)
87
+ $p = Get-FullPath $Path; $w = Get-FullPath $Within
88
+ if (-not $p -or -not $w) { return $false }
89
+ if ($p.Equals($w, [StringComparison]::OrdinalIgnoreCase)) { return $true }
90
+ $prefix = $w
91
+ if (-not $prefix.EndsWith('\')) { $prefix = $prefix + '\' }
92
+ return $p.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)
93
+ }
94
+
95
+ function Initialize-Exclusions {
96
+ <# .SYNOPSIS Normalise --exclude-path (and config excludePaths) into prefixes Get-ProtectionReason can
97
+ test with one StartsWith. Called from Initialize-Settings and NOT from Initialize-Safety: the config
98
+ file is merged AFTER the safety tables are built, so building this table earlier would silently drop
99
+ every config-file exclusion while the flag ones kept working. #>
100
+ $ws = $Script:WS
101
+ $Script:WS_EXCLUDE = @{ Prefixes = @(); Roots = @() }
102
+ foreach ($x in @($ws.ExcludePaths)) {
103
+ if ([string]::IsNullOrWhiteSpace($x)) { continue }
104
+ $fp = Get-FullPath $x
105
+ if (-not $fp) { continue }
106
+ $Script:WS_EXCLUDE.Roots += $fp
107
+ $Script:WS_EXCLUDE.Prefixes += ($fp.TrimEnd('\') + '\')
108
+ }
109
+ }
110
+
111
+ function Get-ProtectionReason {
112
+ <# .SYNOPSIS Why a path may not be deleted, or $null when it is allowed.
113
+ Hot path: called once per file during a prune, so it uses only the tables Initialize-Safety pre-normalized.
114
+ "$p\" starts with "$root\" is exactly Test-PathWithin (equal, or inside) without the per-call resolution. #>
115
+ param([string] $Path)
116
+ if ([string]::IsNullOrWhiteSpace($Path)) { return 'empty path' }
117
+ $raw = $Path.Trim()
118
+ if ($raw -match '(^|[\\/])\.\.([\\/]|$)') { return 'relative ".." segment' }
119
+ if ($raw.StartsWith('\\') -and -not $raw.StartsWith('\\?\')) { return 'UNC path' }
120
+ $p = Get-FullPath $raw
121
+ if (-not $p) { return 'unresolvable path' }
122
+ if ($p.StartsWith('\\')) { return 'UNC path' }
123
+ if ($p -match '^[A-Za-z]:\\?$') { return 'drive root' }
124
+ $pr = $Script:WS_PROTECT
125
+ if ($pr.Exact.Contains($p)) { return "protected root: $p" }
126
+ $ic = [StringComparison]::OrdinalIgnoreCase
127
+ $pSlash = $p + '\'
128
+ foreach ($e in $pr.ExceptionPrefixes) { if ($pSlash.StartsWith($e, $ic)) { return $null } }
129
+ for ($i = 0; $i -lt $pr.SubtreePrefixes.Count; $i++) {
130
+ if ($pSlash.StartsWith($pr.SubtreePrefixes[$i], $ic)) { return "inside protected: $($pr.Subtrees[$i])" }
131
+ }
132
+ for ($i = 0; $i -lt $pr.PatternObjects.Count; $i++) {
133
+ if ($pr.PatternObjects[$i].IsMatch($p)) { return "matches protected pattern: $($pr.Patterns[$i])" }
134
+ }
135
+ $base = [IO.Path]::GetFileName($p)
136
+ foreach ($b in $pr.Basenames) { if ($base.Equals($b, $ic)) { return "protected file name: $b" } }
137
+ if ($Script:WS.Home -and -not $Script:WS.AllowOwnData) {
138
+ if (-not $pr.HomePrefix) { $pr.HomePrefix = (Get-FullPath $Script:WS.Home) + '\' }
139
+ if ($pSlash.StartsWith($pr.HomePrefix, $ic)) { return 'the tool''s own data directory' }
140
+ }
141
+ # A user exclusion is checked LAST, so a path that is both protected and excluded still reports the
142
+ # PROTECTED reason - the stronger statement, and the one no flag can lift. When nothing is excluded the
143
+ # loop runs zero times, which is what makes this safe on a hot path called once per file during a prune.
144
+ if ($Script:WS_EXCLUDE -and $Script:WS_EXCLUDE.Prefixes.Count -gt 0) {
145
+ for ($i = 0; $i -lt $Script:WS_EXCLUDE.Prefixes.Count; $i++) {
146
+ if ($pSlash.StartsWith($Script:WS_EXCLUDE.Prefixes[$i], $ic) -or $p.Equals($Script:WS_EXCLUDE.Roots[$i], $ic)) {
147
+ return "excluded: $($Script:WS_EXCLUDE.Roots[$i])"
148
+ }
149
+ }
150
+ }
151
+ return $null
152
+ }
153
+
154
+ function Test-ProtectedPath {
155
+ param([string] $Path)
156
+ return ($null -ne (Get-ProtectionReason $Path))
157
+ }
158
+
159
+ function New-RemoveResult {
160
+ return [pscustomobject]@{ Removed = $false; Bytes = [long]0; Files = 0; Skipped = 0; SkippedBytes = [long]0; Reason = $null }
161
+ }
162
+
163
+ function Remove-ReadOnlyAttribute {
164
+ param([string] $LongPath)
165
+ try {
166
+ $a = [IO.File]::GetAttributes($LongPath)
167
+ if (($a -band [IO.FileAttributes]::ReadOnly) -ne 0) { [IO.File]::SetAttributes($LongPath, ($a -bxor [IO.FileAttributes]::ReadOnly)) }
168
+ } catch { $null = $_ }
169
+ }
170
+
171
+ function Remove-TreeInternal {
172
+ <# .SYNOPSIS Recursive delete that removes nested reparse points as links and skips locked files. Internal: guards already ran. #>
173
+ param([string] $Path, [pscustomobject] $Result)
174
+ $lp = Get-LongPath $Path
175
+ $entries = @()
176
+ try { $entries = (New-Object IO.DirectoryInfo($lp)).EnumerateFileSystemInfos() } catch { $Result.Skipped++; return $false }
177
+ $allClear = $true
178
+ foreach ($e in $entries) {
179
+ $isLink = (($e.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)
180
+ $isDir = (($e.Attributes -band [IO.FileAttributes]::Directory) -ne 0)
181
+ try {
182
+ if ($isLink) {
183
+ if ($isDir) { [IO.Directory]::Delete($e.FullName, $false) } else { [IO.File]::Delete($e.FullName) }
184
+ Write-LogLine ("removed link (not followed): " + (Remove-LongPrefix $e.FullName))
185
+ } elseif ($isDir) {
186
+ if (-not (Remove-TreeInternal -Path $e.FullName -Result $Result)) { $allClear = $false }
187
+ } else {
188
+ Remove-ReadOnlyAttribute $e.FullName
189
+ [IO.File]::Delete($e.FullName)
190
+ $Result.Files++
191
+ }
192
+ } catch {
193
+ $allClear = $false
194
+ $Result.Skipped++
195
+ try { if (-not $isDir) { $Result.SkippedBytes += [long]$e.Length } } catch { $null = $_ }
196
+ }
197
+ }
198
+ if ($allClear) {
199
+ try { [IO.Directory]::Delete($lp, $false) } catch { $allClear = $false; $Result.Skipped++ }
200
+ }
201
+ return $allClear
202
+ }
203
+
204
+ function Add-ExcludedRefusal {
205
+ <# .SYNOPSIS Record a user exclusion once, for --json `excluded` and the run summary. Deliberately
206
+ narrow: only a reason starting "excluded: " is recorded, so a PROTECTED-path refusal - the tool
207
+ refusing on its own account rather than on the user's - never lands in the user-exclusion list. #>
208
+ param([string] $Reason)
209
+ if (-not $Reason -or -not $Reason.StartsWith('excluded: ')) { return }
210
+ $ws = $Script:WS
211
+ $path = $Reason.Substring(10)
212
+ if ($ws.Excluded -notcontains $path) { $ws.Excluded += $path }
213
+ if ($ws.Refusals -notcontains $Reason) { $ws.Refusals += $Reason }
214
+ Write-LogLine "excluded: $path"
215
+ }
216
+
217
+ function Remove-PathSafe {
218
+ <# .SYNOPSIS The chokepoint. Removes one file, directory or link after every guard passes. Honours --dry-run. #>
219
+ param([Parameter(Mandatory = $true)][string] $Path, [Parameter(Mandatory = $true)][string] $Within, [string] $Label = '')
220
+ $ws = $Script:WS
221
+ $r = New-RemoveResult
222
+ $tag = ''
223
+ if ($Label) { $tag = " [$Label]" }
224
+ $p = Get-FullPath $Path
225
+ $w = Get-FullPath $Within
226
+ if (-not $p -or -not $w) { $r.Reason = 'unresolvable path'; Write-Err "REFUSE ($($r.Reason)): $Path"; return $r }
227
+ $why = Get-ProtectionReason $p
228
+ if ($why) { $r.Reason = $why; Add-ExcludedRefusal $why; Write-Err "REFUSE ($why): $p"; return $r }
229
+ if ($p.Equals($w, [StringComparison]::OrdinalIgnoreCase)) { $r.Reason = 'target is its own declared root'; Write-Err "REFUSE ($($r.Reason)): $p"; return $r }
230
+ if (-not (Test-PathWithin -Path $p -Within $w)) { $r.Reason = "outside declared root $w"; Write-Err "REFUSE ($($r.Reason)): $p"; return $r }
231
+ $info = Get-ItemInfo $p
232
+ if ($null -eq $info) { $r.Removed = $true; return $r }
233
+ $isDir = ($info -is [IO.DirectoryInfo])
234
+ $isLink = Test-ReparsePoint $info
235
+ $bytes = [long]0
236
+ if ($isLink) { $bytes = 0 } elseif ($isDir) { $bytes = (Get-DirectoryStats $p).Bytes } else { $bytes = [long]$info.Length }
237
+ if ($ws.DryRun) {
238
+ Write-DryRun ("would remove {0,10} {1}" -f (Format-Bytes $bytes), $p)
239
+ $r.Removed = $true; $r.Bytes = $bytes
240
+ Add-Freed $bytes
241
+ return $r
242
+ }
243
+ $lp = Get-LongPath $p
244
+ try {
245
+ if ($isLink) {
246
+ if ($isDir) { [IO.Directory]::Delete($lp, $false) } else { [IO.File]::Delete($lp) }
247
+ $r.Removed = $true
248
+ Write-LogLine "removed link (not followed): $p$tag"
249
+ } elseif ($isDir) {
250
+ $r.Removed = Remove-TreeInternal -Path $p -Result $r
251
+ $r.Bytes = [math]::Max([long]0, $bytes - $r.SkippedBytes)
252
+ if ($r.Skipped -gt 0) { Write-Note "$($r.Skipped) item(s) in use or inaccessible, left in place under $p" }
253
+ Write-LogLine ("removed dir: $p ($($r.Bytes) bytes, skipped $($r.Skipped))$tag")
254
+ } else {
255
+ Remove-ReadOnlyAttribute $lp
256
+ [IO.File]::Delete($lp)
257
+ $r.Removed = $true; $r.Bytes = $bytes; $r.Files = 1
258
+ Write-LogLine "removed file: $p ($bytes bytes)$tag"
259
+ }
260
+ Add-Freed $r.Bytes
261
+ } catch {
262
+ $r.Skipped++
263
+ $r.Reason = 'in use or access denied'
264
+ Write-Note "in use or inaccessible, skipped: $p"
265
+ Write-LogLine "skip (locked): $p - $($_.Exception.Message)"
266
+ }
267
+ return $r
268
+ }
269
+
270
+ function Remove-StaleFiles {
271
+ <# .SYNOPSIS Prune files idle >= Days under Root (both timestamps rule), then sweep empty directories. #>
272
+ param([Parameter(Mandatory = $true)][string] $Root, [Parameter(Mandatory = $true)][string] $Within, [int] $Days, [string] $Label = '')
273
+ $ws = $Script:WS
274
+ $out = [pscustomobject]@{ Freed = [long]0; Files = 0; Skipped = 0; Exists = $false }
275
+ if (-not $Label) { $Label = $Root }
276
+ $root = Get-FullPath $Root
277
+ if (-not (Test-DirPresent $root)) { Write-Info "$Label - absent"; return $out }
278
+ if (Test-ReparsePoint (Get-ItemInfo $root)) { Write-Note "$Label - is a link, not entered"; return $out }
279
+ $out.Exists = $true
280
+ $why = Get-ProtectionReason $root
281
+ if ($why) { Write-Err "REFUSE ($why): $root"; return $out }
282
+ if (-not (Test-PathWithin -Path $root -Within $Within)) { Write-Err "REFUSE (outside declared root): $root"; return $out }
283
+ $scan = Get-StaleFiles -Root $root -Days $Days
284
+ # The guard runs ONCE, here, and both the rehearsal and the run read its result. It used to run only
285
+ # in the real-run loop below, so a dry-run counted protected and excluded files the real run then
286
+ # skipped - the estimate was higher than the run, for the one kind of file a person most wants the
287
+ # two to agree about. "The rehearsal is the same command as the performance, minus one word" is the
288
+ # product's promise; it is only true if the same filter decides both.
289
+ $keep = New-Object System.Collections.Generic.List[object]
290
+ foreach ($f in $scan.Files) {
291
+ $why = Get-ProtectionReason $f.Path
292
+ if ($why) { Add-ExcludedRefusal $why; $out.Skipped++; continue }
293
+ $keep.Add($f)
294
+ }
295
+ $total = [long]0
296
+ foreach ($f in $keep) { $total += $f.Bytes }
297
+ if ($keep.Count -eq 0) { Write-Info "$Label - nothing idle for $Days+ days"; return $out }
298
+ if ($ws.DryRun) {
299
+ Write-DryRun ("would prune {0} in {1} files idle {2}+ days from {3}" -f (Format-Bytes $total), $keep.Count, $Days, $root)
300
+ $out.Freed = $total; $out.Files = $keep.Count
301
+ Add-Freed $total
302
+ return $out
303
+ }
304
+ foreach ($f in $keep) {
305
+ try {
306
+ Remove-ReadOnlyAttribute $f.Path
307
+ [IO.File]::Delete($f.Path)
308
+ $out.Freed += $f.Bytes; $out.Files++
309
+ } catch { $out.Skipped++ }
310
+ }
311
+ # Sweep directories that became empty, deepest first; never the root itself.
312
+ $dirs = @($scan.Dirs | Sort-Object { $_.Length } -Descending)
313
+ foreach ($d in $dirs) {
314
+ try {
315
+ $di = New-Object IO.DirectoryInfo($d)
316
+ if (($di.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { continue }
317
+ $any = $false
318
+ foreach ($x in $di.EnumerateFileSystemInfos()) { $any = $true; break }
319
+ if (-not $any) { [IO.Directory]::Delete($d, $false) }
320
+ } catch { $null = $_ }
321
+ }
322
+ Add-Freed $out.Freed
323
+ Write-LogLine ("pruned $($out.Files) files ($($out.Freed) bytes) idle $Days+ days from $root; skipped $($out.Skipped)")
324
+ Write-Ok ("$Label - pruned {0} in {1} files idle {2}+ days ({3} skipped in use)" -f (Format-Bytes $out.Freed), $out.Files, $Days, $out.Skipped)
325
+ return $out
326
+ }
327
+
328
+ function Remove-StaleUnits {
329
+ <# .SYNOPSIS Treat each top-level child as one unit (a tool version, an AVD). Delete units idle >= Days;
330
+ -KeepNewest never removes the freshest unit of each group (-GroupBy maps a name to its group; default: one group). #>
331
+ param([Parameter(Mandatory = $true)][string] $Root, [Parameter(Mandatory = $true)][string] $Within, [int] $Days,
332
+ [string] $Pattern = '*', [switch] $KeepNewest, [switch] $DirectoriesOnly, [scriptblock] $GroupBy = $null,
333
+ [string[]] $ExcludeNames = @(), [string] $Label = '')
334
+ $out = [pscustomobject]@{ Inspected = 0; Pruned = 0; Kept = 0; Freed = [long]0; Exists = $false }
335
+ if (-not $Label) { $Label = $Root }
336
+ $root = Get-FullPath $Root
337
+ if (-not (Test-DirPresent $root)) { Write-Info "$Label - absent"; return $out }
338
+ if (Test-ReparsePoint (Get-ItemInfo $root)) { Write-Note "$Label - is a link, not entered"; return $out }
339
+ $out.Exists = $true
340
+ $why = Get-ProtectionReason $root
341
+ if ($why) { Write-Err "REFUSE ($why): $root"; return $out }
342
+ if (-not (Test-PathWithin -Path $root -Within $Within)) { Write-Err "REFUSE (outside declared root): $root"; return $out }
343
+ $units = @()
344
+ foreach ($e in (Get-ChildEntries $root)) {
345
+ if ($DirectoriesOnly -and -not ($e -is [IO.DirectoryInfo])) { continue }
346
+ if ($e.Name -notlike $Pattern) { continue }
347
+ if ($ExcludeNames -contains $e.Name) { continue }
348
+ if (Test-ReparsePoint $e) { continue }
349
+ $full = Remove-LongPrefix $e.FullName
350
+ $group = 'all'
351
+ if ($GroupBy) { $group = [string](& $GroupBy $e.Name) }
352
+ $units += [pscustomobject]@{ Name = $e.Name; Path = $full; Idle = (Get-IdleDays $full); IsDir = ($e -is [IO.DirectoryInfo]); Group = $group }
353
+ }
354
+ if ($units.Count -eq 0) { Write-Info "$Label - nothing to inspect"; return $out }
355
+ $keep = @{}
356
+ if ($KeepNewest) {
357
+ foreach ($g in ($units | Group-Object Group)) {
358
+ $freshest = $g.Group | Sort-Object Idle | Select-Object -First 1
359
+ $keep[$freshest.Path] = $true
360
+ }
361
+ }
362
+ foreach ($u in ($units | Sort-Object Name)) {
363
+ $out.Inspected++
364
+ if ($KeepNewest -and $keep.ContainsKey($u.Path)) {
365
+ Write-Note ("kept {0} (newest of its kind, {1}d idle)" -f $u.Name, $u.Idle)
366
+ $out.Kept++
367
+ continue
368
+ }
369
+ if ($u.Idle -ge $Days) {
370
+ $r = Remove-PathSafe -Path $u.Path -Within $Within -Label $u.Name
371
+ if ($r.Removed) {
372
+ $out.Pruned++; $out.Freed += $r.Bytes
373
+ if (-not $Script:WS.DryRun) { Write-Ok ("pruned {0} ({1}d idle, {2})" -f $u.Name, $u.Idle, (Format-Bytes $r.Bytes)) }
374
+ }
375
+ } else {
376
+ Write-Note ("kept {0} ({1}d idle, within the {2}-day window)" -f $u.Name, $u.Idle, $Days)
377
+ $out.Kept++
378
+ }
379
+ }
380
+ return $out
381
+ }
382
+
383
+ function Clear-DirectoryContents {
384
+ <# .SYNOPSIS Remove everything inside Path (never Path itself). -OlderThanDays limits it to idle entries. #>
385
+ param([Parameter(Mandatory = $true)][string] $Path, [Parameter(Mandatory = $true)][string] $Within, [int] $OlderThanDays = 0, [string] $Label = '')
386
+ $out = [pscustomobject]@{ Freed = [long]0; Items = 0; Skipped = 0; Exists = $false }
387
+ if (-not $Label) { $Label = $Path }
388
+ $p = Get-FullPath $Path
389
+ if (-not (Test-DirPresent $p)) { Write-Info "$Label - absent"; return $out }
390
+ if (Test-ReparsePoint (Get-ItemInfo $p)) { Write-Note "$Label - is a link, not entered"; return $out }
391
+ $out.Exists = $true
392
+ $why = Get-ProtectionReason $p
393
+ if ($why) { Write-Err "REFUSE ($why): $p"; return $out }
394
+ if (-not (Test-PathWithin -Path $p -Within $Within)) { Write-Err "REFUSE (outside declared root $Within): $p"; return $out }
395
+ $entries = @(Get-ChildEntries $p)
396
+ if ($entries.Count -eq 0) { Write-Info "$Label - already empty"; return $out }
397
+ if ($Script:WS.DryRun) {
398
+ # One line per folder in dry-run: the estimate is the sum of what each eligible child would free.
399
+ $est = [long]0; $n = 0
400
+ foreach ($e in $entries) {
401
+ $full = Remove-LongPrefix $e.FullName
402
+ if ($OlderThanDays -gt 0 -and (Get-IdleDays $full) -lt $OlderThanDays) { continue }
403
+ if (Get-ProtectionReason $full) { continue }
404
+ if (Test-ReparsePoint $e) { $n++; continue }
405
+ if ($e -is [IO.DirectoryInfo]) { $est += (Get-DirectoryStats $full).Bytes } else { $est += [long]$e.Length }
406
+ $n++
407
+ }
408
+ if ($n -eq 0) { Write-Info "$Label - nothing eligible"; return $out }
409
+ $age = ''
410
+ if ($OlderThanDays -gt 0) { $age = " idle $OlderThanDays+ days" }
411
+ Write-DryRun ("would clear {0} in {1} items{2} from {3}" -f (Format-Bytes $est), $n, $age, $p)
412
+ Add-Freed $est
413
+ $out.Items = $n; $out.Freed = $est
414
+ return $out
415
+ }
416
+ foreach ($e in $entries) {
417
+ $full = Remove-LongPrefix $e.FullName
418
+ if ($OlderThanDays -gt 0 -and (Get-IdleDays $full) -lt $OlderThanDays) { continue }
419
+ $r = Remove-PathSafe -Path $full -Within $p -Label $Label
420
+ if ($r.Removed) { $out.Items++; $out.Freed += $r.Bytes }
421
+ $out.Skipped += $r.Skipped
422
+ }
423
+ if (-not $Script:WS.DryRun -and $out.Items -gt 0) {
424
+ Write-Ok ("$Label - cleared {0} ({1} items, {2} skipped in use)" -f (Format-Bytes $out.Freed), $out.Items, $out.Skipped)
425
+ } elseif (-not $Script:WS.DryRun) {
426
+ Write-Info "$Label - nothing eligible"
427
+ }
428
+ return $out
429
+ }
430
+
431
+ function Send-ToRecycleBin {
432
+ <# .SYNOPSIS Move a personal file/folder to the Recycle Bin (or delete when --permanent). Same guards as Remove-PathSafe. #>
433
+ param([Parameter(Mandatory = $true)][string] $Path, [Parameter(Mandatory = $true)][string] $Within)
434
+ $ws = $Script:WS
435
+ if ($ws.Permanent) { return (Remove-PathSafe -Path $Path -Within $Within) }
436
+ $r = New-RemoveResult
437
+ $p = Get-FullPath $Path
438
+ $why = Get-ProtectionReason $p
439
+ if ($why) { $r.Reason = $why; Add-ExcludedRefusal $why; Write-Err "REFUSE ($why): $p"; return $r }
440
+ if (-not (Test-PathWithin -Path $p -Within $Within) -or $p.Equals((Get-FullPath $Within), [StringComparison]::OrdinalIgnoreCase)) {
441
+ $r.Reason = "outside declared root $Within"; Write-Err "REFUSE ($($r.Reason)): $p"; return $r
442
+ }
443
+ $info = Get-ItemInfo $p
444
+ if ($null -eq $info) { $r.Removed = $true; return $r }
445
+ $bytes = [long](Get-DirectoryStats $p).Bytes
446
+ if ($ws.DryRun) { Write-DryRun ("would move to Recycle Bin {0,10} {1}" -f (Format-Bytes $bytes), $p); $r.Removed = $true; $r.Bytes = $bytes; Add-Freed $bytes; return $r }
447
+ try {
448
+ Add-Type -AssemblyName Microsoft.VisualBasic -ErrorAction Stop
449
+ if ($info -is [IO.DirectoryInfo]) {
450
+ [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory($p, [Microsoft.VisualBasic.FileIO.UIOption]::OnlyErrorDialogs, [Microsoft.VisualBasic.FileIO.RecycleOption]::SendToRecycleBin)
451
+ } else {
452
+ [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile($p, [Microsoft.VisualBasic.FileIO.UIOption]::OnlyErrorDialogs, [Microsoft.VisualBasic.FileIO.RecycleOption]::SendToRecycleBin)
453
+ }
454
+ $r.Removed = $true; $r.Bytes = $bytes
455
+ Add-Freed $bytes
456
+ Write-LogLine "recycled: $p ($bytes bytes)"
457
+ Write-Ok ("moved to Recycle Bin: {0} ({1})" -f $p, (Format-Bytes $bytes))
458
+ } catch {
459
+ $r.Skipped++; $r.Reason = $_.Exception.Message
460
+ Write-Warn "could not recycle $p - $($_.Exception.Message)"
461
+ }
462
+ return $r
463
+ }
464
+
465
+ function Invoke-External {
466
+ <# .SYNOPSIS Run an external command, streaming its output to the log. -Destructive commands are skipped in --dry-run. #>
467
+ param([Parameter(Mandatory = $true)][string] $FilePath, [string[]] $ArgumentList = @(), [switch] $Destructive, [switch] $Quiet, [string] $Label = '')
468
+ $display = "$FilePath $($ArgumentList -join ' ')"
469
+ $tag = ''
470
+ if ($Label) { $tag = " [$Label]" }
471
+ if ($Destructive -and $Script:WS.DryRun) {
472
+ Write-DryRun "would run: $display"
473
+ return [pscustomobject]@{ Ran = $false; ExitCode = 0; Output = @() }
474
+ }
475
+ Write-LogLine "run${tag}: $display"
476
+ $lines = @()
477
+ try {
478
+ $raw = & $FilePath @ArgumentList 2>&1
479
+ foreach ($l in $raw) {
480
+ $s = [string]$l
481
+ $lines += $s
482
+ if (-not $Quiet) { Write-Note $s } else { Write-LogLine $s }
483
+ }
484
+ $code = $LASTEXITCODE
485
+ if ($null -eq $code) { $code = 0 }
486
+ } catch {
487
+ $lines += $_.Exception.Message
488
+ $code = 1
489
+ }
490
+ Write-LogLine "exit $code$tag : $display"
491
+ return [pscustomobject]@{ Ran = $true; ExitCode = $code; Output = $lines }
492
+ }
493
+
494
+ function Test-CommandPresent {
495
+ param([string] $Name)
496
+ return ($null -ne (Get-Command $Name -ErrorAction SilentlyContinue))
497
+ }
498
+
499
+ function Test-ProcessRunning {
500
+ <# .SYNOPSIS True when any process with one of the given names is running. #>
501
+ param([string[]] $Names)
502
+ foreach ($n in $Names) {
503
+ if (@(Get-Process -Name $n -ErrorAction SilentlyContinue).Count -gt 0) { return $true }
504
+ }
505
+ return $false
506
+ }
507
+
508
+ function Test-IsAdmin {
509
+ try {
510
+ $id = [Security.Principal.WindowsIdentity]::GetCurrent()
511
+ return (New-Object Security.Principal.WindowsPrincipal($id)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
512
+ } catch { return $false }
513
+ }
514
+
515
+ function Test-CanElevate {
516
+ <# .SYNOPSIS True when the account belongs to Administrators (even in a filtered, non-elevated token). #>
517
+ try {
518
+ $out = & "$env:SystemRoot\System32\whoami.exe" /groups /fo csv 2>$null
519
+ foreach ($l in $out) { if ($l -match 'S-1-5-32-544') { return $true } }
520
+ } catch { $null = $_ }
521
+ return $false
522
+ }
523
+
524
+ function Invoke-Elevated {
525
+ <# .SYNOPSIS Relaunch this run elevated (UAC prompt) and wait for it. Returns the child's exit code. #>
526
+ $ws = $Script:WS
527
+ $childArgs = @('-NoProfile', '-NoLogo', '-ExecutionPolicy', 'Bypass', '-File', ('"' + $ws.ScriptPath + '"'))
528
+ foreach ($a in $ws.RawArgs) {
529
+ if ($a -eq '--elevate') { continue }
530
+ if ($a -match '[\s"]') { $childArgs += ('"' + ($a -replace '"', '\"') + '"') } else { $childArgs += $a }
531
+ }
532
+ $childArgs += '--elevated-child'
533
+ Write-Info 'Requesting elevation (a UAC prompt appears). The elevated run opens in a new window and writes its own log.'
534
+ try {
535
+ $proc = Start-Process -FilePath 'powershell.exe' -ArgumentList $childArgs -Verb RunAs -Wait -PassThru
536
+ return $proc.ExitCode
537
+ } catch {
538
+ Write-Err "elevation refused or failed: $($_.Exception.Message)"
539
+ return $Script:WS_EXIT_REFUSED
540
+ }
541
+ }