Public/Clear-OldExchangeLog.ps1

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

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

        [Parameter()]
        [switch]
        $PassThru
    )

    if (-not $PSBoundParameters.ContainsKey('WhatIf') -or -not $PSBoundParameters['WhatIf']) {
        $Exception = [System.NotSupportedException]::new('Exchange cleanup is preview-only. Run Clear-OldExchangeLog -WhatIf. Removal is not available in this version.')
        $ErrorRecord = Get-TheCleanersErrorRecord -Exception $Exception -ErrorId 'ExchangeCleanupPreviewOnly' -Category NotImplemented
        $PSCmdlet.ThrowTerminatingError($ErrorRecord)
    }
    if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) {
        $Exception = [System.PlatformNotSupportedException]::new('Exchange log discovery requires Windows.')
        $ErrorRecord = Get-TheCleanersErrorRecord -Exception $Exception -ErrorId 'ExchangeWindowsRequired' -Category NotImplemented
        $PSCmdlet.ThrowTerminatingError($ErrorRecord)
    }
    Write-Warning -Message 'Exchange preview only: no files will be removed. Candidate discovery is experimental, not a deletion allowlist.'

    $CutoffUtc = (Get-Date).ToUniversalTime().AddDays(-$Days)
    $RegistryPath = 'HKLM:\SOFTWARE\Microsoft\ExchangeServer\v15\Setup'
    $Setup = $null
    try {
        $Setup = Get-ItemProperty -LiteralPath $RegistryPath -Name MsiInstallPath -ErrorAction Stop
        if ([string]::IsNullOrWhiteSpace($Setup.MsiInstallPath)) {
            throw [System.IO.InvalidDataException]::new('The Exchange Server installation path is missing.')
        }
    } catch {
        $ErrorRecord = Get-TheCleanersErrorRecord -Exception $_.Exception -ErrorId 'ExchangeRegistryDiscoveryFailed' -Category ReadError
        if ($PassThru) {
            $PSCmdlet.WriteError($ErrorRecord)
            $Result = Get-TheCleanersCleanupResult -Command 'Clear-OldExchangeLog' -RootPath $RegistryPath -CutoffUtc $CutoffUtc -DiscoveryStatus 'Failed' -ProtectionStatus 'Unknown' -ProductVersion 'Exchange Server v15' -DiscoverySource 'v15 setup registry' -CandidatePaths @() -Status 'DiscoveryFailed'
            $Result.FileCandidateCount = $null
            $Result.DirectoryCandidateCount = $null
            $Result.DiscoveryErrorCount = 1
            $Result.ErrorIds = @('ExchangeRegistryDiscoveryFailed')
            $Result
        } else {
            $PSCmdlet.ThrowTerminatingError($ErrorRecord)
        }
        return
    }

    $InstallRoot = $null
    try {
        $InstallRoot = Resolve-TheCleanersFileSystemPath -LiteralPath $Setup.MsiInstallPath
        if ($InstallRoot -isnot [System.IO.DirectoryInfo]) {
            throw [System.IO.InvalidDataException]::new("The Exchange Server installation path is not a directory: '$($Setup.MsiInstallPath)'.")
        }
    } catch {
        $ErrorRecord = Get-TheCleanersErrorRecord -Exception $_.Exception -ErrorId 'ExchangeInstallRootValidationFailed' -Category InvalidData -TargetObject $Setup.MsiInstallPath
        if ($PassThru) {
            $PSCmdlet.WriteError($ErrorRecord)
            $Result = Get-TheCleanersCleanupResult -Command 'Clear-OldExchangeLog' -RootPath $Setup.MsiInstallPath -CutoffUtc $CutoffUtc -DiscoveryStatus 'Failed' -ProtectionStatus 'Unknown' -ProductVersion 'Exchange Server v15' -DiscoverySource 'v15 setup registry' -CandidatePaths @() -Status 'DiscoveryFailed'
            $Result.FileCandidateCount = $null
            $Result.DirectoryCandidateCount = $null
            $Result.DiscoveryErrorCount = 1
            $Result.ErrorIds = @('ExchangeInstallRootValidationFailed')
            $Result
        } else {
            $PSCmdlet.ThrowTerminatingError($ErrorRecord)
        }
        return
    }
    $InstallRootPath = Convert-TheCleanersPathForTraversal -Path $Setup.MsiInstallPath

    $Protected = $null
    try {
        $Protected = Get-TheCleanersExchangeProtectedPaths -InstallRoot $InstallRoot
    } catch {
        $ErrorRecord = Get-TheCleanersErrorRecord -Exception $_.Exception -ErrorId 'ExchangeProtectedPathDiscoveryFailed' -Category ReadError -TargetObject $InstallRoot.FullName
        if ($PassThru) {
            $PSCmdlet.WriteError($ErrorRecord)
            $Result = Get-TheCleanersCleanupResult -Command 'Clear-OldExchangeLog' -RootPath $InstallRoot.FullName -CutoffUtc $CutoffUtc -DiscoveryStatus 'Failed' -ProtectionStatus 'Unknown' -ProductVersion 'Exchange Server v15' -DiscoverySource 'v15 setup registry' -CandidatePaths @() -Status 'DiscoveryFailed'
            $Result.FileCandidateCount = $null
            $Result.DirectoryCandidateCount = $null
            $Result.DiscoveryErrorCount = 1
            $Result.ErrorIds = @('ExchangeProtectedPathDiscoveryFailed')
            $Result
        } else {
            $PSCmdlet.ThrowTerminatingError($ErrorRecord)
        }
        return
    }

    $RelativeRoots = @(
        'Logging'
        'Bin/Search/Ceres/Diagnostics/ETLTraces'
        'Bin/Search/Ceres/Diagnostics/Logs'
        'TransportRoles/Logs/MessageTracking'
    )
    $FoundExistingRoot = $false
    $DiscoveryErrorIds = [System.Collections.Generic.List[string]]::new()
    foreach ($RelativeRoot in $RelativeRoots) {
        $RootPath = Convert-TheCleanersPathForTraversal -Path ($InstallRootPath + '\' + $RelativeRoot)
        $RootExists = $false
        try {
            $RootExists = Test-Path -LiteralPath $RootPath -PathType Container -ErrorAction Stop
        } catch {
            $null = $DiscoveryErrorIds.Add('ExchangeDiscoveryFailed')
            $ErrorRecord = Get-TheCleanersErrorRecord -Exception $_.Exception -ErrorId 'ExchangeDiscoveryFailed' -Category ReadError -TargetObject $RootPath
            $PSCmdlet.WriteError($ErrorRecord)
            if ($PassThru) {
                $Result = Get-TheCleanersCleanupResult -Command 'Clear-OldExchangeLog' -RootPath $RootPath -CutoffUtc $CutoffUtc -DiscoveryStatus 'Failed' -ProtectionStatus $Protected.Status -ProtectionPathCount @($Protected.Paths).Count -ProtectionPaths @($Protected.Paths) -ProductVersion 'Exchange Server v15' -DiscoverySource 'v15 setup registry and fixed product roots' -CandidatePaths @() -Status 'DiscoveryFailed'
                $Result.FileCandidateCount = $null
                $Result.DirectoryCandidateCount = $null
                $Result.DiscoveryErrorCount = 1
                $Result.ErrorIds = @('ExchangeDiscoveryFailed')
                $Result
            }
            continue
        }
        if (-not $RootExists) {
            Write-Verbose -Message "Exchange log root not present as a directory: $RootPath"
            continue
        }
        $FoundExistingRoot = $true
        $OldFiles = @()
        $NormalizedRoot = $null
        try {
            $LogRoot = Resolve-TheCleanersFileSystemPath -LiteralPath $RootPath -RootPath $InstallRootPath
            if ($LogRoot -isnot [System.IO.DirectoryInfo]) {
                throw [System.IO.InvalidDataException]::new("Exchange log root is not a directory: '$RootPath'.")
            }
            $TraversalRoot = Convert-TheCleanersPathForTraversal -Path $RootPath
            $NormalizedRoot = Convert-TheCleanersPathForComparison -Path $LogRoot.FullName
            $RootIsProtected = $false
            foreach ($ProtectedPath in @($Protected.Paths)) {
                $RootContainsProtectedPath = $ProtectedPath.StartsWith($NormalizedRoot + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)
                $ProtectedPathContainsRoot = $NormalizedRoot.StartsWith($ProtectedPath + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)
                if ($NormalizedRoot -eq $ProtectedPath -or $RootContainsProtectedPath -or $ProtectedPathContainsRoot) {
                    $RootIsProtected = $true
                    break
                }
            }
            if ($RootIsProtected) {
                throw [System.UnauthorizedAccessException]::new("The Exchange log root overlaps a protected database or transaction-log path: '$NormalizedRoot'.")
            }

            $Pending = [System.Collections.Generic.Stack[string]]::new()
            $Pending.Push($TraversalRoot)
            $Candidates = [System.Collections.Generic.List[object]]::new()
            while ($Pending.Count -gt 0) {
                $DirectoryPath = $Pending.Pop()
                if ($DirectoryPath -ne $TraversalRoot) {
                    $Directory = Resolve-TheCleanersFileSystemPath -LiteralPath $DirectoryPath -RootPath $TraversalRoot
                    if ($Directory -isnot [System.IO.DirectoryInfo]) {
                        throw [System.IO.InvalidDataException]::new("Exchange traversal path is not a directory: '$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) {
                        Write-Verbose -Message "Skipping reparse point: $ItemPath"
                        continue
                    }
                    if ($Item.PSIsContainer) {
                        $Pending.Push($ItemPath)
                        continue
                    }
                    $ComparableItemPath = Convert-TheCleanersPathForComparison -Path $ItemPath
                    $IsProtected = $false
                    foreach ($ProtectedPath in @($Protected.Paths)) {
                        if ($ComparableItemPath -eq $ProtectedPath -or $ComparableItemPath.StartsWith($ProtectedPath + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) {
                            $IsProtected = $true
                            break
                        }
                    }
                    if (-not $IsProtected -and (Test-TheCleanersExchangeLogFileName -Name $Item.Name -RelativeRoot $RelativeRoot) -and $Item.LastWriteTimeUtc -le $CutoffUtc) {
                        $Candidates.Add([pscustomobject]@{
                                FullName         = $ItemPath
                                Name             = $Item.Name
                                LastWriteTimeUtc = $Item.LastWriteTimeUtc
                            })
                    }
                }
            }
            $OldFiles = @($Candidates.ToArray() | Sort-Object -Property FullName)
        } catch {
            $ErrorRecord = Get-TheCleanersErrorRecord -Exception $_.Exception -ErrorId 'ExchangeDiscoveryFailed' -Category ReadError -TargetObject $RootPath
            $PSCmdlet.WriteError($ErrorRecord)
            if ($PassThru) {
                $ResultRootPath = if ($null -ne $NormalizedRoot) { $NormalizedRoot } else { $RootPath }
                $Result = Get-TheCleanersCleanupResult -Command 'Clear-OldExchangeLog' -RootPath $ResultRootPath -CutoffUtc $CutoffUtc -DiscoveryStatus 'Failed' -ProtectionStatus $Protected.Status -ProtectionPathCount @($Protected.Paths).Count -ProtectionPaths @($Protected.Paths) -ProductVersion 'Exchange Server v15' -DiscoverySource 'v15 setup registry and fixed product roots' -CandidatePaths @() -Status 'DiscoveryFailed'
                $Result.FileCandidateCount = $null
                $Result.DirectoryCandidateCount = $null
                $Result.DiscoveryErrorCount = 1
                $Result.ErrorIds = @('ExchangeDiscoveryFailed')
                $Result
            }
            continue
        }

        foreach ($File in $OldFiles) {
            $null = $PSCmdlet.ShouldProcess($File.FullName, 'Preview allowlisted candidate only; Exchange removal is unavailable')
        }
        if ($PassThru) {
            $Result = Get-TheCleanersCleanupResult -Command 'Clear-OldExchangeLog' -RootPath $NormalizedRoot -CutoffUtc $CutoffUtc -DiscoveryStatus 'Experimental' -ProtectionStatus $Protected.Status -ProtectionPathCount @($Protected.Paths).Count -ProtectionPaths @($Protected.Paths) -ProductVersion 'Exchange Server v15' -DiscoverySource 'v15 setup registry and fixed product roots' -CandidatePaths @($OldFiles | ForEach-Object { $_.FullName }) -Status 'WhatIf'
            $Result.FileCandidateCount = $OldFiles.Count
            $Result
        }
    }

    if (-not $FoundExistingRoot -and $DiscoveryErrorIds.Count -eq 0) {
        $Exception = [System.InvalidOperationException]::new('Exchange is installed but no configured product log root could be discovered.')
        $ErrorRecord = Get-TheCleanersErrorRecord -Exception $Exception -ErrorId 'ExchangeDiscoveryUnavailable' -Category ObjectNotFound -TargetObject $InstallRoot.FullName
        $PSCmdlet.WriteError($ErrorRecord)
        if ($PassThru) {
            $Result = Get-TheCleanersCleanupResult -Command 'Clear-OldExchangeLog' -RootPath $InstallRoot.FullName -CutoffUtc $CutoffUtc -DiscoveryStatus 'Failed' -ProtectionStatus $Protected.Status -ProtectionPathCount @($Protected.Paths).Count -ProtectionPaths @($Protected.Paths) -ProductVersion 'Exchange Server v15' -DiscoverySource 'v15 setup registry and fixed product roots' -CandidatePaths @() -Status 'DiscoveryFailed'
            $Result.FileCandidateCount = $null
            $Result.DirectoryCandidateCount = $null
            $Result.DiscoveryErrorCount = 1
            $Result.ErrorIds = @('ExchangeDiscoveryUnavailable')
            $Result
        }
    }
}