Private/Get-TheCleanersTempPlan.ps1

function Get-TheCleanersTempPlan {
    <#
    .SYNOPSIS
        Discover old temporary files and identity-bound directory candidates.
    .DESCRIPTION
        This function only reads the approved root. It records native file IDs for
        candidates and touched directories so the owning public command can compare
        the object opened for mutation with the object found during discovery.
        Discovery errors are terminating to prevent partial plans from being used.
        When identity capture is enabled, each queued directory is opened with a
        stable native handle while the provider enumerates it and its identity is
        captured, so directory replacement or rename fails closed. Directory
        pruning uses a DELETE-capable handle; traversal without pruning uses a
        read-only identity handle. The returned plan retains only handles for candidate-file ancestors and planned empty
        directories; branches with no mutation candidate are released after
        discovery. Retained handles remain open through candidate mutation,
        preventing a required ancestor from being renamed or replaced by a
        reparse point before a file is opened for deletion.
    .PARAMETER Root
        Previously validated temporary directory.
    .PARAMETER TraversalRootPath
        Original fully qualified root path to retain for provider traversal and
        identity operations when the FileSystemInfo representation may normalize
        an extended-length namespace.
    .PARAMETER CutoffUtc
        Inclusive UTC retention cutoff.
    .PARAMETER RemoveEmptyDirectory
        Include directories that can become empty after file removal.
    .PARAMETER CaptureIdentity
        Capture native file and directory identities for a removal-enabled plan
        and retain only the stable directory handles required through mutation.
        Omit this switch for WhatIf discovery so the preview does not initialize
        process-global native interop state.
    .OUTPUTS
        System.Management.Automation.PSCustomObject
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param (
        [Parameter(Mandatory)]
        [System.IO.DirectoryInfo]
        $Root,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]
        $TraversalRootPath,

        [Parameter(Mandatory)]
        [DateTime]
        $CutoffUtc,

        [Parameter()]
        [switch]
        $RemoveEmptyDirectory,

        [Parameter()]
        [switch]
        $CaptureIdentity,

        [Parameter()]
        [psobject]
        $ValidatedRootIdentity
    )

    $HeldDirectoryHandles = [System.Collections.Generic.List[object]]::new()
    $HeldDirectoryHandleByPath = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase)
    try {
        $RootPath = if ($PSBoundParameters.ContainsKey('TraversalRootPath')) {
            Convert-TheCleanersPathForTraversal -Path $TraversalRootPath
        } else {
            Convert-TheCleanersPathForTraversal -Path $Root.FullName
        }
        $RootIdentity = $null
        $DisqualifiedDirectoryPaths = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        $AddPruningBlocker = {
            param (
                [Parameter(Mandatory)]
                [string]
                $Path
            )

            $CurrentPath = $Path
            while (-not [string]::IsNullOrWhiteSpace($CurrentPath)) {
                $null = $DisqualifiedDirectoryPaths.Add($CurrentPath)
                if ($CurrentPath -eq $RootPath) {
                    break
                }
                $ParentPath = Split-Path -Path $CurrentPath -Parent
                if ([string]::IsNullOrWhiteSpace($ParentPath) -or $ParentPath -eq $CurrentPath) {
                    break
                }
                $CurrentPath = $ParentPath
            }
        }

        if ($CaptureIdentity) {
            Initialize-TheCleanersNativeFileInterop
            $RootIdentity = if ($null -eq $ValidatedRootIdentity) {
                Get-TheCleanersFileIdentity -LiteralPath $RootPath -Directory
            } else {
                $ValidatedRootIdentity
            }
            if ($RootIdentity.IsReparsePoint) {
                throw [System.IO.InvalidDataException]::new("The cleanup root is a reparse point: '$RootPath'.")
            }
        }

        $Candidates = [System.Collections.Generic.List[object]]::new()
        $FilePaths = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        $DirectoryPaths = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        $Pending = [System.Collections.Generic.Stack[object]]::new()
        $Pending.Push([pscustomobject]@{
                Path     = $RootPath
                Identity = $RootIdentity
            })

        while ($Pending.Count -gt 0) {
            $DirectoryState = $Pending.Pop()
            $DirectoryPath = [string]$DirectoryState.Path
            $Directory = Resolve-TheCleanersFileSystemPath -LiteralPath $DirectoryPath
            if ($Directory -isnot [System.IO.DirectoryInfo]) {
                throw [System.IO.InvalidDataException]::new("The temp traversal path is not a directory: '$DirectoryPath'.")
            }
            $DirectoryHandle = $null
            try {
                if ($CaptureIdentity) {
                    Initialize-TheCleanersNativeFileInterop
                    $DirectoryHandle = if ($RemoveEmptyDirectory) {
                        [TheCleaners.NativeFileInterop]::OpenForStableEnumeration($DirectoryPath)
                    } else {
                        [TheCleaners.NativeFileInterop]::OpenForIdentityInspection($DirectoryPath)
                    }
                    $HeldDirectoryEntry = [pscustomobject]@{
                        Path     = $DirectoryPath
                        Handle   = $DirectoryHandle
                        Identity = $null
                    }
                    $HeldDirectoryHandles.Add($HeldDirectoryEntry)
                    $HeldDirectoryHandleByPath[$DirectoryPath] = $HeldDirectoryEntry
                    $DirectoryHandle = $null
                    $DirectoryIdentity = [TheCleaners.NativeFileInterop]::ReadIdentity($HeldDirectoryEntry.Handle)
                    $HeldDirectoryEntry.Identity = $DirectoryIdentity
                    if (-not $DirectoryIdentity.IsDirectory -or $DirectoryIdentity.IsReparsePoint -or $null -eq $DirectoryState.Identity -or -not $DirectoryIdentity.Equals($DirectoryState.Identity)) {
                        throw [System.IO.InvalidDataException]::new("The queued temp directory changed before traversal: '$DirectoryPath'.")
                    }
                }

                foreach ($Item in @(Get-ChildItem -LiteralPath $DirectoryPath -Force -ErrorAction Stop)) {
                    $ItemPath = Convert-TheCleanersPathForTraversal -Path ($DirectoryPath.TrimEnd([char[]]@('\', '/')) + '\' + [string]$Item.Name)
                    if ($Item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) {
                        if ($RemoveEmptyDirectory) {
                            & $AddPruningBlocker -Path $DirectoryPath
                        }
                        Write-Verbose -Message "Skipping reparse point: $ItemPath"
                        continue
                    }
                    if ($Item.PSIsContainer) {
                        $ChildIdentity = $null
                        if ($CaptureIdentity) {
                            try {
                                $ChildIdentity = Get-TheCleanersFileIdentity -LiteralPath $ItemPath -Directory
                            } catch {
                                $BaseException = $_.Exception.GetBaseException()
                                throw [System.InvalidOperationException]::new("Could not capture the queued directory identity required for safe mutation: '$ItemPath'.", $BaseException)
                            }
                        }
                        $Pending.Push([pscustomobject]@{
                                Path     = $ItemPath
                                Identity = $ChildIdentity
                            })
                        continue
                    }
                    if ($Item.LastWriteTimeUtc -gt $CutoffUtc) {
                        if ($RemoveEmptyDirectory) {
                            & $AddPruningBlocker -Path $DirectoryPath
                        }
                        continue
                    }

                    $CandidateIdentity = $null
                    if ($CaptureIdentity) {
                        try {
                            $CandidateIdentity = Get-TheCleanersFileIdentity -LiteralPath $ItemPath
                        } catch {
                            $BaseException = $_.Exception.GetBaseException()
                            $CandidateMissing = (
                                $_.Exception -is [System.Management.Automation.ItemNotFoundException] -or
                                $BaseException -is [System.IO.FileNotFoundException] -or
                                $BaseException -is [System.IO.DirectoryNotFoundException] -or
                                ($BaseException -is [System.ComponentModel.Win32Exception] -and $BaseException.NativeErrorCode -in @(2, 3, 53, 123))
                            )
                            if ($CandidateMissing) {
                                Write-Verbose -Message "The candidate disappeared during identity capture and will be skipped: '$ItemPath'"
                                if ($RemoveEmptyDirectory) {
                                    & $AddPruningBlocker -Path $DirectoryPath
                                }
                                continue
                            }
                            throw [System.InvalidOperationException]::new("Could not capture the candidate identity required for safe mutation: '$ItemPath'.", $BaseException)
                        }
                    }

                    $ParentIdentity = $null
                    if ($RemoveEmptyDirectory -and $CaptureIdentity) {
                        try {
                            $ParentIdentity = Get-TheCleanersFileIdentity -LiteralPath $DirectoryPath -Directory
                        } catch {
                            $BaseException = $_.Exception.GetBaseException()
                            throw [System.InvalidOperationException]::new("Could not capture the parent identity required for safe directory pruning: '$DirectoryPath'.", $BaseException)
                        }
                    }

                    $Candidates.Add([pscustomobject]@{
                            Path             = $ItemPath
                            Identity         = $CandidateIdentity
                            LastWriteTimeUtc = $Item.LastWriteTimeUtc
                            ParentPath       = $DirectoryPath
                            ParentIdentity   = $ParentIdentity
                        })
                    $null = $FilePaths.Add($ItemPath)

                    if ($RemoveEmptyDirectory) {
                        $ParentPath = $DirectoryPath
                        while (-not [string]::IsNullOrWhiteSpace($ParentPath) -and $ParentPath -ne $RootPath) {
                            $null = $DirectoryPaths.Add($ParentPath)
                            $ParentPath = Split-Path -Path $ParentPath -Parent
                        }
                    }
                }

                if ($CaptureIdentity) {
                    $CurrentDirectoryIdentity = [TheCleaners.NativeFileInterop]::ReadIdentity($HeldDirectoryEntry.Handle)
                    if (-not $DirectoryIdentity.Equals($CurrentDirectoryIdentity) -or $CurrentDirectoryIdentity.IsReparsePoint) {
                        throw [System.IO.InvalidDataException]::new("The temp traversal path changed while it was being enumerated: '$DirectoryPath'.")
                    }
                }
            } finally {
                if ($null -ne $DirectoryHandle) {
                    $DirectoryHandle.Dispose()
                }
            }
        }

        $PlannedDirectories = [System.Collections.Generic.List[object]]::new()
        $PlannedDirectoryPaths = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        $DirectoryOrder = @($DirectoryPaths | Sort-Object -Property @{ Expression = { $_.Length }; Descending = $true }, @{ Expression = { $_ }; Descending = $false })
        foreach ($DirectoryPath in $DirectoryOrder) {
            if ($DisqualifiedDirectoryPaths.Contains($DirectoryPath)) {
                continue
            }
            $null = Resolve-TheCleanersFileSystemPath -LiteralPath $DirectoryPath -RootPath $RootPath
            $Remaining = @(
                foreach ($RemainingItem in @(Get-ChildItem -LiteralPath $DirectoryPath -Force -ErrorAction Stop)) {
                    $RemainingItemPath = Convert-TheCleanersPathForTraversal -Path ($DirectoryPath.TrimEnd([char[]]@('\', '/')) + '\' + [string]$RemainingItem.Name)
                    if (-not $FilePaths.Contains($RemainingItemPath) -and -not $PlannedDirectoryPaths.Contains($RemainingItemPath)) {
                        $RemainingItem
                    }
                }
            )
            if ($Remaining.Count -ne 0) {
                continue
            }

            $DirectoryIdentity = $null
            if ($CaptureIdentity) {
                try {
                    $DirectoryIdentity = Get-TheCleanersFileIdentity -LiteralPath $DirectoryPath -Directory
                } catch {
                    $BaseException = $_.Exception.GetBaseException()
                    throw [System.InvalidOperationException]::new("Could not capture the directory identity required for safe pruning: '$DirectoryPath'.", $BaseException)
                }
            }
            $ParentPath = Split-Path -Path $DirectoryPath -Parent
            $ParentIdentity = $null
            if ($ParentPath -and $ParentPath -ne $RootPath -and $CaptureIdentity) {
                try {
                    $ParentIdentity = Get-TheCleanersFileIdentity -LiteralPath $ParentPath -Directory
                } catch {
                    $BaseException = $_.Exception.GetBaseException()
                    throw [System.InvalidOperationException]::new("Could not capture the ancestor identity required for safe pruning: '$ParentPath'.", $BaseException)
                }
            }

            $HeldDirectoryEntry = $null
            if ($CaptureIdentity) {
                if (-not $HeldDirectoryHandleByPath.ContainsKey($DirectoryPath)) {
                    throw [System.InvalidOperationException]::new("Could not retain the stable directory handle required for safe pruning: '$DirectoryPath'.")
                }
                $HeldDirectoryEntry = $HeldDirectoryHandleByPath[$DirectoryPath]
            }

            $PlannedDirectories.Add([pscustomobject]@{
                    Path           = $DirectoryPath
                    Identity       = $DirectoryIdentity
                    ParentPath     = $ParentPath
                    ParentIdentity = $ParentIdentity
                    Handle         = if ($null -eq $HeldDirectoryEntry) { $null } else { $HeldDirectoryEntry.Handle }
                })
            $null = $PlannedDirectoryPaths.Add($DirectoryPath)
        }

        if ($CaptureIdentity) {
            $RequiredDirectoryHandlePaths = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
            $AddRequiredDirectoryPath = {
                param (
                    [Parameter(Mandatory)]
                    [string]
                    $Path
                )

                $CurrentPath = $Path
                while (-not [string]::IsNullOrWhiteSpace($CurrentPath)) {
                    $null = $RequiredDirectoryHandlePaths.Add($CurrentPath)
                    if ($CurrentPath -eq $RootPath) {
                        break
                    }
                    $ParentPath = Split-Path -Path $CurrentPath -Parent
                    if ([string]::IsNullOrWhiteSpace($ParentPath) -or $ParentPath -eq $CurrentPath) {
                        break
                    }
                    $CurrentPath = $ParentPath
                }
            }

            & $AddRequiredDirectoryPath -Path $RootPath
            foreach ($Candidate in $Candidates) {
                & $AddRequiredDirectoryPath -Path $Candidate.ParentPath
            }
            foreach ($DirectoryPlan in $PlannedDirectories) {
                & $AddRequiredDirectoryPath -Path $DirectoryPlan.Path
            }

            $RetainedDirectoryHandles = [System.Collections.Generic.List[object]]::new()
            foreach ($HeldDirectoryEntry in @($HeldDirectoryHandles.ToArray())) {
                if ($RequiredDirectoryHandlePaths.Contains($HeldDirectoryEntry.Path)) {
                    $RetainedDirectoryHandles.Add($HeldDirectoryEntry)
                } else {
                    $HeldDirectoryEntry.Handle.Dispose()
                }
            }
            $HeldDirectoryHandles = $RetainedDirectoryHandles
            $HeldDirectoryHandleByPath = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase)
            foreach ($HeldDirectoryEntry in $HeldDirectoryHandles) {
                $HeldDirectoryHandleByPath[$HeldDirectoryEntry.Path] = $HeldDirectoryEntry
            }
        }

        [pscustomobject]@{
            RootPath                   = Convert-TheCleanersPathForComparison -Path $RootPath
            RootIdentity               = $RootIdentity
            CutoffUtc                  = $CutoffUtc.ToUniversalTime()
            Files                      = @($Candidates.ToArray() | Sort-Object -Property Path)
            Directories                = @($PlannedDirectories.ToArray())
            HeldDirectoryHandles       = @($HeldDirectoryHandles.ToArray())
            DisqualifiedDirectoryPaths = @($DisqualifiedDirectoryPaths)
        }
    } catch {
        foreach ($HeldDirectoryEntry in @($HeldDirectoryHandles.ToArray())) {
            if ($null -ne $HeldDirectoryEntry.Handle) {
                $HeldDirectoryEntry.Handle.Dispose()
            }
        }
        throw
    }
}

function Close-TheCleanersTempPlanHandles {
    <#
    .SYNOPSIS
        Release stable directory handles retained by a mutation plan.
    .DESCRIPTION
        Mutation-enabled discovery holds ancestor directory handles through the
        owning command's ShouldProcess decision and candidate operations. This
        helper releases those handles on every command exit path. WhatIf plans
        contain no native handles.
    .PARAMETER Plan
        Temporary cleanup plan returned by Get-TheCleanersTempPlan.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [psobject]
        $Plan
    )

    foreach ($HeldDirectoryEntry in @($Plan.HeldDirectoryHandles)) {
        if ($null -ne $HeldDirectoryEntry -and $null -ne $HeldDirectoryEntry.Handle) {
            $HeldDirectoryEntry.Handle.Dispose()
        }
    }
    if ($Plan.PSObject.Properties.Match('HeldDirectoryHandles').Count -gt 0) {
        $Plan.HeldDirectoryHandles = @()
    }
}