Public/Get-StaleUserProfile.ps1

function Get-StaleUserProfile {
    <#
.EXTERNALHELP TheCleaners-help.xml
#>

    [CmdletBinding()]
    [OutputType('TheCleaners.StaleUserProfile')]
    param (
        [Parameter(Position = 0)]
        [ValidateRange(1, [int16]::MaxValue)]
        [Int16]
        $Days = 90,

        [Parameter()]
        [Alias('ShowSummary')]
        [switch]
        $IncludeSize,

        [Parameter()]
        [switch]
        $IncludeUnknownLastUseTime
    )

    $IsWindowsHost = $PSVersionTable.PSEdition -eq 'Desktop' -or ($PSVersionTable.PSVersion.Major -ge 6 -and $IsWindows)
    if (-not $IsWindowsHost) {
        $Exception = [System.PlatformNotSupportedException]::new('Get-StaleUserProfile requires Windows because it queries Win32_UserProfile.')
        $ErrorRecord = Get-TheCleanersErrorRecord -Exception $Exception -ErrorId 'ProfileWindowsRequired' -Category NotImplemented
        $PSCmdlet.WriteError($ErrorRecord)
        return
    }

    $NowUtc = (Get-Date).ToUniversalTime()
    $CutoffUtc = $NowUtc.AddDays(-$Days)
    $Profiles = $null
    try {
        $Profiles = @(Get-CimInstance -Class Win32_UserProfile -ErrorAction Stop)
    } catch {
        $ErrorRecord = Get-TheCleanersErrorRecord -Exception $_.Exception -ErrorId 'ProfileQueryFailed' -Category ReadError
        $PSCmdlet.WriteError($ErrorRecord)
        return
    }

    $ServiceSids = @('S-1-5-18', 'S-1-5-19', 'S-1-5-20')
    $ProtectedLeaves = @('Default', 'Default User', 'Public', 'All Users', 'systemprofile', 'LocalService', 'NetworkService')
    foreach ($ProfileRecord in $Profiles) {
        $LocalPath = [string]$ProfileRecord.LocalPath
        $Sid = [string]$ProfileRecord.SID
        $Leaf = if ([string]::IsNullOrWhiteSpace($LocalPath)) { '' } else { (Split-Path -Path $LocalPath.TrimEnd([char[]]@('\', '/')) -Leaf) }
        $IsServiceSid = $Sid.ToUpperInvariant() -match '^S-1-5-(80|82)(-\d+)+$'
        $IsService = $ServiceSids -contains $Sid.ToUpperInvariant() -or $IsServiceSid -or $Leaf -in @('systemprofile', 'LocalService', 'NetworkService')
        $IsDefault = $ProtectedLeaves -contains $Leaf
        $IsSystem = $ServiceSids -contains $Sid.ToUpperInvariant()
        if ($ProfileRecord.Special -or $ProfileRecord.Loaded -or $IsDefault -or $IsService) {
            continue
        }

        $LastUseTimeUtc = $null
        $DateStatus = 'Unknown'
        if ($null -ne $ProfileRecord.LastUseTime -and -not [string]::IsNullOrWhiteSpace([string]$ProfileRecord.LastUseTime)) {
            try {
                if ($ProfileRecord.LastUseTime -is [DateTime]) {
                    $LastUseTimeUtc = ([DateTime]$ProfileRecord.LastUseTime).ToUniversalTime()
                } else {
                    $LastUseTimeUtc = [System.Management.ManagementDateTimeConverter]::ToDateTime([string]$ProfileRecord.LastUseTime).ToUniversalTime()
                }
                $DateStatus = 'Known'
            } catch {
                $DateStatus = 'Unknown'
            }
        }
        $IsStale = $null -ne $LastUseTimeUtc -and $LastUseTimeUtc -le $CutoffUtc
        if (-not $IsStale -and -not ($IncludeUnknownLastUseTime -and $DateStatus -eq 'Unknown')) {
            continue
        }

        $Account = $null
        $AccountResolutionStatus = 'Unresolved'
        try {
            if (-not [string]::IsNullOrWhiteSpace($Sid)) {
                $SidObject = [System.Security.Principal.SecurityIdentifier]::new($Sid)
                $Account = $SidObject.Translate([System.Security.Principal.NTAccount]).Value
                $AccountResolutionStatus = 'Resolved'
            }
        } catch {
            $AccountResolutionStatus = 'Unresolved'
        }

        $SizeBytes = $null
        $SizeStatus = 'NotRequested'
        if ($IncludeSize) {
            $SizeStatus = 'Unavailable'
            $HeldDirectoryHandles = [System.Collections.Generic.List[object]]::new()
            $HeldDirectoryHandleByPath = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase)
            $ValidateHeldDirectoryHandles = {
                foreach ($HeldDirectoryEntry in $HeldDirectoryHandles) {
                    $CurrentHeldIdentity = [TheCleaners.NativeFileInterop]::ReadIdentity($HeldDirectoryEntry.Handle)
                    if (-not $CurrentHeldIdentity.IsDirectory -or $CurrentHeldIdentity.IsReparsePoint -or -not $HeldDirectoryEntry.Identity.Equals($CurrentHeldIdentity)) {
                        throw [System.IO.InvalidDataException]::new("The profile traversal path or an ancestor changed while it was being sized: '$($HeldDirectoryEntry.Path)'.")
                    }
                }
            }
            try {
                if (-not [string]::IsNullOrWhiteSpace($LocalPath)) {
                    $ProfileDirectory = Get-Item -LiteralPath $LocalPath -Force -ErrorAction Stop
                    if ($ProfileDirectory -isnot [System.IO.DirectoryInfo]) {
                        throw [System.IO.InvalidDataException]::new("The profile path is not a directory: '$LocalPath'.")
                    }
                    $ProfilePath = [System.IO.Path]::GetFullPath($LocalPath)
                    $AncestorPaths = [System.Collections.Generic.List[string]]::new()
                    while (-not [string]::IsNullOrWhiteSpace($ProfilePath)) {
                        $ProfilePathItem = Get-Item -LiteralPath $ProfilePath -Force -ErrorAction Stop
                        if ($ProfilePathItem -isnot [System.IO.DirectoryInfo]) {
                            throw [System.IO.InvalidDataException]::new("The profile path or an ancestor is not a directory and cannot be sized: '$LocalPath'.")
                        }
                        if ($ProfilePathItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) {
                            throw [System.IO.InvalidDataException]::new("The profile path or an ancestor is a reparse point and cannot be sized: '$LocalPath'.")
                        }
                        $AncestorPaths.Add($ProfilePathItem.FullName)
                        $ParentPath = [System.IO.Path]::GetDirectoryName($ProfilePath)
                        if ([string]::IsNullOrWhiteSpace($ParentPath) -or $ParentPath -eq $ProfilePath) {
                            break
                        }
                        $ProfilePath = $ParentPath
                    }
                    $AncestorPaths.Reverse()
                    Initialize-TheCleanersNativeFileInterop
                    foreach ($AncestorPath in $AncestorPaths) {
                        $ExpectedAncestorHandle = [TheCleaners.NativeFileInterop]::OpenForIdentityInspection($AncestorPath)
                        try {
                            $ExpectedAncestorIdentity = [TheCleaners.NativeFileInterop]::ReadIdentity($ExpectedAncestorHandle)
                        } finally {
                            $ExpectedAncestorHandle.Dispose()
                        }
                        if (-not $ExpectedAncestorIdentity.IsDirectory -or $ExpectedAncestorIdentity.IsReparsePoint) {
                            throw [System.IO.InvalidDataException]::new("The profile path or an ancestor changed before sizing: '$AncestorPath'.")
                        }
                        $AncestorHandle = $null
                        try {
                            $AncestorHandle = [TheCleaners.NativeFileInterop]::OpenForStableEnumeration($AncestorPath)
                        } catch {
                            $AncestorHandle = [TheCleaners.NativeFileInterop]::OpenForIdentityInspection($AncestorPath)
                        }
                        $AncestorEntry = [pscustomobject]@{
                            Path     = $AncestorPath
                            Handle   = $AncestorHandle
                            Identity = $null
                        }
                        $HeldDirectoryHandles.Add($AncestorEntry)
                        $HeldDirectoryHandleByPath[$AncestorPath] = $AncestorEntry
                        $AncestorIdentity = [TheCleaners.NativeFileInterop]::ReadIdentity($AncestorHandle)
                        $AncestorEntry.Identity = $AncestorIdentity
                        if (-not $AncestorIdentity.IsDirectory -or $AncestorIdentity.IsReparsePoint -or -not $AncestorIdentity.Equals($ExpectedAncestorIdentity)) {
                            throw [System.IO.InvalidDataException]::new("The profile path or an ancestor changed before sizing: '$AncestorPath'.")
                        }
                    }
                    $SizeState = [pscustomobject]@{ Total = [Int64]0 }
                    $TraverseDirectory = {
                        param (
                            [Parameter(Mandatory)]
                            [string]
                            $DirectoryPath,

                            [Parameter(Mandatory)]
                            [object]
                            $ExpectedDirectoryIdentity,

                            [Parameter(Mandatory)]
                            [object]
                            $DirectoryEntry,

                            [switch]
                            $KeepHandle
                        )

                        Initialize-TheCleanersNativeFileInterop
                        $DirectoryHandle = $DirectoryEntry.Handle
                        try {
                            $DirectoryIdentity = [TheCleaners.NativeFileInterop]::ReadIdentity($DirectoryHandle)
                            $DirectoryEntry.Identity = $DirectoryIdentity
                            if (-not $DirectoryIdentity.IsDirectory -or $null -eq $ExpectedDirectoryIdentity -or -not $DirectoryIdentity.Equals($ExpectedDirectoryIdentity)) {
                                throw [System.IO.InvalidDataException]::new("The profile traversal path is not a directory: '$DirectoryPath'.")
                            }
                            if ($DirectoryIdentity.IsReparsePoint) {
                                throw [System.IO.InvalidDataException]::new("The profile traversal path became a reparse point and cannot be sized: '$DirectoryPath'.")
                            }
                            & $ValidateHeldDirectoryHandles
                            foreach ($Item in @(Get-ChildItem -LiteralPath $DirectoryPath -Force -ErrorAction Stop)) {
                                if ($Item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) {
                                    continue
                                }
                                if ($Item.PSIsContainer) {
                                    $ChildIdentityHandle = $null
                                    try {
                                        try {
                                            $ChildIdentityHandle = [TheCleaners.NativeFileInterop]::OpenForStableEnumeration($Item.FullName)
                                        } catch {
                                            $ChildIdentityHandle = [TheCleaners.NativeFileInterop]::OpenForIdentityInspection($Item.FullName)
                                        }
                                        $ChildIdentity = [TheCleaners.NativeFileInterop]::ReadIdentity($ChildIdentityHandle)
                                        if (-not $ChildIdentity.IsDirectory -or $ChildIdentity.IsReparsePoint) {
                                            throw [System.IO.InvalidDataException]::new("The profile traversal path changed before it was queued: '$($Item.FullName)'.")
                                        }
                                        $ChildDirectoryEntry = [pscustomobject]@{
                                            Path     = $Item.FullName
                                            Handle   = $ChildIdentityHandle
                                            Identity = $ChildIdentity
                                        }
                                        $HeldDirectoryHandles.Add($ChildDirectoryEntry)
                                        $HeldDirectoryHandleByPath[$Item.FullName] = $ChildDirectoryEntry
                                        $ChildIdentityHandle = $null
                                        & $TraverseDirectory -DirectoryPath $Item.FullName -ExpectedDirectoryIdentity $ChildIdentity -DirectoryEntry $ChildDirectoryEntry
                                    } finally {
                                        if ($null -ne $ChildIdentityHandle) {
                                            $ChildIdentityHandle.Dispose()
                                        }
                                    }
                                } else {
                                    $SizeState.Total = [Int64]($SizeState.Total + [Int64]$Item.Length)
                                }
                            }
                            & $ValidateHeldDirectoryHandles
                            $CurrentDirectoryIdentity = [TheCleaners.NativeFileInterop]::ReadIdentity($DirectoryHandle)
                            if (-not $DirectoryIdentity.Equals($CurrentDirectoryIdentity) -or $CurrentDirectoryIdentity.IsReparsePoint) {
                                throw [System.IO.InvalidDataException]::new("The profile traversal path changed while it was being sized: '$DirectoryPath'.")
                            }
                        } finally {
                            if (-not $KeepHandle) {
                                [void]$HeldDirectoryHandleByPath.Remove($DirectoryPath)
                                [void]$HeldDirectoryHandles.Remove($DirectoryEntry)
                                if ($null -ne $DirectoryEntry.Handle) {
                                    $DirectoryEntry.Handle.Dispose()
                                    $DirectoryEntry.Handle = $null
                                }
                            }
                        }
                    }
                    $ProfileDirectoryEntry = $HeldDirectoryHandleByPath[$ProfileDirectory.FullName]
                    & $TraverseDirectory -DirectoryPath $ProfileDirectory.FullName -ExpectedDirectoryIdentity $ProfileDirectoryEntry.Identity -DirectoryEntry $ProfileDirectoryEntry -KeepHandle
                    & $ValidateHeldDirectoryHandles
                    $SizeBytes = $SizeState.Total
                    $SizeStatus = 'Available'
                }
            } catch {
                $ErrorRecord = Get-TheCleanersErrorRecord -Exception $_.Exception -ErrorId 'ProfileSizeUnavailable' -Category ReadError -TargetObject $LocalPath
                $PSCmdlet.WriteError($ErrorRecord)
            } finally {
                foreach ($HeldDirectoryEntry in $HeldDirectoryHandles) {
                    if ($null -ne $HeldDirectoryEntry.Handle) {
                        $HeldDirectoryEntry.Handle.Dispose()
                    }
                }
            }
        }

        [pscustomobject]@{
            PSTypeName              = 'TheCleaners.StaleUserProfile'
            ContractVersion         = '1.0'
            SID                     = $Sid
            Account                 = $Account
            AccountResolutionStatus = $AccountResolutionStatus
            LocalPath               = $LocalPath
            PathExists              = [System.IO.Directory]::Exists($LocalPath)
            LastUseTime             = if ($null -ne $LastUseTimeUtc) { $LastUseTimeUtc.ToLocalTime() } else { $null }
            LastUseTimeUtc          = $LastUseTimeUtc
            DateStatus              = $DateStatus
            AgeDays                 = if ($null -ne $LastUseTimeUtc) { [math]::Floor(($NowUtc - $LastUseTimeUtc).TotalDays) } else { $null }
            IsStale                 = [bool]$IsStale
            Loaded                  = [bool]$ProfileRecord.Loaded
            Special                 = [bool]$ProfileRecord.Special
            IsDefault               = [bool]$IsDefault
            IsSystem                = [bool]$IsSystem
            IsService               = [bool]$IsService
            SizeBytes               = $SizeBytes
            SizeStatus              = $SizeStatus
        }
    }
}