windowsweep 1.3.2 → 1.4.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.
- package/AI-INTEGRATION-GUIDE.md +8 -8
- package/CHANGELOG.md +80 -0
- package/README.md +27 -28
- package/VERSION +1 -1
- package/lib/actions.ps1 +13 -0
- package/lib/config.ps1 +4 -0
- package/lib/constants.ps1 +12 -8
- package/lib/external.ps1 +13 -0
- package/lib/fs.ps1 +116 -14
- package/lib/safety.ps1 +48 -23
- package/lib/ui.ps1 +20 -2
- package/modules/android_avd.ps1 +187 -36
- package/modules/browser_ai.ps1 +68 -0
- package/modules/browsers.ps1 +101 -33
- package/modules/data_scanner.ps1 +158 -0
- package/modules/globals_audit.ps1 +5 -3
- package/modules/installed_programs.ps1 +69 -2
- package/modules/leftovers.ps1 +388 -0
- package/modules/orphaned_appdata.ps1 +47 -3
- package/modules/personal.ps1 +7 -37
- package/modules/projects.ps1 +2 -1
- package/modules/runner.ps1 +29 -5
- package/modules/self_test_1_4.ps1 +215 -0
- package/modules/self_test_1_4b.ps1 +418 -0
- package/modules/self_test_extra.ps1 +9 -0
- package/modules/system_admin.ps1 +17 -3
- package/modules/tool_releases.ps1 +197 -0
- package/modules/uninstall.ps1 +232 -0
- package/modules/windows_user.ps1 +46 -2
- package/package.json +2 -2
- package/windowsweep.ps1 +19 -1
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
# self_test_1_4b.ps1 - self-test groups [22]-[23] for 1.4.0: uninstall programs (section 26) and the leftover
|
|
2
|
+
# finder (sections 26 and 27). Fixtures and pure logic only; nothing is uninstalled or recycled. ASCII only.
|
|
3
|
+
|
|
4
|
+
function New-ProgramRow {
|
|
5
|
+
<# .SYNOPSIS An uninstall-hive row shaped like Get-InstalledProgramRows returns, for the fixtures below. #>
|
|
6
|
+
param([string] $Name, [string] $Un = '', [string] $Quiet = '', [int] $Msi = 0, [string] $KeyName = '', [string] $Publisher = '',
|
|
7
|
+
[string] $Location = '', [int] $System = 0, [string] $Parent = '')
|
|
8
|
+
return [pscustomobject]@{ DisplayName = $Name; DisplayVersion = ''; Publisher = $Publisher; InstallDate = ''; EstimatedSizeKb = [long]0
|
|
9
|
+
InstallLocation = $Location; UninstallString = $Un; SystemComponent = $System; KeyName = $KeyName; Machine = $false
|
|
10
|
+
KeyPath = "HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Uninstall\$KeyName"; QuietUninstallString = $Quiet
|
|
11
|
+
WindowsInstaller = $Msi; ParentKeyName = $Parent; ReleaseType = '' }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function New-ClaimsFixture {
|
|
15
|
+
param([string[]] $Tokens = @(), [string[]] $Paths = @(), [int] $RegistryCount = 5)
|
|
16
|
+
$set = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::Ordinal)
|
|
17
|
+
foreach ($t in $Tokens) { $null = $set.Add($t) }
|
|
18
|
+
return [pscustomobject]@{ Tokens = $set; RegistryCount = $RegistryCount; Rows = @(); Paths = @($Paths | ForEach-Object { Get-FullPath $_ }) }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function Invoke-SelfTest22 {
|
|
22
|
+
<# .SYNOPSIS Group [22]: section 26's command builder, offer rules and leftover pass. #>
|
|
23
|
+
param([string] $Fixture)
|
|
24
|
+
$checks = 0; $fails = 0
|
|
25
|
+
$ws = $Script:WS
|
|
26
|
+
$root = Join-Path $Fixture 'g22'
|
|
27
|
+
$saved = @{ Queue = $ws.SelectQueue; Paths = $ws.SelectPaths; Interactive = $ws.Interactive; DryRun = $ws.DryRun }
|
|
28
|
+
try {
|
|
29
|
+
Write-Section '[22] 1.4.0: uninstall programs (section 26) and the folders they leave'
|
|
30
|
+
# 22a - command lines exactly as the P52's uninstall entries wrote them (2026-11-02)
|
|
31
|
+
$checks++
|
|
32
|
+
$cases = @(
|
|
33
|
+
@('C:\Program Files (x86)\Steam\uninstall.exe', 'C:\Program Files (x86)\Steam\uninstall.exe', ''),
|
|
34
|
+
@('"C:\Users\x\AppData\Local\Programs\DockerDesktop\Docker Desktop Installer.exe" "uninstall"', 'C:\Users\x\AppData\Local\Programs\DockerDesktop\Docker Desktop Installer.exe', '"uninstall"'),
|
|
35
|
+
@('MsiExec.exe /X{4B508632-2838-4F42-84EB-ED3153633E0A}', 'MsiExec.exe', '/X{4B508632-2838-4F42-84EB-ED3153633E0A}'),
|
|
36
|
+
@('C:\Program Files\Vendor Name\drv.sys', 'C:\Program Files\Vendor Name\drv.sys', ''),
|
|
37
|
+
@('C:\Program Files\Some.Comet\app.exe /quiet', 'C:\Program Files\Some.Comet\app.exe', '/quiet')
|
|
38
|
+
)
|
|
39
|
+
$bad = @()
|
|
40
|
+
foreach ($c in $cases) { $s = Split-CommandLine $c[0]; if ($s.FilePath -ne $c[1] -or $s.Arguments -ne $c[2]) { $bad += "[$($c[0])] -> [$($s.FilePath)] [$($s.Arguments)]" } }
|
|
41
|
+
if ($bad.Count -eq 0) { Write-Ok 'command lines split at the program - unquoted spaces, quotes and dotted folders included' } else { Write-Err "command lines split wrong: $($bad -join '; ')"; $fails++ }
|
|
42
|
+
|
|
43
|
+
# 22b - every registered form maps to the silent command that worked on the P52
|
|
44
|
+
$checks++
|
|
45
|
+
$icons = Join-Path $root 'icons'
|
|
46
|
+
New-Item -ItemType Directory -Force -Path (Join-Path $icons 'PHSP_24.0') | Out-Null
|
|
47
|
+
$hd = 'C:\Program Files (x86)\Common Files\Adobe\Adobe Desktop Common\HDBox'
|
|
48
|
+
$brave = 'C:\Program Files\BraveSoftware\Brave-Browser\Application\156.1.98.63\Installer\setup.exe'
|
|
49
|
+
$copilot = 'C:\Program Files (x86)\Microsoft\Copilot\Application\156.0.4314.58\Installer\copilot_setup.exe'
|
|
50
|
+
$docker = 'C:\Users\x\AppData\Local\Programs\DockerDesktop\Docker Desktop Installer.exe'
|
|
51
|
+
$forms = @(
|
|
52
|
+
@((New-ProgramRow 'CMake' -Un 'MsiExec.exe /X{4B508632-2838-4F42-84EB-ED3153633E0A}' -Msi 1), 'msi', 'msiexec.exe', '/x {4B508632-2838-4F42-84EB-ED3153633E0A} /qn /norestart'),
|
|
53
|
+
@((New-ProgramRow 'VMware Workstation' -Msi 1 -KeyName '{6EC7759F-B700-4743-BB65-BD3ED905D545}'), 'msi', 'msiexec.exe', '/x {6EC7759F-B700-4743-BB65-BD3ED905D545} /qn /norestart'),
|
|
54
|
+
@((New-ProgramRow 'Ollama' -Un '"C:\Users\x\AppData\Local\Programs\Ollama\unins000.exe"'), 'inno', 'C:\Users\x\AppData\Local\Programs\Ollama\unins000.exe', '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART'),
|
|
55
|
+
@((New-ProgramRow 'Steam' -Un 'C:\Program Files (x86)\Steam\uninstall.exe'), 'nsis', 'C:\Program Files (x86)\Steam\uninstall.exe', '/S'),
|
|
56
|
+
@((New-ProgramRow 'Brave' -Un "`"$brave`" --uninstall --system-level"), 'chromium', $brave, '--uninstall --system-level --force-uninstall'),
|
|
57
|
+
@((New-ProgramRow 'Copilot' -Un "`"$copilot`" --uninstall --system-level"), 'chromium', $copilot, '--uninstall --system-level --force-uninstall'),
|
|
58
|
+
@((New-ProgramRow 'Docker Desktop' -Un "`"$docker`" `"uninstall`""), 'quiet-flag', $docker, 'uninstall --quiet'),
|
|
59
|
+
@((New-ProgramRow 'Vulkan SDK' -Un '"C:\VulkanSDK\1.4.363.0\maintenancetool.exe" --start-uninstaller'), 'qt-installer', 'C:\VulkanSDK\1.4.363.0\maintenancetool.exe', 'purge --confirm-command'),
|
|
60
|
+
@((New-ProgramRow 'Adobe Photoshop 2023' -Un "`"$hd\Uninstaller.exe`" --uninstall=1 --sapCode=PHSP --productVersion=24.1 --productPlatform=win64"), 'adobe', "$hd\Setup.exe", '--uninstall=1 --sapCode=PHSP --baseVersion=24.0 --platform=win64 --deleteUserPreferences=false'),
|
|
61
|
+
@((New-ProgramRow 'Quiet Tool' -Un '"C:\Q\uninst.exe"' -Quiet '"C:\Q\uninst.exe" /quiet'), 'quiet', 'C:\Q\uninst.exe', '/quiet'),
|
|
62
|
+
@((New-ProgramRow 'jq' -Un 'winget uninstall --product-code jqlang.jq_Microsoft.Winget.Source_8wekyb3d8bbwe'), 'winget', 'winget', 'uninstall --product-code jqlang.jq_Microsoft.Winget.Source_8wekyb3d8bbwe --silent --disable-interactivity --accept-source-agreements'),
|
|
63
|
+
@((New-ProgramRow 'Rustup' -Un '"C:\Users\x\.cargo\bin\rustup.exe" self uninstall'), 'rustup', 'C:\Users\x\.cargo\bin\rustup.exe', 'self uninstall -y'),
|
|
64
|
+
@((New-ProgramRow 'Bundle' -Un '"C:\ProgramData\Package Cache\{91d90ae4-6607-4b46-968a-8def759dfadf}\Setup.exe" /uninstall'), 'wix-bundle', 'C:\ProgramData\Package Cache\{91d90ae4-6607-4b46-968a-8def759dfadf}\Setup.exe', '/uninstall /quiet /norestart'),
|
|
65
|
+
@((New-ProgramRow 'Python 3.14.3' -Un '"C:\Users\x\pymanager.exe" uninstall --yes --by-id "pythoncore-3.14-64"'), 'registered-silent', 'C:\Users\x\pymanager.exe', 'uninstall --yes --by-id "pythoncore-3.14-64"'),
|
|
66
|
+
@((New-ProgramRow 'Odd Tool' -Un '"C:\O\remove-me.exe" -x'), 'vendor-ui', 'C:\O\remove-me.exe', '-x'),
|
|
67
|
+
@((New-ProgramRow 'Ghost'), 'none', '', '')
|
|
68
|
+
)
|
|
69
|
+
$bad = @()
|
|
70
|
+
foreach ($f in $forms) {
|
|
71
|
+
$cmd = Get-UninstallCommand -Row $f[0] -AdobeIconsRoot $icons
|
|
72
|
+
if ($cmd.Strategy -ne $f[1] -or $cmd.FilePath -ne $f[2] -or $cmd.Arguments -ne $f[3]) { $bad += "$($f[0].DisplayName): [$($cmd.Strategy)] [$($cmd.FilePath)] [$($cmd.Arguments)]" }
|
|
73
|
+
}
|
|
74
|
+
$okCodes = ((Get-UninstallCommand -Row $forms[4][0]).SuccessCodes -contains 19) -and ((Get-UninstallCommand -Row $forms[0][0]).SuccessCodes -contains 3010)
|
|
75
|
+
if ($bad.Count -eq 0 -and $okCodes) { Write-Ok "uninstall commands: $($forms.Count) registered forms give their silent command (MSI, Inno, NSIS, Chromium, Docker, Qt, Adobe, quiet) or the vendor's window" } else { Write-Err "uninstall commands wrong: $($bad -join '; ') codesOk=$okCodes"; $fails++ }
|
|
76
|
+
|
|
77
|
+
# 22c - what is never offered
|
|
78
|
+
$checks++
|
|
79
|
+
$offerRules = @(
|
|
80
|
+
(Test-UninstallOffered (New-ProgramRow 'Steam' -Un 'C:\S\uninstall.exe')),
|
|
81
|
+
(-not (Test-UninstallOffered (New-ProgramRow 'Microsoft Visual C++ 2015-2022 Redistributable (x64) - 14.40' -Un 'C:\v\x.exe'))),
|
|
82
|
+
(-not (Test-UninstallOffered (New-ProgramRow 'NVIDIA Graphics Driver 566.14' -Un 'C:\n\x.exe'))),
|
|
83
|
+
(-not (Test-UninstallOffered (New-ProgramRow "Intel$([char]0x00AE) PROSet/Wireless Software" -Un 'C:\i\x.exe'))),
|
|
84
|
+
(-not (Test-UninstallOffered (New-ProgramRow 'Some Component' -Un 'C:\c\x.exe' -System 1))),
|
|
85
|
+
(-not (Test-UninstallOffered (New-ProgramRow 'Office Patch' -Un 'C:\p\x.exe' -Parent 'Office12'))),
|
|
86
|
+
(-not (Test-UninstallOffered (New-ProgramRow 'windowsweep' -Un 'C:\w\x.exe'))),
|
|
87
|
+
(-not (Test-UninstallOffered (New-ProgramRow 'No Command')))
|
|
88
|
+
)
|
|
89
|
+
if (@($offerRules | Where-Object { -not $_ }).Count -eq 0) { Write-Ok 'runtimes, drivers, Windows parts, patches, this tool and entries without an uninstaller are never offered' } else { Write-Err "offer rules wrong: [$($offerRules -join ',')]"; $fails++ }
|
|
90
|
+
|
|
91
|
+
# 22d - leftovers: the install folder and folders named like the program or its publisher; claimed ones stay
|
|
92
|
+
$checks++
|
|
93
|
+
$pf = Join-Path $root 'pf'
|
|
94
|
+
foreach ($d in 'Steam', 'SteamLibrary', 'Valve', 'ContosoData', 'SteamService', 'Internet Explorer', 'Notepad++') { New-Item -ItemType Directory -Force -Path (Join-Path $pf $d) | Out-Null }
|
|
95
|
+
$svc = Join-Path $pf 'SteamService\svc.exe'
|
|
96
|
+
[IO.File]::WriteAllText($svc, 'x')
|
|
97
|
+
$claims = New-ClaimsFixture -Tokens @('contosodata') -Paths @($svc)
|
|
98
|
+
$steam = New-ProgramRow 'Steam' -Un 'C:\S\uninstall.exe' -Publisher 'Valve Corporation' -Location (Join-Path $pf 'Steam')
|
|
99
|
+
$contoso = New-ProgramRow 'Contoso Viewer' -Un 'C:\C\uninstall.exe' -Publisher 'Contoso Ltd'
|
|
100
|
+
$found = @(Find-ProgramLeftovers -Programs @($steam, $contoso) -Roots @($pf) -Claims $claims)
|
|
101
|
+
$offer = @($found | Where-Object { -not $_.Reason } | ForEach-Object { Split-Path -Leaf $_.Path } | Sort-Object)
|
|
102
|
+
$kept = @($found | Where-Object { $_.Reason } | ForEach-Object { Split-Path -Leaf $_.Path } | Sort-Object)
|
|
103
|
+
if (($offer -join ',') -eq 'Steam,SteamLibrary,Valve' -and ($kept -join ',') -eq 'ContosoData,SteamService') { Write-Ok 'leftovers: install folder and folders named like the program or publisher; a claimed name or a path still in use is only reported' } else { Write-Err "leftovers wrong: offer=[$($offer -join ',')] kept=[$($kept -join ',')]"; $fails++ }
|
|
104
|
+
|
|
105
|
+
# 22e - an install folder another program still uses, or a protected root, is never offered
|
|
106
|
+
$checks++
|
|
107
|
+
$shared = Join-Path $root 'shared'
|
|
108
|
+
New-Item -ItemType Directory -Force -Path (Join-Path $shared 'Other') | Out-Null
|
|
109
|
+
$other = Join-Path $shared 'Other\app.exe'
|
|
110
|
+
[IO.File]::WriteAllText($other, 'x')
|
|
111
|
+
$progs = @((New-ProgramRow 'Shared Thing' -Un 'C:\x\uninstall.exe' -Location $shared), (New-ProgramRow 'Rooted Thing' -Un 'C:\y\uninstall.exe' -Location $Script:P.PF))
|
|
112
|
+
$g = @(Find-ProgramLeftovers -Programs $progs -Roots @((Join-Path $root 'none')) -Claims (New-ClaimsFixture -Paths @($other)))
|
|
113
|
+
$pfRow = @($g | Where-Object { $_.Path -eq (Get-FullPath $Script:P.PF) })
|
|
114
|
+
if ($g.Count -eq 2 -and @($g | Where-Object { -not $_.Reason }).Count -eq 0 -and $pfRow.Count -eq 1 -and $pfRow[0].Reason -like 'protected root*') { Write-Ok 'an install folder still in use, or registered as a protected root, is reported and never offered' } else { Write-Err "install-folder guard wrong: $(@($g | ForEach-Object { "$($_.Path)=$($_.Reason)" }) -join '; ')"; $fails++ }
|
|
115
|
+
|
|
116
|
+
# 22f - an unreadable registry offers nothing
|
|
117
|
+
$checks++
|
|
118
|
+
$fc = @(Find-ProgramLeftovers -Programs @($steam) -Roots @($pf) -Claims (New-ClaimsFixture -RegistryCount 0))
|
|
119
|
+
if ($fc.Count -eq 0) { Write-Ok 'no readable uninstall entries: no leftover is offered (fail closed)' } else { Write-Err "fail-closed gate broken: $($fc.Count) row(s)"; $fails++ }
|
|
120
|
+
|
|
121
|
+
# 22g - the second prompt never takes a queued --select meant for a later prompt; section 27's does
|
|
122
|
+
$checks++
|
|
123
|
+
$ws.Interactive = $false; $ws.DryRun = $true; $ws.SelectPaths = @(); $ws.SelectQueue = @('1')
|
|
124
|
+
$row = [pscustomobject]@{ Path = (Get-FullPath (Join-Path $pf 'Valve')); Root = (Get-FullPath $pf); Program = 'Steam'; Reason = $null; Idle = -1; Bytes = [long]0 }
|
|
125
|
+
Invoke-LeftoverChoice -Rows @($row) -Section 26 -PathsOrConsoleOnly
|
|
126
|
+
$left26 = @($ws.SelectQueue).Count; $scripted26 = [bool]$ws.LastSelectionScripted
|
|
127
|
+
Invoke-LeftoverChoice -Rows @($row) -Section 27
|
|
128
|
+
$left27 = @($ws.SelectQueue).Count; $scripted27 = [bool]$ws.LastSelectionScripted
|
|
129
|
+
if ($left26 -eq 1 -and -not $scripted26 -and $left27 -eq 0 -and $scripted27 -and (Test-Path -LiteralPath $row.Path)) { Write-Ok "section 26's leftover prompt never picks from a queued --select; section 27's prompt does" } else { Write-Err "select queue handling wrong: 26 left=$left26 scripted=$scripted26; 27 left=$left27 scripted=$scripted27"; $fails++ }
|
|
130
|
+
|
|
131
|
+
# 22h - the catalogue contract: picked by a person only, never batched
|
|
132
|
+
$checks++
|
|
133
|
+
$s26 = Get-Section 26; $s27 = Get-Section 27
|
|
134
|
+
$batched = @($Script:WS_SAFE_BATCH + $Script:WS_SAFE_BATCH_ADMIN + @($Script:WS_PROFILES.Values | ForEach-Object { $_ }))
|
|
135
|
+
$walk = @($Script:WS_WALKTHROUGH + $Script:WS_WALKTHROUGH_ADMIN)
|
|
136
|
+
$ok = ($s26.Batch -eq 'interactive' -and -not $s26.Admin -and $s26.Tier -eq 'permanent' -and $s27.Batch -eq 'interactive' -and $s27.Admin -and
|
|
137
|
+
$s27.Tier -eq 'recycle' -and $batched -notcontains 26 -and $batched -notcontains 27 -and $walk -notcontains 26 -and $Script:WS_WALKTHROUGH_ADMIN -contains 27)
|
|
138
|
+
if ($ok) { Write-Ok 'sections 26 and 27 are interactive and in no batch or profile; 27 joins the elevated walkthrough' } else { Write-Err 'catalogue contract for 26/27 wrong'; $fails++ }
|
|
139
|
+
} catch {
|
|
140
|
+
Write-Err "group [22] crashed: $($_.Exception.Message) | at $(($_.ScriptStackTrace -split "`n" | Select-Object -First 3) -join ' <- ')"; $fails++
|
|
141
|
+
} finally {
|
|
142
|
+
$ws.SelectQueue = $saved.Queue; $ws.SelectPaths = $saved.Paths; $ws.Interactive = $saved.Interactive; $ws.DryRun = $saved.DryRun
|
|
143
|
+
try { if (Test-Path -LiteralPath $root) { [IO.Directory]::Delete((Get-LongPath $root), $true) } } catch { $null = $_ }
|
|
144
|
+
}
|
|
145
|
+
return [pscustomobject]@{ Checks = $checks; Fails = $fails }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function Invoke-SelfTest26 {
|
|
149
|
+
<# .SYNOPSIS Elevated JSON handoff to a waiting desktop or CLI parent; no UAC needed. #>
|
|
150
|
+
param([Parameter(Mandatory)] [string] $Fixture)
|
|
151
|
+
$savedDir = $Script:WS.ReportsDir
|
|
152
|
+
$savedChild = $Script:WS.ElevatedChild
|
|
153
|
+
$savedToken = $Script:WS.ElevatedSummaryToken
|
|
154
|
+
$token = [guid]::NewGuid().ToString('N')
|
|
155
|
+
$path = Join-Path $Fixture ("elevated-summary-$token.json")
|
|
156
|
+
$checks = 1; $fails = 0
|
|
157
|
+
try {
|
|
158
|
+
$Script:WS.ReportsDir = $Fixture
|
|
159
|
+
$Script:WS.ElevatedChild = $true
|
|
160
|
+
$Script:WS.ElevatedSummaryToken = $token
|
|
161
|
+
$json = '{"candidates":[{"section":27,"path":"C:\\Fixture"}]}'
|
|
162
|
+
Save-ElevatedSummary -Json $json
|
|
163
|
+
$returned = [IO.File]::ReadAllText($path) | ConvertFrom-Json
|
|
164
|
+
Remove-Item -LiteralPath $path -Force
|
|
165
|
+
$Script:WS.ElevatedChild = $false
|
|
166
|
+
Save-ElevatedSummary -Json $json
|
|
167
|
+
if ($returned.candidates[0].section -eq 27 -and -not (Test-Path -LiteralPath $path)) {
|
|
168
|
+
Write-Ok 'elevated JSON reaches the parent file; ordinary runs write no handoff'
|
|
169
|
+
} else { Write-Err 'elevated JSON handoff or ordinary-run guard failed'; $fails++ }
|
|
170
|
+
} catch { Write-Err "elevated JSON handoff failed: $($_.Exception.Message)"; $fails++ }
|
|
171
|
+
finally {
|
|
172
|
+
Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue
|
|
173
|
+
$Script:WS.ReportsDir = $savedDir
|
|
174
|
+
$Script:WS.ElevatedChild = $savedChild
|
|
175
|
+
$Script:WS.ElevatedSummaryToken = $savedToken
|
|
176
|
+
}
|
|
177
|
+
return [pscustomobject]@{ Checks = $checks; Fails = $fails }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function Invoke-SelfTest24 {
|
|
181
|
+
<# .SYNOPSIS Group [24]: the 1.4.0 extensions to sections 4, 7, 10 and 24. #>
|
|
182
|
+
param([string] $Fixture)
|
|
183
|
+
$checks = 0; $fails = 0
|
|
184
|
+
$root = Join-Path $Fixture 'g24'
|
|
185
|
+
$pr = $Script:WS_PROTECT
|
|
186
|
+
$savedEx = @($pr.Exceptions); $savedExP = @($pr.ExceptionPrefixes)
|
|
187
|
+
try {
|
|
188
|
+
Write-Section '[24] 1.4.0: emulator leftovers (4), declared profiles (7), declared temp roots (10), last run (24)'
|
|
189
|
+
# 24a - an AVD, its pointer, and a pointer to a folder that is gone (the P52's pointed into a missing drive)
|
|
190
|
+
$checks++
|
|
191
|
+
$avdRoot = Join-Path $root 'avd'
|
|
192
|
+
$pix = Join-Path $avdRoot 'Pixel.avd'
|
|
193
|
+
New-Item -ItemType Directory -Force -Path $pix | Out-Null
|
|
194
|
+
[IO.File]::WriteAllLines((Join-Path $pix 'config.ini'), [string[]]@('hw.cpu.arch=x86_64', 'image.sysdir.1=system-images\android-34\default\x86_64\'))
|
|
195
|
+
[IO.File]::WriteAllLines((Join-Path $avdRoot 'Pixel.ini'), [string[]]@('avd.ini.encoding=UTF-8', "path=$pix"))
|
|
196
|
+
[IO.File]::WriteAllLines((Join-Path $avdRoot 'Gone.ini'), [string[]]@('avd.ini.encoding=UTF-8', ('path=' + (Join-Path $root 'missing\Gone.avd'))))
|
|
197
|
+
$inv = Get-AvdInventory -Roots @($avdRoot)
|
|
198
|
+
if (@($inv.Avds).Count -eq 1 -and -not $inv.Avds[0].External -and @($inv.Dangling).Count -eq 1 -and (Split-Path -Leaf $inv.Dangling[0].Path) -eq 'Gone.ini') { Write-Ok 'AVDs: a pointer whose AVD folder is gone is found; the live pair is one AVD' } else { Write-Err "AVD inventory wrong: avds=$(@($inv.Avds).Count) dangling=[$(@($inv.Dangling | ForEach-Object { $_.Path }) -join ',')]"; $fails++ }
|
|
199
|
+
|
|
200
|
+
# 24b - images: one in use stays; with no AVD left everything and the emulator go; an AVD naming none keeps all
|
|
201
|
+
$checks++
|
|
202
|
+
$imgs = @(
|
|
203
|
+
[pscustomobject]@{ Package = 'system-images;android-34;default;x86_64'; Path = 'x'; Rel = 'system-images\android-34\default\x86_64' },
|
|
204
|
+
[pscustomobject]@{ Package = 'system-images;android-36.1;google_apis_playstore;x86_64'; Path = 'y'; Rel = 'system-images\android-36.1\google_apis_playstore\x86_64' }
|
|
205
|
+
)
|
|
206
|
+
$one = @((Get-UnusedSdkPackages -Images $imgs -Avds @($inv.Avds) -EmulatorPresent $true).Packages | ForEach-Object { $_.Package })
|
|
207
|
+
$none = @((Get-UnusedSdkPackages -Images $imgs -Avds @() -EmulatorPresent $true).Packages | ForEach-Object { $_.Package })
|
|
208
|
+
$blank = Join-Path $root 'blank.avd'
|
|
209
|
+
New-Item -ItemType Directory -Force -Path $blank | Out-Null
|
|
210
|
+
$odd = Get-UnusedSdkPackages -Images $imgs -Avds @([pscustomobject]@{ Name = 'blank.avd'; Path = $blank }) -EmulatorPresent $true
|
|
211
|
+
if (($one -join ',') -eq 'system-images;android-36.1;google_apis_playstore;x86_64' -and $none.Count -eq 3 -and $none -contains 'emulator' -and @($odd.Packages).Count -eq 0 -and $odd.Reason) { Write-Ok 'SDK packages: only images no AVD uses; the emulator once none is left; an unreadable AVD keeps every image' } else { Write-Err "SDK package plan wrong: one=[$($one -join ',')] none=[$($none -join ',')] odd=$(@($odd.Packages).Count)"; $fails++ }
|
|
212
|
+
|
|
213
|
+
# 24c - a declared profile root: only its cache folders become exceptions (D79), never the profile or its data
|
|
214
|
+
$checks++
|
|
215
|
+
$prof = Join-Path $root 'profiles\run1'
|
|
216
|
+
foreach ($d in 'Default\Cache', 'Default\Code Cache', 'Default\Local Storage', 'ShaderCache') { New-Item -ItemType Directory -Force -Path (Join-Path $prof $d) | Out-Null }
|
|
217
|
+
[IO.File]::WriteAllText((Join-Path $prof 'Local State'), '{}')
|
|
218
|
+
$dirs = @(Get-DeclaredProfileDirs -Roots @((Join-Path $root 'profiles')))
|
|
219
|
+
$added = Add-CacheLeafExceptions -Paths @(@(Get-ChromiumCacheDirs $prof) + $prof + (Join-Path $prof 'Default\Local Storage'))
|
|
220
|
+
$ex = @($pr.Exceptions)
|
|
221
|
+
if ($dirs.Count -eq 1 -and $added -eq 3 -and $ex -contains (Get-FullPath (Join-Path $prof 'Default\Cache')) -and $ex -notcontains (Get-FullPath $prof) -and $ex -notcontains (Get-FullPath (Join-Path $prof 'Default\Local Storage'))) { Write-Ok 'declared profiles: exactly the cache folders are lifted - the profile and its Local Storage stay protected' } else { Write-Err "profile-root exceptions wrong: dirs=$($dirs.Count) added=$added"; $fails++ }
|
|
222
|
+
|
|
223
|
+
# 24d - the three ways a browser command line names its profile
|
|
224
|
+
$checks++
|
|
225
|
+
$forms = @(
|
|
226
|
+
@('chrome.exe --user-data-dir=C:\t\sl-1 --headless', 'C:\t\sl-1'),
|
|
227
|
+
@('chrome.exe --user-data-dir="C:\a b\p" --x', 'C:\a b\p'),
|
|
228
|
+
@('"chrome.exe" "--user-data-dir=C:\a b\q" --x', 'C:\a b\q'),
|
|
229
|
+
@('chrome.exe --type=renderer', '')
|
|
230
|
+
)
|
|
231
|
+
$bad = @($forms | Where-Object { (Get-UserDataDirFromCommandLine $_[0]) -ne $_[1] } | ForEach-Object { $_[0] })
|
|
232
|
+
if ($bad.Count -eq 0) { Write-Ok '--user-data-dir is read bare, quoted and inside a quoted argument' } else { Write-Err "user-data-dir parsing wrong: $($bad -join '; ')"; $fails++ }
|
|
233
|
+
|
|
234
|
+
# 24e - a declared temp root: idle entries go whole, a fresh one stays, one a running program names stays
|
|
235
|
+
$checks++
|
|
236
|
+
$entries = @(
|
|
237
|
+
[pscustomobject]@{ Path = 'C:\t\sl-old'; Idle = 10 }, [pscustomobject]@{ Path = 'C:\t\old.txt'; Idle = 10 },
|
|
238
|
+
[pscustomobject]@{ Path = 'C:\t\sl-fresh'; Idle = 0 }, [pscustomobject]@{ Path = 'C:\t\sl-busy'; Idle = 10 }
|
|
239
|
+
)
|
|
240
|
+
$go = @(Get-TempRootPlan -Entries $entries -Days 3 -BusyText @('chrome.exe --user-data-dir=C:\t\sl-busy --headless', '') | ForEach-Object { $_.Path })
|
|
241
|
+
if (($go -join ',') -eq 'C:\t\sl-old,C:\t\old.txt') { Write-Ok 'declared temp roots: idle entries go whole; fresh ones and ones in use stay' } else { Write-Err "temp-root plan wrong: [$($go -join ',')]"; $fails++ }
|
|
242
|
+
|
|
243
|
+
# 24g - a scratch entry a scanner just read (fresh last-access, old writes) is still idle by write time
|
|
244
|
+
$checks++
|
|
245
|
+
$scan = New-AgedFiles -Dir (Join-Path $root 'scratch\sl-old') -Count 30 -WrittenDaysAgo 20 -FreshReads 20
|
|
246
|
+
$wo = Get-IdleDays $scan -WriteOnly
|
|
247
|
+
if ($wo -ge 19) { Write-Ok "declared temp roots measure write time: 2 of 3 files read today, still $wo days idle" } else { Write-Err "write-only idle wrong: $wo"; $fails++ }
|
|
248
|
+
|
|
249
|
+
# 24h - D82: old files below hundreds of new ones still give the root its own verdict (P52 %TEMP%, 2026-11-03)
|
|
250
|
+
$checks++
|
|
251
|
+
$deep = Join-Path $root 'deepsample'
|
|
252
|
+
New-Item -ItemType Directory -Force -Path $deep | Out-Null
|
|
253
|
+
for ($i = 0; $i -lt 450; $i++) { [IO.File]::WriteAllText((Join-Path $deep ("n{0:D3}.tmp" -f $i)), 'x') }
|
|
254
|
+
$null = New-AgedFiles -Dir (Join-Path $deep 'zz-old') -Count 30 -WrittenDaysAgo 30 -FreshReads 30
|
|
255
|
+
Reset-AccessVerdicts
|
|
256
|
+
$verdict = Test-LastAccessLive -Root $deep
|
|
257
|
+
Reset-AccessVerdicts
|
|
258
|
+
if ($verdict) { Write-Ok 'D82: 30 old files under 450 new ones are still sampled - the root is judged itself, not by section order' } else { Write-Err 'D82 sample stopped before the old files: the root inherited a verdict'; $fails++ }
|
|
259
|
+
|
|
260
|
+
# 24f - Prefetch: file names, and days since the newest run of any of a program's files
|
|
261
|
+
$checks++
|
|
262
|
+
$pf = @{ 'chrome.exe' = [datetime]::UtcNow.AddDays(-2); 'steam.exe' = [datetime]::UtcNow.AddDays(-40); 'uninstall.exe' = [datetime]::UtcNow }
|
|
263
|
+
$ok = ((ConvertFrom-PrefetchName 'CHROME.EXE-1A2B3C4D.pf') -eq 'chrome.exe' -and (ConvertFrom-PrefetchName 'Layout.ini') -eq '' -and
|
|
264
|
+
(ConvertFrom-PrefetchName 'NOTEPAD.EXE-ZZZZZZZZ.pf') -eq '' -and (Get-LastRunDays -ExeNames @('steam.exe', 'steamwebhelper.exe') -Prefetch $pf) -eq 40 -and
|
|
265
|
+
(Get-LastRunDays -ExeNames @('chrome.exe', 'steam.exe') -Prefetch $pf) -eq 2 -and (Get-LastRunDays -ExeNames @('none.exe') -Prefetch $pf) -eq -1 -and
|
|
266
|
+
(Get-LastRunDays -ExeNames @('filezilla.exe', 'uninstall.exe') -Prefetch $pf) -eq -1)
|
|
267
|
+
if ($ok) { Write-Ok 'last run: Prefetch names parse, a program counts from its most recently run file, and a shared uninstall.exe never counts' } else { Write-Err 'Prefetch parsing or last-run days wrong'; $fails++ }
|
|
268
|
+
} catch {
|
|
269
|
+
Write-Err "group [24] crashed: $($_.Exception.Message) | at $(($_.ScriptStackTrace -split "`n" | Select-Object -First 3) -join ' <- ')"; $fails++
|
|
270
|
+
} finally {
|
|
271
|
+
$pr.Exceptions = $savedEx; $pr.ExceptionPrefixes = $savedExP
|
|
272
|
+
try { if (Test-Path -LiteralPath $root) { [IO.Directory]::Delete((Get-LongPath $root), $true) } } catch { $null = $_ }
|
|
273
|
+
}
|
|
274
|
+
return [pscustomobject]@{ Checks = $checks; Fails = $fails }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function Invoke-SelfTest25 {
|
|
278
|
+
<# .SYNOPSIS Group [25]: section 19's data scanner - reasons, refusals, root rules, the change check, --json. #>
|
|
279
|
+
param([string] $Fixture)
|
|
280
|
+
$checks = 0; $fails = 0
|
|
281
|
+
$ws = $Script:WS
|
|
282
|
+
$root = Join-Path $Fixture 'g25'
|
|
283
|
+
$savedExclude = $Script:WS_EXCLUDE
|
|
284
|
+
$saved = @{ Json = $ws.JsonMode; Cands = $ws.Candidates; Permanent = $ws.Permanent }
|
|
285
|
+
try {
|
|
286
|
+
Write-Section '[25] 1.4.0: the data scanner (section 19) - large or old, refusals, the change check'
|
|
287
|
+
$d = Join-Path $root 'data'
|
|
288
|
+
New-Item -ItemType Directory -Force -Path (Join-Path $d 'sub') | Out-Null
|
|
289
|
+
$old = [datetime]::UtcNow.AddDays(-400)
|
|
290
|
+
$spec = @(@('big-new.bin', 2048, $false), @('small-old.txt', 10, $true), @('big-old.iso', 4096, $true), @('small-new.txt', 10, $false), @('sub\deep-old.log', 10, $true), @('excluded-old.txt', 10, $true))
|
|
291
|
+
foreach ($s in $spec) {
|
|
292
|
+
$p = Join-Path $d $s[0]
|
|
293
|
+
[IO.File]::WriteAllBytes($p, (New-Object byte[] ([int]$s[1])))
|
|
294
|
+
if ($s[2]) { [IO.File]::SetCreationTimeUtc($p, $old); [IO.File]::SetLastWriteTimeUtc($p, $old); [IO.File]::SetLastAccessTimeUtc($p, $old) }
|
|
295
|
+
}
|
|
296
|
+
$exPath = Get-FullPath (Join-Path $d 'excluded-old.txt')
|
|
297
|
+
$Script:WS_EXCLUDE = @{ Prefixes = @($exPath + '\'); Roots = @($exPath) }
|
|
298
|
+
$rows = @(Find-PersonalFiles -Roots @((Get-FullPath $d)) -MinBytes 1024 -Days 100)
|
|
299
|
+
$Script:WS_EXCLUDE = $savedExclude
|
|
300
|
+
$got = (@($rows | ForEach-Object { "$(Split-Path -Leaf $_.Path)=$($_.Reason)" } | Sort-Object) -join ',')
|
|
301
|
+
|
|
302
|
+
# 25a - large at any age, old at any size, both, each file once; a small new file is not listed
|
|
303
|
+
$checks++
|
|
304
|
+
if ($got -eq 'big-new.bin=large,big-old.iso=both,deep-old.log=old,excluded-old.txt=old,small-old.txt=old') { Write-Ok 'data scanner: large (any age), old (any size) and both, each file once' } else { Write-Err "scanner reasons wrong: $got"; $fails++ }
|
|
305
|
+
|
|
306
|
+
# 25b - a file the chokepoint refuses stays visible with its reason and cannot be picked
|
|
307
|
+
$checks++
|
|
308
|
+
$ex = @($rows | Where-Object { $_.Path -eq $exPath })
|
|
309
|
+
if ($ex.Count -eq 1 -and -not $ex[0].Selectable -and $ex[0].SkipReason -like 'excluded: *' -and @($rows | Where-Object { -not $_.Selectable }).Count -eq 1) { Write-Ok 'a refused file is listed with its reason and is not selectable' } else { Write-Err 'refused file handling wrong'; $fails++ }
|
|
310
|
+
|
|
311
|
+
# 25c - personal roots: never a drive root, the profile, a system/program/app-data folder, a link or a missing folder
|
|
312
|
+
$checks++
|
|
313
|
+
$link = Join-Path $root 'link'
|
|
314
|
+
New-Item -ItemType Junction -Path $link -Target $d | Out-Null
|
|
315
|
+
$verdicts = @(
|
|
316
|
+
((Test-PersonalRoot 'C:\') -eq 'a drive root'), ((Test-PersonalRoot $Script:P.U) -eq 'the profile folder itself'),
|
|
317
|
+
((Test-PersonalRoot $Script:P.PF) -like 'inside a system*'), ((Test-PersonalRoot (Join-Path $Script:P.SR 'Temp')) -like 'inside a system*'),
|
|
318
|
+
((Test-PersonalRoot (Join-Path $Script:P.L 'Temp')) -like 'inside a system*'), ((Test-PersonalRoot $link) -like 'a link*'),
|
|
319
|
+
((Test-PersonalRoot (Join-Path $root 'missing')) -eq 'not an existing folder'), ($null -eq (Test-PersonalRoot $d))
|
|
320
|
+
)
|
|
321
|
+
if (@($verdicts | Where-Object { -not $_ }).Count -eq 0) { Write-Ok 'personal roots: drive roots, the profile, system, program and app-data folders, links and missing folders are refused' } else { Write-Err "personal root rules wrong: [$($verdicts -join ',')]"; $fails++ }
|
|
322
|
+
|
|
323
|
+
# 25d - right before removal: a changed file, and a folder on the path that became a link, are refused
|
|
324
|
+
$checks++
|
|
325
|
+
$one = @($rows | Where-Object { $_.Path -like '*small-old.txt' })[0]
|
|
326
|
+
$before = Test-PersonalFileUnchanged $one
|
|
327
|
+
[IO.File]::AppendAllText($one.Path, 'x')
|
|
328
|
+
$after = Test-PersonalFileUnchanged $one
|
|
329
|
+
$deepRow = @($rows | Where-Object { $_.Path -like '*deep-old.log' })[0]
|
|
330
|
+
Rename-Item -LiteralPath (Join-Path $d 'sub') -NewName 'sub-real'
|
|
331
|
+
New-Item -ItemType Junction -Path (Join-Path $d 'sub') -Target (Join-Path $d 'sub-real') | Out-Null
|
|
332
|
+
$swap = Test-PersonalFileUnchanged $deepRow
|
|
333
|
+
if ($null -eq $before -and $after -like 'changed since the scan*' -and $swap -like 'a folder on its path became a link*') { Write-Ok 'the change check refuses a file edited after the scan and one now reached through a link' } else { Write-Err "change check wrong: before='$before' after='$after' swap='$swap'"; $fails++ }
|
|
334
|
+
|
|
335
|
+
# 25e - --json carries the scanner's own fields after the shared ones
|
|
336
|
+
$checks++
|
|
337
|
+
$ws.JsonMode = $true; $ws.Candidates = @()
|
|
338
|
+
Add-JsonCandidate -Section 19 -Index 1 -Path 'C:\x' -Bytes 5 -IdleDays 400 -Extra ([ordered]@{ reason = 'both'; age_basis = 'write'; selectable = $true })
|
|
339
|
+
$c = $ws.Candidates[0]
|
|
340
|
+
if ((@($c.Keys) -join ',') -eq 'section,index,path,bytes,idle_days,project,reason,age_basis,selectable') { Write-Ok '--json: section 19 candidates add reason, age_basis and selectable after the shared fields' } else { Write-Err "candidate shape wrong: $(@($c.Keys) -join ',')"; $fails++ }
|
|
341
|
+
$checks++
|
|
342
|
+
$ws.Permanent = $true
|
|
343
|
+
$recycleOnly = -not (Test-UsePermanentRemoval -Permanent $ws.Permanent -AlwaysRecycle $true)
|
|
344
|
+
$normalPermanent = Test-UsePermanentRemoval -Permanent $ws.Permanent -AlwaysRecycle $false
|
|
345
|
+
if ($recycleOnly -and $normalPermanent) { Write-Ok 'personal-file Recycle Bin override survives --permanent; ordinary permanent removal still works' } else { Write-Err 'personal-file Recycle Bin override failed'; $fails++ }
|
|
346
|
+
} catch {
|
|
347
|
+
Write-Err "group [25] crashed: $($_.Exception.Message) | at $(($_.ScriptStackTrace -split "`n" | Select-Object -First 3) -join ' <- ')"; $fails++
|
|
348
|
+
} finally {
|
|
349
|
+
$Script:WS_EXCLUDE = $savedExclude
|
|
350
|
+
$ws.JsonMode = $saved.Json; $ws.Candidates = $saved.Cands; $ws.Permanent = $saved.Permanent
|
|
351
|
+
foreach ($j in @((Join-Path $root 'link'), (Join-Path $root 'data\sub'))) { try { if ((Test-Path -LiteralPath $j) -and (Test-ReparsePoint (Get-Item -LiteralPath $j -Force))) { [IO.Directory]::Delete($j, $false) } } catch { $null = $_ } }
|
|
352
|
+
try { if (Test-Path -LiteralPath $root) { [IO.Directory]::Delete((Get-LongPath $root), $true) } } catch { $null = $_ }
|
|
353
|
+
}
|
|
354
|
+
return [pscustomobject]@{ Checks = $checks; Fails = $fails }
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function Invoke-SelfTest23 {
|
|
358
|
+
<# .SYNOPSIS Group [23]: section 27's command-path reader and machine leftover finder. #>
|
|
359
|
+
param([string] $Fixture)
|
|
360
|
+
$checks = 0; $fails = 0
|
|
361
|
+
$root = Join-Path $Fixture 'g23'
|
|
362
|
+
$savedExclude = $Script:WS_EXCLUDE
|
|
363
|
+
try {
|
|
364
|
+
Write-Section '[23] 1.4.0: leftover program folders (section 27)'
|
|
365
|
+
# 23a - registered commands as services, drivers, tasks and Run entries write them
|
|
366
|
+
$checks++
|
|
367
|
+
$SR = $Script:P.SR
|
|
368
|
+
$cases = @(
|
|
369
|
+
@('\SystemRoot\System32\drivers\foo.sys', "$SR\System32\drivers\foo.sys"),
|
|
370
|
+
@('system32\DRIVERS\bar.sys', "$SR\system32\DRIVERS\bar.sys"),
|
|
371
|
+
@('\??\C:\Drivers\z.sys', 'C:\Drivers\z.sys'),
|
|
372
|
+
@('"C:\Program Files\X\x.exe" --data "D:\Data Dir\cfg"', 'C:\Program Files\X\x.exe|D:\Data Dir\cfg'),
|
|
373
|
+
@('rundll32.exe C:\Tools\a.dll,Entry', 'C:\Tools\a.dll'),
|
|
374
|
+
@('%SystemRoot%\system32\svchost.exe -k netsvcs', ([Environment]::ExpandEnvironmentVariables('%SystemRoot%') + '\system32\svchost.exe'))
|
|
375
|
+
)
|
|
376
|
+
$bad = @()
|
|
377
|
+
foreach ($c in $cases) { $got = (@(Get-CommandPaths $c[0]) -join '|'); if ($got -ne $c[1]) { $bad += "[$($c[0])] -> [$got]" } }
|
|
378
|
+
if ($bad.Count -eq 0) { Write-Ok 'service, driver, task and Run commands resolve to the paths they use' } else { Write-Err "command paths wrong: $($bad -join '; ')"; $fails++ }
|
|
379
|
+
|
|
380
|
+
# 23b - only an unclaimed folder idle --days is offered
|
|
381
|
+
$checks++
|
|
382
|
+
$m = Join-Path $root 'machine'
|
|
383
|
+
$old = [datetime]::UtcNow.AddDays(-40)
|
|
384
|
+
foreach ($d in 'OldVendor', 'FreshVendor', 'UsedByPath', 'ClaimedName', 'Windows Defender', 'MSECache', 'DESIGNER', 'Validity') { $null = New-AgedFiles -Dir (Join-Path $m $d) -Count 2 -WrittenDaysAgo 40 -FreshReads 0 }
|
|
385
|
+
[IO.File]::WriteAllText((Join-Path $m 'FreshVendor\new.txt'), 'x')
|
|
386
|
+
$claims = New-ClaimsFixture -Tokens @('claimedname') -Paths @((Join-Path $m 'UsedByPath\f000.bin'))
|
|
387
|
+
$rows = @(Find-MachineLeftovers -Days 30 -Roots @($m) -Claims $claims)
|
|
388
|
+
$names = @($rows | ForEach-Object { Split-Path -Leaf $_.Path })
|
|
389
|
+
if (($names -join ',') -eq 'OldVendor' -and -not $rows[0].Reason -and $rows[0].Idle -ge 30) { Write-Ok 'machine leftovers: a fresh folder, one still in use, one an installed program names and a Windows folder are never offered' } else { Write-Err "machine leftovers wrong: [$($names -join ',')] old=$($old.ToString('u'))"; $fails++ }
|
|
390
|
+
|
|
391
|
+
# 23c - a folder the person excluded (or a protected one) is reported, never offered
|
|
392
|
+
$checks++
|
|
393
|
+
$oldPath = Get-FullPath (Join-Path $m 'OldVendor')
|
|
394
|
+
$Script:WS_EXCLUDE = @{ Prefixes = @($oldPath + '\'); Roots = @($oldPath) }
|
|
395
|
+
$ex = @(Find-MachineLeftovers -Days 30 -Roots @($m) -Claims $claims)
|
|
396
|
+
$Script:WS_EXCLUDE = $savedExclude
|
|
397
|
+
if ($ex.Count -eq 1 -and $ex[0].Reason -like 'excluded: *') { Write-Ok 'an excluded or protected leftover carries its reason and is only reported' } else { Write-Err "exclusion not honoured: $(@($ex | ForEach-Object { "$($_.Path)=$($_.Reason)" }) -join '; ')"; $fails++ }
|
|
398
|
+
|
|
399
|
+
# 23e - an 8.3 name in a registration (COM servers store them) resolves to the long path a folder is listed by
|
|
400
|
+
$checks++
|
|
401
|
+
$long = Join-Path $Script:P.PF86 'Common Files'
|
|
402
|
+
$short = ''
|
|
403
|
+
try { $short = (New-Object -ComObject Scripting.FileSystemObject).GetFolder($long).ShortPath } catch { $short = '' }
|
|
404
|
+
if (-not $short -or $short -eq $long) { Write-Ok '8.3 names are off on this volume - nothing to expand (not judged)' }
|
|
405
|
+
elseif ((Expand-ShortPath "$short\x.dll") -eq "$long\x.dll") { Write-Ok "8.3 names expand: $short -> $long" } else { Write-Err "short path not expanded: $short -> $(Expand-ShortPath "$short\x.dll")"; $fails++ }
|
|
406
|
+
|
|
407
|
+
# 23d - an unreadable registry offers nothing
|
|
408
|
+
$checks++
|
|
409
|
+
$none = @(Find-MachineLeftovers -Days 0 -Roots @($m) -Claims (New-ClaimsFixture -RegistryCount 0))
|
|
410
|
+
if ($none.Count -eq 0) { Write-Ok 'no readable uninstall entries: no machine leftover is offered (fail closed)' } else { Write-Err "fail-closed gate broken: $($none.Count) row(s)"; $fails++ }
|
|
411
|
+
} catch {
|
|
412
|
+
Write-Err "group [23] crashed: $($_.Exception.Message) | at $(($_.ScriptStackTrace -split "`n" | Select-Object -First 3) -join ' <- ')"; $fails++
|
|
413
|
+
} finally {
|
|
414
|
+
$Script:WS_EXCLUDE = $savedExclude
|
|
415
|
+
try { if (Test-Path -LiteralPath $root) { [IO.Directory]::Delete((Get-LongPath $root), $true) } } catch { $null = $_ }
|
|
416
|
+
}
|
|
417
|
+
return [pscustomobject]@{ Checks = $checks; Fails = $fails }
|
|
418
|
+
}
|
|
@@ -354,6 +354,15 @@ function Invoke-SelfTestExtra {
|
|
|
354
354
|
$contract = Invoke-SelfTestContract -Fixture $fx
|
|
355
355
|
$checks += $contract.Checks; $fails += $contract.Fails
|
|
356
356
|
|
|
357
|
+
# [19] the 1.4.0 behaviour found on a real machine lives in modules/self_test_1_4.ps1 (P9).
|
|
358
|
+
$g19 = Invoke-SelfTest14 -Fixture $fx
|
|
359
|
+
$checks += $g19.Checks; $fails += $g19.Fails
|
|
360
|
+
$g20 = Invoke-SelfTest20 -Fixture $fx
|
|
361
|
+
$checks += $g20.Checks; $fails += $g20.Fails
|
|
362
|
+
$g21 = Invoke-SelfTest21
|
|
363
|
+
$checks += $g21.Checks; $fails += $g21.Fails
|
|
364
|
+
foreach ($g in @((Invoke-SelfTest22 -Fixture $fx), (Invoke-SelfTest23 -Fixture $fx), (Invoke-SelfTest24 -Fixture $fx), (Invoke-SelfTest25 -Fixture $fx), (Invoke-SelfTest26 -Fixture $fx))) { $checks += $g.Checks; $fails += $g.Fails }
|
|
365
|
+
|
|
357
366
|
} catch {
|
|
358
367
|
& $unmute
|
|
359
368
|
Write-Err "extra self-test crashed: $($_.Exception.Message)"; $fails++
|
package/modules/system_admin.ps1
CHANGED
|
@@ -27,11 +27,19 @@ function Set-ServiceState {
|
|
|
27
27
|
if ($Action -eq 'stop') {
|
|
28
28
|
if ($svc.Status -ne 'Running') { return $false }
|
|
29
29
|
if ($Script:WS.DryRun) { Write-DryRun "would stop service $Name"; return $true }
|
|
30
|
-
try {
|
|
30
|
+
try {
|
|
31
|
+
Stop-Service -Name $Name -Force -ErrorAction Stop -WarningAction SilentlyContinue -WarningVariable serviceWarnings
|
|
32
|
+
foreach ($warning in $serviceWarnings) { Write-Warn ([string]$warning) }
|
|
33
|
+
Write-Note "stopped $Name"
|
|
34
|
+
} catch { Write-Warn "could not stop $Name : $($_.Exception.Message)" }
|
|
31
35
|
return $true
|
|
32
36
|
}
|
|
33
37
|
if ($Script:WS.DryRun) { Write-DryRun "would start service $Name"; return $true }
|
|
34
|
-
try {
|
|
38
|
+
try {
|
|
39
|
+
Start-Service -Name $Name -ErrorAction Stop -WarningAction SilentlyContinue -WarningVariable serviceWarnings
|
|
40
|
+
foreach ($warning in $serviceWarnings) { Write-Warn ([string]$warning) }
|
|
41
|
+
Write-Note "started $Name"
|
|
42
|
+
} catch { Write-Warn "could not start $Name : $($_.Exception.Message)" }
|
|
35
43
|
return $true
|
|
36
44
|
}
|
|
37
45
|
|
|
@@ -56,7 +64,13 @@ function Invoke-Section12 {
|
|
|
56
64
|
if (Get-Command Delete-DeliveryOptimizationCache -ErrorAction SilentlyContinue) {
|
|
57
65
|
if ($ws.DryRun) { Write-DryRun 'would run Delete-DeliveryOptimizationCache -Force' }
|
|
58
66
|
else {
|
|
59
|
-
|
|
67
|
+
# Every stream is captured: the cmdlet prints "Deleting..." and a success line on stdout, which broke the
|
|
68
|
+
# one-line --json contract on the P52 (2026-11-02). Its words go to the log instead.
|
|
69
|
+
try {
|
|
70
|
+
$said = @(Delete-DeliveryOptimizationCache -Force -ErrorAction Stop *>&1)
|
|
71
|
+
foreach ($line in $said) { Write-LogLine "Delete-DeliveryOptimizationCache: $line" }
|
|
72
|
+
Write-Ok 'Delivery Optimization cache cleared'
|
|
73
|
+
} catch { Write-Warn "Delivery Optimization: $($_.Exception.Message)" }
|
|
60
74
|
}
|
|
61
75
|
}
|
|
62
76
|
}
|