Public/Clear-WindowsTemp.ps1

function Clear-WindowsTemp {
    <#
.EXTERNALHELP TheCleaners-help.xml
#>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    [Alias('Clean-WindowsTemp')]
    [OutputType('TheCleaners.CleanupResult')]
    param (
        [Parameter()]
        [ValidateRange(1, [Int16]::MaxValue)]
        [Int16]
        $Days = 30,

        [Parameter()]
        [switch]
        $RemoveEmptyDirectory,

        [Parameter()]
        [switch]
        $PassThru
    )

    if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) {
        $Exception = [System.PlatformNotSupportedException]::new('Clear-WindowsTemp requires Windows.')
        $ErrorRecord = Get-TheCleanersErrorRecord -Exception $Exception -ErrorId 'TempWindowsRequired' -Category NotImplemented
        $PSCmdlet.ThrowTerminatingError($ErrorRecord)
    }

    $CutoffUtc = (Get-Date).ToUniversalTime().AddDays(-$Days)
    $Root = $null
    $ValidatedRootIdentity = $null
    $TraversalRootPath = $null
    try {
        $Root = Get-TheCleanersWindowsTempRoot
        $TraversalRootPath = Convert-TheCleanersPathForTraversal -Path $Root.FullName
        if (-not $WhatIfPreference) {
            $ValidatedRootIdentity = Get-TheCleanersFileIdentity -LiteralPath $TraversalRootPath -Directory
            if ($ValidatedRootIdentity.IsReparsePoint) {
                throw [System.IO.InvalidDataException]::new("The Windows temporary root is a reparse point: '$TraversalRootPath'.")
            }
        }
    } catch {
        $FailureRootPath = if ($null -ne $Root) {
            $Root.FullName.TrimEnd([char[]]@('\', '/'))
        } else {
            'Windows temporary root'
        }
        $Result = Get-TheCleanersCleanupResult -Command 'Clear-WindowsTemp' -RootPath $FailureRootPath -CutoffUtc $CutoffUtc -PrivilegeStatus (Get-TheCleanersPrivilegeStatus) -DiscoveryStatus 'Failed' -Status 'DiscoveryFailed'
        $Result.FileCandidateCount = $null
        $Result.DirectoryCandidateCount = $null
        $Result.DiscoveryErrorCount = 1
        $Result.ErrorIds = @('TempRootValidationFailed')
        $ErrorRecord = Get-TheCleanersErrorRecord -Exception $_.Exception -ErrorId 'TempRootValidationFailed' -Category InvalidData
        $PSCmdlet.WriteError($ErrorRecord)
        if ($PassThru) {
            $Result
        }
        return
    }

    $Result = Get-TheCleanersCleanupResult -Command 'Clear-WindowsTemp' -RootPath $TraversalRootPath -CutoffUtc $CutoffUtc -PrivilegeStatus (Get-TheCleanersPrivilegeStatus)
    try {
        $Plan = Get-TheCleanersTempPlan -Root $Root -TraversalRootPath $TraversalRootPath -CutoffUtc $CutoffUtc -RemoveEmptyDirectory:$RemoveEmptyDirectory -CaptureIdentity:(-not $WhatIfPreference) -ValidatedRootIdentity $ValidatedRootIdentity
    } catch {
        $Result.DiscoveryStatus = 'Failed'
        $Result.Status = 'DiscoveryFailed'
        $Result.FileCandidateCount = $null
        $Result.DirectoryCandidateCount = $null
        $Result.DiscoveryErrorCount = 1
        $Result.ErrorIds = @('TempDiscoveryFailed')
        $ErrorRecord = Get-TheCleanersErrorRecord -Exception $_.Exception -ErrorId 'TempDiscoveryFailed' -Category ReadError -TargetObject $Result.RootPath
        $PSCmdlet.WriteError($ErrorRecord)
        if ($PassThru) {
            $Result
        }
        return
    }

    try {
        $Files = @($Plan.Files)
        $Directories = @($Plan.Directories)
        $Result.CandidatePaths = @($Files | ForEach-Object { $_.Path })
        $Result.FileCandidateCount = $Files.Count
        $Result.DirectoryCandidateCount = $Directories.Count
        foreach ($File in $Files) {
            Write-Verbose -Message ('Candidate file: {0}' -f $File.Path)
        }
        foreach ($Directory in $Directories) {
            Write-Verbose -Message ('Planned directory: {0}' -f $Directory.Path)
        }
        if ($Files.Count -eq 0) {
            if ($PassThru) {
                $Result
            }
            return
        }

        $Action = 'Remove {0} old Windows temp files and up to {1} identity-checked directories; inclusive UTC cutoff {2:u}' -f $Files.Count, $Directories.Count, $CutoffUtc
        if (-not $PSCmdlet.ShouldProcess($Result.RootPath, $Action)) {
            $Result.Status = if ($WhatIfPreference) { 'WhatIf' } else { 'Declined' }
            if ($PassThru) {
                $Result
            }
            return
        }

        try {
            $CurrentRootIdentity = Get-TheCleanersFileIdentity -LiteralPath $TraversalRootPath -Directory
            if ($CurrentRootIdentity.IsReparsePoint -or -not $CurrentRootIdentity.Equals($Plan.RootIdentity)) {
                throw [System.IO.InvalidDataException]::new("The cleanup root changed after discovery: '$($Result.RootPath)'.")
            }
        } catch {
            $Result.Status = 'DiscoveryFailed'
            $Result.DiscoveryStatus = 'Failed'
            $Result.DiscoveryErrorCount = 1
            $Result.ErrorIds = @('TempRootChanged')
            $ErrorRecord = Get-TheCleanersErrorRecord -Exception $_.Exception -ErrorId 'TempRootChanged' -Category InvalidData -TargetObject $Result.RootPath
            $PSCmdlet.WriteError($ErrorRecord)
            if ($PassThru) {
                $Result
            }
            return
        }

        $TouchedIdentities = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        $DisqualifiedIdentities = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        $DisqualifiedPaths = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        foreach ($BlockedPath in @($Plan.DisqualifiedDirectoryPaths)) {
            if (-not [string]::IsNullOrWhiteSpace($BlockedPath)) {
                $null = $DisqualifiedPaths.Add($BlockedPath)
            }
        }
        $MarkDirectoryDisqualified = {
            param (
                [string] $Path,
                [psobject] $Identity
            )

            if (-not [string]::IsNullOrWhiteSpace($Path)) {
                $null = $DisqualifiedPaths.Add($Path)
            }
            if ($null -ne $Identity) {
                $null = $DisqualifiedIdentities.Add($Identity.Key)
            }
        }
        $ReparsePointAttributes = [System.IO.FileAttributes]::ReparsePoint
        foreach ($Candidate in $Files) {
            $CurrentHandle = $null
            try {
                $CurrentItem = Get-Item -LiteralPath $Candidate.Path -Force -ErrorAction Stop
                if ($CurrentItem.PSIsContainer -or ($CurrentItem.Attributes -band $ReparsePointAttributes)) {
                    $Result.FilesSkipped++
                    & $MarkDirectoryDisqualified $Candidate.ParentPath $Candidate.ParentIdentity
                    continue
                }
                $null = Resolve-TheCleanersFileSystemPath -LiteralPath $Candidate.Path -RootPath $TraversalRootPath
                $CurrentItem.Refresh()
                if (-not $CurrentItem.Exists) {
                    $Result.FilesSkipped++
                    & $MarkDirectoryDisqualified $Candidate.ParentPath $Candidate.ParentIdentity
                    continue
                }
                $CurrentHandle = [TheCleaners.NativeFileInterop]::OpenForDeletion($Candidate.Path, $false)
                $CurrentIdentity = [TheCleaners.NativeFileInterop]::ReadIdentity($CurrentHandle)
                if ($CurrentIdentity.IsDirectory -or $CurrentIdentity.IsReparsePoint -or $null -eq $Candidate.Identity -or -not $CurrentIdentity.Equals($Candidate.Identity)) {
                    $Result.FilesSkipped++
                    & $MarkDirectoryDisqualified $Candidate.ParentPath $Candidate.ParentIdentity
                    continue
                }
                if ($CurrentIdentity.LastWriteTimeUtc -gt $Plan.CutoffUtc) {
                    $Result.FilesSkipped++
                    & $MarkDirectoryDisqualified $Candidate.ParentPath $Candidate.ParentIdentity
                    continue
                }
                $Length = $CurrentIdentity.Length
                [TheCleaners.NativeFileInterop]::MarkForDeletion($CurrentHandle)
            } catch {
                $BaseException = $_.Exception.GetBaseException()
                $NativeErrorCode = if ($BaseException -is [System.ComponentModel.Win32Exception]) { $BaseException.NativeErrorCode } else { -1 }
                $MissingCandidate = $_.Exception -is [System.Management.Automation.ItemNotFoundException] -or $BaseException -is [System.IO.FileNotFoundException] -or $BaseException -is [System.IO.DirectoryNotFoundException] -or $NativeErrorCode -in @(2, 3, 53, 123)
                if ($MissingCandidate) {
                    $Result.FilesSkipped++
                } else {
                    $Result.FileFailureCount++
                    $Result.ErrorIds = @($Result.ErrorIds + 'TempFileRemovalFailed')
                    $Category = if ($BaseException -is [System.UnauthorizedAccessException] -or $NativeErrorCode -in @(5, 32, 33)) { 'PermissionDenied' } else { 'WriteError' }
                    $ErrorRecord = Get-TheCleanersErrorRecord -Exception $BaseException -ErrorId 'TempFileRemovalFailed' -Category $Category -TargetObject $Candidate.Path
                    $PSCmdlet.WriteError($ErrorRecord)
                }
                & $MarkDirectoryDisqualified $Candidate.ParentPath $Candidate.ParentIdentity
                continue
            } finally {
                if ($null -ne $CurrentHandle) {
                    $CurrentHandle.Dispose()
                }
            }

            if ([System.IO.File]::Exists($Candidate.Path) -or [System.IO.Directory]::Exists($Candidate.Path)) {
                $Result.FileFailureCount++
                $Result.ErrorIds = @($Result.ErrorIds + 'TempFileRemovalFailed')
                $Exception = [System.IO.IOException]::new("The candidate path still exists after its deletion handle closed: '$($Candidate.Path)'.")
                $ErrorRecord = Get-TheCleanersErrorRecord -Exception $Exception -ErrorId 'TempFileRemovalFailed' -Category WriteError -TargetObject $Candidate.Path
                $PSCmdlet.WriteError($ErrorRecord)
                & $MarkDirectoryDisqualified $Candidate.ParentPath $Candidate.ParentIdentity
                continue
            }

            $Result.FilesRemoved++
            $Result.BytesReclaimed += $Length
            if ($null -ne $Candidate.ParentIdentity) {
                $null = $TouchedIdentities.Add($Candidate.ParentIdentity.Key)
            }
        }

        foreach ($DirectoryPlan in $Directories) {
            $DirectoryDisqualified = (
                ($null -ne $DirectoryPlan.Identity -and $DisqualifiedIdentities.Contains($DirectoryPlan.Identity.Key)) -or
                (-not [string]::IsNullOrWhiteSpace($DirectoryPlan.Path) -and $DisqualifiedPaths.Contains($DirectoryPlan.Path))
            )
            if ($DirectoryDisqualified -or $null -eq $DirectoryPlan.Identity -or -not $TouchedIdentities.Contains($DirectoryPlan.Identity.Key)) {
                $Result.DirectoriesSkipped++
                & $MarkDirectoryDisqualified $DirectoryPlan.ParentPath $DirectoryPlan.ParentIdentity
                continue
            }

            $CurrentHandle = $null
            $PlanOwnsCurrentHandle = $false
            $DeletionRequested = $false
            try {
                $CurrentDirectory = Get-Item -LiteralPath $DirectoryPlan.Path -Force -ErrorAction Stop
                if ($CurrentDirectory -isnot [System.IO.DirectoryInfo] -or ($CurrentDirectory.Attributes -band $ReparsePointAttributes)) {
                    $Result.DirectoriesSkipped++
                    & $MarkDirectoryDisqualified $DirectoryPlan.ParentPath $DirectoryPlan.ParentIdentity
                    continue
                }
                $null = Resolve-TheCleanersFileSystemPath -LiteralPath $DirectoryPlan.Path -RootPath $TraversalRootPath
                $CurrentHandle = if ($null -ne $DirectoryPlan.Handle) {
                    $PlanOwnsCurrentHandle = $true
                    $DirectoryPlan.Handle
                } else {
                    [TheCleaners.NativeFileInterop]::OpenForDeletion($DirectoryPlan.Path, $true)
                }
                $CurrentIdentity = [TheCleaners.NativeFileInterop]::ReadIdentity($CurrentHandle)
                if (-not $CurrentIdentity.IsDirectory -or $CurrentIdentity.IsReparsePoint -or -not $CurrentIdentity.Equals($DirectoryPlan.Identity)) {
                    $Result.DirectoriesSkipped++
                    & $MarkDirectoryDisqualified $DirectoryPlan.ParentPath $DirectoryPlan.ParentIdentity
                    continue
                }
                if (@(Get-ChildItem -LiteralPath $DirectoryPlan.Path -Force -ErrorAction Stop).Count -gt 0) {
                    $Result.DirectoriesSkipped++
                    & $MarkDirectoryDisqualified $DirectoryPlan.ParentPath $DirectoryPlan.ParentIdentity
                    continue
                }
                [TheCleaners.NativeFileInterop]::MarkForDeletion($CurrentHandle)
                $DeletionRequested = $true
            } catch {
                $BaseException = $_.Exception.GetBaseException()
                $NativeErrorCode = if ($BaseException -is [System.ComponentModel.Win32Exception]) { $BaseException.NativeErrorCode } else { -1 }
                $MissingDirectory = $_.Exception -is [System.Management.Automation.ItemNotFoundException] -or $BaseException -is [System.IO.DirectoryNotFoundException] -or $NativeErrorCode -in @(2, 3, 53, 123)
                if ($MissingDirectory) {
                    $Result.DirectoriesSkipped++
                } else {
                    $Result.DirectoryFailureCount++
                    $Result.ErrorIds = @($Result.ErrorIds + 'TempDirectoryRemovalFailed')
                    $Category = if ($BaseException -is [System.UnauthorizedAccessException] -or $NativeErrorCode -in @(5, 32, 33)) { 'PermissionDenied' } else { 'WriteError' }
                    $ErrorRecord = Get-TheCleanersErrorRecord -Exception $BaseException -ErrorId 'TempDirectoryRemovalFailed' -Category $Category -TargetObject $DirectoryPlan.Path
                    $PSCmdlet.WriteError($ErrorRecord)
                }
                & $MarkDirectoryDisqualified $DirectoryPlan.ParentPath $DirectoryPlan.ParentIdentity
                continue
            } finally {
                if ($null -ne $CurrentHandle -and (-not $PlanOwnsCurrentHandle -or $DeletionRequested)) {
                    $CurrentHandle.Dispose()
                }
            }

            if ((-not $PlanOwnsCurrentHandle -or $DeletionRequested) -and [System.IO.Directory]::Exists($DirectoryPlan.Path)) {
                $Result.DirectoryFailureCount++
                $Result.ErrorIds = @($Result.ErrorIds + 'TempDirectoryRemovalFailed')
                $Exception = [System.IO.IOException]::new("The directory path still exists after its deletion handle closed: '$($DirectoryPlan.Path)'.")
                $ErrorRecord = Get-TheCleanersErrorRecord -Exception $Exception -ErrorId 'TempDirectoryRemovalFailed' -Category WriteError -TargetObject $DirectoryPlan.Path
                $PSCmdlet.WriteError($ErrorRecord)
                & $MarkDirectoryDisqualified $DirectoryPlan.ParentPath $DirectoryPlan.ParentIdentity
                continue
            }

            $Result.DirectoriesRemoved++
            if ($null -ne $DirectoryPlan.ParentIdentity) {
                $null = $TouchedIdentities.Add($DirectoryPlan.ParentIdentity.Key)
            }
        }

        $Result.Status = if ($Result.FileFailureCount -gt 0 -or $Result.DirectoryFailureCount -gt 0) {
            'PartialFailure'
        } elseif ($Result.FilesSkipped -gt 0 -or $Result.DirectoriesSkipped -gt 0) {
            'CompletedWithSkips'
        } else {
            'Completed'
        }
        if ($PassThru) {
            $Result
        }
    } finally {
        Close-TheCleanersTempPlanHandles -Plan $Plan
    }
}