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 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.

        Anti-fork guard (same principle as the dev-side ledger): a RunHistory
        directory that contains run records but no valid index.json is not
        ours to write into; refusing beats silently starting a second history
        next to an existing one. A genuinely empty or absent directory is
        initialized with an index.

        Writes are atomic (temp file + rename) so a crash mid-write cannot
        leave a truncated record that later parses as a baseline.
    #>

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

    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 (Test-Path $root) {
        $existingRuns = @(Get-ChildItem -Path $root -Filter 'run-*.json' -File -ErrorAction SilentlyContinue)
        if ($existingRuns.Count -gt 0) {
            $indexOk = $false
            if (Test-Path $indexPath) {
                try {
                    $idx = Get-Content -Path $indexPath -Raw | ConvertFrom-Json
                    if ($idx.schemaVersion -eq 1 -and $idx.store -eq 'guerrilla-run-history') { $indexOk = $true }
                } catch { }
            }
            if (-not $indexOk) {
                throw ("RunHistory at '$root' contains run records but no valid index.json. " +
                    'Refusing to write: this directory is not a recognized Guerrilla run history, and ' +
                    'silently starting a second history next to an existing one would fork it. ' +
                    'Move or repair the directory before the next run.')
            }
        }
    } else {
        New-Item -ItemType Directory -Path $root -Force | Out-Null
    }

    if (-not (Test-Path $indexPath)) {
        [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.'
        } | ConvertTo-Json | Set-Content -Path $indexPath -Encoding utf8
    }

    $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

    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)
        and the same platform set. An AD-only run is never diffed against a
        full campaign; the checks that "vanished" 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.
    #>

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

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

    $wanted = @($Platforms | Sort-Object) -join ','
    $best = $null
    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.schemaVersion -ne 1) { continue }
        if ("$($rec.scope.targetHash)" -ne $TargetHash) { continue }
        if ((@($rec.scope.platforms | Sort-Object) -join ',') -ne $wanted) { continue }
        if ($null -eq $best -or ([datetime]$rec.generatedAt) -gt ([datetime]$best.generatedAt)) { $best = $rec }
    }
    return $best
}