Private/Kinds/UserProfileOther.ps1

# The other User Profiles of the machine: a part of the UserProfile Kind, and no Kind.
#
# A file of its own because the UserProfile Kind is three things - the User Profile of
# the User, where its folders lie, and every other User Profile on the disk - and one
# file of three thousand lines was all of them. What is here is called by
# Kinds/UserProfile.ps1 and by Kinds/UserProfileElevated.ps1, and is loaded like every
# file under Private.
#
# No Kind, and it must not become one: a Kind is a file here that defines
# Get-<Kind>Data AND ConvertTo-<Kind>Finding (see Initialize-KindVocabulary). This one
# defines Get-UserProfileOtherData, which the UserProfile Gatherer calls, and no
# ConvertTo-UserProfileOtherFinding: its Finding is Get-UserProfileOtherFinding, made by
# the Judge of the UserProfile Kind. A function of that other name here would let a
# Check Definition select half a Check. A test holds the vocabulary to not having it.
#
# No parameter of a Check Definition is read here, for the same reason: the reference
# of the Kinds is built file by file, and a limit read in this file would be documented
# for a Kind that does not exist. The Judge reads UnusedDays and hands it over.
#
# What else lives on this disk: every User Profile of a person, with the account, the
# kind, the size of the profile folder and when it was last used, and nothing else of
# another User (ADR-0006). No folder of it, no file, no number of files.
#
# What an account without admin rights reads of the User Profile of another (seen on
# Windows 11): that it is there, its folder, the marks its kind is known by, whether it
# is loaded, and the times Windows keeps of it in the registry. Not its size: the folder
# of another account does not open. The UserProfileElevated Check measures that in the
# Elevated Part, and its reading fills in here, User Profile by User Profile.
#
# What is read is not what is kept. A reading is reduced where it is gathered, in both
# Parts, by ConvertTo-UserProfileOtherEntry, to what a Report may hold of another User:
# the data of neither Check holds where a User Profile lies, where it roams to, or what
# Windows keeps of it in the registry. The data of the Elevated Part is written to disk
# and handed from one process to the other, and that is no place for more than a Report
# may show.
#
# All of it is true of the machine and is said whichever Session the Run is in.
#
# Get-UserProfileOtherReading and Get-UserProfileOtherData read the machine and decide
# nothing. Everything else here is pure.

# Whose User Profile is a person's, by the number of the account: S-1-5-21-... is an
# account of the machine or of a domain, S-1-12-1-... one of Entra ID (Microsoft, the
# list of well-known SIDs). That leaves out the three of the system, S-1-5-18, -19 and
# -20, and the accounts Windows makes for a service or an application pool.
$script:UserProfileOtherPersonSid = '^S-1-(5-21|12-1)-'

# Where Windows keeps the list of User Profiles in the registry.
$script:UserProfileOtherListKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList'

# What is kept of the kind of the User Profile of another User, and of how its size came
# about: closed lists, so that neither can carry anything else. 'Unread' is what was not
# read; the other states of a size are those of the measuring.
$script:UserProfileOtherKind      = @('Local', 'Roaming', 'Mandatory', 'Temporary', 'Unread')
$script:UserProfileOtherSizeState = @('Unread', 'NotMeasured', 'CutShort', 'Complete', 'Missing', 'Unreadable')

function Test-UserProfileOtherPerson {
    <#
    .SYNOPSIS
        Whether this User Profile belongs to a person, and not to the system, a service
        or a template. Pure.
    .DESCRIPTION
        By the number of the account, and by what Windows marks as special in
        Win32_UserProfile: the profile of the system, of the two service accounts, the
        default one and the public one.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowNull()]$UserProfile)

    if ("$(Get-DataProperty $UserProfile 'Sid')".Trim() -notmatch $script:UserProfileOtherPersonSid) { return $false }
    -not [bool](Get-DataProperty $UserProfile 'Special')
}

function ConvertFrom-UserProfileOtherFileTime {
    <#
    .SYNOPSIS
        A time Windows keeps in the registry as two halves, as a local time; nothing
        where it kept none, which it says with zero. Pure.
    .DESCRIPTION
        A FILETIME, in a value ending in "High" and one ending in "Low". The registry
        hands each over as a signed number of 32 bits, so the lower half may be below zero.
    #>

    [CmdletBinding()]
    param([AllowNull()]$High, [AllowNull()]$Low)

    $upper = ConvertTo-Number $High
    $lower = ConvertTo-Number $Low
    if ($null -eq $upper -or $null -eq $lower) { return $null }

    $ticks = (([long]$upper -band 4294967295) -shl 32) -bor ([long]$lower -band 4294967295)
    if ($ticks -le 0) { return $null }
    try { [datetime]::FromFileTime($ticks) } catch { $null }
}

function ConvertTo-UserProfileOtherTime {
    <#
    .SYNOPSIS
        A time of a User Profile as a time, whichever way it reached here, and nothing as
        nothing. Pure.
    .DESCRIPTION
        Read the one way a Judge reads a time, and never by a free parse: see
        ConvertTo-DataTime.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Value)

    if ($null -eq $Value) { return $null }
    ConvertTo-DataTime $Value
}

function Get-UserProfileOtherReading {
    <#
    .SYNOPSIS
        Every User Profile of a person on this machine, as Windows lists it. Decides nothing.
    .DESCRIPTION
        The two sources of Get-UserProfileReading, each asked once for all of them: the
        class Win32_UserProfile and the keys under ProfileList in the registry. One row
        for each account either knows, shaped as that function shapes it, and three
        things more: the name of the account, and the two times Windows keeps in the
        registry of when it loaded the User Profile and when it unloaded it.
 
        Those two are what "last used" is taken from, and not LastUseTime of
        Win32_UserProfile. That one is the time the file ntuser.dat was last written, and
        on current Windows a cumulative update and a virus scanner write it: every User
        Profile of a machine then reads as used on the day of the last update. The values
        LocalProfileLoadTime and LocalProfileUnloadTime (High and Low) are written by the
        profile service when somebody signs in and out, and by nothing else. They are
        readable without admin rights, for every account (seen on Windows 11). An account
        without admin rights is given no LastUseTime of another account at all.
        LastUseTime is kept in the row for where Windows has not written the two.
 
        The folder is the profile folder and nothing below it. It is in the row for the
        measuring and for telling a temporary User Profile, and goes no further: no row
        of this is kept as it is. See ConvertTo-UserProfileOtherEntry.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param()

    $listed = @{}
    try {
        Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | ForEach-Object { if ($_.SID) { $listed["$($_.SID)"] = $_ } }
    }
    catch { }

    $entries = @{}
    $backups = @{}
    try {
        Get-ChildItem -LiteralPath $script:UserProfileOtherListKey -ErrorAction Stop | ForEach-Object {
            $name = "$($_.PSChildName)"
            if ($name -like '*.bak') { $backups[$name.Substring(0, $name.Length - 4)] = $true; return }
            try { $entries[$name] = Get-ItemProperty -LiteralPath $_.PSPath -ErrorAction Stop } catch { }
        }
    }
    catch { }

    foreach ($sid in @(@($listed.Keys) + @($entries.Keys) + @($backups.Keys) | Sort-Object -Unique)) {
        $known = $listed[$sid]
        $entry = $entries[$sid]

        $path = "$(Get-DataProperty $known 'LocalPath')"
        if (-not $path) { $path = [Environment]::ExpandEnvironmentVariables("$(Get-DataProperty $entry 'ProfileImagePath')") }

        $row = [pscustomobject]@{
            Sid               = "$sid"
            Account           = ''
            LocalPath         = $path
            Status            = Get-DataProperty $known 'Status'
            RoamingConfigured = Get-DataProperty $known 'RoamingConfigured'
            RoamingPath       = Get-DataProperty $known 'RoamingPath'
            Special           = Get-DataProperty $known 'Special'
            Loaded            = Get-DataProperty $known 'Loaded'
            LastUseTime       = Get-DataProperty $known 'LastUseTime'
            RegistryState     = Get-DataProperty $entry 'State'
            RegistryBackupKey = [bool]$backups[$sid]
            CentralProfile    = Get-DataProperty $entry 'CentralProfile'
            LoadedAt          = ConvertFrom-UserProfileOtherFileTime -High (Get-DataProperty $entry 'LocalProfileLoadTimeHigh') -Low (Get-DataProperty $entry 'LocalProfileLoadTimeLow')
            UnloadedAt        = ConvertFrom-UserProfileOtherFileTime -High (Get-DataProperty $entry 'LocalProfileUnloadTimeHigh') -Low (Get-DataProperty $entry 'LocalProfileUnloadTimeLow')
        }
        if (-not (Test-UserProfileOtherPerson -UserProfile $row)) { continue }

        # Asked of Windows once for each account in a Run, by the one function that asks:
        # see Resolve-AccountName. '' where Windows does not resolve the number - the
        # account was deleted, or its domain cannot be reached at the moment.
        $row.Account = (Resolve-AccountName -Sid $row.Sid).Name
        $row
    }
}

function ConvertTo-UserProfileOtherSize {
    <#
    .SYNOPSIS
        What is kept of the measuring of one profile folder: how it ended, by its name,
        how many bytes, and how many folders below it did not open. Pure.
    .DESCRIPTION
        Three properties, built here one by one, whatever the row carried: no path gets
        through, and no number of files, which is not what is reported of another User.
        Nothing measured is NotMeasured, and that is not zero bytes.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Row)

    $state = 'NotMeasured'
    $number = ConvertTo-Number (Get-DataProperty $Row 'State')
    if ($null -ne $number -and $number -ge 0 -and $number -lt $script:UserProfileMeasureState.Count) {
        $state = $script:UserProfileMeasureState[[int]$number]
    }

    [pscustomobject]@{
        SizeState  = $state
        SizeBytes  = ConvertTo-UserProfileCount (Get-DataProperty $Row 'Bytes')
        SizeUnread = ConvertTo-UserProfileCount (Get-DataProperty $Row 'Unread')
    }
}

function ConvertTo-UserProfileOtherEntry {
    <#
    .SYNOPSIS
        What is kept of the User Profile of one person: the account and its number, the
        kind, a size and how it came about, when it was last used and where that is
        from, and whether it is loaded. Nothing for what belongs to no person. Pure.
    .DESCRIPTION
        ADR-0006 in code, for the User Profiles of other Users: the counterpart of
        ConvertTo-UserProfileFolder. Both Gatherers hand every row they read through
        here before it becomes data, and what the Elevated Part hands over goes through
        once more on arrival. Whatever a row carries on its way in, what goes on is built
        here, property by property:
 
            Sid, Account the number of the account and its name
            Kind out of a closed list, classified here and now, while what
                             it is known by is still there: see Get-UserProfileKind
            SizeState out of a closed list; 'Unread' where nobody measured
            SizeBytes a number, and nothing where there is none
            SizeUnread how many folders below it did not open, as a number
            LastUsedAt one date: the later of the two times Windows keeps of
                             loading and unloading the User Profile, and where it kept
                             neither, the time its profile list gives
            LastUsedSource 'Registry' or 'Windows': which of those two it is
            Loaded yes, no, or nothing where that was not read
 
        There is no way through for where the User Profile lies, where it roams to, the
        flags Windows keeps of it, or the two times apart.
 
        A row that was through here already comes out as it went in: its kind and its
        date are taken as they are, and held to the same lists.
    .PARAMETER Size
        The measuring of the profile folder, as Measure-UserProfileFolder gives it.
    .PARAMETER Measured
        That the profile folders were measured: a row without a measuring is then one the
        time ran out before, and not one nobody measured.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Row, [AllowNull()]$Size, [switch]$Measured)

    if ($null -eq $Row -or -not (Test-UserProfileOtherPerson -UserProfile $Row)) { return }

    $has = {
        param([string]$Name)
        if ($Row -is [hashtable]) { return $Row.ContainsKey($Name) }
        [bool]$Row.PSObject.Properties[$Name]
    }

    $sid     = "$(Get-DataProperty $Row 'Sid')".Trim()
    $account = "$(Get-DataProperty $Row 'Account')".Trim()

    $kind = 'Unread'
    if (& $has 'Kind') {
        $said  = "$(Get-DataProperty $Row 'Kind')"
        $known = $script:UserProfileOtherKind | Where-Object { $_ -ceq $said } | Select-Object -First 1
        if ($known) { $kind = $known }
    }
    elseif ($null -ne (Get-DataProperty $Row 'Status') -or $null -ne (Get-DataProperty $Row 'RegistryState') -or
        "$(Get-DataProperty $Row 'LocalPath')".Trim()) {
        $kind = (Get-UserProfileKind -UserProfile $Row -Account $account).Kind
    }

    $lastUsed = $null
    $source = $null
    if (& $has 'LastUsedAt') {
        $lastUsed = ConvertTo-UserProfileOtherTime (Get-DataProperty $Row 'LastUsedAt')
        if ($null -ne $lastUsed) { $source = $(if ("$(Get-DataProperty $Row 'LastUsedSource')" -ceq 'Windows') { 'Windows' } else { 'Registry' }) }
    }
    else {
        $kept = @(@((Get-DataProperty $Row 'LoadedAt'), (Get-DataProperty $Row 'UnloadedAt')) |
            ForEach-Object { ConvertTo-UserProfileOtherTime $_ } | Where-Object { $null -ne $_ } | Sort-Object)
        if ($kept.Count) { $lastUsed = $kept[-1]; $source = 'Registry' }
        else {
            $lastUsed = ConvertTo-UserProfileOtherTime (Get-DataProperty $Row 'LastUseTime')
            if ($null -ne $lastUsed) { $source = 'Windows' }
        }
    }

    $state = 'Unread'; $bytes = $null; $unopened = [long]0
    if ($Measured -or $null -ne $Size) {
        $measuring = ConvertTo-UserProfileOtherSize -Row $Size
        $state = $measuring.SizeState; $bytes = $measuring.SizeBytes; $unopened = $measuring.SizeUnread
    }
    elseif (& $has 'SizeState') {
        $said  = "$(Get-DataProperty $Row 'SizeState')"
        $known = $script:UserProfileOtherSizeState | Where-Object { $_ -ceq $said } | Select-Object -First 1
        if ($known -and $known -ne 'Unread') {
            $state    = $known
            $bytes    = ConvertTo-UserProfileCount (Get-DataProperty $Row 'SizeBytes')
            $unopened = ConvertTo-UserProfileCount (Get-DataProperty $Row 'SizeUnread')
        }
    }

    $loaded = Get-DataProperty $Row 'Loaded'
    if ($null -ne $loaded) { $loaded = [bool]$loaded }

    [pscustomobject]@{
        Sid            = $sid
        Account        = $account
        Kind           = $kind
        SizeState      = $state
        SizeBytes      = $bytes
        SizeUnread     = $unopened
        LastUsedAt     = $lastUsed
        LastUsedSource = $source
        Loaded         = $loaded
    }
}

function Get-UserProfileOtherData {
    <#
    .SYNOPSIS
        The User Profiles of the machine as this account reads them, and beside them what
        the Run knows already: the same read with admin rights, and what the Storage
        Check gathered. Decides nothing.
    .DESCRIPTION
        Of each User Profile what a Report may hold of another User and no more: both
        lists go through ConvertTo-UserProfileOtherEntry here, the one read with admin
        rights for the second time. Which of the reading with admin rights fills in
        what, and whether the system drive is short of space, is for the pure half.
    .PARAMETER Observed
        'UserProfileElevated' is absent where that Check was not performed: nobody read
        with admin rights in this Run. 'Storage' is absent where the Storage Check was
        not performed.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][hashtable]$Observed = @{})

    $elevated = $null
    if ($Observed -and $Observed.ContainsKey('UserProfileElevated')) { $elevated = $Observed['UserProfileElevated'] }
    $storage = $null
    if ($Observed -and $Observed.ContainsKey('Storage')) { $storage = $Observed['Storage'] }

    [pscustomobject]@{
        Listed              = @(Get-UserProfileOtherReading | ForEach-Object { ConvertTo-UserProfileOtherEntry -Row $_ })
        ElevatedRead        = ($null -ne $elevated)
        ElevatedProfiles    = @((Get-DataCollection $elevated 'Profiles') | ForEach-Object { $_ } |
            ForEach-Object { ConvertTo-UserProfileOtherEntry -Row $_ })
        OtherMeasureSeconds = Get-DataProperty $elevated 'OtherMeasureSeconds'
        Storage             = $storage
    }
}

function ConvertTo-UserProfileOtherRow {
    <#
    .SYNOPSIS
        Every User Profile of a person on the machine, with what this account could not
        read of it filled in from the reading taken with admin rights. Pure.
    .DESCRIPTION
        Matched on the number of the account, which is what a User Profile is kept under.
        What this account read itself stays; only what it left unread is filled in. A
        User Profile only the reading with admin rights has is added: it is on the disk
        whoever saw it.
 
        The size of the own User Profile is the one its Check measured, folder by folder.
        Of every other it is the one the reading with admin rights gives. SizeState says
        how the number came about:
 
            Complete measured to the end
            CutShort the time was up: a lower bound
            Partial measured to the end, with folders below it that did not open: a
                         lower bound
            NotMeasured the time was up before this one's turn: no number
            Unreadable the profile folder did not open, with admin rights either: no number
            Missing there is no profile folder: no number
            Unread nobody measured it: no number
 
        LastUsedAt is the later of the two times Windows keeps of loading and unloading
        the User Profile, and LastUsedSource then 'Registry'. Where it kept neither it is
        the time the profile list of Windows gives, and LastUsedSource 'Windows': see
        Get-UserProfileOtherReading for why that one comes second. One of the registry
        comes before one of the profile list, whichever reading has it.
 
        The kind is the one this account read. Where it read none, or found nothing
        that marks the User Profile as other than local and the reading with admin
        rights did, it is that one's: an administrator is given more of the marks.
 
        The rows it works from hold what a Report may hold of another User and no more:
        see ConvertTo-UserProfileOtherEntry, which every row is handed through once more
        here, so that data from anywhere is held to it. A row is built property by
        property, and whatever else it carried is not carried on.
 
        Not filled in by Join-ElevatedReading, by which the Program and the Session Kind
        fill in. That one keeps to the rows of the Main Part and takes from the other
        reading what this one left absent, and three things here are another rule: a
        User Profile only the other reading has is added; a kind read as local is not
        absent and is replaced all the same; and which date of last use counts goes by
        where it is from and not by who read it. Why a size is still unread is asked the
        same way as there: Get-UnreadReason.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data)

    $other = Get-DataProperty $Data 'Other'
    if ($null -eq $other) { return }

    $ownSid = "$(Get-DataProperty $Data 'OwnSid')".Trim()
    $own    = Get-UserProfileOwn -Data $Data

    $mine = @{}
    $theirs = @{}
    $sids = New-Object System.Collections.Generic.List[string]
    (Get-DataCollection $other 'Listed') | ForEach-Object { $_ } | ForEach-Object { ConvertTo-UserProfileOtherEntry -Row $_ } | ForEach-Object {
        if (-not $mine.ContainsKey($_.Sid)) { $mine[$_.Sid] = $_; $sids.Add($_.Sid) }
    }
    if ($null -ne $own -and $ownSid -and -not $mine.ContainsKey($ownSid)) {
        # The own one, where the listing does not have it: as its own Check read it.
        $entry = ConvertTo-UserProfileOtherEntry -Row $own
        if ($null -ne $entry) { $mine[$ownSid] = $entry; $sids.Add($ownSid) }
    }
    (Get-DataCollection $other 'ElevatedProfiles') | ForEach-Object { $_ } | ForEach-Object { ConvertTo-UserProfileOtherEntry -Row $_ } | ForEach-Object {
        if ($theirs.ContainsKey($_.Sid)) { return }
        $theirs[$_.Sid] = $_
        if (-not $mine.ContainsKey($_.Sid)) { $sids.Add($_.Sid) }
    }

    $ownSize = $null
    if ($null -ne $own) { $ownSize = Get-UserProfileSize -UserProfile $own }

    $rows = @(foreach ($sid in $sids) {
        $here  = $mine[$sid]
        $there = $theirs[$sid]

        # What this account read, and where it read nothing, what was read with admin rights.
        $account = "$(Get-DataProperty $here 'Account')".Trim()
        if (-not $account) { $account = "$(Get-DataProperty $there 'Account')".Trim() }

        $kind = "$(Get-DataProperty $here 'Kind')"
        $otherKind = "$(Get-DataProperty $there 'Kind')"
        if ($otherKind -and $otherKind -ne 'Unread' -and $kind -in '', 'Unread', 'Local') { $kind = $otherKind }
        if (-not $kind) { $kind = 'Unread' }

        $lastUsed = $null
        $source = $null
        $dated = @(@($here, $there) | Where-Object { $null -ne (Get-DataProperty $_ 'LastUsedAt') })
        $kept  = @($dated | Where-Object { "$(Get-DataProperty $_ 'LastUsedSource')" -eq 'Registry' } |
            ForEach-Object { Get-DataProperty $_ 'LastUsedAt' } | Sort-Object)
        if ($kept.Count) { $lastUsed = $kept[-1]; $source = 'Registry' }
        elseif ($dated.Count) { $lastUsed = Get-DataProperty $dated[0] 'LastUsedAt'; $source = 'Windows' }

        $loaded = Get-DataProperty $here 'Loaded'
        if ($null -eq $loaded) { $loaded = Get-DataProperty $there 'Loaded' }

        $bytes = $null
        $state = 'Unread'
        if ($sid -eq $ownSid -and $null -ne $ownSize -and $ownSize.Counted) {
            $bytes = [long]$ownSize.TotalBytes
            $state = $(if (-not $ownSize.TotalAtLeast) { 'Complete' } elseif ($ownSize.CutShortInside) { 'CutShort' } else { 'Partial' })
        }
        elseif ($null -ne $there) {
            $measured = ConvertTo-Number (Get-DataProperty $there 'SizeBytes')
            $unopened = ConvertTo-Number (Get-DataProperty $there 'SizeUnread')
            switch ("$(Get-DataProperty $there 'SizeState')") {
                'Complete'    { if ($null -ne $measured) { $bytes = [long]$measured; $state = $(if ($unopened -gt 0) { 'Partial' } else { 'Complete' }) } }
                'CutShort'    { if ($null -ne $measured) { $bytes = [long]$measured; $state = 'CutShort' } }
                'NotMeasured' { $state = 'NotMeasured' }
                'Unreadable'  { $state = 'Unreadable' }
                'Missing'     { $state = 'Missing' }
            }
        }

        [pscustomobject]@{
            Account        = $(if ($account) { $account } else { $null })
            Sid            = $sid
            Kind           = $kind
            SizeBytes      = $bytes
            SizeState      = $state
            LastUsedAt     = $lastUsed
            LastUsedSource = $source
            Loaded         = [bool]$loaded
            Own            = [bool]($ownSid -and $sid -eq $ownSid)
        }
    })

    # Three keys, so that the order does not depend on how a sort treats equals, nor on
    # the order Windows listed the User Profiles in.
    @($rows | Sort-Object @{ Expression = { if ($null -ne $_.SizeBytes) { $_.SizeBytes } else { [long]-1 } }; Descending = $true },
        @{ Expression = { "$($_.Account)".ToLowerInvariant() }; Descending = $false }, @{ Expression = 'Sid'; Descending = $false })
}

function Get-UserProfileOtherAccountText {
    <#
    .SYNOPSIS
        What a User Profile is called in the Report: its account, and where Windows did
        not resolve one, that in words with the number beside it. Pure.
    .DESCRIPTION
        The number never stands alone. It is shown, because it is what the entry of the
        User Profile in the registry is found by.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Row)

    $account = "$(Get-DataProperty $Row 'Account')".Trim()
    if ($account) { return $account }
    (Get-Text 'Value.UserProfile.Other.AccountUnknown') -f "$(Get-DataProperty $Row 'Sid')"
}

function Get-UserProfileOtherFinding {
    <#
    .SYNOPSIS
        How many User Profiles of the machine nobody has used for a long time, and how
        much room they take together. Pure.
    .DESCRIPTION
        From how many days on a User Profile counts as unused is the Judge's limit, which
        hands it over: UnusedDays, see ConvertTo-UserProfileFinding.
 
        The own User Profile and one that is loaded now never count, whatever their times
        say. One whose last use is not known does not count either: it is said how many
        those are.
 
        Information, and a warning only where the system drive is short of space. Whether
        it is, is the Storage Check's to say and is asked of it: Get-StorageFreeSpace,
        with what that Check gathered and the limits of its Check Definition, which it
        hands on with what it gathered. There is no second definition of "short" here.
        Where the Storage Check was not performed or could not read the drive, this stays
        information and says that free space was not weighed.
 
        Nothing where the User Profiles of the machine were not listed at all: data from
        before this was read.
    .PARAMETER UnusedDays
        After how many days without use a User Profile counts as unused.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data, [double]$UnusedDays = 180)

    $other = Get-DataProperty $Data 'Other'
    if ($null -eq $other) { return }

    $check = Get-Text 'Check.UserProfile.Other.Unused'
    $rows = @(ConvertTo-UserProfileOtherRow -Data $Data)
    if (-not $rows.Count) {
        return New-UnavailableFinding -Category User -Check $check -Hint (Get-Text 'Hint.UserProfile.Repeat')
    }

    $gatheredAt = ConvertTo-UserProfileOtherTime (Get-DataProperty $Data 'GatheredAt')
    $candidates = @($rows | Where-Object { -not $_.Own -and -not $_.Loaded })
    $unknown = @($candidates | Where-Object { $null -eq $_.LastUsedAt -or $null -eq $gatheredAt })
    $unused  = @($candidates | Where-Object { $null -ne $_.LastUsedAt -and $null -ne $gatheredAt -and ($gatheredAt - $_.LastUsedAt).TotalDays -gt $UnusedDays })

    $value = @((Get-Text 'Value.UserProfile.Other.Count') -f $rows.Count)
    $reference = @(New-Reference -Section 'Title.UserProfile.Other')

    if (-not $unused.Count) {
        $value += (Get-Text 'Value.UserProfile.Other.NoneUnused') -f $UnusedDays
        if (-not $unknown.Count) {
            return New-Finding -Category User -Check $check -Severity OK -Value ($value -join ' | ') -Reference $reference
        }
        # Not "none": of these it is not known.
        $value += (Get-Text 'Value.UserProfile.Other.LastUseUnknown') -f $unknown.Count
        return New-Finding -Category User -Check $check -Severity INFO -Value ($value -join ' | ') -Reference $reference `
            -Meaning (Get-Text 'Meaning.UserProfile.Other.LastUseUnknown') -Hint (Get-Text 'Hint.UserProfile.Repeat')
    }

    $names = @($unused | ForEach-Object { Get-UserProfileOtherAccountText -Row $_ } | Sort-Object)
    $value += (Get-Text 'Value.UserProfile.Other.Unused') -f $unused.Count, $UnusedDays, ($names -join ', ')

    # A profile folder that is not there takes no room, and that is known.
    $sized = @($unused | Where-Object { $null -ne $_.SizeBytes })
    $open  = @($unused | Where-Object { $_.SizeState -notin 'Complete', 'Missing' })
    $total = [long]0
    foreach ($row in $sized) { $total += [long]$row.SizeBytes }

    if (-not $sized.Count -and $open.Count) { $value += Get-Text 'Value.UserProfile.Other.UnusedSizeUnknown' }
    elseif ($open.Count) { $value += (Get-Text 'Value.UserProfile.Other.UnusedSizeAtLeast') -f (Format-DataSize -Bytes $total), $open.Count }
    else { $value += (Get-Text 'Value.UserProfile.Other.UnusedSize') -f (Format-DataSize -Bytes $total) }

    # Whose fault it is that a size is not there: nobody measured with admin rights in
    # this Run, or the measuring with admin rights did not get it.
    if (@($open | Where-Object { $_.SizeState -ne 'Partial' }).Count) {
        if ((Get-UnreadReason $other) -eq 'Refused') { $value += Get-Text 'Value.UserProfile.Other.SizeProtected' }
        else { $value += Get-Text 'Value.UserProfile.Other.SizeNoAdmin' }
    }
    if ($unknown.Count) { $value += (Get-Text 'Value.UserProfile.Other.LastUseUnknown') -f $unknown.Count }

    $meaning = (Get-Text 'Meaning.UserProfile.Other.Unused') -f $UnusedDays
    $remove  = Get-Text 'Hint.UserProfile.Other.Remove'

    $space = Get-StorageFreeSpace -Data (Get-DataProperty $other 'Storage')
    if ($null -eq $space) {
        $value += Get-Text 'Value.UserProfile.Other.FreeNotWeighed'
        return New-Finding -Category User -Check $check -Severity INFO -Value ($value -join ' | ') -Reference $reference `
            -Meaning ($meaning + ' ' + (Get-Text 'Meaning.UserProfile.Other.NotWeighed')) `
            -Hint ((Get-Text 'Hint.UserProfile.Other.NoRush') + ' | ' + $remove)
    }

    if ($space.Severity -ne 'OK') {
        $value += (Get-Text 'Value.UserProfile.Other.FreeLow') -f $space.Drive, $space.FreeGB, $space.FreePercent
        return New-Finding -Category User -Check $check -Severity WARN -Value ($value -join ' | ') `
            -Reference @($reference; (New-Reference -Signal 'system-drive-low')) `
            -Meaning ($meaning + ' ' + (Get-Text 'Meaning.UserProfile.Other.Low')) -Hint $remove
    }

    $value += (Get-Text 'Value.UserProfile.Other.FreeFine') -f $space.Drive, $space.FreeGB, $space.FreePercent
    New-Finding -Category User -Check $check -Severity INFO -Value ($value -join ' | ') -Reference $reference `
        -Meaning $meaning -Hint ((Get-Text 'Hint.UserProfile.Other.NoRush') + ' | ' + $remove)
}

function ConvertTo-UserProfileOtherSection {
    <#
    .SYNOPSIS
        Every User Profile of a person on the machine as a table, the own one included. Pure.
    .DESCRIPTION
        True of the machine, and shown whichever Session the Run is in. The own one is
        the User Profile of the account the Run runs as: the User's where the Run is in
        the Session of the User, and said not to be where it is not.
 
        No cell is empty. What is not there is said in one of two wordings, and the last
        column says the same at length: "nicht gelesen" where nobody read with admin
        rights in this Run, "nicht lesbar" where Windows did not give it with admin
        rights either. See Get-UnreadText.
 
        Nothing where the User Profiles of the machine were not listed.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data)

    $other = Get-DataProperty $Data 'Other'
    if ($null -eq $other) { return }
    $rows = @(ConvertTo-UserProfileOtherRow -Data $Data)
    if (-not $rows.Count) { return }

    $elevated = (Get-UnreadReason $other) -eq 'Refused'
    $wrong    = [bool](Get-DataProperty (Get-DataProperty $Data 'RunUser') 'Differs')
    $seconds  = ConvertTo-Number (Get-DataProperty $other 'OtherMeasureSeconds')
    if ($null -eq $seconds) { $seconds = 60 }
    $unread   = Get-UnreadText -Reason (Get-UnreadReason $other)

    New-Section -Title (Get-Text 'Title.UserProfile.Other') -Row @(
        foreach ($entry in $rows) {
            $note = @()
            if ($entry.Own -and $wrong) { $note += Get-Text 'Value.UserProfile.Other.Note.OwnRun' }
            elseif ($entry.Own)         { $note += Get-Text 'Value.UserProfile.Other.Note.Own' }

            $sizeCell = $unread
            switch ($entry.SizeState) {
                'Complete'    { $sizeCell = Format-DataSize -Bytes $entry.SizeBytes }
                'CutShort'    {
                    $sizeCell = Format-DataSize -Bytes $entry.SizeBytes -AtLeast
                    # Of the own one the time was that of its own Check, and its Finding says so.
                    if ($entry.Own) { $note += Get-Text 'Value.UserProfile.Note.CutShort' }
                    else            { $note += (Get-Text 'Value.UserProfile.Other.Note.SizeCutShort') -f $seconds }
                }
                'Partial'     {
                    $sizeCell = Format-DataSize -Bytes $entry.SizeBytes -AtLeast
                    $note += Get-Text 'Value.UserProfile.Other.Note.SizePartial'
                }
                'NotMeasured' { $sizeCell = Get-Text 'Value.UserProfile.Cell.NotMeasured'; $note += (Get-Text 'Value.UserProfile.Other.Note.SizeNotMeasured') -f $seconds }
                'Missing'     { $sizeCell = Get-Text 'Value.UserProfile.Other.Cell.Missing'; $note += Get-Text 'Value.UserProfile.Other.Note.Missing' }
                default       {
                    if ($elevated) { $note += Get-Text 'Value.UserProfile.Other.Note.SizeProtected' }
                    else           { $note += Get-Text 'Value.UserProfile.Other.Note.SizeUnread' }
                }
            }

            $usedCell = $unread
            if ($entry.Loaded) { $usedCell = Get-Text 'Value.UserProfile.Other.Cell.Loaded' }
            elseif ($null -ne $entry.LastUsedAt) {
                $usedCell = (Get-Text 'Value.UserProfile.Other.At') -f $entry.LastUsedAt
                if ($entry.LastUsedSource -eq 'Windows') { $note += Get-Text 'Value.UserProfile.Other.Note.LastUseWindows' }
            }
            elseif ($elevated) { $usedCell = Get-Text 'Value.UserProfile.Other.Cell.NotKept'; $note += Get-Text 'Value.UserProfile.Other.Note.LastUseProtected' }
            else { $note += Get-Text 'Value.UserProfile.Other.Note.LastUseUnread' }

            $row = [ordered]@{}
            $row[(Get-Text 'Column.UserProfile.Other.Account')]  = Get-UserProfileOtherAccountText -Row $entry
            # A kind neither reading has is unread for the reason every cell here is.
            $row[(Get-Text 'Column.UserProfile.Other.Kind')]     = $(if ($entry.Kind -eq 'Unread') { $unread } else { Get-UserProfileKindText -Kind $entry.Kind })
            $row[(Get-Text 'Column.UserProfile.Other.Size')]     = $sizeCell
            $row[(Get-Text 'Column.UserProfile.Other.LastUsed')] = $usedCell
            $row[(Get-Text 'Column.Note')]                       = $note -join '; '
            [pscustomobject]$row
        }
    )
}