modules/shared/ScanState.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    Shared scan-state and delta-history layer for incremental / scheduled runs.

.DESCRIPTION
    Persists a small JSON document under <OutputPath>/state/scan-state.json that
    captures, between runs:

      * Run metadata: last run time, last run mode, last baseline time.
      * Per-tool status: lastScanUtc, lastSuccessUtc, runMode, sinceUsedUtc,
        status, findingCount.
      * Per-finding history (keyed by Get-ReportDeltaKey): FirstSeenUtc,
        LastSeenUtc, LastScanUtc -- so age and recurrence can be reported
        independently of finding payload timestamps.

    The API is intentionally additive. Tool wrappers that have not yet
    opted into incremental queries are still safe; the orchestrator marks them
    as FullFallback when -Incremental is requested.

    All disk writes route through Remove-Credentials when available, and all
    paths are validated to stay under the supplied state root (no traversal).
#>

[CmdletBinding()]
param ()

Set-StrictMode -Version Latest

$script:ScanStateSchemaVersion = 1
$script:ScanStateRunModes      = @('Full','Incremental','Cached','FullFallback','Partial')

# Dot-source ReportDelta if available so Get-ReportDeltaKey is in scope.
$script:_scanStateReportDeltaPath = Join-Path $PSScriptRoot 'ReportDelta.ps1'
if ((Test-Path $script:_scanStateReportDeltaPath) -and -not (Get-Command Get-ReportDeltaKey -ErrorAction SilentlyContinue)) {
    . $script:_scanStateReportDeltaPath
}

function Get-ScanStateRoot {
    <#
    .SYNOPSIS
        Returns the canonical state directory under an output path.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $OutputPath
    )
    return [System.IO.Path]::GetFullPath((Join-Path $OutputPath 'state'))
}

function Get-ScanStatePath {
    <#
    .SYNOPSIS
        Returns the canonical scan-state.json path under an output path.
        Validates no traversal escapes the state root.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $OutputPath,

        [string] $FileName = 'scan-state.json'
    )

    if ($FileName -match '[/\\]' -or $FileName -match '\.\.') {
        throw "ScanState file name '$FileName' must be a simple file name."
    }

    $root = Get-ScanStateRoot -OutputPath $OutputPath
    $resolved = [System.IO.Path]::GetFullPath((Join-Path $root $FileName))
    $sep = [System.IO.Path]::DirectorySeparatorChar
    $rootWithSep = if ($root.EndsWith($sep)) { $root } else { "$root$sep" }
    if (-not $resolved.StartsWith($rootWithSep, [System.StringComparison]::OrdinalIgnoreCase)) {
        throw "ScanState path resolution escaped state root: $resolved"
    }
    return $resolved
}

function New-EmptyScanState {
    <#
    .SYNOPSIS
        Returns a fresh scan-state hashtable with the current schema.
    #>

    [CmdletBinding()]
    param ()
    return [ordered]@{
        schemaVersion = $script:ScanStateSchemaVersion
        runs = [ordered]@{
            lastRunUtc      = $null
            lastRunMode     = $null
            lastBaselineUtc = $null
        }
        tools    = @{}
        findings = @{}
    }
}

function ConvertTo-ScanStateHashtable {
    param ($Object)
    if ($null -eq $Object) { return $null }
    if ($Object -is [hashtable] -or $Object -is [System.Collections.IDictionary]) { return $Object }
    if ($Object -is [System.Management.Automation.PSCustomObject]) {
        $h = @{}
        foreach ($prop in $Object.PSObject.Properties) {
            $h[$prop.Name] = ConvertTo-ScanStateHashtable -Object $prop.Value
        }
        return $h
    }
    return $Object
}

function Read-ScanState {
    <#
    .SYNOPSIS
        Loads scan-state from disk. Returns a fresh state if missing or corrupt.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $OutputPath
    )

    $path = Get-ScanStatePath -OutputPath $OutputPath
    if (-not (Test-Path $path)) {
        return New-EmptyScanState
    }

    try {
        $raw = Get-Content -Raw -Path $path -ErrorAction Stop
        $obj = $raw | ConvertFrom-Json -ErrorAction Stop
        $state = ConvertTo-ScanStateHashtable -Object $obj
        if (-not $State.Contains('schemaVersion')) {
            return New-EmptyScanState
        }
        if (-not $State.Contains('runs'))     { $state['runs']     = (New-EmptyScanState).runs }
        if (-not $State.Contains('tools'))    { $state['tools']    = @{} }
        if (-not $State.Contains('findings')) { $state['findings'] = @{} }
        return $state
    } catch {
        Write-Warning "Scan state '$path' is corrupt or unreadable; starting fresh. $_"
        return New-EmptyScanState
    }
}

function Write-ScanState {
    <#
    .SYNOPSIS
        Atomically writes scan-state to disk under <OutputPath>/state/.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $OutputPath,

        [Parameter(Mandatory)]
        [ValidateNotNull()]
        $State
    )

    $root = Get-ScanStateRoot -OutputPath $OutputPath
    if (-not (Test-Path $root)) {
        $null = New-Item -ItemType Directory -Path $root -Force
    }

    $path = Get-ScanStatePath -OutputPath $OutputPath
    $json = $State | ConvertTo-Json -Depth 10
    if (Get-Command Remove-Credentials -ErrorAction SilentlyContinue) {
        $json = Remove-Credentials $json
    }

    $tempPath = "$path.tmp-$([Guid]::NewGuid().ToString('N'))"
    Set-Content -Path $tempPath -Value $json -Encoding utf8
    Move-Item -Path $tempPath -Destination $path -Force
    return $path
}

function Resolve-IncrementalSince {
    <#
    .SYNOPSIS
        Resolves the effective -Since DateTime for a given tool.

    .DESCRIPTION
        Precedence:
          1. Explicit $Override (operator-controlled) wins.
          2. Else, when -Incremental is requested, returns the previous
             lastSuccessUtc for that tool (so each tool gets its own window).
          3. Else, returns $null (full scan).
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] $State,
        [Parameter(Mandatory)] [string] $Tool,
        [switch] $Incremental,
        [Nullable[datetime]] $Override
    )

    if ($null -ne $Override) { return [datetime]::SpecifyKind($Override, [System.DateTimeKind]::Utc)
    }
    if (-not $Incremental) { return $null }

    if (-not $State.Contains('tools') -or -not $State['tools']) { return $null }
    $tools = $State['tools']
    if ($tools -is [hashtable] -or $tools -is [System.Collections.IDictionary]) {
        if (-not $tools.Contains($Tool)) { return $null }
        $entry = $tools[$Tool]
    } elseif ($tools.PSObject.Properties[$Tool]) {
        $entry = $tools.$Tool
    } else {
        return $null
    }
    if ($null -eq $entry) { return $null }

    $val = $null
    if ($entry -is [hashtable] -or $entry -is [System.Collections.IDictionary]) {
        if ($entry.Contains('lastSuccessUtc')) { $val = $entry['lastSuccessUtc'] }
    } elseif ($entry.PSObject.Properties['lastSuccessUtc']) {
        $val = $entry.lastSuccessUtc
    }
    if (-not $val) { return $null }
    try {
        return [datetime]::Parse($val, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal)
    } catch {
        return $null
    }
}

function Update-ScanStateToolEntry {
    <#
    .SYNOPSIS
        Records the outcome of a tool run inside the scan-state.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] $State,
        [Parameter(Mandatory)] [string] $Tool,
        [Parameter(Mandatory)] [ValidateSet('Success','Failed','Skipped','Partial')] [string] $Status,
        [ValidateSet('Full','Incremental','Cached','FullFallback','Partial')] [string] $RunMode = 'Full',
        [int] $FindingCount = 0,
        [Nullable[datetime]] $SinceUsed,
        [Nullable[datetime]] $Now
    )

    if (-not $State.Contains('tools') -or $null -eq $State['tools']) { $State['tools'] = @{} }

    $nowUtc = if ($null -ne $Now) { ([datetime]$Now).ToUniversalTime() } else { (Get-Date).ToUniversalTime() }
    $iso    = $nowUtc.ToString('o')

    $entry = $null
    if ($State['tools'].Contains($Tool)) {
        $existing = $State['tools'][$Tool]
        $entry = ConvertTo-ScanStateHashtable -Object $existing
    }
    if (-not $entry -or -not ($entry -is [hashtable] -or $entry -is [System.Collections.IDictionary])) {
        $entry = [ordered]@{
            lastScanUtc    = $null
            lastSuccessUtc = $null
            runMode        = $null
            sinceUsedUtc   = $null
            status         = $null
            findingCount   = 0
        }
    }

    $entry['lastScanUtc']  = $iso
    $entry['runMode']      = $RunMode
    $entry['status']       = $Status
    $entry['findingCount'] = [int]$FindingCount
    $entry['sinceUsedUtc'] = if ($null -ne $SinceUsed) { ([datetime]$SinceUsed).ToUniversalTime().ToString('o') } else { $null }
    # Only fully-successful runs advance the incremental watermark. A Partial run
    # means some findings may have been missed; advancing would cause the next
    # -Incremental run to skip the window that contained the misses (#94 R1).
    if ($Status -eq 'Success') {
        $entry['lastSuccessUtc'] = $iso
    }

    $State['tools'][$Tool] = $entry
    return $State
}

function Update-FindingHistoryFromDelta {
    <#
    .SYNOPSIS
        Updates per-finding history (FirstSeenUtc / LastSeenUtc / LastScanUtc)
        from a current findings array. Resolved (absent) keys keep their last
        timestamps so they remain trendable.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] $State,
        [Parameter(Mandatory)] $Current,
        [Nullable[datetime]] $Now
    )

    if (-not (Get-Command Get-ReportDeltaKey -ErrorAction SilentlyContinue)) {
        throw "Get-ReportDeltaKey not available. Dot-source ReportDelta.ps1 first."
    }

    $nowUtc = if ($null -ne $Now) { ([datetime]$Now).ToUniversalTime() } else { (Get-Date).ToUniversalTime() }
    $iso    = $nowUtc.ToString('o')

    if (-not $State.Contains('findings') -or $null -eq $State['findings']) { $State['findings'] = @{} }
    $hist = $State['findings']

    foreach ($row in @($Current)) {
        if (-not $row) { continue }
        $key = Get-ReportDeltaKey -Row $row
        if ($hist.Contains($key)) {
            $existing = ConvertTo-ScanStateHashtable -Object $hist[$key]
            if (-not $existing.Contains('FirstSeenUtc') -or -not $existing['FirstSeenUtc']) {
                $existing['FirstSeenUtc'] = $iso
            }
            $existing['LastSeenUtc'] = $iso
            $existing['LastScanUtc'] = $iso
            $hist[$key] = $existing
        } else {
            $hist[$key] = [ordered]@{
                FirstSeenUtc = $iso
                LastSeenUtc  = $iso
                LastScanUtc  = $iso
            }
        }
    }

    $State['findings'] = $hist
    return $State
}

function Update-ScanStateRun {
    <#
    .SYNOPSIS
        Stamps run-level metadata on the state document.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] $State,
        [Parameter(Mandatory)] [ValidateSet('Full','Incremental','Cached','FullFallback','Partial')] [string] $RunMode,
        [Nullable[datetime]] $Now,
        [switch] $UpdateBaseline
    )

    $nowUtc = if ($null -ne $Now) { ([datetime]$Now).ToUniversalTime() } else { (Get-Date).ToUniversalTime() }
    $iso    = $nowUtc.ToString('o')

    if (-not $State.Contains('runs') -or $null -eq $State['runs']) {
        $State['runs'] = (New-EmptyScanState).runs
    }
    $runs = ConvertTo-ScanStateHashtable -Object $State['runs']
    $runs['lastRunUtc']  = $iso
    $runs['lastRunMode'] = $RunMode
    if ($UpdateBaseline -or -not $runs['lastBaselineUtc']) {
        $runs['lastBaselineUtc'] = $iso
    }
    $State['runs'] = $runs
    return $State
}

function Get-ScanStateToolEntry {
    <#
    .SYNOPSIS
        Convenience accessor for a tool's last entry. Returns $null if absent.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] $State,
        [Parameter(Mandatory)] [string] $Tool
    )
    if (-not $State.Contains('tools') -or $null -eq $State['tools']) { return $null }
    if (-not $State['tools'].Contains($Tool)) { return $null }
    return ConvertTo-ScanStateHashtable -Object $State['tools'][$Tool]
}