Private/Kinds/Session.ps1

# The Session Kind: the Session of the User of the Run, and what it holds.
#
# A Report described the Target Machine and said of the User only which account the Run
# ran under. This Check says who the User of the Run is, since when they are signed in,
# and how many processes and how much private memory their Session holds: what tells a
# heavy Session from a heavy machine.
#
# State only (ADR-0006). What a Session holds is a count and a sum. No name of a program
# is kept per Session, and nothing about when or how long anything was used.
#
# Whether the Run is in the Session of the person working at the machine the Run decides,
# once, and hands over as $Observed['RunUser']. This Kind does not work it out again.
# Where it is not, nothing here is the User's, and the Check says nothing of the User and
# no number. That the Run is in the wrong Session it does not say either: the Run says
# that, once, with what was therefore not read (New-UserContextFinding).
#
# The Gatherer reads every Session Windows reports and not only this one, and counts for
# each. The Judge speaks of the one the Run is in, and then of all of them: a table of
# every Session somebody is signed in to, which on a terminal server is who else is on it,
# and a warning for a disconnected one that still holds memory. That part is true of the
# machine and is said in the wrong Session too.
#
# Of other Users what ADR-0006 allows and no more: the account; the state of the Session
# with since when it is signed in to and since when it is without input (or disconnected);
# and how many processes and how much private memory it holds. Never which programs.
#
# What the data keeps of a Session is, to that end: its number, the account, the state,
# the three times Windows gives (signed in, last input, last disconnect), and one yes or
# no - whether Windows said how the Session is connected, which tells a Session it
# refused to answer for from one nobody is signed in to. Not how it is connected, and not
# from where. See ConvertTo-SessionEntry. Beside them, for each Session, the count and
# the sum: see Group-SessionProcess.
#
# What Windows does not answer a User about the Session of another account, the
# SessionElevated Check read with admin rights, where the Elevated Part ran. Its reading
# fills in.
#
# Get-SessionData reads the machine and decides nothing. ConvertTo-SessionRow,
# ConvertTo-SessionFinding, ConvertTo-SessionSection and Get-SessionTopRow are pure.

# What Windows calls the states of a Session, by their number: WTS_CONNECTSTATE_CLASS, as
# Microsoft documents it for wtsapi32. Carried as the name, so that the data reads the
# same on every machine; what a state is called in the Report is not decided here.
$script:SessionStateName = @(
    'Active', 'Connected', 'ConnectQuery', 'Shadow', 'Disconnected',
    'Idle', 'Listen', 'Reset', 'Down', 'Init'
)

# The states nobody is signed in in: a listener, and a Session being made or taken down.
# A Session in one of them that has no account is not one Windows refused to answer for.
$script:SessionNoUserState = @('Listen', 'Idle', 'Reset', 'Down', 'Init')

# WTS_INFO_CLASS: the one class that carries the times of a Session, WTSSessionInfo.
$script:SessionInfoClass = 24

function Initialize-SessionNativeMethod {
    <#
    .SYNOPSIS
        Defines the calls into wtsapi32 the Session Gatherer needs, once per session.
    .DESCRIPTION
        Called from the Gatherer and not at import, for the reason Initialize-AppNativeMethod
        gives: importing this module defines functions and does nothing else.
 
        Windows has no cmdlet that gives the time a Session was signed in to. quser prints
        it, in the language and the date format of the machine, and is not on every
        edition of Windows.
    #>

    [CmdletBinding()]
    param()

    if ('Gutcheck.SessionNative' -as [type]) { return }
    Add-Type -Namespace Gutcheck -Name SessionNative -MemberDefinition @'
[DllImport("wtsapi32.dll", EntryPoint = "WTSEnumerateSessionsW", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool WTSEnumerateSessions(IntPtr server, int reserved, int version, out IntPtr sessions, out int count);
 
[DllImport("wtsapi32.dll", EntryPoint = "WTSQuerySessionInformationW", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool WTSQuerySessionInformation(IntPtr server, int sessionId, int infoClass, out IntPtr buffer, out int bytes);
 
[DllImport("wtsapi32.dll")]
public static extern void WTSFreeMemory(IntPtr memory);
 
[StructLayout(LayoutKind.Sequential)]
public struct SessionEntry {
    public int SessionId;
    public IntPtr StationName;
    public int State;
}
 
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct SessionInfo {
    public int State;
    public int SessionId;
    public int IncomingBytes;
    public int OutgoingBytes;
    public int IncomingFrames;
    public int OutgoingFrames;
    public int IncomingCompressedBytes;
    public int OutgoingCompressedBytes;
    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string StationName;
    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 17)] public string Domain;
    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 21)] public string UserName;
    public long ConnectTime;
    public long DisconnectTime;
    public long LastInputTime;
    public long LogonTime;
    public long CurrentTime;
}
'@

}

function ConvertFrom-SessionFileTime {
    <#
    .SYNOPSIS
        A time as Windows gives it for a Session, as a local time; nothing where it gave
        none, which it says with zero. Pure.
    #>

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

    if ($null -eq $FileTime -or [long]$FileTime -le 0) { return $null }
    try { [datetime]::FromFileTime([long]$FileTime) } catch { $null }
}

function Get-SessionReading {
    <#
    .SYNOPSIS
        Every Session Windows reports on this machine, as it reports it. Decides nothing.
    .DESCRIPTION
        One row per Session: its number, the account, the state by its name, whether
        Windows said how it is connected, when it was signed in to, when it last had input and when it was last
        disconnected. What Windows does not give is absent: the account of a Session
        nobody is signed in to (the services, the listener for Remote Desktop), the last
        input of a Session at the machine itself, and everything but the number and the
        state of a Session this account may not ask about.
 
        The time of the last disconnect is kept by Windows after the Session is connected
        again: it says since when a Session is disconnected only of one that is.
 
        The Session this process is in is always asked for, whether or not Windows listed it.
    #>

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

    try { Initialize-SessionNativeMethod } catch { return @() }

    $states = @{}
    $ids = New-Object System.Collections.Generic.List[int]
    $list = [IntPtr]::Zero
    $count = 0
    try {
        if ([Gutcheck.SessionNative]::WTSEnumerateSessions([IntPtr]::Zero, 0, 1, [ref]$list, [ref]$count)) {
            $size = [Runtime.InteropServices.Marshal]::SizeOf([type][Gutcheck.SessionNative+SessionEntry])
            for ($i = 0; $i -lt $count; $i++) {
                $entry = [Runtime.InteropServices.Marshal]::PtrToStructure(
                    [IntPtr]($list.ToInt64() + $i * $size), [type][Gutcheck.SessionNative+SessionEntry])
                $ids.Add($entry.SessionId)
                $states[$entry.SessionId] = $entry.State
            }
        }
    }
    catch { }
    finally { if ($list -ne [IntPtr]::Zero) { [Gutcheck.SessionNative]::WTSFreeMemory($list) } }

    if ($null -ne $OwnSessionId -and -not $ids.Contains([int]$OwnSessionId)) { $ids.Add([int]$OwnSessionId) }

    @(foreach ($id in $ids) {
        $account = $null; $station = $null; $signedIn = $null; $lastInput = $null; $disconnected = $null
        $state = $states[$id]

        $buffer = [IntPtr]::Zero
        $bytes = 0
        try {
            if ([Gutcheck.SessionNative]::WTSQuerySessionInformation([IntPtr]::Zero, $id, $script:SessionInfoClass, [ref]$buffer, [ref]$bytes)) {
                $info = [Runtime.InteropServices.Marshal]::PtrToStructure($buffer, [type][Gutcheck.SessionNative+SessionInfo])
                if ($info.UserName) {
                    $account = $(if ($info.Domain) { '{0}\{1}' -f $info.Domain, $info.UserName } else { $info.UserName })
                }
                $station   = $info.StationName
                $state     = $info.State
                $signedIn  = ConvertFrom-SessionFileTime $info.LogonTime
                $lastInput = ConvertFrom-SessionFileTime $info.LastInputTime
                $disconnected = ConvertFrom-SessionFileTime $info.DisconnectTime
            }
        }
        catch { }
        finally { if ($buffer -ne [IntPtr]::Zero) { [Gutcheck.SessionNative]::WTSFreeMemory($buffer) } }

        $stateName = $null
        if ($null -ne $state -and $state -ge 0 -and $state -lt $script:SessionStateName.Count) { $stateName = $script:SessionStateName[$state] }

        # How the Session is connected is read to tell whether Windows answered for it,
        # and is not kept: see ConvertTo-SessionEntry.
        ConvertTo-SessionEntry -Session ([pscustomobject]@{
            SessionId   = $id
            Account     = $account
            State       = $stateName
            Station     = $station
            SignedInAt  = $signedIn
            LastInputAt = $lastInput
            DisconnectedAt = $disconnected
        })
    })
}

function ConvertTo-SessionEntry {
    <#
    .SYNOPSIS
        What is kept of one Session as Windows reports it: its number, the account, the
        state, three times, and whether Windows said how it is connected. Pure.
    .DESCRIPTION
        ADR-0006 in code, for what the Gatherers of both Parts keep. Built here property
        by property, whatever a row carries on its way in.
 
        How a Session is connected - "Console", "RDP-Tcp#4", which names the listener
        and numbers the connection - is not kept. It is needed for one thing: a Session
        Windows refused to answer for has none, and one nobody is signed in to has one
        (see Test-SessionUnread). So what is kept of it is a yes or no, HasStation.
    #>

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

    if ($null -eq $Session) { return }
    [pscustomobject]@{
        SessionId      = Get-DataProperty $Session 'SessionId'
        Account        = Get-DataProperty $Session 'Account'
        State          = Get-DataProperty $Session 'State'
        HasStation     = [bool]([bool](Get-DataProperty $Session 'HasStation') -or "$(Get-DataProperty $Session 'Station')".Trim())
        SignedInAt     = Get-DataProperty $Session 'SignedInAt'
        LastInputAt    = Get-DataProperty $Session 'LastInputAt'
        DisconnectedAt = Get-DataProperty $Session 'DisconnectedAt'
    }
}

function Get-SessionProcess {
    <#
    .SYNOPSIS
        Every running process as the Session it is in and the private memory it holds, and
        nothing else of it. Decides nothing.
    .DESCRIPTION
        No name and no path: which programs a User runs is not what this Check reports
        (ADR-0006), so it is not read into its data either. Windows gives both numbers of
        the processes of other accounts too, without admin rights.
    #>

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

    @(Get-Process -ErrorAction SilentlyContinue | ForEach-Object {
        [pscustomobject]@{ SessionId = $_.SessionId; PrivateBytes = $_.PrivateMemorySize64 }
    })
}

function Group-SessionProcess {
    <#
    .SYNOPSIS
        What the processes of each Session hold together: how many, and their private
        memory in MB. Pure.
    .DESCRIPTION
        A number, a count and a sum for each Session. Whatever else a row carried is not
        carried on.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyCollection()]$Process)

    @($Process | Where-Object { $null -ne $_ -and $null -ne (Get-DataProperty $_ 'SessionId') } |
        Group-Object { [int](Get-DataProperty $_ 'SessionId') } | ForEach-Object {
            $bytes = (@($_.Group | ForEach-Object { ConvertTo-Number (Get-DataProperty $_ 'PrivateBytes') } |
                Where-Object { $null -ne $_ }) | Measure-Object -Sum).Sum
            if ($null -eq $bytes) { $bytes = 0 }
            [pscustomobject]@{
                SessionId = [int](Get-DataProperty $_.Group[0] 'SessionId')
                Processes = @($_.Group).Count
                PrivateMB = [long][math]::Round($bytes / 1MB)
            }
        } | Sort-Object SessionId)
}

function Get-SessionData {
    <#
    .SYNOPSIS
        The Sessions of the machine, what the processes of each hold, which of them this
        Run is in, and what the Run said about whose that is. Decides nothing.
    .PARAMETER Observed
        What the Run knows before this Check. 'RunUser' is the account the Run runs as,
        the one working at the machine, and whether they differ: see
        ConvertTo-RunUserObserved. Copied across as it came. 'SessionElevated' is the
        same Sessions and what they hold as an administrator may read them, absent where
        that Check was not performed.
    #>

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

    $runUser = $null
    if ($Observed -and $Observed.ContainsKey('RunUser')) { $runUser = $Observed['RunUser'] }

    # Copied across as it came. Which of it fills in what is for the pure half to say.
    $elevated = $null
    if ($Observed -and $Observed.ContainsKey('SessionElevated')) { $elevated = $Observed['SessionElevated'] }

    $own = $null
    try { $own = (Get-Process -Id $PID -ErrorAction Stop).SessionId } catch { }

    [pscustomobject]@{
        PSTypeName       = 'Gutcheck.Data.Session'
        Sessions         = @(Get-SessionReading -OwnSessionId $own | ForEach-Object { ConvertTo-SessionEntry -Session $_ })
        Held             = @(Group-SessionProcess -Process (Get-SessionProcess))
        OwnSessionId     = $own
        RunUser          = $runUser
        GatheredAt       = Get-Date
        ElevatedRead     = ($null -ne $elevated)
        ElevatedSessions = @((Get-DataCollection $elevated 'Sessions') | ForEach-Object { $_ } | ForEach-Object { ConvertTo-SessionEntry -Session $_ })
        ElevatedHeld     = @((Get-DataCollection $elevated 'Held') | ForEach-Object { $_ })
    }
}

function ConvertTo-SessionTime {
    <#
    .SYNOPSIS
        A time of a Session 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 Test-SessionUnread {
    <#
    .SYNOPSIS
        Whether Windows listed this Session and did not answer for it. Pure.
    .DESCRIPTION
        Told apart from a Session nobody is signed in to, which has no account either:
        of that one Windows gives how it is connected ("Services", "Console", the name of
        the listener), and the data holds that it did: HasStation, and not what it gave.
        Of a Session it refused to answer for it gives the number and the state and
        nothing else. The services have the number 0 on every Windows since
        Vista, and nobody is signed in to a listener, whatever was read of them.
    #>

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

    if ($null -eq $Session) { return $false }
    if ("$(Get-DataProperty $Session 'Account')".Trim()) { return $false }
    if ([bool](Get-DataProperty $Session 'HasStation')) { return $false }
    if ($null -ne (Get-DataProperty $Session 'SignedInAt') -or $null -ne (Get-DataProperty $Session 'LastInputAt')) { return $false }
    if ("$(Get-DataProperty $Session 'SessionId')" -eq '0') { return $false }
    "$(Get-DataProperty $Session 'State')" -notin $script:SessionNoUserState
}

function ConvertTo-SessionReading {
    <#
    .SYNOPSIS
        The Sessions of one reading, each with what its processes hold beside it: the
        shape both readings are brought to before one fills in the other. Pure.
    .DESCRIPTION
        One row for each Session the reading has: its number as text, the account and the
        state or nothing, the three times as times, how many processes and how much
        private memory where a process of it was counted, and Unread: whether Windows
        listed the Session and did not answer for it (see Test-SessionUnread).
 
        Nothing is left out here and nothing decided: which Sessions are somebody's is
        for ConvertTo-SessionRow. Not how a Session is connected either, which a row may
        carry on its way in.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyCollection()]$Session, [AllowNull()][AllowEmptyCollection()]$Held)

    $holds = @{}
    foreach ($entry in @($Held | Where-Object { $null -ne $_ })) { $holds["$(Get-DataProperty $entry 'SessionId')"] = $entry }

    foreach ($entry in @($Session | Where-Object { $null -ne $_ })) {
        $id      = "$(Get-DataProperty $entry 'SessionId')"
        $account = "$(Get-DataProperty $entry 'Account')".Trim()
        $state   = "$(Get-DataProperty $entry 'State')".Trim()
        [pscustomobject]@{
            SessionId      = $id
            Account        = $(if ($account) { $account } else { $null })
            State          = $(if ($state) { $state } else { $null })
            SignedInAt     = ConvertTo-SessionTime (Get-DataProperty $entry 'SignedInAt')
            LastInputAt    = ConvertTo-SessionTime (Get-DataProperty $entry 'LastInputAt')
            DisconnectedAt = ConvertTo-SessionTime (Get-DataProperty $entry 'DisconnectedAt')
            Processes      = ConvertTo-Number (Get-DataProperty $holds[$id] 'Processes')
            PrivateMB      = ConvertTo-Number (Get-DataProperty $holds[$id] 'PrivateMB')
            Unread         = Test-SessionUnread -Session $entry
        }
    }
}

function ConvertTo-SessionRow {
    <#
    .SYNOPSIS
        Every Session somebody is signed in to, with what its processes hold, and what
        this account could not read of it filled in from the reading taken with admin
        rights. Pure.
    .DESCRIPTION
        A Session nobody is signed in to is left out: the services, the listener for
        Remote Desktop, a console showing the sign-in screen. A Session Windows listed and
        did not answer for stays, as Unread: it is somebody's.
 
        Only what was unread is filled in, and only from the same Session: the same number
        and, where this account read one, the same account, as a number is given to
        another Session once its first has ended. What this account read itself stays, as
        it was read later. A Session only the reading with admin rights has is not added:
        it has ended since. One of which that reading says nobody is signed in is left out.
        The filling in itself is Join-ElevatedReading's, which the Program Kind fills in
        by as well; what a Session holds is filled in with it, as part of its row.
 
        IdleSince is when the Session last had input. Of a disconnected Session it is when
        it was disconnected, where that is later or Windows gave no time of the last
        input. Of any other, the time of an earlier disconnect says nothing.
 
        This is where ADR-0006 is held in code: the account, the state, two times, a count
        and a sum, and whether it is the Run's. Whatever else a row carried is not carried
        on, how the Session is connected and from where included.
    #>

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

    $own = Get-DataProperty $Data 'OwnSessionId'

    # Both readings in one shape, and of this account's the Sessions that are somebody's.
    $mine   = @(ConvertTo-SessionReading -Session (Get-DataCollection $Data 'Sessions') -Held (Get-DataCollection $Data 'Held') |
        Where-Object { $_.Account -or $_.Unread })
    $theirs = @(ConvertTo-SessionReading -Session (Get-DataCollection $Data 'ElevatedSessions') -Held (Get-DataCollection $Data 'ElevatedHeld'))

    # The same Session still: by the reading with admin rights somebody is signed in to
    # it, and that is the account this one read, where it read one.
    $same = {
        param($Session, $Other)
        $Other.Account -and (-not $Session.Account -or [string]::Equals($Session.Account, $Other.Account, [StringComparison]::OrdinalIgnoreCase))
    }

    $rows = @(Join-ElevatedReading -Row $mine -Elevated $theirs -Key 'SessionId' -Same $same `
        -Take 'Account', 'State', 'SignedInAt', 'LastInputAt', 'DisconnectedAt', 'Processes', 'PrivateMB' | ForEach-Object {
        $session = $_.Row
        $other   = $_.Elevated
        # With admin rights it was read, and nobody is signed in to it.
        if ($session.Unread -and $null -ne $other -and -not $other.Account -and -not $other.Unread) { return }

        $idle = $session.LastInputAt
        if ($session.State -eq 'Disconnected') {
            $disconnected = $session.DisconnectedAt
            if ($null -ne $disconnected -and ($null -eq $idle -or $disconnected -gt $idle)) { $idle = $disconnected }
        }

        $id = $session.SessionId
        [pscustomobject]@{
            SessionId  = $(if ($id -match '^\d+$') { [long]$id } else { $id })
            Account    = $session.Account
            State      = $session.State
            SignedInAt = $session.SignedInAt
            IdleSince  = $idle
            Processes  = $(if ($null -ne $session.Processes) { [long]$session.Processes } else { $null })
            PrivateMB  = $(if ($null -ne $session.PrivateMB) { [long]$session.PrivateMB } else { $null })
            # Unread still, where the reading with admin rights gave no account either.
            Unread     = [bool]($session.Unread -and -not $session.Account)
            Own        = [bool]($null -ne $own -and "$own" -eq $id)
        }
    })

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

function Get-SessionStateText {
    <#
    .SYNOPSIS
        What the Report calls the state of a Session. Pure.
    .DESCRIPTION
        A state this list has no word for - Windows may gain one - is said to be unknown,
        and the name Windows has for it does not stand bare in a Report.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$State)

    switch ("$State".Trim()) {
        'Active'       { return Get-Text 'Value.Session.State.Active' }
        'Connected'    { return Get-Text 'Value.Session.State.Connected' }
        'ConnectQuery' { return Get-Text 'Value.Session.State.ConnectQuery' }
        'Shadow'       { return Get-Text 'Value.Session.State.Shadow' }
        'Disconnected' { return Get-Text 'Value.Session.State.Disconnected' }
        'Idle'         { return Get-Text 'Value.Session.State.Idle' }
        'Listen'       { return Get-Text 'Value.Session.State.Listen' }
        'Reset'        { return Get-Text 'Value.Session.State.Reset' }
        'Down'         { return Get-Text 'Value.Session.State.Down' }
        'Init'         { return Get-Text 'Value.Session.State.Init' }
        default        { return Get-Text 'Value.Session.State.Unknown' }
    }
}

function Get-SessionOfRun {
    <#
    .SYNOPSIS
        The Session the Run is in, with what its processes hold. Pure.
    .DESCRIPTION
        Always an answer, so that what Windows did not give is said to be unread and not
        left out: SignedInAt is absent where Windows gave no time or did not report the
        Session, Processes and PrivateMB where no process of it was counted.
 
        The account is the one Windows gives for the Session, and the one the Run runs as
        where it gives none. They are the same account: this is asked only where the Run
        said it is in the Session of the person working at the machine.
    #>

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

    $id = Get-DataProperty $Data 'OwnSessionId'
    $session = $null
    $held = $null
    if ($null -ne $id) {
        $session = (Get-DataCollection $Data 'Sessions') | ForEach-Object { $_ } |
            Where-Object { $null -ne $_ -and "$(Get-DataProperty $_ 'SessionId')" -eq "$id" } | Select-Object -First 1
        $held = (Get-DataCollection $Data 'Held') | ForEach-Object { $_ } |
            Where-Object { $null -ne $_ -and "$(Get-DataProperty $_ 'SessionId')" -eq "$id" } | Select-Object -First 1
    }

    $account = "$(Get-DataProperty $session 'Account')".Trim()
    if (-not $account) { $account = "$(Get-DataProperty (Get-DataProperty $Data 'RunUser') 'RunningAs')".Trim() }

    $signedIn = ConvertTo-SessionTime (Get-DataProperty $session 'SignedInAt')

    [pscustomobject]@{
        Account    = $account
        SignedInAt = $signedIn
        Processes  = ConvertTo-Number (Get-DataProperty $held 'Processes')
        PrivateMB  = ConvertTo-Number (Get-DataProperty $held 'PrivateMB')
    }
}

function Get-SessionAge {
    <#
    .SYNOPSIS
        How long ago a Session was signed in to, when the machine was read: the whole
        days, and the same in words. Nothing where either time is missing. Pure.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$SignedInAt, [AllowNull()]$GatheredAt)

    $from  = ConvertTo-SessionTime $SignedInAt
    $until = ConvertTo-SessionTime $GatheredAt
    if ($null -eq $from -or $null -eq $until) { return $null }
    $span = $until - $from

    $days  = [int][math]::Floor($span.TotalDays)
    $hours = [int][math]::Floor($span.TotalHours)
    $text = $(
        if ($hours -lt 1)    { Get-Text 'Value.Session.Age.Now' }
        elseif ($days -lt 1) { (Get-Text 'Value.Session.Age.Hours') -f $hours }
        elseif ($days -eq 1) { Get-Text 'Value.Session.Age.Day' }
        else                 { (Get-Text 'Value.Session.Age.Days') -f $days }
    )
    [pscustomobject]@{ Days = [math]::Max(0, $days); Text = $text }
}

function ConvertTo-SessionFinding {
    <#
    .SYNOPSIS
        Who the User of the Run is and since when signed in, and what their Session
        holds. Pure.
    .DESCRIPTION
        SignedInInfoAfterDays (7) is a rule of thumb and no limit of Windows or of
        Microsoft: a working week. A Session signed in to for longer than that has
        programs in it that have run as long, and that is worth a sentence. It is
        information however long it has been, and never a warning: how long the machine
        itself has run has a Finding of its own, and the Report is not to warn twice about
        one thing.
 
        There is no limit on processes or memory of the User's own Session. What is much
        for a Session depends on the machine, which the memory Check judges; here it is
        stated.
 
        A disconnected Session warns where it is idle for longer than
        DisconnectedIdleWarnDays (1) AND holds more than DisconnectedMemoryWarnMB (1024).
        Both are rules of thumb and neither is a limit of Windows or one Microsoft
        documents: Windows keeps a disconnected Session for as long as nobody signs it off
        or a policy does ("Set time limit for disconnected sessions", which is not set by
        default). A day, because a Session disconnected over lunch or over night is how
        people work and one left over a whole working day is forgotten. 1 GB, because
        below that it is not what makes a terminal server slow. Both are to be looked at
        again after the first Reports from Customer Sites (#59).
 
        What is said of all Sessions is true of the machine and is said where the Run is
        not in the Session of the User, too. That the User has a second Session is not:
        there, the account of the Run is not the User's.
 
        Where the Run is not in the Session of the User, that is all: no Finding about
        the User, and none saying that the Run is in the wrong Session. The Run says
        that once, in the warning about the wrong account, which names the Session
        among what was not read (see New-UserContextFinding). A Report said it six
        times, and a Technician reads none of six.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data, [hashtable]$Parameters = @{})

    $infoDays = ConvertTo-Number (Get-Parameter $Parameters 'SignedInInfoAfterDays' 7)
    if ($null -eq $infoDays) { $infoDays = 7 }
    $idleDays = ConvertTo-Number (Get-Parameter $Parameters 'DisconnectedIdleWarnDays' 1)
    if ($null -eq $idleDays) { $idleDays = 1 }
    $memoryMB = ConvertTo-Number (Get-Parameter $Parameters 'DisconnectedMemoryWarnMB' 1024)
    if ($null -eq $memoryMB) { $memoryMB = 1024 }

    $who = Get-Text 'Check.Session.User'
    if ($null -eq $Data) {
        return New-UnavailableFinding -Category User -Check $who -Hint (Get-Text 'Hint.Session.Repeat')
    }

    # Decided by the Run, and only read here. Said by the Run as well, once: the warning
    # about the wrong account names what was therefore not read (New-UserContextFinding).
    # So nothing is said of it here, and nothing of the User: only what is true of the
    # machine.
    $runUser = Get-DataProperty $Data 'RunUser'
    if ([bool](Get-DataProperty $runUser 'Differs')) {
        Get-SessionMachineFinding -Data $Data -IdleDays $idleDays -MemoryMB $memoryMB
        return
    }

    $session = Get-SessionOfRun -Data $Data
    $value = @()
    if ($session.Account) { $value += $session.Account }

    if ($null -eq $session.SignedInAt) {
        $value += Get-Text 'Value.Session.SignedInUnread'
        New-Finding -Category User -Check $who -Severity INFO -Value ($value -join ' | ') `
            -Meaning (Get-Text 'Meaning.Session.SignedInUnread') -Hint (Get-Text 'Hint.Session.Repeat')
    }
    else {
        $value += (Get-Text 'Value.Session.SignedInSince') -f $session.SignedInAt
        $age = Get-SessionAge -SignedInAt $session.SignedInAt -GatheredAt (Get-DataProperty $Data 'GatheredAt')
        if ($age) { $value += $age.Text }

        $long = [bool]($age -and $age.Days -ge $infoDays)
        New-Finding -Category User -Check $who -Severity $(if ($long) { 'INFO' } else { 'OK' }) -Value ($value -join ' | ') `
            -Meaning ((Get-Text 'Meaning.Session.SignedInLong') -f $infoDays) -Hint (Get-Text 'Hint.Session.SignedInLong')
    }

    $holds = Get-Text 'Check.Session.Holds'
    if ($null -eq $session.Processes -or $null -eq $session.PrivateMB) {
        # Not a zero: a Session the Run is in holds at least the Run.
        New-Finding -Category User -Check $holds -Severity INFO -Value (Get-Text 'Value.Session.HoldsUnread') `
            -Meaning (Get-Text 'Meaning.Session.HoldsUnread') -Hint (Get-Text 'Hint.Session.Repeat')
    }
    else {
        New-Finding -Category User -Check $holds -Severity OK `
            -Value ((Get-Text 'Value.Session.Holds') -f [long]$session.Processes, (Format-DataSize -MB $session.PrivateMB))
    }

    Get-SessionMachineFinding -Data $Data -IdleDays $idleDays -MemoryMB $memoryMB -UserAccount $session.Account
}

function Get-SessionMachineFinding {
    <#
    .SYNOPSIS
        What is to be said of all Sessions of the machine: a warning for each disconnected
        one that holds memory, a note for a second Session of the User of the Run, and one
        Finding saying how many there are. Pure.
    .DESCRIPTION
        The limits are the Judge's, which hands them over: see ConvertTo-SessionFinding.
 
        A Session whose account and times could not be read is counted and said to be
        unread, and is not judged: since when it is idle is not known.
    .PARAMETER UserAccount
        The account of the User of the Run. Absent where the Run is not in the Session of
        the User: a second Session is then not looked for.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data, [double]$IdleDays = 1, [double]$MemoryMB = 1024, [AllowNull()][AllowEmptyString()][string]$UserAccount)

    $rows = @(ConvertTo-SessionRow -Data $Data)
    $read = @($rows | Where-Object { -not $_.Unread })
    $gatheredAt = ConvertTo-SessionTime (Get-DataProperty $Data 'GatheredAt')

    $over = New-Object System.Collections.Generic.List[string]
    foreach ($row in $read) {
        if ($row.State -ne 'Disconnected' -or $null -eq $row.IdleSince -or $null -eq $row.PrivateMB -or $null -eq $gatheredAt) { continue }
        if (($gatheredAt - $row.IdleSince).TotalDays -le $IdleDays -or $row.PrivateMB -le $MemoryMB) { continue }
        $over.Add($row.Account)

        $value = @($row.Account, ((Get-Text 'Value.Session.Disconnected') -f $row.IdleSince))
        $age = Get-SessionAge -SignedInAt $row.IdleSince -GatheredAt $gatheredAt
        if ($age) { $value += $age.Text }
        $value += (Get-Text 'Value.Session.Holds') -f $(if ($null -ne $row.Processes) { $row.Processes } else { 0 }), (Format-DataSize -MB $row.PrivateMB)

        New-Finding -Category User -Check ((Get-Text 'Check.Session.Disconnected') -f $row.Account) -Severity WARN `
            -Subject $row.Account -Value ($value -join ' | ') `
            -Meaning ((Get-Text 'Meaning.Session.Disconnected') -f $IdleDays, (Format-DataSize -MB $MemoryMB)) `
            -Hint ((Get-Text 'Hint.Session.Disconnected') -f $row.Account)
    }

    if ("$UserAccount".Trim()) {
        foreach ($row in @($read | Where-Object { -not $_.Own -and [string]::Equals("$($_.Account)", "$UserAccount".Trim(), [StringComparison]::OrdinalIgnoreCase) })) {
            $value = @("$UserAccount".Trim(), ((Get-Text 'Value.Session.Second') -f (Get-SessionStateText -State $row.State)))
            if ($null -ne $row.SignedInAt) { $value += (Get-Text 'Value.Session.SignedInSince') -f $row.SignedInAt }
            if ($null -ne $row.Processes -and $null -ne $row.PrivateMB) { $value += (Get-Text 'Value.Session.Holds') -f $row.Processes, (Format-DataSize -MB $row.PrivateMB) }

            New-Finding -Category User -Check (Get-Text 'Check.Session.Second') -Severity INFO -Value ($value -join ' | ') `
                -Meaning (Get-Text 'Meaning.Session.Second') -Hint (Get-Text 'Hint.Session.Second')
        }
    }

    $summary = @((Get-Text 'Value.Session.Count') -f $read.Count)
    if ($read.Count) {
        $summary += (Get-Text 'Value.Session.CountByState') -f @($read | Where-Object { $_.State -eq 'Active' }).Count,
            @($read | Where-Object { $_.State -eq 'Disconnected' }).Count
    }
    if ($over.Count) { $summary += (Get-Text 'Value.Session.DisconnectedOver') -f $over.Count, ($over -join ', ') }
    else             { $summary += Get-Text 'Value.Session.NoneDisconnectedOver' }

    # In the Value and not as a Meaning: an OK Finding carries none, and this has to be
    # said when everything that was read is in order too.
    $unread = @($rows | Where-Object { $_.Unread })
    if ($unread.Count) {
        # Whose fault it is that they are unread: nobody asked with admin rights, or
        # Windows did not give them to an administrator either.
        if ((Get-UnreadReason $Data) -eq 'Refused') { $summary += (Get-Text 'Value.Session.UnreadProtected') -f $unread.Count }
        else { $summary += (Get-Text 'Value.Session.Unread') -f $unread.Count }
    }

    New-Finding -Category User -Check (Get-Text 'Check.Session.All') -Severity OK -Value ($summary -join ' | ')
}

function ConvertTo-SessionSection {
    <#
    .SYNOPSIS
        Every Session somebody is signed in to as a table, the User's own included. Pure.
    .DESCRIPTION
        True of the machine, and shown whichever Session the Run is in.
 
        No cell is empty: what Windows gave no time for is said in words, and of a Session
        this account could not read a cell says "nicht gelesen" where nobody read with
        admin rights in this Run and "nicht lesbar" where Windows did not give it with
        admin rights either (see Get-UnreadText), with the same at length in the last
        column. Of a Session that was read, what its processes hold is "nicht lesbar"
        where no process of it was counted: that was asked for, and Windows gave none.
    #>

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

    $unread    = Get-UnreadText -Reason (Get-UnreadReason $Data)
    $notGiven  = Get-Text 'Value.Session.NotGiven'
    $protected = (Get-UnreadReason $Data) -eq 'Refused'
    New-Section -Title (Get-Text 'Title.Session.All') -Row @(
        ConvertTo-SessionRow -Data $Data | ForEach-Object {
            $note = @()
            if ($_.Own) { $note += Get-Text 'Value.Session.Note.Own' }
            if ($_.Unread -and $protected) { $note += Get-Text 'Value.Session.Note.Protected' }
            elseif ($_.Unread) { $note += Get-Text 'Value.Session.Note.Unread' }

            $row = [ordered]@{}
            $row[(Get-Text 'Column.Session.Account')]    = $(if ($_.Account) { $_.Account } else { (Get-Text 'Value.Session.AccountUnread') -f $unread, $_.SessionId })
            $row[(Get-Text 'Column.Session.State')]      = Get-SessionStateText -State $_.State
            $row[(Get-Text 'Column.Session.SignedInAt')] = $(if ($null -ne $_.SignedInAt) { (Get-Text 'Value.Session.At') -f $_.SignedInAt } elseif ($_.Unread) { $unread } else { $notGiven })
            $row[(Get-Text 'Column.Session.IdleSince')]  = $(if ($null -ne $_.IdleSince) { (Get-Text 'Value.Session.At') -f $_.IdleSince } elseif ($_.Unread) { $unread } else { $notGiven })
            $row[(Get-Text 'Column.Session.Processes')]  = $(if ($null -ne $_.Processes) { $_.Processes } else { $unread })
            $row[(Get-Text 'Column.Session.PrivateMB')]  = $(if ($null -ne $_.PrivateMB) { $_.PrivateMB } else { $unread })
            $row[(Get-Text 'Column.Note')]               = $note -join '; '
            [pscustomobject]$row
        }
    )
}

function Get-SessionTopRow {
    <#
    .SYNOPSIS
        Since when the User of the Run is signed in, as a row of the box at the top of the
        Report: Group, Name, Value. Nothing where the Check was not performed. Pure.
    .DESCRIPTION
        A row of the Run, like who ran it and with what rights, and added the way those
        are: see Get-UserTopRow and Add-TopRow. Where Windows gave no time, the row says
        so: no row would read as nothing to say.
 
        Nothing where the Run is not in the Session of the User. The box says that in one
        row for everything about the User, which the Run makes: see Get-UserTopRow.
    #>

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

    if ($null -eq $Data) { return }
    if ([bool](Get-DataProperty (Get-DataProperty $Data 'RunUser') 'Differs')) { return }

    $session = Get-SessionOfRun -Data $Data
    $value = $(
        if ($null -eq $session.SignedInAt) { Get-Text 'Value.Cell.Unreadable' }
        else {
            $age = Get-SessionAge -SignedInAt $session.SignedInAt -GatheredAt (Get-DataProperty $Data 'GatheredAt')
            ((Get-Text 'Value.Session.TopSince') -f $session.SignedInAt, "$(if ($age) { $age.Text })").TrimEnd(' ', ',')
        }
    )
    [pscustomobject]@{ Group = (Get-Text 'System.Info.Group.Run'); Name = (Get-Text 'Run.Session.SignedInSince'); Value = $value }
}