From dc74d9068c615c18cfa10cc15d6e3bee42329ac0 Mon Sep 17 00:00:00 2001 From: pomodori92 Date: Mon, 5 Oct 2026 10:09:55 +0200 Subject: [PATCH 01/12] fix: add regression tests for WinDeleteUserProfiles and improve localization handling --- .../Unit/WinDeleteUserProfiles.Tests.ps1 | 226 ++++++++++++++++++ .github/workflows/_reusable-lint-test.yml | 3 +- tools/WinDeleteUserProfiles.ps1 | 174 ++++++++++---- wintoolkit-modules/10-Module.Localization.ps1 | 2 +- 4 files changed, 352 insertions(+), 53 deletions(-) create mode 100644 .github/tests/Unit/WinDeleteUserProfiles.Tests.ps1 diff --git a/.github/tests/Unit/WinDeleteUserProfiles.Tests.ps1 b/.github/tests/Unit/WinDeleteUserProfiles.Tests.ps1 new file mode 100644 index 00000000..f90c045b --- /dev/null +++ b/.github/tests/Unit/WinDeleteUserProfiles.Tests.ps1 @@ -0,0 +1,226 @@ +# WinToolkit CI/CD V4.2.0 +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } +<# +Regression coverage for issue #189 without running profile deletion. +Loads individual function definitions from the AST, never the tool or toolkit scripts. +Only initialization, localization, logging, and result collection are invoked. +Removal helpers are registered as text and inspected, never called. +#> +param([string]$CompiledScriptPath) + +Describe 'Issue #189 safe regressions' { + BeforeAll { + $script:RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path + $toolPath = Join-Path $script:RepoRoot 'tools\WinDeleteUserProfiles.ps1' + $localizationPath = Join-Path $script:RepoRoot 'wintoolkit-modules\10-Module.Localization.ps1' + $loggingPath = Join-Path $script:RepoRoot 'wintoolkit-modules\30-Module.Logging.ps1' + $processesPath = Join-Path $script:RepoRoot 'wintoolkit-modules\60-Module.Processes.ps1' + $uiPath = Join-Path $script:RepoRoot 'wintoolkit-modules\20-Module.UI.ps1' + + $definitions = @( + @{ Path = $toolPath; Name = 'New-ProfileRemovalSessionState' }, + @{ Path = $toolPath; Name = 'Receive-ProfileRemovalResult' }, + @{ Path = $toolPath; Name = 'Remove-ProfileRegistryEntries' }, + @{ Path = $localizationPath; Name = 'Get-SourceTextLoc' }, + @{ Path = $loggingPath; Name = 'Write-ToolkitLog' }, + @{ Path = $processesPath; Name = 'Invoke-ExternalCommandWithLog' }, + @{ Path = $processesPath; Name = 'Remove-ItemSafely' }, + @{ Path = $uiPath; Name = 'Get-SpinnerChar' } + ) + foreach ($definition in $definitions) { + $path = if ($CompiledScriptPath) { $CompiledScriptPath } else { $definition.Path } + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$errors) + if ($errors) { throw "Invalid PowerShell syntax in $path" } + $function = $ast.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $definition.Name + }, $true) + if (-not $function) { throw "Missing helper $($definition.Name) in $path" } + . ([scriptblock]::Create($function.Extent.Text)) + } + + Import-LocalizedData -BindingVariable italianData -BaseDirectory (Join-Path $script:RepoRoot 'languages') -FileName 'WinToolkit.psd1' -UICulture 'it-IT' + Import-LocalizedData -BindingVariable englishData -BaseDirectory (Join-Path $script:RepoRoot 'languages') -FileName 'WinToolkit.psd1' -UICulture 'en-US' + $script:ItalianData = $italianData + $script:EnglishData = $englishData + $script:SavedGlobals = @{} + foreach ($name in @( + 'SourceTextLanguageData', 'SourceTextDefaultLanguageData', 'SourceTextKeyAliases', + 'CurrentLogFile', 'CurrentToolName', 'Spinners', 'GuiSessionActive' + )) { + $variable = Get-Variable -Name $name -Scope Global -ErrorAction SilentlyContinue + $script:SavedGlobals[$name] = @{ Exists = $null -ne $variable; Value = if ($variable) { $variable.Value } else { $null } } + } + + function Invoke-ProfileWorkerProbe { + param([scriptblock]$Probe, [int]$Count = 1) + + $sessionState = New-ProfileRemovalSessionState + $pool = [RunspaceFactory]::CreateRunspacePool(1, 2, $sessionState, $Host) + $jobs = @() + try { + $pool.Open() + for ($index = 0; $index -lt $Count; $index++) { + $powerShell = [PowerShell]::Create() + $powerShell.RunspacePool = $pool + [void]$powerShell.AddScript($Probe.ToString()) + $jobs += @{ PowerShell = $powerShell; Handle = $powerShell.BeginInvoke() } + } + foreach ($job in $jobs) { + $job.PowerShell.EndInvoke($job.Handle) + } + } + finally { + foreach ($job in $jobs) { $job.PowerShell.Dispose() } + $pool.Close() + $pool.Dispose() + } + } + } + + BeforeEach { + $Global:SourceTextLanguageData = $script:ItalianData.Clone() + $Global:SourceTextDefaultLanguageData = $script:EnglishData.Clone() + $Global:SourceTextKeyAliases = @{} + $Global:CurrentLogFile = Join-Path $TestDrive 'profile-worker.log' + $Global:CurrentToolName = 'Issue189Probe' + $Global:Spinners = '|/-\'.ToCharArray() + $Global:GuiSessionActive = $false + Set-Content -LiteralPath $Global:CurrentLogFile -Value '' -Encoding UTF8 + } + + AfterAll { + foreach ($name in $script:SavedGlobals.Keys) { + $saved = $script:SavedGlobals[$name] + if ($saved.Exists) { + Set-Variable -Name $name -Scope Global -Value $saved.Value + } + else { + Remove-Variable -Name $name -Scope Global -ErrorAction SilentlyContinue + } + } + } + + Context 'WinDeleteUserProfiles worker initialization without deletion' { + It 'provides localization, logging, and every worker helper to pooled runspaces' { + $results = @(Invoke-ProfileWorkerProbe -Count 4 -Probe { + $message = Get-SourceTextLoc 'toolText.registeredProfilesRemoved0' -Args @(0) + Write-ToolkitLog -Level INFO -Message $message -Context @{ Tool = $Global:CurrentToolName } + [pscustomobject]@{ + Message = $message + LogPath = $Global:CurrentLogFile + ToolName = $Global:CurrentToolName + Helpers = @( + 'Get-SourceTextLoc', 'Write-ToolkitLog', 'Invoke-ExternalCommandWithLog', + 'Remove-ItemSafely', 'Remove-ProfileRegistryEntries', 'Get-SpinnerChar', + 'Clear-ProgressLine', 'Write-ProgressUpdate' + ) | ForEach-Object { (Get-Command -Name $_ -CommandType Function -ErrorAction Stop).Name } + } + }) + + $results.Count | Should -Be 4 + foreach ($result in $results) { + $result.Message | Should -Be 'Profili registrati rimossi: 0' + $result.LogPath | Should -Be $Global:CurrentLogFile + $result.ToolName | Should -Be 'Issue189Probe' + $result.Helpers.Count | Should -Be 8 + } + $logLines = @(Get-Content -LiteralPath $Global:CurrentLogFile | Where-Object { $_ -match '\[INFO\]' }) + $logLines.Count | Should -Be 4 + $logLines | Should -Match 'Profili registrati rimossi: 0' + $logLines | Should -Match 'Issue189Probe' + } + + It 'keeps worker progress helpers silent while the parent owns the console' { + $result = Invoke-ProfileWorkerProbe -Probe { + Clear-ProgressLine + Write-ProgressUpdate -Activity 'Probe' -Percent 50 + [pscustomobject]@{ + Quiet = $Global:GuiSessionActive + ClearDefinition = (Get-Command Clear-ProgressLine).Definition + ProgressDefinition = (Get-Command Write-ProgressUpdate).Definition + } + } + $result.Quiet | Should -BeTrue + $result.ClearDefinition | Should -BeNullOrEmpty + $result.ProgressDefinition | Should -BeNullOrEmpty + $Global:GuiSessionActive | Should -BeFalse + } + + It 'passes aliases and the English fallback to workers' { + $Global:SourceTextKeyAliases['test.alias'] = 'toolText.registeredProfilesRemoved0' + $Global:SourceTextDefaultLanguageData['test.fallback'] = 'Fallback: {0}' + $result = Invoke-ProfileWorkerProbe -Probe { + [pscustomobject]@{ + Alias = Get-SourceTextLoc 'test.alias' -Args @(0) + Fallback = Get-SourceTextLoc 'test.fallback' -Args @(0) + } + } + $result.Alias | Should -Be 'Profili registrati rimossi: 0' + $result.Fallback | Should -Be 'Fallback: 0' + } + + It 'fails initialization when a required helper is unavailable' { + Mock Get-Command { throw 'Required helper unavailable' } -ParameterFilter { $Name -eq 'Remove-ItemSafely' } + { New-ProfileRemovalSessionState } | Should -Throw '*Required helper unavailable*' + } + } + + Context 'Profile result collection without deletion' { + It 'records a worker exception as one failed profile with its identity' { + $powerShell = [PowerShell]::Create() + [void]$powerShell.AddScript("throw 'Synthetic runspace failure'") + $job = [pscustomobject]@{ + PowerShell = $powerShell + Handle = $powerShell.BeginInvoke() + Profile = [pscustomobject]@{ LocalPath = 'C:\Users\Issue189Synthetic'; SID = 'S-1-5-21-189-1' } + } + $results = @(Receive-ProfileRemovalResult -Job $job) + + $results.Count | Should -Be 1 + $results[0].Type | Should -Be 'Profile' + $results[0].UserName | Should -Be 'Issue189Synthetic' + $results[0].Path | Should -Be $job.Profile.LocalPath + $results[0].Sid | Should -Be $job.Profile.SID + $results[0].Success | Should -BeFalse + @($results | Where-Object { -not $_.Success }).Count | Should -Be 1 + Get-Content -LiteralPath $Global:CurrentLogFile -Raw | Should -Match '\[ERROR\].*Synthetic runspace failure' + } + + It 'preserves a structured worker result without adding other output' -ForEach @( + @{ Success = $true }, @{ Success = $false } + ) { + $powerShell = [PowerShell]::Create() + [void]$powerShell.AddScript('param($success) [pscustomobject]@{ Type = "Profile"; Success = $success }').AddArgument($Success) + $job = [pscustomobject]@{ PowerShell = $powerShell; Handle = $powerShell.BeginInvoke(); Profile = $null } + $results = @(Receive-ProfileRemovalResult -Job $job) + $results.Count | Should -Be 1 + $results[0].Type | Should -Be 'Profile' + $results[0].Success | Should -Be $Success + } + } + + Context 'Localized arguments with zero or empty values' { + It 'formats the argument' -ForEach @( + @{ Name = 'zero'; Value = 0; Expected = 'Value: 0' }, + @{ Name = 'false'; Value = $false; Expected = 'Value: False' }, + @{ Name = 'empty string'; Value = ''; Expected = 'Value: ' }, + @{ Name = 'null'; Value = $null; Expected = 'Value: ' }, + @{ Name = 'nonzero'; Value = 2; Expected = 'Value: 2' } + ) { + $Global:SourceTextLanguageData['test.argument'] = 'Value: {0}' + Get-SourceTextLoc 'test.argument' -Args @($Value) | Should -Be $Expected + } + + It 'leaves the template intact when no arguments are supplied' { + $Global:SourceTextLanguageData['test.argument'] = 'Value: {0}' + Get-SourceTextLoc 'test.argument' | Should -Be 'Value: {0}' + } + + It 'prints both Italian zero counts in the cleanup summary' { + Get-SourceTextLoc 'toolText.registeredProfilesRemoved0' -Args @(0) | Should -Be 'Profili registrati rimossi: 0' + Get-SourceTextLoc 'toolText.residualFoldersRemoved0' -Args @(0) | Should -Be 'Cartelle residue rimosse: 0' + } + } +} diff --git a/.github/workflows/_reusable-lint-test.yml b/.github/workflows/_reusable-lint-test.yml index 3695c765..7d35bad4 100644 --- a/.github/workflows/_reusable-lint-test.yml +++ b/.github/workflows/_reusable-lint-test.yml @@ -289,7 +289,8 @@ jobs: # loading them there is not a missing test, it is a suite that cannot run. $requiredFolders = @( @{ Path = '.github/tests/CI-Smoke.Tests.ps1'; Root = 'wintoolkit-modules' }, - @{ Path = '.github/tests/Unit/Tools.Contract.Tests.ps1'; Root = 'tools' } + @{ Path = '.github/tests/Unit/Tools.Contract.Tests.ps1'; Root = 'tools' }, + @{ Path = '.github/tests/Unit/WinDeleteUserProfiles.Tests.ps1'; Root = 'tools' } ) $suitePaths = @( $requiredFolders | diff --git a/tools/WinDeleteUserProfiles.ps1 b/tools/WinDeleteUserProfiles.ps1 index 5ef7347f..e9420183 100644 --- a/tools/WinDeleteUserProfiles.ps1 +++ b/tools/WinDeleteUserProfiles.ps1 @@ -87,7 +87,7 @@ function WinDeleteUserProfiles { function New-ProtectedNameSet { $excluded = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $protectedProfileNames | ForEach-Object { [void]$excluded.Add($_) } - return ,$excluded + return , $excluded } function Remove-ProfileRegistryEntries { @@ -99,19 +99,25 @@ function WinDeleteUserProfiles { [string]$UserName ) - if ([string]::IsNullOrWhiteSpace($Sid) -or $Sid -eq 'NULL') { return $false } + if ([string]::IsNullOrWhiteSpace($Sid) -or $Sid -eq 'NULL') { + return $false + } $profileRegKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\' + $Sid - if (-not (Test-Path -LiteralPath $profileRegKey)) { return $false } + if (-not (Test-Path -LiteralPath $profileRegKey)) { + return $true + } try { Remove-Item -LiteralPath $profileRegKey -Recurse -Force -ErrorAction Stop Write-ToolkitLog -Level 'SUCCESS' -Message (Get-SourceTextLoc 'toolText.registryEntryRemovedForSid01' -Args @($UserName, $Sid)) + return $true } catch { Write-ToolkitLog -Level 'WARNING' -Message (Get-SourceTextLoc 'toolText.failedToRemoveRegistryEntryForSid01' -Args @($UserName, $($_.Exception.Message))) + return $false } } @@ -124,24 +130,25 @@ function WinDeleteUserProfiles { } catch { Write-ToolkitLog -Level 'WARNING' -Message (Get-SourceTextLoc 'toolText.cimProfileDetectionFailed0' -Args @($($_.Exception.Message))) - return ,$pathSet + + return , $pathSet } $cimProfiles | - Where-Object { - $_.LocalPath -and - $_.LocalPath.StartsWith($usersRoot, [System.StringComparison]::OrdinalIgnoreCase) - } | - ForEach-Object { - try { - [void]$pathSet.Add([System.IO.Path]::GetFullPath($_.LocalPath).TrimEnd('\')) - } - catch { - Write-ToolkitLog -Level 'WARNING' -Message (Get-SourceTextLoc 'toolText.failedToNormalizeRegisteredProfileLocalpath0' -Args @($($_.LocalPath))) - } + Where-Object { + $_.LocalPath -and + $_.LocalPath.StartsWith($usersRoot, [System.StringComparison]::OrdinalIgnoreCase) + } | + ForEach-Object { + try { + [void]$pathSet.Add([System.IO.Path]::GetFullPath($_.LocalPath).TrimEnd('\')) + } + catch { + Write-ToolkitLog -Level 'WARNING' -Message (Get-SourceTextLoc 'toolText.failedToNormalizeRegisteredProfileLocalpath0' -Args @($($_.LocalPath))) } + } - return ,$pathSet + return , $pathSet } function Get-RemovableUserProfiles { @@ -191,14 +198,85 @@ function WinDeleteUserProfiles { } } + function New-ProfileRemovalSessionState { + # Runspaces do not inherit the toolkit functions or the active language/log context. + $sessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() + + foreach ($functionName in @( + 'Get-SourceTextLoc', + 'Write-ToolkitLog', + 'Invoke-ExternalCommandWithLog', + 'Remove-ItemSafely', + 'Remove-ProfileRegistryEntries', + 'Get-SpinnerChar' + )) { + $command = Get-Command -Name $functionName -CommandType Function -ErrorAction Stop + $sessionState.Commands.Add( + [System.Management.Automation.Runspaces.SessionStateFunctionEntry]::new($functionName, $command.Definition) + ) + } + + # Only the parent runspace may update the shared console progress line. + foreach ($functionName in @('Clear-ProgressLine', 'Write-ProgressUpdate')) { + $sessionState.Commands.Add( + [System.Management.Automation.Runspaces.SessionStateFunctionEntry]::new($functionName, '') + ) + } + + foreach ($variableName in @( + 'SourceTextLanguageData', + 'SourceTextDefaultLanguageData', + 'SourceTextKeyAliases', + 'CurrentLogFile', + 'CurrentToolName', + 'Spinners' + )) { + $value = Get-Variable -Name $variableName -Scope Global -ValueOnly -ErrorAction Stop + $sessionState.Variables.Add( + [System.Management.Automation.Runspaces.SessionStateVariableEntry]::new($variableName, $value, '') + ) + } + $sessionState.Variables.Add( + [System.Management.Automation.Runspaces.SessionStateVariableEntry]::new('GuiSessionActive', $true, '') + ) + + return $sessionState + } + + function Receive-ProfileRemovalResult { + param( + [Parameter(Mandatory = $true)] + [object]$Job + ) + + try { + $Job.PowerShell.EndInvoke($Job.Handle) + } + catch { + Write-ToolkitLog -Level 'ERROR' -Message (Get-SourceTextLoc 'toolText.runspaceError0' -Args @($($_.Exception.Message))) + [PSCustomObject]@{ + Type = 'Profile' + UserName = [System.IO.Path]::GetFileName($Job.Profile.LocalPath) + Path = $Job.Profile.LocalPath + Sid = $Job.Profile.SID + Success = $false + Duration = [TimeSpan]::Zero + } + } + finally { + $Job.PowerShell.Commands.Clear() + $Job.PowerShell.Dispose() + } + } + function Invoke-ProfileRemovalBatch { param( [Parameter(Mandatory = $true)] [array]$Profiles ) - $pool = [RunspaceFactory]::CreateRunspacePool(1, $maxThreadsEffective) - $pool.Open() + $sessionState = New-ProfileRemovalSessionState + $pool = [RunspaceFactory]::CreateRunspacePool(1, $maxThreadsEffective, $sessionState, $Host) $jobs = [System.Collections.Generic.List[object]]::new() @@ -236,7 +314,7 @@ function WinDeleteUserProfiles { -Arguments @("`"$tempEmpty`"", "`"$userPath`"", '/MIR', '/R:1', '/W:1', '/NFL', '/NDL', '/NJH', '/NJS', '/NP') ` -LogContextKey "ProfileCleanup-Robocopy-$userName" | Out-Null - Remove-ItemSafely -Path $userPath -Recurse + [void](Remove-ItemSafely -Path $userPath -Recurse) Write-ToolkitLog -Level 'SUCCESS' -Message (Get-SourceTextLoc 'toolText.folderRemoved0' -Args @($userName)) } @@ -246,7 +324,7 @@ function WinDeleteUserProfiles { try { Invoke-ExternalCommandWithLog -Command 'takeown.exe' -Arguments @('/F', "`"$userPath`"", '/R', '/D', 'Y') -LogContextKey "ProfileCleanup-TakeOwn-$userName" | Out-Null Invoke-ExternalCommandWithLog -Command 'icacls.exe' -Arguments @("`"$userPath`"", '/grant', 'Administrators:F', '/T', '/C') -LogContextKey "ProfileCleanup-Icacls-$userName" | Out-Null - Remove-ItemSafely -Path $userPath -Recurse + [void](Remove-ItemSafely -Path $userPath -Recurse) Write-ToolkitLog -Level 'SUCCESS' -Message (Get-SourceTextLoc 'toolText.folderRemovedAfterAclReset0' -Args @($userName)) } catch { @@ -256,11 +334,12 @@ function WinDeleteUserProfiles { } $folderGone = -not [System.IO.Directory]::Exists($userPath) + $registrySuccess = $cimSuccess if (-not $cimSuccess -and $folderGone -and $userSid) { - Remove-ProfileRegistryEntries -Sid $userSid -UserName $userName + $registrySuccess = Remove-ProfileRegistryEntries -Sid $userSid -UserName $userName } - $success = $folderGone + $success = $folderGone -and $registrySuccess $duration = New-TimeSpan -Start $start -End (Get-Date) if ($success) { @@ -281,19 +360,21 @@ function WinDeleteUserProfiles { } try { + $pool.Open() foreach ($profile in $Profiles) { $ps = [PowerShell]::Create() $ps.RunspacePool = $pool [void]$ps.AddScript($scriptBlock, $true). - AddArgument($profile) + AddArgument($profile) $handle = $ps.BeginInvoke() $jobs.Add([PSCustomObject]@{ - PowerShell = $ps - Handle = $handle - }) + PowerShell = $ps + Handle = $handle + Profile = $profile + }) } $total = $jobs.Count @@ -315,16 +396,7 @@ function WinDeleteUserProfiles { Clear-ProgressLine $results = foreach ($job in $jobs) { - try { - $job.PowerShell.EndInvoke($job.Handle) - } - catch { - Write-ToolkitLog -Level 'ERROR' -Message (Get-SourceTextLoc 'toolText.runspaceError0' -Args @($($_.Exception.Message))) - } - finally { - $job.PowerShell.Commands.Clear() - $job.PowerShell.Dispose() - } + Receive-ProfileRemovalResult -Job $job } return $results @@ -345,9 +417,9 @@ function WinDeleteUserProfiles { Write-ToolkitLog -Level 'INFO' -Message (Get-SourceTextLoc 'toolText.checkResidualFoldersInTheUsersDirectory') $folders = Get-ChildItem -Path $usersRoot -Directory -Force | - Where-Object { - -not ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) - } + Where-Object { + -not ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) + } $profileListKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList' $registeredSids = @() @@ -422,7 +494,7 @@ function WinDeleteUserProfiles { try { Invoke-ExternalCommandWithLog -Command 'takeown.exe' -Arguments @('/F', "`"$folderPath`"", '/R', '/D', 'Y') -LogContextKey "ResidualTakeOwn-$($folder.Name)" | Out-Null Invoke-ExternalCommandWithLog -Command 'icacls.exe' -Arguments @("`"$folderPath`"", '/grant', 'Administrators:F', '/T', '/C') -LogContextKey "ResidualIcacls-$($folder.Name)" | Out-Null - Remove-ItemSafely -Path $folderPath -Recurse + [void](Remove-ItemSafely -Path $folderPath -Recurse) $success = -not [System.IO.Directory]::Exists($folderPath) } catch { @@ -441,12 +513,12 @@ function WinDeleteUserProfiles { } $results.Add([PSCustomObject]@{ - Type = 'ResidualFolder' - UserName = $folder.Name - Path = $folder.Path - Success = $success - Duration = $duration - }) | Out-Null + Type = 'ResidualFolder' + UserName = $folder.Name + Path = $folder.Path + Success = $success + Duration = $duration + }) | Out-Null } Write-ProgressUpdate -Activity (Get-SourceTextLoc 'toolText.extra.removingResidualFoldersInCUsers') -Status (Get-SourceTextLoc 'uiText.completed') -Percent 100 -Icon '✅' @@ -490,11 +562,11 @@ function WinDeleteUserProfiles { Write-Host '' $targets | - Select-Object @{Name='User'; Expression={ [System.IO.Path]::GetFileName($_.LocalPath) }}, - @{Name='Loaded'; Expression={ $_.Loaded }}, - @{Name='LastUseTime'; Expression={ $_.LastUseTime }}, - @{Name='Path'; Expression={ $_.LocalPath }} | - Format-Table -AutoSize + Select-Object @{Name = 'User'; Expression = { [System.IO.Path]::GetFileName($_.LocalPath) } }, + @{Name = 'Loaded'; Expression = { $_.Loaded } }, + @{Name = 'LastUseTime'; Expression = { $_.LastUseTime } }, + @{Name = 'Path'; Expression = { $_.LocalPath } } | + Format-Table -AutoSize Write-Host '' Write-StyledMessage -Type 'Info' -Text ("🚀 " + (Get-SourceTextLoc 'toolText.startAutomaticRemovalOf0RegisteredProfiles' -Args @($targets.Count))) diff --git a/wintoolkit-modules/10-Module.Localization.ps1 b/wintoolkit-modules/10-Module.Localization.ps1 index a9d14599..cf385206 100644 --- a/wintoolkit-modules/10-Module.Localization.ps1 +++ b/wintoolkit-modules/10-Module.Localization.ps1 @@ -126,7 +126,7 @@ function Get-SourceTextLoc { } if ($null -eq $value) { $value = $Key } - if ($Arguments -and $Arguments.Count -gt 0) { return [string]::Format($value, $Arguments) } + if ($null -ne $Arguments -and $Arguments.Count -gt 0) { return [string]::Format($value, $Arguments) } return $value } From d78309e9d42423d2cc899c9ae2e07ffbbb685e9c Mon Sep 17 00:00:00 2001 From: pomodori92 <45762339+pomodori92@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:13:39 +0000 Subject: [PATCH 02/12] =?UTF-8?q?=E2=9C=85=20Build=20WinToolkit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WinToolkit.ps1 | 164 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 112 insertions(+), 52 deletions(-) diff --git a/WinToolkit.ps1 b/WinToolkit.ps1 index 136f1c95..4c4695d5 100644 --- a/WinToolkit.ps1 +++ b/WinToolkit.ps1 @@ -238,7 +238,7 @@ function Get-SourceTextLoc { } } if ($null -eq $value) { $value = $Key } - if ($Arguments -and $Arguments.Count -gt 0) { return [string]::Format($value, $Arguments) } + if ($null -ne $Arguments -and $Arguments.Count -gt 0) { return [string]::Format($value, $Arguments) } return $value } function Format-SourceText { @@ -3961,7 +3961,7 @@ function WinDeleteUserProfiles { function New-ProtectedNameSet { $excluded = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $protectedProfileNames | ForEach-Object { [void]$excluded.Add($_) } - return ,$excluded + return , $excluded } function Remove-ProfileRegistryEntries { param( @@ -3970,9 +3970,13 @@ function WinDeleteUserProfiles { [Parameter(Mandatory = $true)] [string]$UserName ) - if ([string]::IsNullOrWhiteSpace($Sid) -or $Sid -eq 'NULL') { return $false } + if ([string]::IsNullOrWhiteSpace($Sid) -or $Sid -eq 'NULL') { + return $false + } $profileRegKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\' + $Sid - if (-not (Test-Path -LiteralPath $profileRegKey)) { return $false } + if (-not (Test-Path -LiteralPath $profileRegKey)) { + return $true + } try { Remove-Item -LiteralPath $profileRegKey -Recurse -Force -ErrorAction Stop Write-ToolkitLog -Level 'SUCCESS' -Message (Get-SourceTextLoc 'toolText.registryEntryRemovedForSid01' -Args @($UserName, $Sid)) @@ -3990,22 +3994,22 @@ function WinDeleteUserProfiles { } catch { Write-ToolkitLog -Level 'WARNING' -Message (Get-SourceTextLoc 'toolText.cimProfileDetectionFailed0' -Args @($($_.Exception.Message))) - return ,$pathSet + return , $pathSet } $cimProfiles | - Where-Object { - $_.LocalPath -and - $_.LocalPath.StartsWith($usersRoot, [System.StringComparison]::OrdinalIgnoreCase) - } | - ForEach-Object { - try { - [void]$pathSet.Add([System.IO.Path]::GetFullPath($_.LocalPath).TrimEnd('\')) - } - catch { - Write-ToolkitLog -Level 'WARNING' -Message (Get-SourceTextLoc 'toolText.failedToNormalizeRegisteredProfileLocalpath0' -Args @($($_.LocalPath))) - } + Where-Object { + $_.LocalPath -and + $_.LocalPath.StartsWith($usersRoot, [System.StringComparison]::OrdinalIgnoreCase) + } | + ForEach-Object { + try { + [void]$pathSet.Add([System.IO.Path]::GetFullPath($_.LocalPath).TrimEnd('\')) } - return ,$pathSet + catch { + Write-ToolkitLog -Level 'WARNING' -Message (Get-SourceTextLoc 'toolText.failedToNormalizeRegisteredProfileLocalpath0' -Args @($($_.LocalPath))) + } + } + return , $pathSet } function Get-RemovableUserProfiles { $excluded = New-ProtectedNameSet @@ -4045,13 +4049,75 @@ function WinDeleteUserProfiles { $profile } } + function New-ProfileRemovalSessionState { + $sessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() + foreach ($functionName in @( + 'Get-SourceTextLoc', + 'Write-ToolkitLog', + 'Invoke-ExternalCommandWithLog', + 'Remove-ItemSafely', + 'Remove-ProfileRegistryEntries', + 'Get-SpinnerChar' + )) { + $command = Get-Command -Name $functionName -CommandType Function -ErrorAction Stop + $sessionState.Commands.Add( + [System.Management.Automation.Runspaces.SessionStateFunctionEntry]::new($functionName, $command.Definition) + ) + } + foreach ($functionName in @('Clear-ProgressLine', 'Write-ProgressUpdate')) { + $sessionState.Commands.Add( + [System.Management.Automation.Runspaces.SessionStateFunctionEntry]::new($functionName, '') + ) + } + foreach ($variableName in @( + 'SourceTextLanguageData', + 'SourceTextDefaultLanguageData', + 'SourceTextKeyAliases', + 'CurrentLogFile', + 'CurrentToolName', + 'Spinners' + )) { + $value = Get-Variable -Name $variableName -Scope Global -ValueOnly -ErrorAction Stop + $sessionState.Variables.Add( + [System.Management.Automation.Runspaces.SessionStateVariableEntry]::new($variableName, $value, '') + ) + } + $sessionState.Variables.Add( + [System.Management.Automation.Runspaces.SessionStateVariableEntry]::new('GuiSessionActive', $true, '') + ) + return $sessionState + } + function Receive-ProfileRemovalResult { + param( + [Parameter(Mandatory = $true)] + [object]$Job + ) + try { + $Job.PowerShell.EndInvoke($Job.Handle) + } + catch { + Write-ToolkitLog -Level 'ERROR' -Message (Get-SourceTextLoc 'toolText.runspaceError0' -Args @($($_.Exception.Message))) + [PSCustomObject]@{ + Type = 'Profile' + UserName = [System.IO.Path]::GetFileName($Job.Profile.LocalPath) + Path = $Job.Profile.LocalPath + Sid = $Job.Profile.SID + Success = $false + Duration = [TimeSpan]::Zero + } + } + finally { + $Job.PowerShell.Commands.Clear() + $Job.PowerShell.Dispose() + } + } function Invoke-ProfileRemovalBatch { param( [Parameter(Mandatory = $true)] [array]$Profiles ) - $pool = [RunspaceFactory]::CreateRunspacePool(1, $maxThreadsEffective) - $pool.Open() + $sessionState = New-ProfileRemovalSessionState + $pool = [RunspaceFactory]::CreateRunspacePool(1, $maxThreadsEffective, $sessionState, $Host) $jobs = [System.Collections.Generic.List[object]]::new() $scriptBlock = { param($Profile) @@ -4079,7 +4145,7 @@ function WinDeleteUserProfiles { Invoke-ExternalCommandWithLog -Command 'robocopy.exe' ` -Arguments @("`"$tempEmpty`"", "`"$userPath`"", '/MIR', '/R:1', '/W:1', '/NFL', '/NDL', '/NJH', '/NJS', '/NP') ` -LogContextKey "ProfileCleanup-Robocopy-$userName" | Out-Null - Remove-ItemSafely -Path $userPath -Recurse + [void](Remove-ItemSafely -Path $userPath -Recurse) Write-ToolkitLog -Level 'SUCCESS' -Message (Get-SourceTextLoc 'toolText.folderRemoved0' -Args @($userName)) } catch { @@ -4087,7 +4153,7 @@ function WinDeleteUserProfiles { try { Invoke-ExternalCommandWithLog -Command 'takeown.exe' -Arguments @('/F', "`"$userPath`"", '/R', '/D', 'Y') -LogContextKey "ProfileCleanup-TakeOwn-$userName" | Out-Null Invoke-ExternalCommandWithLog -Command 'icacls.exe' -Arguments @("`"$userPath`"", '/grant', 'Administrators:F', '/T', '/C') -LogContextKey "ProfileCleanup-Icacls-$userName" | Out-Null - Remove-ItemSafely -Path $userPath -Recurse + [void](Remove-ItemSafely -Path $userPath -Recurse) Write-ToolkitLog -Level 'SUCCESS' -Message (Get-SourceTextLoc 'toolText.folderRemovedAfterAclReset0' -Args @($userName)) } catch { @@ -4096,10 +4162,11 @@ function WinDeleteUserProfiles { } } $folderGone = -not [System.IO.Directory]::Exists($userPath) + $registrySuccess = $cimSuccess if (-not $cimSuccess -and $folderGone -and $userSid) { - Remove-ProfileRegistryEntries -Sid $userSid -UserName $userName + $registrySuccess = Remove-ProfileRegistryEntries -Sid $userSid -UserName $userName } - $success = $folderGone + $success = $folderGone -and $registrySuccess $duration = New-TimeSpan -Start $start -End (Get-Date) if ($success) { Write-ToolkitLog -Level 'SUCCESS' -Message (Get-SourceTextLoc 'toolText.completedProfile01' -Args @($userName, $duration.ToString())) @@ -4117,16 +4184,18 @@ function WinDeleteUserProfiles { } } try { + $pool.Open() foreach ($profile in $Profiles) { $ps = [PowerShell]::Create() $ps.RunspacePool = $pool [void]$ps.AddScript($scriptBlock, $true). - AddArgument($profile) + AddArgument($profile) $handle = $ps.BeginInvoke() $jobs.Add([PSCustomObject]@{ - PowerShell = $ps - Handle = $handle - }) + PowerShell = $ps + Handle = $handle + Profile = $profile + }) } $total = $jobs.Count $lastPercent = -1 @@ -4142,16 +4211,7 @@ function WinDeleteUserProfiles { Write-ProgressUpdate -Activity (Get-SourceTextLoc 'toolText.extra.removingRegisteredProfiles') -Status (Get-SourceTextLoc 'uiText.completed') -Percent 100 -Icon '✅' Clear-ProgressLine $results = foreach ($job in $jobs) { - try { - $job.PowerShell.EndInvoke($job.Handle) - } - catch { - Write-ToolkitLog -Level 'ERROR' -Message (Get-SourceTextLoc 'toolText.runspaceError0' -Args @($($_.Exception.Message))) - } - finally { - $job.PowerShell.Commands.Clear() - $job.PowerShell.Dispose() - } + Receive-ProfileRemovalResult -Job $job } return $results } @@ -4168,9 +4228,9 @@ function WinDeleteUserProfiles { Write-StyledMessage -Type 'Info' -Text ("🔎 " + (Get-SourceTextLoc 'toolText.checkResidualFoldersInTheUsersDirectory')) Write-ToolkitLog -Level 'INFO' -Message (Get-SourceTextLoc 'toolText.checkResidualFoldersInTheUsersDirectory') $folders = Get-ChildItem -Path $usersRoot -Directory -Force | - Where-Object { - -not ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) - } + Where-Object { + -not ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) + } $profileListKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList' $registeredSids = @() try { @@ -4229,7 +4289,7 @@ function WinDeleteUserProfiles { try { Invoke-ExternalCommandWithLog -Command 'takeown.exe' -Arguments @('/F', "`"$folderPath`"", '/R', '/D', 'Y') -LogContextKey "ResidualTakeOwn-$($folder.Name)" | Out-Null Invoke-ExternalCommandWithLog -Command 'icacls.exe' -Arguments @("`"$folderPath`"", '/grant', 'Administrators:F', '/T', '/C') -LogContextKey "ResidualIcacls-$($folder.Name)" | Out-Null - Remove-ItemSafely -Path $folderPath -Recurse + [void](Remove-ItemSafely -Path $folderPath -Recurse) $success = -not [System.IO.Directory]::Exists($folderPath) } catch { @@ -4245,12 +4305,12 @@ function WinDeleteUserProfiles { Write-ToolkitLog -Level 'ERROR' -Message (Get-SourceTextLoc 'toolText.failedResidualFolder0' -Args @($($folder.Name))) } $results.Add([PSCustomObject]@{ - Type = 'ResidualFolder' - UserName = $folder.Name - Path = $folder.Path - Success = $success - Duration = $duration - }) | Out-Null + Type = 'ResidualFolder' + UserName = $folder.Name + Path = $folder.Path + Success = $success + Duration = $duration + }) | Out-Null } Write-ProgressUpdate -Activity (Get-SourceTextLoc 'toolText.extra.removingResidualFoldersInCUsers') -Status (Get-SourceTextLoc 'uiText.completed') -Percent 100 -Icon '✅' Clear-ProgressLine @@ -4282,11 +4342,11 @@ function WinDeleteUserProfiles { Write-StyledMessage -Type 'Warning' -Text (Get-SourceTextLoc 'toolText.registeredProfilesSelectedForAutomaticRemoval') Write-Host '' $targets | - Select-Object @{Name='User'; Expression={ [System.IO.Path]::GetFileName($_.LocalPath) }}, - @{Name='Loaded'; Expression={ $_.Loaded }}, - @{Name='LastUseTime'; Expression={ $_.LastUseTime }}, - @{Name='Path'; Expression={ $_.LocalPath }} | - Format-Table -AutoSize + Select-Object @{Name = 'User'; Expression = { [System.IO.Path]::GetFileName($_.LocalPath) } }, + @{Name = 'Loaded'; Expression = { $_.Loaded } }, + @{Name = 'LastUseTime'; Expression = { $_.LastUseTime } }, + @{Name = 'Path'; Expression = { $_.LocalPath } } | + Format-Table -AutoSize Write-Host '' Write-StyledMessage -Type 'Info' -Text ("🚀 " + (Get-SourceTextLoc 'toolText.startAutomaticRemovalOf0RegisteredProfiles' -Args @($targets.Count))) Write-Host '' From 7d4ac8325dd83d78f969694d0577c7e2da983808 Mon Sep 17 00:00:00 2001 From: pomodori92 Date: Tue, 6 Oct 2026 08:17:35 +0200 Subject: [PATCH 03/12] fix: restore user profile deletion fallback Propagate folder removal errors so ACL recovery runs and verify ProfileList cleanup even after CIM reports success. Use the administrators SID and exclude junctions during cleanup. Refs Magnetarman/WinToolkit#189 --- .../Unit/WinDeleteUserProfiles.Tests.ps1 | 130 +++++++++++++++++- WinToolkit.ps1 | 18 ++- tools/WinDeleteUserProfiles.ps1 | 18 ++- 3 files changed, 142 insertions(+), 24 deletions(-) diff --git a/.github/tests/Unit/WinDeleteUserProfiles.Tests.ps1 b/.github/tests/Unit/WinDeleteUserProfiles.Tests.ps1 index f90c045b..be49fd9e 100644 --- a/.github/tests/Unit/WinDeleteUserProfiles.Tests.ps1 +++ b/.github/tests/Unit/WinDeleteUserProfiles.Tests.ps1 @@ -1,10 +1,9 @@ # WinToolkit CI/CD V4.2.0 #Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } <# -Regression coverage for issue #189 without running profile deletion. +Regression coverage for issue #189 without deleting Windows profiles. Loads individual function definitions from the AST, never the tool or toolkit scripts. -Only initialization, localization, logging, and result collection are invoked. -Removal helpers are registered as text and inspected, never called. +Deletion commands are mocked; folder removal tests use empty TestDrive directories. #> param([string]$CompiledScriptPath) @@ -21,11 +20,14 @@ Describe 'Issue #189 safe regressions' { @{ Path = $toolPath; Name = 'New-ProfileRemovalSessionState' }, @{ Path = $toolPath; Name = 'Receive-ProfileRemovalResult' }, @{ Path = $toolPath; Name = 'Remove-ProfileRegistryEntries' }, + @{ Path = $toolPath; Name = 'Remove-ResidualUserFolders' }, @{ Path = $localizationPath; Name = 'Get-SourceTextLoc' }, @{ Path = $loggingPath; Name = 'Write-ToolkitLog' }, @{ Path = $processesPath; Name = 'Invoke-ExternalCommandWithLog' }, @{ Path = $processesPath; Name = 'Remove-ItemSafely' }, - @{ Path = $uiPath; Name = 'Get-SpinnerChar' } + @{ Path = $uiPath; Name = 'Get-SpinnerChar' }, + @{ Path = $uiPath; Name = 'Clear-ProgressLine' }, + @{ Path = $uiPath; Name = 'Write-ProgressUpdate' } ) foreach ($definition in $definitions) { $path = if ($CompiledScriptPath) { $CompiledScriptPath } else { $definition.Path } @@ -40,6 +42,22 @@ Describe 'Issue #189 safe regressions' { . ([scriptblock]::Create($function.Extent.Text)) } + $workerSourcePath = if ($CompiledScriptPath) { $CompiledScriptPath } else { $toolPath } + $toolAst = [System.Management.Automation.Language.Parser]::ParseFile($workerSourcePath, [ref]$null, [ref]$null) + $batchFunction = $toolAst.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Invoke-ProfileRemovalBatch' + }, $true) + $workerAssignment = $batchFunction.Body.Find({ + param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and $node.Left.Extent.Text -eq '$scriptBlock' + }, $true) + $workerExpression = $workerAssignment.Right.Find({ + param($node) + $node -is [System.Management.Automation.Language.ScriptBlockExpressionAst] + }, $true) + $script:ProfileRemovalWorker = $workerExpression.ScriptBlock.GetScriptBlock() + Import-LocalizedData -BindingVariable italianData -BaseDirectory (Join-Path $script:RepoRoot 'languages') -FileName 'WinToolkit.psd1' -UICulture 'it-IT' Import-LocalizedData -BindingVariable englishData -BaseDirectory (Join-Path $script:RepoRoot 'languages') -FileName 'WinToolkit.psd1' -UICulture 'en-US' $script:ItalianData = $italianData @@ -201,6 +219,110 @@ Describe 'Issue #189 safe regressions' { } } + Context 'Folder removal failures with mocked Windows operations' { + BeforeEach { + $script:ProbeFolderPath = Join-Path $TestDrive 'Issue189[Probe]' + [void][System.IO.Directory]::CreateDirectory($script:ProbeFolderPath) + $script:ProbeProfile = [pscustomobject]@{ + LocalPath = $script:ProbeFolderPath + SID = 'S-1-5-21-189-1' + } + $script:RemovalAttempts = 0 + Mock Remove-CimInstance { throw 'Synthetic CIM removal failure' } + Mock Remove-ProfileRegistryEntries { $true } + Mock Invoke-ExternalCommandWithLog { [pscustomobject]@{ Success = $true; ExitCode = 0 } } + Mock Remove-ItemSafely { $false } + Mock Test-Path { $true } -ParameterFilter { $Path -eq (Join-Path $env:TEMP 'EmptyFolder') } + Mock Clear-ProgressLine {} + Mock Write-ProgressUpdate {} + } + + It 'recovers with ACL reset when the first literal folder removal fails' { + Mock Remove-Item { + $script:RemovalAttempts++ + if ($script:RemovalAttempts -eq 1) { throw 'Synthetic access denied' } + if ($LiteralPath -ne $script:ProbeFolderPath) { throw 'Unexpected deletion target' } + [System.IO.Directory]::Delete($LiteralPath) + } + + $result = & $script:ProfileRemovalWorker $script:ProbeProfile + + $result.Success | Should -BeTrue + [System.IO.Directory]::Exists($script:ProbeFolderPath) | Should -BeFalse + Should -Invoke Remove-Item -Times 2 -Exactly -ParameterFilter { + $LiteralPath -eq $script:ProbeFolderPath -and $Recurse -and $Force -and $ErrorAction -eq 'Stop' + } + Should -Invoke Invoke-ExternalCommandWithLog -Times 1 -Exactly -ParameterFilter { $Command -eq 'takeown.exe' } + Should -Invoke Invoke-ExternalCommandWithLog -Times 1 -Exactly -ParameterFilter { + $Command -eq 'robocopy.exe' -and $Arguments -contains '/XJ' + } + Should -Invoke Invoke-ExternalCommandWithLog -Times 1 -Exactly -ParameterFilter { + $Command -eq 'icacls.exe' -and $Arguments -contains '*S-1-5-32-544:F' + } + Should -Invoke Remove-ProfileRegistryEntries -Times 1 -Exactly -ParameterFilter { $Sid -eq $script:ProbeProfile.SID } + Should -Invoke Remove-ItemSafely -Times 0 -Exactly + } + + It 'reports failure and preserves the registry when both folder removal attempts fail' { + Mock Remove-Item { throw 'Synthetic access denied' } + + $result = & $script:ProfileRemovalWorker $script:ProbeProfile + + $result.Success | Should -BeFalse + [System.IO.Directory]::Exists($script:ProbeFolderPath) | Should -BeTrue + Should -Invoke Remove-Item -Times 2 -Exactly + Should -Invoke Remove-ProfileRegistryEntries -Times 0 -Exactly + Get-Content -LiteralPath $Global:CurrentLogFile -Raw | Should -Match '\[ERROR\].*Synthetic access denied' + } + + It 'checks the remaining registry entry even when CIM reported success' { + Mock Remove-CimInstance {} + Mock Remove-ProfileRegistryEntries { $false } + Mock Remove-Item { + if ($LiteralPath -ne $script:ProbeFolderPath) { throw 'Unexpected deletion target' } + [System.IO.Directory]::Delete($LiteralPath) + } + + $result = & $script:ProfileRemovalWorker $script:ProbeProfile + + $result.Success | Should -BeFalse + [System.IO.Directory]::Exists($script:ProbeFolderPath) | Should -BeFalse + Should -Invoke Remove-ProfileRegistryEntries -Times 1 -Exactly + } + + It 'retries a residual folder using its literal path after ACL reset' { + Mock Remove-Item { + $script:RemovalAttempts++ + if ($script:RemovalAttempts -eq 1) { throw 'Synthetic access denied' } + if ($LiteralPath -ne $script:ProbeFolderPath) { throw 'Unexpected deletion target' } + [System.IO.Directory]::Delete($LiteralPath) + } + $folder = [pscustomobject]@{ Name = 'Issue189[Probe]'; Path = $script:ProbeFolderPath } + + $result = Remove-ResidualUserFolders -Folders @($folder) + + $result.Success | Should -BeTrue + Should -Invoke Remove-Item -Times 2 -Exactly -ParameterFilter { + $LiteralPath -eq $script:ProbeFolderPath -and $Recurse -and $Force -and $ErrorAction -eq 'Stop' + } + Should -Invoke Invoke-ExternalCommandWithLog -Times 1 -Exactly -ParameterFilter { + $Command -eq 'icacls.exe' -and $Arguments -contains '*S-1-5-32-544:F' + } + } + + It 'reports a residual folder failure when ACL recovery cannot remove it' { + Mock Remove-Item { throw 'Synthetic access denied' } + $folder = [pscustomobject]@{ Name = 'Issue189[Probe]'; Path = $script:ProbeFolderPath } + + $result = Remove-ResidualUserFolders -Folders @($folder) + + $result.Success | Should -BeFalse + [System.IO.Directory]::Exists($script:ProbeFolderPath) | Should -BeTrue + Should -Invoke Remove-Item -Times 2 -Exactly + Get-Content -LiteralPath $Global:CurrentLogFile -Raw | Should -Match '\[ERROR\].*Synthetic access denied' + } + } + Context 'Localized arguments with zero or empty values' { It 'formats the argument' -ForEach @( @{ Name = 'zero'; Value = 0; Expected = 'Value: 0' }, diff --git a/WinToolkit.ps1 b/WinToolkit.ps1 index 4c4695d5..616bcf74 100644 --- a/WinToolkit.ps1 +++ b/WinToolkit.ps1 @@ -4127,11 +4127,9 @@ function WinDeleteUserProfiles { $userSid = $Profile.SID $start = Get-Date Write-ToolkitLog -Level 'INFO' -Message (Get-SourceTextLoc 'toolText.startResidualFolder01' -Args @($userName, $userPath)) - $cimSuccess = $false try { Remove-CimInstance -InputObject $Profile -ErrorAction Stop -Confirm:$false Write-ToolkitLog -Level 'SUCCESS' -Message (Get-SourceTextLoc 'toolText.cimProfileRemoved0' -Args @($userName)) - $cimSuccess = $true } catch { Write-ToolkitLog -Level 'WARNING' -Message (Get-SourceTextLoc 'toolText.cimRemoveFailed01' -Args @($userName, $($_.Exception.Message))) @@ -4143,17 +4141,17 @@ function WinDeleteUserProfiles { New-Item -ItemType Directory -Path $tempEmpty | Out-Null } Invoke-ExternalCommandWithLog -Command 'robocopy.exe' ` - -Arguments @("`"$tempEmpty`"", "`"$userPath`"", '/MIR', '/R:1', '/W:1', '/NFL', '/NDL', '/NJH', '/NJS', '/NP') ` + -Arguments @("`"$tempEmpty`"", "`"$userPath`"", '/MIR', '/XJ', '/R:1', '/W:1', '/NFL', '/NDL', '/NJH', '/NJS', '/NP') ` -LogContextKey "ProfileCleanup-Robocopy-$userName" | Out-Null - [void](Remove-ItemSafely -Path $userPath -Recurse) + Remove-Item -LiteralPath $userPath -Recurse -Force -ErrorAction Stop -Confirm:$false Write-ToolkitLog -Level 'SUCCESS' -Message (Get-SourceTextLoc 'toolText.folderRemoved0' -Args @($userName)) } catch { Write-ToolkitLog -Level 'WARNING' -Message (Get-SourceTextLoc 'toolText.standardFolderCleanupFailed01' -Args @($userName, $($_.Exception.Message))) try { Invoke-ExternalCommandWithLog -Command 'takeown.exe' -Arguments @('/F', "`"$userPath`"", '/R', '/D', 'Y') -LogContextKey "ProfileCleanup-TakeOwn-$userName" | Out-Null - Invoke-ExternalCommandWithLog -Command 'icacls.exe' -Arguments @("`"$userPath`"", '/grant', 'Administrators:F', '/T', '/C') -LogContextKey "ProfileCleanup-Icacls-$userName" | Out-Null - [void](Remove-ItemSafely -Path $userPath -Recurse) + Invoke-ExternalCommandWithLog -Command 'icacls.exe' -Arguments @("`"$userPath`"", '/grant', '*S-1-5-32-544:F', '/T', '/C') -LogContextKey "ProfileCleanup-Icacls-$userName" | Out-Null + Remove-Item -LiteralPath $userPath -Recurse -Force -ErrorAction Stop -Confirm:$false Write-ToolkitLog -Level 'SUCCESS' -Message (Get-SourceTextLoc 'toolText.folderRemovedAfterAclReset0' -Args @($userName)) } catch { @@ -4162,8 +4160,8 @@ function WinDeleteUserProfiles { } } $folderGone = -not [System.IO.Directory]::Exists($userPath) - $registrySuccess = $cimSuccess - if (-not $cimSuccess -and $folderGone -and $userSid) { + $registrySuccess = $false + if ($folderGone -and $userSid) { $registrySuccess = Remove-ProfileRegistryEntries -Sid $userSid -UserName $userName } $success = $folderGone -and $registrySuccess @@ -4288,8 +4286,8 @@ function WinDeleteUserProfiles { Write-ToolkitLog -Level 'WARNING' -Message (Get-SourceTextLoc 'toolText.standardResidualFolderRemovalFailed01' -Args @($folderPath, $($_.Exception.Message))) try { Invoke-ExternalCommandWithLog -Command 'takeown.exe' -Arguments @('/F', "`"$folderPath`"", '/R', '/D', 'Y') -LogContextKey "ResidualTakeOwn-$($folder.Name)" | Out-Null - Invoke-ExternalCommandWithLog -Command 'icacls.exe' -Arguments @("`"$folderPath`"", '/grant', 'Administrators:F', '/T', '/C') -LogContextKey "ResidualIcacls-$($folder.Name)" | Out-Null - [void](Remove-ItemSafely -Path $folderPath -Recurse) + Invoke-ExternalCommandWithLog -Command 'icacls.exe' -Arguments @("`"$folderPath`"", '/grant', '*S-1-5-32-544:F', '/T', '/C') -LogContextKey "ResidualIcacls-$($folder.Name)" | Out-Null + Remove-Item -LiteralPath $folderPath -Recurse -Force -ErrorAction Stop -Confirm:$false $success = -not [System.IO.Directory]::Exists($folderPath) } catch { diff --git a/tools/WinDeleteUserProfiles.ps1 b/tools/WinDeleteUserProfiles.ps1 index e9420183..00bb56a6 100644 --- a/tools/WinDeleteUserProfiles.ps1 +++ b/tools/WinDeleteUserProfiles.ps1 @@ -292,11 +292,9 @@ function WinDeleteUserProfiles { Write-ToolkitLog -Level 'INFO' -Message (Get-SourceTextLoc 'toolText.startResidualFolder01' -Args @($userName, $userPath)) - $cimSuccess = $false try { Remove-CimInstance -InputObject $Profile -ErrorAction Stop -Confirm:$false Write-ToolkitLog -Level 'SUCCESS' -Message (Get-SourceTextLoc 'toolText.cimProfileRemoved0' -Args @($userName)) - $cimSuccess = $true } catch { Write-ToolkitLog -Level 'WARNING' -Message (Get-SourceTextLoc 'toolText.cimRemoveFailed01' -Args @($userName, $($_.Exception.Message))) @@ -311,10 +309,10 @@ function WinDeleteUserProfiles { } Invoke-ExternalCommandWithLog -Command 'robocopy.exe' ` - -Arguments @("`"$tempEmpty`"", "`"$userPath`"", '/MIR', '/R:1', '/W:1', '/NFL', '/NDL', '/NJH', '/NJS', '/NP') ` + -Arguments @("`"$tempEmpty`"", "`"$userPath`"", '/MIR', '/XJ', '/R:1', '/W:1', '/NFL', '/NDL', '/NJH', '/NJS', '/NP') ` -LogContextKey "ProfileCleanup-Robocopy-$userName" | Out-Null - [void](Remove-ItemSafely -Path $userPath -Recurse) + Remove-Item -LiteralPath $userPath -Recurse -Force -ErrorAction Stop -Confirm:$false Write-ToolkitLog -Level 'SUCCESS' -Message (Get-SourceTextLoc 'toolText.folderRemoved0' -Args @($userName)) } @@ -323,8 +321,8 @@ function WinDeleteUserProfiles { try { Invoke-ExternalCommandWithLog -Command 'takeown.exe' -Arguments @('/F', "`"$userPath`"", '/R', '/D', 'Y') -LogContextKey "ProfileCleanup-TakeOwn-$userName" | Out-Null - Invoke-ExternalCommandWithLog -Command 'icacls.exe' -Arguments @("`"$userPath`"", '/grant', 'Administrators:F', '/T', '/C') -LogContextKey "ProfileCleanup-Icacls-$userName" | Out-Null - [void](Remove-ItemSafely -Path $userPath -Recurse) + Invoke-ExternalCommandWithLog -Command 'icacls.exe' -Arguments @("`"$userPath`"", '/grant', '*S-1-5-32-544:F', '/T', '/C') -LogContextKey "ProfileCleanup-Icacls-$userName" | Out-Null + Remove-Item -LiteralPath $userPath -Recurse -Force -ErrorAction Stop -Confirm:$false Write-ToolkitLog -Level 'SUCCESS' -Message (Get-SourceTextLoc 'toolText.folderRemovedAfterAclReset0' -Args @($userName)) } catch { @@ -334,8 +332,8 @@ function WinDeleteUserProfiles { } $folderGone = -not [System.IO.Directory]::Exists($userPath) - $registrySuccess = $cimSuccess - if (-not $cimSuccess -and $folderGone -and $userSid) { + $registrySuccess = $false + if ($folderGone -and $userSid) { $registrySuccess = Remove-ProfileRegistryEntries -Sid $userSid -UserName $userName } @@ -493,8 +491,8 @@ function WinDeleteUserProfiles { try { Invoke-ExternalCommandWithLog -Command 'takeown.exe' -Arguments @('/F', "`"$folderPath`"", '/R', '/D', 'Y') -LogContextKey "ResidualTakeOwn-$($folder.Name)" | Out-Null - Invoke-ExternalCommandWithLog -Command 'icacls.exe' -Arguments @("`"$folderPath`"", '/grant', 'Administrators:F', '/T', '/C') -LogContextKey "ResidualIcacls-$($folder.Name)" | Out-Null - [void](Remove-ItemSafely -Path $folderPath -Recurse) + Invoke-ExternalCommandWithLog -Command 'icacls.exe' -Arguments @("`"$folderPath`"", '/grant', '*S-1-5-32-544:F', '/T', '/C') -LogContextKey "ResidualIcacls-$($folder.Name)" | Out-Null + Remove-Item -LiteralPath $folderPath -Recurse -Force -ErrorAction Stop -Confirm:$false $success = -not [System.IO.Directory]::Exists($folderPath) } catch { From db5edd6f0949fbda1040f3160ac67066cccfe8d2 Mon Sep 17 00:00:00 2001 From: pomodori92 Date: Tue, 6 Oct 2026 08:41:27 +0200 Subject: [PATCH 04/12] fix: rename $profile variables in $profileItem because there is a built-in $profile variable --- tools/WinDeleteUserProfiles.ps1 | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/tools/WinDeleteUserProfiles.ps1 b/tools/WinDeleteUserProfiles.ps1 index 00bb56a6..60684728 100644 --- a/tools/WinDeleteUserProfiles.ps1 +++ b/tools/WinDeleteUserProfiles.ps1 @@ -173,28 +173,28 @@ function WinDeleteUserProfiles { $_.LocalPath.StartsWith($usersRoot, [System.StringComparison]::OrdinalIgnoreCase) } - foreach ($profile in $profiles) { - $profileName = [System.IO.Path]::GetFileName($profile.LocalPath) + foreach ($profileItem in $profiles) { + $profileName = [System.IO.Path]::GetFileName($profileItem.LocalPath) if ($excluded.Contains($profileName)) { - Write-ToolkitLog -Level 'INFO' -Message (Get-SourceTextLoc 'toolText.excludedProfile01' -Args @($profileName, $($profile.LocalPath))) + Write-ToolkitLog -Level 'INFO' -Message (Get-SourceTextLoc 'toolText.excludedProfile01' -Args @($profileName, $($profileItem.LocalPath))) continue } - if ($profile.SID -eq $currentUserSid) { - Write-ToolkitLog -Level 'INFO' -Message (Get-SourceTextLoc 'toolText.excludedProfileBecauseSidMatchesCurrentUser0' -Args @($profileName, $profile.SID)) + if ($profileItem.SID -eq $currentUserSid) { + Write-ToolkitLog -Level 'INFO' -Message (Get-SourceTextLoc 'toolText.excludedProfileBecauseSidMatchesCurrentUser0' -Args @($profileName, $profileItem.SID)) continue } - if ($minimumLastUseDate -and $profile.LastUseTime) { - $lastUse = $profile.LastUseTime + if ($minimumLastUseDate -and $profileItem.LastUseTime) { + $lastUse = $profileItem.LastUseTime if ($lastUse -gt $minimumLastUseDate) { Write-ToolkitLog -Level 'INFO' -Message (Get-SourceTextLoc 'toolText.profileExcludedDueToTimeThreshold0LastUse1' -Args @($profileName, $lastUse)) continue } } - $profile + $profileItem } } @@ -281,19 +281,19 @@ function WinDeleteUserProfiles { $jobs = [System.Collections.Generic.List[object]]::new() $scriptBlock = { - param($Profile) + param($ProfileItem) $ErrorActionPreference = 'Stop' - $userPath = $Profile.LocalPath + $userPath = $ProfileItem.LocalPath $userName = [System.IO.Path]::GetFileName($userPath) - $userSid = $Profile.SID + $userSid = $ProfileItem.SID $start = Get-Date Write-ToolkitLog -Level 'INFO' -Message (Get-SourceTextLoc 'toolText.startResidualFolder01' -Args @($userName, $userPath)) try { - Remove-CimInstance -InputObject $Profile -ErrorAction Stop -Confirm:$false + Remove-CimInstance -InputObject $ProfileItem -ErrorAction Stop -Confirm:$false Write-ToolkitLog -Level 'SUCCESS' -Message (Get-SourceTextLoc 'toolText.cimProfileRemoved0' -Args @($userName)) } catch { @@ -359,19 +359,19 @@ function WinDeleteUserProfiles { try { $pool.Open() - foreach ($profile in $Profiles) { + foreach ($profileItem in $Profiles) { $ps = [PowerShell]::Create() $ps.RunspacePool = $pool [void]$ps.AddScript($scriptBlock, $true). - AddArgument($profile) + AddArgument($profileItem) $handle = $ps.BeginInvoke() $jobs.Add([PSCustomObject]@{ PowerShell = $ps Handle = $handle - Profile = $profile + Profile = $profileItem }) } From a660c5e6d89ad3b6305cc64f5f7efef99ea9410f Mon Sep 17 00:00:00 2001 From: pomodori92 <45762339+pomodori92@users.noreply.github.com> Date: Tue, 6 Oct 2026 06:51:14 +0000 Subject: [PATCH 05/12] =?UTF-8?q?=E2=9C=85=20Build=20WinToolkit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WinToolkit.ps1 | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/WinToolkit.ps1 b/WinToolkit.ps1 index 616bcf74..7997a244 100644 --- a/WinToolkit.ps1 +++ b/WinToolkit.ps1 @@ -4029,24 +4029,24 @@ function WinDeleteUserProfiles { $_.LocalPath -and $_.LocalPath.StartsWith($usersRoot, [System.StringComparison]::OrdinalIgnoreCase) } - foreach ($profile in $profiles) { - $profileName = [System.IO.Path]::GetFileName($profile.LocalPath) + foreach ($profileItem in $profiles) { + $profileName = [System.IO.Path]::GetFileName($profileItem.LocalPath) if ($excluded.Contains($profileName)) { - Write-ToolkitLog -Level 'INFO' -Message (Get-SourceTextLoc 'toolText.excludedProfile01' -Args @($profileName, $($profile.LocalPath))) + Write-ToolkitLog -Level 'INFO' -Message (Get-SourceTextLoc 'toolText.excludedProfile01' -Args @($profileName, $($profileItem.LocalPath))) continue } - if ($profile.SID -eq $currentUserSid) { - Write-ToolkitLog -Level 'INFO' -Message (Get-SourceTextLoc 'toolText.excludedProfileBecauseSidMatchesCurrentUser0' -Args @($profileName, $profile.SID)) + if ($profileItem.SID -eq $currentUserSid) { + Write-ToolkitLog -Level 'INFO' -Message (Get-SourceTextLoc 'toolText.excludedProfileBecauseSidMatchesCurrentUser0' -Args @($profileName, $profileItem.SID)) continue } - if ($minimumLastUseDate -and $profile.LastUseTime) { - $lastUse = $profile.LastUseTime + if ($minimumLastUseDate -and $profileItem.LastUseTime) { + $lastUse = $profileItem.LastUseTime if ($lastUse -gt $minimumLastUseDate) { Write-ToolkitLog -Level 'INFO' -Message (Get-SourceTextLoc 'toolText.profileExcludedDueToTimeThreshold0LastUse1' -Args @($profileName, $lastUse)) continue } } - $profile + $profileItem } } function New-ProfileRemovalSessionState { @@ -4120,15 +4120,15 @@ function WinDeleteUserProfiles { $pool = [RunspaceFactory]::CreateRunspacePool(1, $maxThreadsEffective, $sessionState, $Host) $jobs = [System.Collections.Generic.List[object]]::new() $scriptBlock = { - param($Profile) + param($ProfileItem) $ErrorActionPreference = 'Stop' - $userPath = $Profile.LocalPath + $userPath = $ProfileItem.LocalPath $userName = [System.IO.Path]::GetFileName($userPath) - $userSid = $Profile.SID + $userSid = $ProfileItem.SID $start = Get-Date Write-ToolkitLog -Level 'INFO' -Message (Get-SourceTextLoc 'toolText.startResidualFolder01' -Args @($userName, $userPath)) try { - Remove-CimInstance -InputObject $Profile -ErrorAction Stop -Confirm:$false + Remove-CimInstance -InputObject $ProfileItem -ErrorAction Stop -Confirm:$false Write-ToolkitLog -Level 'SUCCESS' -Message (Get-SourceTextLoc 'toolText.cimProfileRemoved0' -Args @($userName)) } catch { @@ -4183,16 +4183,16 @@ function WinDeleteUserProfiles { } try { $pool.Open() - foreach ($profile in $Profiles) { + foreach ($profileItem in $Profiles) { $ps = [PowerShell]::Create() $ps.RunspacePool = $pool [void]$ps.AddScript($scriptBlock, $true). - AddArgument($profile) + AddArgument($profileItem) $handle = $ps.BeginInvoke() $jobs.Add([PSCustomObject]@{ PowerShell = $ps Handle = $handle - Profile = $profile + Profile = $profileItem }) } $total = $jobs.Count From 0630a79be6e7009b4fa110a86ce401137e067346 Mon Sep 17 00:00:00 2001 From: pomodori92 Date: Tue, 6 Oct 2026 10:01:21 +0200 Subject: [PATCH 06/12] fix: add comments to clarify SID usage for Windows language compatibility in profile deletion --- tools/WinDeleteUserProfiles.ps1 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/WinDeleteUserProfiles.ps1 b/tools/WinDeleteUserProfiles.ps1 index 60684728..b4a6beb1 100644 --- a/tools/WinDeleteUserProfiles.ps1 +++ b/tools/WinDeleteUserProfiles.ps1 @@ -321,6 +321,7 @@ function WinDeleteUserProfiles { try { Invoke-ExternalCommandWithLog -Command 'takeown.exe' -Arguments @('/F', "`"$userPath`"", '/R', '/D', 'Y') -LogContextKey "ProfileCleanup-TakeOwn-$userName" | Out-Null + # S-1-5-32-544 - Fixed SID across Windows languages; avoids depending on localized group names. Invoke-ExternalCommandWithLog -Command 'icacls.exe' -Arguments @("`"$userPath`"", '/grant', '*S-1-5-32-544:F', '/T', '/C') -LogContextKey "ProfileCleanup-Icacls-$userName" | Out-Null Remove-Item -LiteralPath $userPath -Recurse -Force -ErrorAction Stop -Confirm:$false Write-ToolkitLog -Level 'SUCCESS' -Message (Get-SourceTextLoc 'toolText.folderRemovedAfterAclReset0' -Args @($userName)) @@ -491,6 +492,7 @@ function WinDeleteUserProfiles { try { Invoke-ExternalCommandWithLog -Command 'takeown.exe' -Arguments @('/F', "`"$folderPath`"", '/R', '/D', 'Y') -LogContextKey "ResidualTakeOwn-$($folder.Name)" | Out-Null + # S-1-5-32-544 - Fixed SID across Windows languages; avoids depending on localized group names. Invoke-ExternalCommandWithLog -Command 'icacls.exe' -Arguments @("`"$folderPath`"", '/grant', '*S-1-5-32-544:F', '/T', '/C') -LogContextKey "ResidualIcacls-$($folder.Name)" | Out-Null Remove-Item -LiteralPath $folderPath -Recurse -Force -ErrorAction Stop -Confirm:$false $success = -not [System.IO.Directory]::Exists($folderPath) From c428a534ea0c901a06faf240707d93e9a17171a7 Mon Sep 17 00:00:00 2001 From: pomodori92 Date: Tue, 6 Oct 2026 11:54:50 +0200 Subject: [PATCH 07/12] fix: optimize profile removal process with improved job handling and progress reporting --- .../Unit/WinDeleteUserProfiles.Tests.ps1 | 120 ++++++++++++++++++ tools/WinDeleteUserProfiles.ps1 | 55 +++++--- 2 files changed, 154 insertions(+), 21 deletions(-) diff --git a/.github/tests/Unit/WinDeleteUserProfiles.Tests.ps1 b/.github/tests/Unit/WinDeleteUserProfiles.Tests.ps1 index be49fd9e..0df9a76e 100644 --- a/.github/tests/Unit/WinDeleteUserProfiles.Tests.ps1 +++ b/.github/tests/Unit/WinDeleteUserProfiles.Tests.ps1 @@ -57,6 +57,10 @@ Describe 'Issue #189 safe regressions' { $node -is [System.Management.Automation.Language.ScriptBlockExpressionAst] }, $true) $script:ProfileRemovalWorker = $workerExpression.ScriptBlock.GetScriptBlock() + $script:ProfileRemovalBatchDefinition = $batchFunction.Extent.Text + $script:ProfileRemovalWorkerOffset = $workerAssignment.Extent.StartOffset - $batchFunction.Extent.StartOffset + $script:ProfileRemovalWorkerLength = $workerAssignment.Extent.EndOffset - $workerAssignment.Extent.StartOffset + $script:ProfileBatchResultReceiver = (Get-Command Receive-ProfileRemovalResult).ScriptBlock Import-LocalizedData -BindingVariable italianData -BaseDirectory (Join-Path $script:RepoRoot 'languages') -FileName 'WinToolkit.psd1' -UICulture 'it-IT' Import-LocalizedData -BindingVariable englishData -BaseDirectory (Join-Path $script:RepoRoot 'languages') -FileName 'WinToolkit.psd1' -UICulture 'en-US' @@ -345,4 +349,120 @@ Describe 'Issue #189 safe regressions' { Get-SourceTextLoc 'toolText.residualFoldersRemoved0' -Args @(0) | Should -Be 'Cartelle residue rimosse: 0' } } + + Context 'Bounded profile scheduling with synthetic workers' { + BeforeAll { + $syntheticWorker = @' +$scriptBlock = { + param($ProfileItem) + Start-Sleep -Milliseconds $ProfileItem.DelayMilliseconds + if ($ProfileItem.Fail) { throw 'Synthetic bounded-worker failure' } + [pscustomobject]@{ + Type = 'Profile' + UserName = $ProfileItem.Name + Success = $true + Duration = [TimeSpan]::Zero + } +} +'@ + $probeSource = $script:ProfileRemovalBatchDefinition.Remove( + $script:ProfileRemovalWorkerOffset, $script:ProfileRemovalWorkerLength + ).Insert($script:ProfileRemovalWorkerOffset, $syntheticWorker) + $probeSource = $probeSource.Replace('$ps = [PowerShell]::Create()', '$ps = New-TrackedProfilePowerShell') + if ($probeSource -match '\b(Remove-CimInstance|Remove-Item|Invoke-ExternalCommandWithLog)\b') { + throw 'A destructive command remained in the scheduling probe.' + } + . ([scriptblock]::Create($probeSource)) + + function New-TrackedProfilePowerShell { + $outstanding = $script:BatchCreatedPowerShells.Count - $script:BatchCollectedCount + 1 + $script:BatchPeakOutstanding = [Math]::Max($script:BatchPeakOutstanding, $outstanding) + $powerShell = [PowerShell]::Create() + $script:BatchCreatedPowerShells.Add($powerShell) + return $powerShell + } + } + + BeforeEach { + $script:BatchCreatedPowerShells = [System.Collections.Generic.List[object]]::new() + $script:BatchCollectedCount = 0 + $script:BatchPeakOutstanding = 0 + $script:BatchReceivedNames = [System.Collections.Generic.List[string]]::new() + $script:BatchProgress = [System.Collections.Generic.List[int]]::new() + Mock New-ProfileRemovalSessionState { [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() } + Mock Write-ProgressUpdate { $script:BatchProgress.Add($Percent) } + Mock Clear-ProgressLine {} + Mock Receive-ProfileRemovalResult { + $script:BatchReceivedNames.Add([System.IO.Path]::GetFileName($Job.Profile.LocalPath)) + $result = & $script:ProfileBatchResultReceiver -Job $Job + $script:BatchCollectedCount++ + $result + } + } + + AfterEach { + foreach ($powerShell in $script:BatchCreatedPowerShells) { $powerShell.Dispose() } + } + + It 'limits profiles to outstanding jobs and preserves every result' -ForEach @( + @{ Count = 3; Threads = 1 }, + @{ Count = 32; Threads = 2 }, + @{ Count = 256; Threads = 4 } + ) { + $maxThreadsEffective = $Threads + $profiles = @(for ($index = 0; $index -lt $Count; $index++) { + [pscustomobject]@{ + Name = "Synthetic$index" + LocalPath = "C:\Users\Synthetic$index" + SID = "S-1-5-21-189-$index" + DelayMilliseconds = 1 + Fail = $false + } + }) + + $results = @(Invoke-ProfileRemovalBatch -Profiles $profiles) + + $script:BatchPeakOutstanding | Should -BeLessOrEqual $Threads + $script:BatchCreatedPowerShells.Count | Should -Be $Count + $script:BatchCollectedCount | Should -Be $Count + $results.Count | Should -Be $Count + @($results | Where-Object { -not $_.Success }).Count | Should -Be 0 + for ($index = 0; $index -lt $Count; $index++) { + $results[$index].UserName | Should -Be $profiles[$index].Name + } + $script:BatchProgress[-1] | Should -Be 100 + } + + It 'returns input order when a later worker finishes first' { + $maxThreadsEffective = 2 + $profiles = @( + [pscustomobject]@{ Name = 'Slow'; LocalPath = 'C:\Users\Slow'; SID = 'slow'; DelayMilliseconds = 250; Fail = $false }, + [pscustomobject]@{ Name = 'Fast'; LocalPath = 'C:\Users\Fast'; SID = 'fast'; DelayMilliseconds = 1; Fail = $false } + ) + + $results = @(Invoke-ProfileRemovalBatch -Profiles $profiles) + + $script:BatchReceivedNames[0] | Should -Be 'Fast' + $results[0].UserName | Should -Be 'Slow' + $results[1].UserName | Should -Be 'Fast' + } + + It 'retains one failed result while scheduling replacements' { + $maxThreadsEffective = 1 + $profiles = @( + [pscustomobject]@{ Name = 'Failed'; LocalPath = 'C:\Users\Failed'; SID = 'failed'; DelayMilliseconds = 1; Fail = $true }, + [pscustomobject]@{ Name = 'Next'; LocalPath = 'C:\Users\Next'; SID = 'next'; DelayMilliseconds = 1; Fail = $false } + ) + + $results = @(Invoke-ProfileRemovalBatch -Profiles $profiles) + + $script:BatchPeakOutstanding | Should -Be 1 + $results.Count | Should -Be 2 + $results[0].UserName | Should -Be 'Failed' + $results[0].Success | Should -BeFalse + $results[1].UserName | Should -Be 'Next' + $results[1].Success | Should -BeTrue + $script:BatchCollectedCount | Should -Be 2 + } + } } diff --git a/tools/WinDeleteUserProfiles.ps1 b/tools/WinDeleteUserProfiles.ps1 index b4a6beb1..41c15492 100644 --- a/tools/WinDeleteUserProfiles.ps1 +++ b/tools/WinDeleteUserProfiles.ps1 @@ -360,27 +360,41 @@ function WinDeleteUserProfiles { try { $pool.Open() - foreach ($profileItem in $Profiles) { - $ps = [PowerShell]::Create() - $ps.RunspacePool = $pool + $total = $Profiles.Count + $results = [object[]]::new($total) + $nextProfileIndex = 0 + $completed = 0 + $lastPercent = -1 - [void]$ps.AddScript($scriptBlock, $true). - AddArgument($profileItem) + do { + for ($jobIndex = $jobs.Count - 1; $jobIndex -ge 0; $jobIndex--) { + $job = $jobs[$jobIndex] + if (-not $job.Handle.IsCompleted) { continue } - $handle = $ps.BeginInvoke() + $results[$job.Index] = Receive-ProfileRemovalResult -Job $job + $jobs.RemoveAt($jobIndex) + $completed++ + } - $jobs.Add([PSCustomObject]@{ - PowerShell = $ps - Handle = $handle - Profile = $profileItem - }) - } + while ($nextProfileIndex -lt $total -and $jobs.Count -lt $maxThreadsEffective) { + $profileItem = $Profiles[$nextProfileIndex] + $ps = [PowerShell]::Create() + $ps.RunspacePool = $pool - $total = $jobs.Count - $lastPercent = -1 + [void]$ps.AddScript($scriptBlock, $true). + AddArgument($profileItem) + + $handle = $ps.BeginInvoke() + + $jobs.Add([PSCustomObject]@{ + PowerShell = $ps + Handle = $handle + Profile = $profileItem + Index = $nextProfileIndex + }) + $nextProfileIndex++ + } - do { - $completed = ($jobs | Where-Object { $_.Handle.IsCompleted }).Count $percent = if ($total -gt 0) { [math]::Floor(($completed / $total) * 100) } else { 100 } if ($percent -ne $lastPercent) { @@ -388,16 +402,15 @@ function WinDeleteUserProfiles { Write-ProgressUpdate -Activity (Get-SourceTextLoc 'toolText.extra.removingRegisteredProfiles') -Status (Get-SourceTextLoc 'toolText.extra.01Completed' -Args @($completed, $total)) -Percent $percent -Icon '🗑️' } - Start-Sleep -Milliseconds 500 + if ($jobs.Count -gt 0) { + $waitHandles = [System.Threading.WaitHandle[]]@($jobs | ForEach-Object { $_.Handle.AsyncWaitHandle }) + [void][System.Threading.WaitHandle]::WaitAny($waitHandles, 500) + } } while ($completed -lt $total) Write-ProgressUpdate -Activity (Get-SourceTextLoc 'toolText.extra.removingRegisteredProfiles') -Status (Get-SourceTextLoc 'uiText.completed') -Percent 100 -Icon '✅' Clear-ProgressLine - $results = foreach ($job in $jobs) { - Receive-ProfileRemovalResult -Job $job - } - return $results } finally { From 05b430f81ee5aedcccc2fe7b90a1d4747cb4e681 Mon Sep 17 00:00:00 2001 From: pomodori92 <45762339+pomodori92@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:58:00 +0000 Subject: [PATCH 08/12] =?UTF-8?q?=E2=9C=85=20Build=20WinToolkit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WinToolkit.ps1 | 48 ++++++++++++++++++++++++++++++------------------ 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/WinToolkit.ps1 b/WinToolkit.ps1 index 7997a244..c0b13362 100644 --- a/WinToolkit.ps1 +++ b/WinToolkit.ps1 @@ -4183,34 +4183,46 @@ function WinDeleteUserProfiles { } try { $pool.Open() - foreach ($profileItem in $Profiles) { - $ps = [PowerShell]::Create() - $ps.RunspacePool = $pool - [void]$ps.AddScript($scriptBlock, $true). - AddArgument($profileItem) - $handle = $ps.BeginInvoke() - $jobs.Add([PSCustomObject]@{ - PowerShell = $ps - Handle = $handle - Profile = $profileItem - }) - } - $total = $jobs.Count + $total = $Profiles.Count + $results = [object[]]::new($total) + $nextProfileIndex = 0 + $completed = 0 $lastPercent = -1 do { - $completed = ($jobs | Where-Object { $_.Handle.IsCompleted }).Count + for ($jobIndex = $jobs.Count - 1; $jobIndex -ge 0; $jobIndex--) { + $job = $jobs[$jobIndex] + if (-not $job.Handle.IsCompleted) { continue } + $results[$job.Index] = Receive-ProfileRemovalResult -Job $job + $jobs.RemoveAt($jobIndex) + $completed++ + } + while ($nextProfileIndex -lt $total -and $jobs.Count -lt $maxThreadsEffective) { + $profileItem = $Profiles[$nextProfileIndex] + $ps = [PowerShell]::Create() + $ps.RunspacePool = $pool + [void]$ps.AddScript($scriptBlock, $true). + AddArgument($profileItem) + $handle = $ps.BeginInvoke() + $jobs.Add([PSCustomObject]@{ + PowerShell = $ps + Handle = $handle + Profile = $profileItem + Index = $nextProfileIndex + }) + $nextProfileIndex++ + } $percent = if ($total -gt 0) { [math]::Floor(($completed / $total) * 100) } else { 100 } if ($percent -ne $lastPercent) { $lastPercent = $percent Write-ProgressUpdate -Activity (Get-SourceTextLoc 'toolText.extra.removingRegisteredProfiles') -Status (Get-SourceTextLoc 'toolText.extra.01Completed' -Args @($completed, $total)) -Percent $percent -Icon '🗑️' } - Start-Sleep -Milliseconds 500 + if ($jobs.Count -gt 0) { + $waitHandles = [System.Threading.WaitHandle[]]@($jobs | ForEach-Object { $_.Handle.AsyncWaitHandle }) + [void][System.Threading.WaitHandle]::WaitAny($waitHandles, 500) + } } while ($completed -lt $total) Write-ProgressUpdate -Activity (Get-SourceTextLoc 'toolText.extra.removingRegisteredProfiles') -Status (Get-SourceTextLoc 'uiText.completed') -Percent 100 -Icon '✅' Clear-ProgressLine - $results = foreach ($job in $jobs) { - Receive-ProfileRemovalResult -Job $job - } return $results } finally { From 5ecfb8c914c2ec406816dd8cb3e395c606050ebd Mon Sep 17 00:00:00 2001 From: pomodori92 Date: Tue, 6 Oct 2026 12:12:22 +0200 Subject: [PATCH 09/12] fix: enhance profile removal process with improved power shell handling and disposal management --- .../Unit/WinDeleteUserProfiles.Tests.ps1 | 101 ++++++++++++++++++ tools/WinDeleteUserProfiles.ps1 | 43 +++++++- 2 files changed, 141 insertions(+), 3 deletions(-) diff --git a/.github/tests/Unit/WinDeleteUserProfiles.Tests.ps1 b/.github/tests/Unit/WinDeleteUserProfiles.Tests.ps1 index 0df9a76e..c4c7e2b6 100644 --- a/.github/tests/Unit/WinDeleteUserProfiles.Tests.ps1 +++ b/.github/tests/Unit/WinDeleteUserProfiles.Tests.ps1 @@ -19,6 +19,7 @@ Describe 'Issue #189 safe regressions' { $definitions = @( @{ Path = $toolPath; Name = 'New-ProfileRemovalSessionState' }, @{ Path = $toolPath; Name = 'Receive-ProfileRemovalResult' }, + @{ Path = $toolPath; Name = 'Close-ProfileRemovalPowerShell' }, @{ Path = $toolPath; Name = 'Remove-ProfileRegistryEntries' }, @{ Path = $toolPath; Name = 'Remove-ResidualUserFolders' }, @{ Path = $localizationPath; Name = 'Get-SourceTextLoc' }, @@ -369,6 +370,7 @@ $scriptBlock = { $script:ProfileRemovalWorkerOffset, $script:ProfileRemovalWorkerLength ).Insert($script:ProfileRemovalWorkerOffset, $syntheticWorker) $probeSource = $probeSource.Replace('$ps = [PowerShell]::Create()', '$ps = New-TrackedProfilePowerShell') + $probeSource = $probeSource.Replace('$handle = $ps.BeginInvoke()', '$handle = Invoke-ProfileProbeBeginInvoke -PowerShell $ps') if ($probeSource -match '\b(Remove-CimInstance|Remove-Item|Invoke-ExternalCommandWithLog)\b') { throw 'A destructive command remained in the scheduling probe.' } @@ -381,6 +383,19 @@ $scriptBlock = { $script:BatchCreatedPowerShells.Add($powerShell) return $powerShell } + + function Invoke-ProfileProbeBeginInvoke { + param($PowerShell) + $PowerShell.BeginInvoke() + } + + function Assert-ProfileProbePowerShellDisposed { + param($PowerShell) + $disposed = $false + try { [void]$PowerShell.AddScript('$null') } + catch { $disposed = $_.Exception.InnerException -is [System.ObjectDisposedException] } + $disposed | Should -BeTrue + } } BeforeEach { @@ -431,6 +446,9 @@ $scriptBlock = { $results[$index].UserName | Should -Be $profiles[$index].Name } $script:BatchProgress[-1] | Should -Be 100 + foreach ($powerShell in $script:BatchCreatedPowerShells) { + Assert-ProfileProbePowerShellDisposed -PowerShell $powerShell + } } It 'returns input order when a later worker finishes first' { @@ -464,5 +482,88 @@ $scriptBlock = { $results[1].Success | Should -BeTrue $script:BatchCollectedCount | Should -Be 2 } + + It 'disposes every outstanding instance when progress reporting fails' { + $maxThreadsEffective = 2 + $profiles = @( + [pscustomobject]@{ Name = 'First'; LocalPath = 'C:\Users\First'; SID = 'first'; DelayMilliseconds = 1000; Fail = $false }, + [pscustomobject]@{ Name = 'Second'; LocalPath = 'C:\Users\Second'; SID = 'second'; DelayMilliseconds = 1000; Fail = $false } + ) + Mock Write-ProgressUpdate { throw 'Synthetic progress failure' } + + { Invoke-ProfileRemovalBatch -Profiles $profiles } | Should -Throw '*Synthetic progress failure*' + + $script:BatchCreatedPowerShells.Count | Should -Be 2 + foreach ($powerShell in $script:BatchCreatedPowerShells) { + Assert-ProfileProbePowerShellDisposed -PowerShell $powerShell + } + } + + It 'disposes an instance when startup fails before job registration' { + $maxThreadsEffective = 1 + $profiles = @([pscustomobject]@{ + Name = 'Startup'; LocalPath = 'C:\Users\Startup'; SID = 'startup'; DelayMilliseconds = 1; Fail = $false + }) + Mock Invoke-ProfileProbeBeginInvoke { throw 'Synthetic startup failure' } + + { Invoke-ProfileRemovalBatch -Profiles $profiles } | Should -Throw '*Synthetic startup failure*' + + $script:BatchCreatedPowerShells.Count | Should -Be 1 + Assert-ProfileProbePowerShellDisposed -PowerShell $script:BatchCreatedPowerShells[0] + } + + It 'disposes remaining instances when collection fails after one instance was disposed' { + $maxThreadsEffective = 2 + $profiles = @( + [pscustomobject]@{ Name = 'Collected'; LocalPath = 'C:\Users\Collected'; SID = 'collected'; DelayMilliseconds = 1; Fail = $false }, + [pscustomobject]@{ Name = 'Remaining'; LocalPath = 'C:\Users\Remaining'; SID = 'remaining'; DelayMilliseconds = 1000; Fail = $false } + ) + Mock Receive-ProfileRemovalResult { + & $script:ProfileBatchResultReceiver -Job $Job | Out-Null + $script:BatchCollectedCount++ + throw 'Synthetic collection failure' + } + + { Invoke-ProfileRemovalBatch -Profiles $profiles } | Should -Throw '*Synthetic collection failure*' + + $script:BatchCollectedCount | Should -Be 1 + foreach ($powerShell in $script:BatchCreatedPowerShells) { + Assert-ProfileProbePowerShellDisposed -PowerShell $powerShell + } + } + + It 'still disposes an instance when stopping it fails' { + $powerShell = [pscustomobject]@{ Disposed = $false } + $powerShell | Add-Member -MemberType ScriptMethod -Name Stop -Value { throw 'Synthetic stop failure' } + $powerShell | Add-Member -MemberType ScriptMethod -Name Dispose -Value { $this.Disposed = $true } + Mock Write-Warning {} + + { Close-ProfileRemovalPowerShell -PowerShell $powerShell } | Should -Not -Throw + + $powerShell.Disposed | Should -BeTrue + Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { + $Message -like '*Synthetic stop failure*' -and $WarningAction -eq 'Continue' + } + } + + It 'continues cleanup after a disposal failure and reports the failure' { + $failedPowerShell = [pscustomobject]@{} + $failedPowerShell | Add-Member -MemberType ScriptMethod -Name Stop -Value {} + $failedPowerShell | Add-Member -MemberType ScriptMethod -Name Dispose -Value { throw 'Synthetic dispose failure' } + $nextPowerShell = [PowerShell]::Create() + $script:BatchCreatedPowerShells.Add($nextPowerShell) + Mock Write-Warning {} + + { + foreach ($powerShell in @($failedPowerShell, $nextPowerShell)) { + Close-ProfileRemovalPowerShell -PowerShell $powerShell + } + } | Should -Not -Throw + + Assert-ProfileProbePowerShellDisposed -PowerShell $nextPowerShell + Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { + $Message -like '*Synthetic dispose failure*' -and $WarningAction -eq 'Continue' + } + } } } diff --git a/tools/WinDeleteUserProfiles.ps1 b/tools/WinDeleteUserProfiles.ps1 index 41c15492..b2ed5f51 100644 --- a/tools/WinDeleteUserProfiles.ps1 +++ b/tools/WinDeleteUserProfiles.ps1 @@ -269,6 +269,29 @@ function WinDeleteUserProfiles { } } + function Close-ProfileRemovalPowerShell { + param( + [Parameter(Mandatory = $true)] + [object]$PowerShell + ) + + try { + $PowerShell.Stop() + } + catch [System.ObjectDisposedException] {} + catch { + Write-Warning -Message $_.Exception.Message -WarningAction Continue + } + finally { + try { + $PowerShell.Dispose() + } + catch { + Write-Warning -Message $_.Exception.Message -WarningAction Continue + } + } + } + function Invoke-ProfileRemovalBatch { param( [Parameter(Mandatory = $true)] @@ -279,6 +302,7 @@ function WinDeleteUserProfiles { $pool = [RunspaceFactory]::CreateRunspacePool(1, $maxThreadsEffective, $sessionState, $Host) $jobs = [System.Collections.Generic.List[object]]::new() + $pendingPowerShell = $null $scriptBlock = { param($ProfileItem) @@ -379,6 +403,7 @@ function WinDeleteUserProfiles { while ($nextProfileIndex -lt $total -and $jobs.Count -lt $maxThreadsEffective) { $profileItem = $Profiles[$nextProfileIndex] $ps = [PowerShell]::Create() + $pendingPowerShell = $ps $ps.RunspacePool = $pool [void]$ps.AddScript($scriptBlock, $true). @@ -392,6 +417,7 @@ function WinDeleteUserProfiles { Profile = $profileItem Index = $nextProfileIndex }) + $pendingPowerShell = $null $nextProfileIndex++ } @@ -414,9 +440,20 @@ function WinDeleteUserProfiles { return $results } finally { - if ($pool) { - $pool.Close() - $pool.Dispose() + try { + if ($pendingPowerShell) { + Close-ProfileRemovalPowerShell -PowerShell $pendingPowerShell + } + foreach ($job in $jobs) { + Close-ProfileRemovalPowerShell -PowerShell $job.PowerShell + } + $jobs.Clear() + } + finally { + if ($pool) { + try { $pool.Close() } + finally { $pool.Dispose() } + } } } } From a98517b7b3fd6f81bb38042e45f0859afd9ab9b5 Mon Sep 17 00:00:00 2001 From: pomodori92 <45762339+pomodori92@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:15:44 +0000 Subject: [PATCH 10/12] =?UTF-8?q?=E2=9C=85=20Build=20WinToolkit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WinToolkit.ps1 | 41 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/WinToolkit.ps1 b/WinToolkit.ps1 index c0b13362..c1f7ac23 100644 --- a/WinToolkit.ps1 +++ b/WinToolkit.ps1 @@ -4111,6 +4111,27 @@ function WinDeleteUserProfiles { $Job.PowerShell.Dispose() } } + function Close-ProfileRemovalPowerShell { + param( + [Parameter(Mandatory = $true)] + [object]$PowerShell + ) + try { + $PowerShell.Stop() + } + catch [System.ObjectDisposedException] {} + catch { + Write-Warning -Message $_.Exception.Message -WarningAction Continue + } + finally { + try { + $PowerShell.Dispose() + } + catch { + Write-Warning -Message $_.Exception.Message -WarningAction Continue + } + } + } function Invoke-ProfileRemovalBatch { param( [Parameter(Mandatory = $true)] @@ -4119,6 +4140,7 @@ function WinDeleteUserProfiles { $sessionState = New-ProfileRemovalSessionState $pool = [RunspaceFactory]::CreateRunspacePool(1, $maxThreadsEffective, $sessionState, $Host) $jobs = [System.Collections.Generic.List[object]]::new() + $pendingPowerShell = $null $scriptBlock = { param($ProfileItem) $ErrorActionPreference = 'Stop' @@ -4199,6 +4221,7 @@ function WinDeleteUserProfiles { while ($nextProfileIndex -lt $total -and $jobs.Count -lt $maxThreadsEffective) { $profileItem = $Profiles[$nextProfileIndex] $ps = [PowerShell]::Create() + $pendingPowerShell = $ps $ps.RunspacePool = $pool [void]$ps.AddScript($scriptBlock, $true). AddArgument($profileItem) @@ -4209,6 +4232,7 @@ function WinDeleteUserProfiles { Profile = $profileItem Index = $nextProfileIndex }) + $pendingPowerShell = $null $nextProfileIndex++ } $percent = if ($total -gt 0) { [math]::Floor(($completed / $total) * 100) } else { 100 } @@ -4226,9 +4250,20 @@ function WinDeleteUserProfiles { return $results } finally { - if ($pool) { - $pool.Close() - $pool.Dispose() + try { + if ($pendingPowerShell) { + Close-ProfileRemovalPowerShell -PowerShell $pendingPowerShell + } + foreach ($job in $jobs) { + Close-ProfileRemovalPowerShell -PowerShell $job.PowerShell + } + $jobs.Clear() + } + finally { + if ($pool) { + try { $pool.Close() } + finally { $pool.Dispose() } + } } } } From 9e0e3caa3b93c34b88a6051244047111e5cd04db Mon Sep 17 00:00:00 2001 From: pomodori92 Date: Tue, 6 Oct 2026 13:00:32 +0200 Subject: [PATCH 11/12] fix: improve comments and documentation for profile deletion process --- tools/WinDeleteUserProfiles.ps1 | 58 ++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/tools/WinDeleteUserProfiles.ps1 b/tools/WinDeleteUserProfiles.ps1 index b2ed5f51..9b9d462b 100644 --- a/tools/WinDeleteUserProfiles.ps1 +++ b/tools/WinDeleteUserProfiles.ps1 @@ -1,3 +1,4 @@ +# Coordinate registered-profile and residual-folder cleanup with shared protections and result reporting. function WinDeleteUserProfiles { <# .SYNOPSIS @@ -55,9 +56,11 @@ function WinDeleteUserProfiles { ) $script:ToolName = 'WinDeleteUserProfiles' + # Keep a trailing separator so root checks cannot match sibling paths with the same prefix. $usersRoot = [System.IO.Path]::GetFullPath($UsersRoot.TrimEnd('\') + '\') $currentUser = $env:USERNAME $computerName = $env:COMPUTERNAME + # Resolve the current profile by SID because its folder name can differ from the account name. $currentUserSid = ([System.Security.Principal.WindowsIdentity]::GetCurrent()).User.Value $currentUserProfilePath = (Get-CimInstance -ClassName Win32_UserProfile -ErrorAction SilentlyContinue | Where-Object { $_.SID -eq $currentUserSid } | Select-Object -First 1 -ExpandProperty LocalPath -ErrorAction SilentlyContinue) if ($currentUserProfilePath -and $currentUserProfilePath.StartsWith($usersRoot, [System.StringComparison]::OrdinalIgnoreCase)) { @@ -68,8 +71,10 @@ function WinDeleteUserProfiles { } $minimumLastUseDate = if ($MinimumProfileAgeDays -gt 0) { (Get-Date).AddDays(-$MinimumProfileAgeDays) } else { $null } $rebootRecommended = $false + # Cap simultaneous CIM operations and the resources held by the worker batch. $maxThreadsEffective = [Math]::Min($MaxThreads, 4) + # Preserve shared/system folders and both names that can identify the current user's profile. $protectedProfileNames = @( 'Public', 'Pubblica', @@ -84,12 +89,15 @@ function WinDeleteUserProfiles { $currentUserFolder ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + # Apply the same case-insensitive name exclusions to registered profiles and residual folders. function New-ProtectedNameSet { $excluded = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $protectedProfileNames | ForEach-Object { [void]$excluded.Add($_) } + # The unary comma returns the set itself instead of enumerating its entries into the pipeline. return , $excluded } + # Remove leftover ProfileList state after the profile directory is gone, reporting registry failure separately. function Remove-ProfileRegistryEntries { param( [Parameter(Mandatory = $true)] @@ -99,12 +107,14 @@ function WinDeleteUserProfiles { [string]$UserName ) + # Never construct a destructive registry path from a missing or placeholder SID. if ([string]::IsNullOrWhiteSpace($Sid) -or $Sid -eq 'NULL') { return $false } $profileRegKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\' + $Sid + # CIM removal can already delete this key; an absent entry counts as successful cleanup. if (-not (Test-Path -LiteralPath $profileRegKey)) { return $true } @@ -122,6 +132,7 @@ function WinDeleteUserProfiles { } } + # Collect normalized registered paths so the residual scan preserves folders still associated with CIM profiles. function Get-RegisteredProfilePathSet { $pathSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) @@ -151,6 +162,7 @@ function WinDeleteUserProfiles { return , $pathSet } + # Select unloaded, non-special profiles under the configured root before scheduling destructive work. function Get-RemovableUserProfiles { $excluded = New-ProtectedNameSet @@ -166,6 +178,7 @@ function WinDeleteUserProfiles { return } + # Active sessions and Windows-managed special profiles must keep their profile data. $profiles = $profiles | Where-Object { -not $_.Special -and -not $_.Loaded -and @@ -181,11 +194,13 @@ function WinDeleteUserProfiles { continue } + # Protect the current account independently of folder-name exclusions. if ($profileItem.SID -eq $currentUserSid) { Write-ToolkitLog -Level 'INFO' -Message (Get-SourceTextLoc 'toolText.excludedProfileBecauseSidMatchesCurrentUser0' -Args @($profileName, $profileItem.SID)) continue } + # Apply the optional age cutoff only when Windows provides a last-use timestamp. if ($minimumLastUseDate -and $profileItem.LastUseTime) { $lastUse = $profileItem.LastUseTime if ($lastUse -gt $minimumLastUseDate) { @@ -198,8 +213,8 @@ function WinDeleteUserProfiles { } } + # Supply worker dependencies explicitly: runspaces do not inherit toolkit functions or language/log context. function New-ProfileRemovalSessionState { - # Runspaces do not inherit the toolkit functions or the active language/log context. $sessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() foreach ($functionName in @( @@ -236,6 +251,7 @@ function WinDeleteUserProfiles { [System.Management.Automation.Runspaces.SessionStateVariableEntry]::new($variableName, $value, '') ) } + # Shared helpers must leave console rendering to the parent even inside worker sessions. $sessionState.Variables.Add( [System.Management.Automation.Runspaces.SessionStateVariableEntry]::new('GuiSessionActive', $true, '') ) @@ -243,6 +259,7 @@ function WinDeleteUserProfiles { return $sessionState } + # Collect a finished worker's result and release its pipeline before scheduling replacement work. function Receive-ProfileRemovalResult { param( [Parameter(Mandatory = $true)] @@ -254,6 +271,7 @@ function WinDeleteUserProfiles { } catch { Write-ToolkitLog -Level 'ERROR' -Message (Get-SourceTextLoc 'toolText.runspaceError0' -Args @($($_.Exception.Message))) + # Preserve identity in a failed result so worker exceptions remain visible in summary counts. [PSCustomObject]@{ Type = 'Profile' UserName = [System.IO.Path]::GetFileName($Job.Profile.LocalPath) @@ -264,11 +282,13 @@ function WinDeleteUserProfiles { } } finally { + # Release command references and pipeline resources as soon as collection finishes. $Job.PowerShell.Commands.Clear() $Job.PowerShell.Dispose() } } + # Clean up workers abandoned by a batch failure while keeping individual cleanup errors non-terminating. function Close-ProfileRemovalPowerShell { param( [Parameter(Mandatory = $true)] @@ -278,11 +298,14 @@ function WinDeleteUserProfiles { try { $PowerShell.Stop() } + # Collection may have disposed the instance before a later batch operation failed. catch [System.ObjectDisposedException] {} catch { + # Explicit Continue prevents the caller's warning preference from interrupting cleanup. Write-Warning -Message $_.Exception.Message -WarningAction Continue } finally { + # Dispose must still run when Stop fails. try { $PowerShell.Dispose() } @@ -292,6 +315,7 @@ function WinDeleteUserProfiles { } } + # Bound the number of live pipelines while returning one result per profile in the original input order. function Invoke-ProfileRemovalBatch { param( [Parameter(Mandatory = $true)] @@ -301,12 +325,15 @@ function WinDeleteUserProfiles { $sessionState = New-ProfileRemovalSessionState $pool = [RunspaceFactory]::CreateRunspacePool(1, $maxThreadsEffective, $sessionState, $Host) + # Retain only outstanding jobs so pipeline memory stays bounded by the concurrency limit. $jobs = [System.Collections.Generic.List[object]]::new() + # Keep ownership of a new instance until startup succeeds and it joins the tracked job list. $pendingPowerShell = $null $scriptBlock = { param($ProfileItem) + # Terminating errors route failed deletion steps through their fallback cleanup paths. $ErrorActionPreference = 'Stop' $userPath = $ProfileItem.LocalPath @@ -316,6 +343,7 @@ function WinDeleteUserProfiles { Write-ToolkitLog -Level 'INFO' -Message (Get-SourceTextLoc 'toolText.startResidualFolder01' -Args @($userName, $userPath)) + # Let Windows remove the registered profile first; filesystem cleanup handles any leftovers. try { Remove-CimInstance -InputObject $ProfileItem -ErrorAction Stop -Confirm:$false Write-ToolkitLog -Level 'SUCCESS' -Message (Get-SourceTextLoc 'toolText.cimProfileRemoved0' -Args @($userName)) @@ -326,6 +354,7 @@ function WinDeleteUserProfiles { if ([System.IO.Directory]::Exists($userPath)) { try { + # An empty source lets /MIR remove leftover contents; /XJ excludes junctions. $tempEmpty = Join-Path $env:TEMP "EmptyFolder" if (-not (Test-Path $tempEmpty)) { @@ -343,6 +372,7 @@ function WinDeleteUserProfiles { catch { Write-ToolkitLog -Level 'WARNING' -Message (Get-SourceTextLoc 'toolText.standardFolderCleanupFailed01' -Args @($userName, $($_.Exception.Message))) + # Repair ownership and access if permissions prevented standard folder removal. try { Invoke-ExternalCommandWithLog -Command 'takeown.exe' -Arguments @('/F', "`"$userPath`"", '/R', '/D', 'Y') -LogContextKey "ProfileCleanup-TakeOwn-$userName" | Out-Null # S-1-5-32-544 - Fixed SID across Windows languages; avoids depending on localized group names. @@ -356,6 +386,7 @@ function WinDeleteUserProfiles { } } + # Remove any remaining registry entry only after the directory is gone; require both outcomes for success. $folderGone = -not [System.IO.Directory]::Exists($userPath) $registrySuccess = $false if ($folderGone -and $userSid) { @@ -385,12 +416,14 @@ function WinDeleteUserProfiles { try { $pool.Open() $total = $Profiles.Count + # Workers finish out of order, so store each result at its original profile index. $results = [object[]]::new($total) $nextProfileIndex = 0 $completed = 0 $lastPercent = -1 do { + # Walk backwards so removing completed jobs cannot shift an unvisited entry. for ($jobIndex = $jobs.Count - 1; $jobIndex -ge 0; $jobIndex--) { $job = $jobs[$jobIndex] if (-not $job.Handle.IsCompleted) { continue } @@ -400,6 +433,7 @@ function WinDeleteUserProfiles { $completed++ } + # Create replacements only after collection frees a slot, bounding queued and running instances. while ($nextProfileIndex -lt $total -and $jobs.Count -lt $maxThreadsEffective) { $profileItem = $Profiles[$nextProfileIndex] $ps = [PowerShell]::Create() @@ -417,18 +451,21 @@ function WinDeleteUserProfiles { Profile = $profileItem Index = $nextProfileIndex }) + # Transfer cleanup ownership only after the new job has been registered successfully. $pendingPowerShell = $null $nextProfileIndex++ } $percent = if ($total -gt 0) { [math]::Floor(($completed / $total) * 100) } else { 100 } + # Update the console only when the whole-number percentage changes. if ($percent -ne $lastPercent) { $lastPercent = $percent Write-ProgressUpdate -Activity (Get-SourceTextLoc 'toolText.extra.removingRegisteredProfiles') -Status (Get-SourceTextLoc 'toolText.extra.01Completed' -Args @($completed, $total)) -Percent $percent -Icon '🗑️' } if ($jobs.Count -gt 0) { + # Wait for any worker or a short timeout instead of continuously polling completion. $waitHandles = [System.Threading.WaitHandle[]]@($jobs | ForEach-Object { $_.Handle.AsyncWaitHandle }) [void][System.Threading.WaitHandle]::WaitAny($waitHandles, 500) } @@ -440,6 +477,7 @@ function WinDeleteUserProfiles { return $results } finally { + # Failure paths can bypass collection, leaving pending or tracked instances for this cleanup. try { if ($pendingPowerShell) { Close-ProfileRemovalPowerShell -PowerShell $pendingPowerShell @@ -450,6 +488,7 @@ function WinDeleteUserProfiles { $jobs.Clear() } finally { + # Release the pool even after instance cleanup fails, and dispose it even if Close fails. if ($pool) { try { $pool.Close() } finally { $pool.Dispose() } @@ -458,18 +497,22 @@ function WinDeleteUserProfiles { } } + # Select leftover directories only after protected-name, CIM-path, registered-SID, and link exclusions. function Get-ResidualUserFolders { $excluded = New-ProtectedNameSet + # Refresh registration after profile removal so this scan reflects the state Windows now reports. $registeredProfilePaths = Get-RegisteredProfilePathSet Write-StyledMessage -Type 'Info' -Text ("🔎 " + (Get-SourceTextLoc 'toolText.checkResidualFoldersInTheUsersDirectory')) Write-ToolkitLog -Level 'INFO' -Message (Get-SourceTextLoc 'toolText.checkResidualFoldersInTheUsersDirectory') + # Exclude reparse points so residual cleanup does not select links into other locations. $folders = Get-ChildItem -Path $usersRoot -Directory -Force | Where-Object { -not ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) } + # SID-named folders need a registry exclusion in addition to the CIM path exclusion. $profileListKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList' $registeredSids = @() try { @@ -498,6 +541,7 @@ function WinDeleteUserProfiles { continue } + # Keep the link exclusion at candidate selection as well as during initial enumeration. if ($folder.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { Write-ToolkitLog -Level 'WARNING' -Message (Get-SourceTextLoc 'toolText.residualFolderExcludedBecauseReparsePointSymlink01' -Args @($folderName, $folderPath)) continue @@ -510,6 +554,7 @@ function WinDeleteUserProfiles { } } + # Remove selected orphan folders separately, recording each outcome and retrying after ACL recovery if needed. function Remove-ResidualUserFolders { param( [Parameter(Mandatory = $true)] @@ -534,12 +579,15 @@ function WinDeleteUserProfiles { $folderPath = $folder.Path try { + # Literal paths preserve folder names containing wildcard characters during deletion. Remove-Item -LiteralPath $folderPath -Force -Recurse -ErrorAction Stop -Confirm:$false + # A completed command is insufficient; verify that the directory is actually gone. $success = -not [System.IO.Directory]::Exists($folderPath) } catch { Write-ToolkitLog -Level 'WARNING' -Message (Get-SourceTextLoc 'toolText.standardResidualFolderRemovalFailed01' -Args @($folderPath, $($_.Exception.Message))) + # Retry with ownership and access repaired when normal filesystem deletion fails. try { Invoke-ExternalCommandWithLog -Command 'takeown.exe' -Arguments @('/F', "`"$folderPath`"", '/R', '/D', 'Y') -LogContextKey "ResidualTakeOwn-$($folder.Name)" | Out-Null # S-1-5-32-544 - Fixed SID across Windows languages; avoids depending on localized group names. @@ -577,9 +625,11 @@ function WinDeleteUserProfiles { return $results } + # Both cleanup phases share the toolkit's progress, localization, and log context. Start-ToolkitSession -ToolName $script:ToolName -SubTitle (Get-SourceTextLoc 'script.WinDeleteUserProfiles') try { + # Validate the configured directory before either cleanup phase can delete anything. if (-not (Test-Path -LiteralPath $usersRoot -PathType Container)) { throw (Get-SourceTextLoc 'toolText.extra.profilePathDoesNotExist0' -Args @($usersRoot)) } @@ -629,6 +679,7 @@ function WinDeleteUserProfiles { Write-StyledMessage -Type 'Success' -Text ((Get-SourceTextLoc 'toolText.noRemovableRegisteredProfilesFound')) } + # Scan leftovers after registered-profile removal using the updated registration state. if (-not $SkipResidualFolderCleanup) { $residualFolders = @(Get-ResidualUserFolders) @@ -651,11 +702,13 @@ function WinDeleteUserProfiles { Write-StyledMessage -Type 'Warning' -Text (Get-SourceTextLoc 'toolText.residualFolderCleanupSkippedForSkipresidualfoldercleanupParameter') } + # Report successes separately for each phase, but base restart advice on failures from either phase. $allResults = @($profileResults) + @($residualResults) $failedCount = @($allResults | Where-Object { -not $_.Success }).Count $profileSuccessCount = @($profileResults | Where-Object { $_.Success }).Count $residualSuccessCount = @($residualResults | Where-Object { $_.Success }).Count + # Open sessions or handles may prevent cleanup; recommend a restart only when items failed. if ($failedCount -gt 0) { $rebootRecommended = $true Write-StyledMessage -Type 'Warning' -Text ((Get-SourceTextLoc 'toolText.extra.restartRecommendedAfterProfileCleanup')) @@ -669,14 +722,17 @@ function WinDeleteUserProfiles { } if ($rebootRecommended) { + # Forward suppression so a toolkit batch can manage its final restart centrally. Invoke-ToolkitReboot -Message (Get-SourceTextLoc 'toolText.extra.restartRecommendedAfterProfileCleanup') -Seconds $CountdownSeconds -SuppressIndividualReboot:$SuppressIndividualReboot } } catch { Write-ToolkitError -Record $_ -ToolName $script:ToolName + # Preserve the original exception so the caller can detect an unsuccessful run. throw } finally { + # Report that the session ended even when a cleanup phase throws. Write-StyledMessage -Type 'Info' -Text ("♻️ " + (Get-SourceTextLoc 'toolText.winDeleteUserProfilesSessionEnded')) Write-ToolkitLog -Level INFO -Message (Get-SourceTextLoc 'toolText.winDeleteUserProfilesSessionEnded') } From 0db0414be63ccf98f38906493deafe01cbc97983 Mon Sep 17 00:00:00 2001 From: pomodori92 <45762339+pomodori92@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:03:44 +0000 Subject: [PATCH 12/12] =?UTF-8?q?=E2=9C=85=20Build=20WinToolkit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WinToolkit.ps1 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/WinToolkit.ps1 b/WinToolkit.ps1 index c1f7ac23..9a8db596 100644 --- a/WinToolkit.ps1 +++ b/WinToolkit.ps1 @@ -3916,6 +3916,7 @@ function DisableBitlocker { Write-ToolkitLog -Level INFO -Message (Get-SourceTextLoc 'toolText.disablebitlockerSessionEnded') } } +function WinDeleteUserProfiles { function WinDeleteUserProfiles { [CmdletBinding()] param( @@ -4446,6 +4447,7 @@ function WinDeleteUserProfiles { Write-ToolkitLog -Level INFO -Message (Get-SourceTextLoc 'toolText.winDeleteUserProfilesSessionEnded') } } +} function Install-Office { [CmdletBinding()] param(