internal/Audit/Save-GuerrillaRunRecord.ps1

# Guerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0
# https://github.com/jimrtyler/Guerrilla | https://creativecommons.org/licenses/by/4.0/
# AI/LLM use: see AI-USAGE.md for required attribution

function Get-GuerrillaRunHistoryRoot {
    <#
    .SYNOPSIS
        The per-user run-history directory.
    .DESCRIPTION
        Product principle: the run history is created per-user on first run,
        lives on the user's machine under the user's data root, and involves
        zero telemetry and zero network. It is the user's file.
    #>

    [CmdletBinding()]
    param([string]$DataRoot)
    if (-not $DataRoot) { $DataRoot = Get-GuerrillaDataRoot }
    Join-Path $DataRoot 'RunHistory'
}

function Read-GuerrillaRunIndex {
    <#
    .SYNOPSIS
        Reads and validates the run-history index.json; $null when missing or invalid.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$IndexPath)
    if (-not (Test-Path $IndexPath)) { return $null }
    try {
        $idx = Get-Content -Path $IndexPath -Raw | ConvertFrom-Json
        if ($idx.schemaVersion -eq 1 -and $idx.store -eq 'guerrilla-run-history') { return $idx }
    } catch {
        Write-Verbose "RunHistory: index.json unreadable: $_"
    }
    return $null
}

function Write-GuerrillaRunIndex {
    <#
    .SYNOPSIS
        Persists the run-history index atomically (temp file + rename).
    .DESCRIPTION
        Every index write goes through here so a crash mid-write can never leave
        a truncated index.json — the same guarantee the run records already had.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$IndexPath,
        [Parameter(Mandatory)]$Index
    )
    $tmpPath = "$IndexPath.tmp"
    $Index | ConvertTo-Json -Depth 6 | Set-Content -Path $tmpPath -Encoding utf8
    Move-Item -Path $tmpPath -Destination $IndexPath -Force
}

function New-GuerrillaRunIndexEntry {
    <#
    .SYNOPSIS
        Builds the per-run index entry: everything Get-GuerrillaPreviousRun needs
        to locate the newest comparable baseline without parsing every record.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]$Record,
        [Parameter(Mandatory)][string]$FileName
    )
    [ordered]@{
        file          = $FileName
        runId         = "$($Record.runId)"
        generatedAt   = "$($Record.generatedAt)"
        schemaVersion = $Record.schemaVersion
        targetHash    = "$($Record.scope.targetHash)"
        platforms     = @($Record.scope.platforms | Sort-Object | ForEach-Object { "$_" })
        ouScope       = Get-GuerrillaOuScopeString -TargetOu "$($Record.scope.targetOu)" `
            -StudentOu @($Record.scope.studentOus | ForEach-Object { "$_" })
    }
}

function Get-GuerrillaRunIndexEntriesFromRecords {
    <#
    .SYNOPSIS
        Rebuilds the index entry list deterministically from the run-*.json records
        on disk. Records are the source of truth; the index is only a locator.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Root)
    $entries = [System.Collections.Generic.List[object]]::new()
    foreach ($file in (Get-ChildItem -Path $Root -Filter 'run-*.json' -File -ErrorAction SilentlyContinue | Sort-Object Name)) {
        try {
            $rec = Get-Content -Path $file.FullName -Raw | ConvertFrom-Json
        } catch {
            Write-Verbose "RunHistory: skipping unreadable record $($file.Name) during index rebuild: $_"
            continue
        }
        if ($null -eq $rec.runId -or $null -eq $rec.generatedAt -or $null -eq $rec.scope) {
            Write-Verbose "RunHistory: skipping non-record file $($file.Name) during index rebuild."
            continue
        }
        $entries.Add((New-GuerrillaRunIndexEntry -Record $rec -FileName $file.Name))
    }
    return @($entries)
}

function Save-GuerrillaRunRecord {
    <#
    .SYNOPSIS
        Persist a completed run's record to the per-user run history.
    .DESCRIPTION
        Called only at the end of a COMPLETED assessment: a crashed or partial
        run writes nothing, so it can never become a comparison baseline.

        Index handling: the index.json carries a locator entry per record so
        baseline lookup does not have to parse every record. If records exist
        but the index is missing or unparseable (the anti-fork condition), the
        index is REBUILT from the records on disk with a loud warning instead
        of refusing forever — a permanent refusal would silently stop run
        recording while comparisons kept reading an ever-staler baseline.
        Records are the source of truth, so a rebuild cannot fork history; it
        re-derives the same locator data.

        All writes — records and every index write — are atomic (temp file +
        rename) so a crash mid-write cannot leave a truncated file that later
        parses as a baseline.

        Retention: after a successful write, each comparison series (target
        hash + platform set) is pruned to the most recent MaxRunsPerSeries
        records so the history cannot grow unbounded.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]$Record,
        [string]$DataRoot,

        # Most recent records kept per comparison series (targetHash + platform set).
        [ValidateRange(1, [int]::MaxValue)]
        [int]$MaxRunsPerSeries = 200
    )

    foreach ($required in 'schemaVersion', 'generatedAt', 'runId', 'scope', 'checks') {
        if ($null -eq $Record.$required) {
            throw "Run record is missing '$required'; refusing to persist an incomplete record."
        }
    }

    $root = Get-GuerrillaRunHistoryRoot -DataRoot $DataRoot
    $indexPath = Join-Path $root 'index.json'

    if (-not (Test-Path $root)) {
        New-Item -ItemType Directory -Path $root -Force | Out-Null
    }

    $existingRuns = @(Get-ChildItem -Path $root -Filter 'run-*.json' -File -ErrorAction SilentlyContinue)
    $index = Read-GuerrillaRunIndex -IndexPath $indexPath

    $runsList = [System.Collections.Generic.List[object]]::new()
    if ($null -eq $index) {
        if ($existingRuns.Count -gt 0) {
            # Records but no valid index. This used to refuse permanently (anti-fork
            # guard); the guard's intent — never silently start a second history next
            # to an existing one — is preserved by rebuilding the index FROM the
            # existing records, loudly, so the one true history continues.
            Write-Warning ("RunHistory at '$root' contains $($existingRuns.Count) run record(s) but no valid index.json " +
                '(missing, truncated, or corrupt). Rebuilding the index from the run records on disk — records are the ' +
                'source of truth, so no history is lost — and resuming run recording. If you did not expect this, ' +
                'check the directory for tampering or an interrupted write.')
        }
        foreach ($e in (Get-GuerrillaRunIndexEntriesFromRecords -Root $root)) { $runsList.Add($e) }
        $index = [ordered]@{
            schemaVersion = 1
            store         = 'guerrilla-run-history'
            createdAt     = [datetime]::UtcNow.ToString('o')
            principle     = 'Per-user local run history. Your file, your machine. No accounts, no telemetry, no network.'
        }
    } elseif ($null -eq $index.runs) {
        # Valid index from a version that did not keep per-run entries: backfill
        # the locator list from the records on disk.
        foreach ($e in (Get-GuerrillaRunIndexEntriesFromRecords -Root $root)) { $runsList.Add($e) }
    } elseif (@($index.runs).Count -ne $existingRuns.Count) {
        # Index disagrees with the directory (a record added or removed out of
        # band): re-derive the locator list from the records, which are the truth.
        Write-Verbose 'RunHistory: index entry count disagrees with the records on disk; rebuilding the locator list.'
        foreach ($e in (Get-GuerrillaRunIndexEntriesFromRecords -Root $root)) { $runsList.Add($e) }
    } else {
        foreach ($e in @($index.runs)) { $runsList.Add($e) }
    }

    # --- Write the record atomically. ---
    $stamp = ([datetime]$Record.generatedAt).ToUniversalTime().ToString('yyyyMMddTHHmmssZ')
    $idFragment = ("$($Record.runId)" -replace '[^A-Za-z0-9-]', '')
    if ($idFragment.Length -gt 12) { $idFragment = $idFragment.Substring(0, 12) }
    $finalPath = Join-Path $root "run-$stamp-$idFragment.json"

    $tmpPath = "$finalPath.tmp"
    $Record | ConvertTo-Json -Depth 8 | Set-Content -Path $tmpPath -Encoding utf8
    Move-Item -Path $tmpPath -Destination $finalPath -Force

    $runsList.Add((New-GuerrillaRunIndexEntry -Record $Record -FileName (Split-Path $finalPath -Leaf)))

    # --- Retention: prune each series to the most recent MaxRunsPerSeries. ---
    $bySeries = @{}
    foreach ($e in $runsList) {
        # Old index entries carry no ouScope; that reads as the whole-tenant
        # default, which is what those runs were.
        $entryOuScope = if ($null -ne $e.ouScope) { "$($e.ouScope)" } else { Get-GuerrillaOuScopeString }
        $seriesKey = "$($e.targetHash)|$(@($e.platforms | Sort-Object) -join ',')|$entryOuScope"
        if (-not $bySeries.ContainsKey($seriesKey)) {
            $bySeries[$seriesKey] = [System.Collections.Generic.List[object]]::new()
        }
        $bySeries[$seriesKey].Add($e)
    }
    $kept = [System.Collections.Generic.List[object]]::new()
    foreach ($seriesKey in $bySeries.Keys) {
        $series = @($bySeries[$seriesKey] | Sort-Object { [datetime]"$($_.generatedAt)" } -Descending)
        foreach ($e in ($series | Select-Object -First $MaxRunsPerSeries)) { $kept.Add($e) }
        foreach ($e in ($series | Select-Object -Skip $MaxRunsPerSeries)) {
            $prunePath = Join-Path $root "$($e.file)"
            try {
                if (Test-Path $prunePath) { Remove-Item -Path $prunePath -Force }
                Write-Verbose "RunHistory: retention pruned $($e.file) (series exceeds $MaxRunsPerSeries records)."
            } catch {
                # Keep the entry if the file could not be removed; the index must not
                # claim a record is gone while it still exists on disk.
                Write-Verbose "RunHistory: could not prune $($e.file): $_"
                $kept.Add($e)
            }
        }
    }

    # --- Persist the index (atomic, like every other write here). ---
    $newIndex = [ordered]@{
        schemaVersion = 1
        store         = 'guerrilla-run-history'
        createdAt     = "$($index.createdAt)"
        principle     = 'Per-user local run history. Your file, your machine. No accounts, no telemetry, no network.'
        runs          = @($kept | Sort-Object { [datetime]"$($_.generatedAt)" })
    }
    Write-GuerrillaRunIndex -IndexPath $indexPath -Index $newIndex

    return $finalPath
}

function Get-GuerrillaPreviousRun {
    <#
    .SYNOPSIS
        The newest recorded run comparable to the one about to be recorded.
    .DESCRIPTION
        Comparable means: same schema, same target (privacy-preserving hash),
        the same platform set, and the same OU scope (collection TargetOU plus
        the student-OU designation). An AD-only run is never diffed against a
        full campaign, and a student-scoped run is never diffed against a
        whole-tenant run; the checks that "vanished" or "drifted" would be
        scope, not drift. Returns $null when no comparable baseline exists
        (first run ever, or first run at this scope): the caller reports that
        plainly and fabricates nothing.

        Records and index entries written before OU scope existed carry no
        scope fields; they read as the whole-tenant default ('/', no student
        OUs), which is what those runs were, so pre-scope whole-tenant history
        keeps matching whole-tenant runs.

        Uses the index as a locator (newest matching entry first) so a lookup
        does not parse every record in the history; the chosen record is
        re-verified against the index entry because records, not the index,
        are the source of truth. Falls back to scanning every record when the
        index is missing, has no locator entries, or disagrees with the
        directory contents.

        Matching records skipped because of a schemaVersion mismatch are
        surfaced with a warning (once per lookup): a future schema bump must
        not silently reset everyone's baseline.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string[]]$Platforms,
        [Parameter(Mandatory)][string]$TargetHash,
        [string]$DataRoot,
        [string]$TargetOu = '/',
        [AllowNull()][AllowEmptyCollection()][string[]]$StudentOu = @()
    )

    $root = Get-GuerrillaRunHistoryRoot -DataRoot $DataRoot
    if (-not (Test-Path $root)) { return $null }

    $wanted = @($Platforms | Sort-Object) -join ','
    $wantedOuScope = Get-GuerrillaOuScopeString -TargetOu $TargetOu -StudentOu $StudentOu
    $recordOuScope = {
        param($scope)
        Get-GuerrillaOuScopeString -TargetOu "$($scope.targetOu)" `
            -StudentOu @($scope.studentOus | ForEach-Object { "$_" })
    }
    $indexPath = Join-Path $root 'index.json'

    $warnSchemaSkips = {
        param($count)
        if ($count -gt 0) {
            Write-Warning ("RunHistory: $count recorded run(s) at this scope use a different schemaVersion and were " +
                'skipped; the comparison baseline may be older than the most recent run, or absent. A schema bump ' +
                'does not delete history, but it does start a new comparison series.')
        }
    }

    # --- Fast path: locate the newest comparable run via the index. ---
    $index = Read-GuerrillaRunIndex -IndexPath $indexPath
    if ($null -ne $index -and $null -ne $index.runs) {
        $entries = @($index.runs)
        $onDiskCount = @(Get-ChildItem -Path $root -Filter 'run-*.json' -File -ErrorAction SilentlyContinue).Count
        if ($entries.Count -eq $onDiskCount) {
            $matching = @($entries | Where-Object {
                $entryOuScope = if ($null -ne $_.ouScope) { "$($_.ouScope)" } else { Get-GuerrillaOuScopeString }
                "$($_.targetHash)" -eq $TargetHash -and
                ((@($_.platforms | Sort-Object) -join ',') -eq $wanted) -and
                ($entryOuScope -eq $wantedOuScope)
            })
            $schemaSkips = @($matching | Where-Object { $_.schemaVersion -ne 1 }).Count
            $ordered = @($matching | Where-Object { $_.schemaVersion -eq 1 } |
                Sort-Object { [datetime]"$($_.generatedAt)" } -Descending)
            foreach ($entry in $ordered) {
                $path = Join-Path $root "$($entry.file)"
                if (-not (Test-Path $path)) { continue }
                try {
                    $rec = Get-Content -Path $path -Raw | ConvertFrom-Json
                } catch {
                    Write-Verbose "RunHistory: indexed record $($entry.file) unreadable, trying next: $_"
                    continue
                }
                # Re-verify against the record itself; the index is only a locator.
                if ($rec.schemaVersion -ne 1) { continue }
                if ("$($rec.scope.targetHash)" -ne $TargetHash) { continue }
                if ((@($rec.scope.platforms | Sort-Object) -join ',') -ne $wanted) { continue }
                if ((& $recordOuScope $rec.scope) -ne $wantedOuScope) { continue }
                & $warnSchemaSkips $schemaSkips
                return $rec
            }
            & $warnSchemaSkips $schemaSkips
            return $null
        }
        Write-Verbose 'RunHistory: index entry count disagrees with the records on disk; falling back to a full scan.'
    }

    # --- Fallback: scan every record (no usable index). ---
    $best = $null
    $schemaSkips = 0
    foreach ($file in (Get-ChildItem -Path $root -Filter 'run-*.json' -File -ErrorAction SilentlyContinue)) {
        try {
            $rec = Get-Content -Path $file.FullName -Raw | ConvertFrom-Json
        } catch {
            Write-Verbose "RunHistory: skipping unreadable record $($file.Name): $_"
            continue
        }
        if ("$($rec.scope.targetHash)" -ne $TargetHash) { continue }
        if ((@($rec.scope.platforms | Sort-Object) -join ',') -ne $wanted) { continue }
        if ((& $recordOuScope $rec.scope) -ne $wantedOuScope) { continue }
        if ($rec.schemaVersion -ne 1) { $schemaSkips++; continue }
        if ($null -eq $best -or ([datetime]$rec.generatedAt) -gt ([datetime]$best.generatedAt)) { $best = $rec }
    }
    & $warnSchemaSkips $schemaSkips
    return $best
}