Public/Test-LedgerIntegrity.ps1

<#
.SYNOPSIS
Checks that no sealed verification or attachment has been changed (tamper detection).

.DESCRIPTION
Every verification and attachment written by PSLedger is sealed in the fiscal
year's integrity.txt, a hash chain where each record holds the SHA-256 of the item
and a chain hash over the previous record. Test-LedgerIntegrity recomputes the
chain and the hashes and returns one result per fiscal year. Nothing is modified.

The result's Status is:
- Valid - the chain is intact and matches every verification and attachment.
- Invalid - at least one issue was found (see Issues).
- Unsealed - the year has verifications but no chain yet (a journal created
             before tamper detection). Seal it with Protect-LedgerFiscalYear.

Each issue has Kind (Verification, Attachment or Chain), Key (the verification
number, or number/file name), Problem and Detail. Problems:
- Modified - the item differs from the sealed hash.
- Missing - a sealed item has been deleted. Attachments removed with
                Remove-LedgerAttachment are logged in the chain and not reported.
- Unsealed - an item exists that the chain does not cover, e.g. a file added by
                hand or by another program.
- ChainBroken - a chain record has been edited, removed or re-ordered.
- Malformed - a chain line cannot be read.
- Duplicate - a verification is sealed more than once.
- HashMismatch - the final chain hash differs from -ExpectedHash.

Anyone who can edit the files can also rebuild the whole chain, which this check
cannot detect on its own. Save the ChainHash somewhere else (Close-LedgerFiscalYear
returns it) and pass it as -ExpectedHash to detect that as well.

.PARAMETER JournalPath
The path to an existing journal directory. If omitted, uses the current journal.

.PARAMETER FiscalYear
The fiscal year to check. If omitted, every fiscal year is checked.
Accepts pipeline input from fiscal year objects.

.PARAMETER ExpectedHash
A chain hash saved earlier (for example when the year was closed). An issue is
reported if the year's current final chain hash differs. Requires -FiscalYear.

.EXAMPLE
Test-LedgerIntegrity -JournalPath .\MinFirma.ledger

Checks every fiscal year of Min Firma AB and returns one result per year.

.EXAMPLE
$result = Test-LedgerIntegrity -JournalPath .\Konsult.ledger -FiscalYear '2024-01_2024-12' `
    -ExpectedHash '3f5c0e...'
if (-not $result.IsValid) { $result.Issues | Format-Table }

Checks 2024 against the chain hash saved when the year was closed and lists any
verification (for example 1930 Företagskonto postings) that has been altered.
#>

function Test-LedgerIntegrity {
    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param (
        [Parameter()]
        [string]$JournalPath,

        [Parameter(ValueFromPipelineByPropertyName)]
        [Alias('Name')]
        [string]$FiscalYear,

        [Parameter()]
        [ValidatePattern('^[0-9a-fA-F]{64}$')]
        [string]$ExpectedHash
    )
    process {
        $JournalPath = Resolve-LedgerJournalPath -JournalPath $JournalPath -SchemaCheck Read

        if ($ExpectedHash -and -not $FiscalYear) {
            throw '-ExpectedHash requires -FiscalYear.'
        }

        $years = if ($FiscalYear) {
            @(Resolve-LedgerFiscalYear -FiscalYear $FiscalYear -JournalPath $JournalPath)
        }
        else {
            @(Get-LedgerFiscalYear -JournalPath $JournalPath | ForEach-Object Name)
        }

        foreach ($year in $years) {
            $yearDir = Join-Path $JournalPath $year
            if (-not (Test-Path -LiteralPath $yearDir -PathType Container)) {
                throw "Fiscal year not found: $year"
            }

            $issues = [System.Collections.Generic.List[object]]::new()
            $addIssue = {
                param($Kind, $Key, $Problem, $Detail)
                $issues.Add([pscustomobject]@{ Kind = $Kind; Key = $Key; Problem = $Problem; Detail = $Detail })
            }

            $chain = Read-LedgerIntegrityChain -YearDir $yearDir
            $content = Get-LedgerYearContent -YearDir $yearDir

            if ($null -eq $chain) {
                $status = if ($content.Verifications.Count -gt 0) { 'Unsealed' } else { 'Valid' }
                if ($ExpectedHash) {
                    & $addIssue 'Chain' '' 'HashMismatch' "The year has no integrity chain; expected chain hash $ExpectedHash."
                    $status = 'Invalid'
                }
                [pscustomobject]@{
                    FiscalYear    = $year
                    Status        = $status
                    IsValid       = $status -eq 'Valid'
                    Verifications = 0
                    Attachments   = 0
                    ChainHash     = $null
                    Issues        = $issues.ToArray()
                }
                continue
            }

            # Verify every chain hash against the previous well-formed record.
            $previous = $script:LedgerIntegrityGenesis
            $sealedVerifications = @{}
            foreach ($r in $chain) {
                if (-not $r.WellFormed) {
                    & $addIssue 'Chain' "line $($r.LineNumber)" 'Malformed' "Cannot read integrity.txt line $($r.LineNumber)."
                    continue
                }
                $expected = Get-LedgerIntegrityChainHash -Previous $previous -Kind $r.Kind -Key $r.Key -Sealed $r.Sealed -Hash $r.Hash
                if ($expected -ne $r.ChainHash) {
                    & $addIssue 'Chain' "line $($r.LineNumber)" 'ChainBroken' "The chain hash on integrity.txt line $($r.LineNumber) ($($r.Kind) $($r.Key)) does not match; a record has been edited, removed or re-ordered."
                }
                if ($r.Kind -eq 'Verification') {
                    if ($sealedVerifications.ContainsKey($r.Key)) {
                        & $addIssue 'Verification' $r.Key 'Duplicate' "Verification $($r.Key) is sealed more than once."
                    }
                    $sealedVerifications[$r.Key] = $true
                }
                $previous = $r.ChainHash
            }

            $state = Get-LedgerIntegrityState -Chain $chain
            $files = @{}
            foreach ($v in $content.Verifications) { $files["V:$($v.Key)"] = $v.Path }
            foreach ($a in $content.Attachments) { $files["A:$($a.Key)"] = $a.Path }

            foreach ($key in $state.Verifications.Keys) {
                $path = $files["V:$key"]
                if (-not $path) {
                    & $addIssue 'Verification' $key 'Missing' "Sealed verification $key has been deleted."
                }
                elseif ((Get-LedgerContentHash -Path $path) -ne $state.Verifications[$key]) {
                    & $addIssue 'Verification' $key 'Modified' "Verification $key has been changed since it was sealed."
                }
            }
            foreach ($key in $state.Attachments.Keys) {
                $path = $files["A:$key"]
                if (-not $path) {
                    & $addIssue 'Attachment' $key 'Missing' "Sealed attachment $key has been deleted without Remove-LedgerAttachment."
                }
                elseif ((Get-LedgerContentHash -Path $path) -ne $state.Attachments[$key]) {
                    & $addIssue 'Attachment' $key 'Modified' "Attachment $key has been changed since it was sealed."
                }
            }
            foreach ($v in $content.Verifications) {
                if (-not $state.Verifications.Contains($v.Key)) {
                    & $addIssue 'Verification' $v.Key 'Unsealed' "Verification $($v.Key) is not in the integrity chain. Run Protect-LedgerFiscalYear if it is legitimate."
                }
            }
            foreach ($a in $content.Attachments) {
                if (-not $state.Attachments.Contains($a.Key)) {
                    & $addIssue 'Attachment' $a.Key 'Unsealed' "Attachment $($a.Key) is not in the integrity chain. Run Protect-LedgerFiscalYear if it is legitimate."
                }
            }

            $head = Get-LedgerIntegrityHead -Chain $chain
            if ($ExpectedHash -and $head -ne $ExpectedHash.ToLowerInvariant()) {
                & $addIssue 'Chain' '' 'HashMismatch' "The final chain hash is $head, expected $($ExpectedHash.ToLowerInvariant())."
            }

            $status = if ($issues.Count -gt 0) { 'Invalid' } else { 'Valid' }
            [pscustomobject]@{
                FiscalYear    = $year
                Status        = $status
                IsValid       = $status -eq 'Valid'
                Verifications = $state.Verifications.Count
                Attachments   = $state.Attachments.Count
                ChainHash     = $head
                Issues        = $issues.ToArray()
            }
        }
    }
}