Public/Test-LedgerArchive.ps1

<#
.SYNOPSIS
Verifies an archive package created by Export-LedgerArchive.

.DESCRIPTION
Checks a package (zip file or directory) against its checksums.txt and, when the
package recorded a chain hash, checks the archived fiscal year's integrity chain
against that hash with Test-LedgerIntegrity.

A zip file is extracted to a temporary directory, which is removed afterwards.

The result has Status 'Valid' or 'Invalid', and Issues with Path, Problem and Detail.
Problem is one of:
- Missing - a file listed in checksums.txt is not in the package.
- Modified - a file's SHA-256 differs from checksums.txt.
- Unlisted - a file in the package is not listed in checksums.txt.
- Malformed - checksums.txt or manifest.txt is missing or a line cannot be read.
- Integrity - the archived fiscal year fails Test-LedgerIntegrity (Detail has the
              verification or attachment and the problem).

.PARAMETER Path
The archive package: a .zip file or the package directory (the one containing
checksums.txt). Accepts pipeline input, for example from Export-LedgerArchive.

.EXAMPLE
Test-LedgerArchive -Path .\archive\MinFirma_2024-01_2024-12_archive.zip

Verifies Min Firma AB's archive of 2024.

.EXAMPLE
Get-ChildItem E:\Arkiv -Filter '*_archive.zip' | Test-LedgerArchive |
    Where-Object { -not $_.IsValid } | Select-Object -ExpandProperty Issues

Checks every archived year of Konsult AB on an external disk and lists the problems.
#>

function Test-LedgerArchive {
    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('FullName')]
        [string]$Path
    )
    process {
        $full = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($Path)
        if (-not (Test-Path -LiteralPath $full)) {
            throw "Archive not found: $Path"
        }

        $temp = $null
        try {
            if (Test-Path -LiteralPath $full -PathType Leaf) {
                $temp = Join-Path ([System.IO.Path]::GetTempPath()) "psledger_archive_$([guid]::NewGuid().ToString('N'))"
                [System.IO.Compression.ZipFile]::ExtractToDirectory($full, $temp)
                $root = $temp
                $children = @(Get-ChildItem -LiteralPath $temp -Force)
                if (-not (Test-Path -LiteralPath (Join-Path $temp $script:LedgerArchiveChecksumFile)) -and
                    $children.Count -eq 1 -and $children[0].PSIsContainer) {
                    $root = $children[0].FullName
                }
            }
            else {
                $root = $full
            }

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

            $listed = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
            $checksumPath = Join-Path $root $script:LedgerArchiveChecksumFile
            if (-not (Test-Path -LiteralPath $checksumPath -PathType Leaf)) {
                & $addIssue $script:LedgerArchiveChecksumFile 'Malformed' 'checksums.txt is missing; this is not an archive package.'
            }
            else {
                $lineNumber = 0
                foreach ($line in [System.IO.File]::ReadAllLines($checksumPath)) {
                    $lineNumber++
                    if (-not $line) { continue }
                    if ($line -notmatch '^([0-9a-f]{64}) (.+)$') {
                        & $addIssue $script:LedgerArchiveChecksumFile 'Malformed' "Cannot read checksums.txt line $lineNumber."
                        continue
                    }
                    $hash = $Matches[1]
                    $rel = $Matches[2]
                    [void]$listed.Add($rel)
                    $file = Join-Path $root $rel
                    if ($rel -match '(^|/)\.\.(/|$)' -or -not (Test-Path -LiteralPath $file -PathType Leaf)) {
                        & $addIssue $rel 'Missing' "$rel is listed in checksums.txt but not in the package."
                    }
                    elseif ((Get-LedgerArchiveFileHash -Path $file) -ne $hash) {
                        & $addIssue $rel 'Modified' "$rel differs from its checksum."
                    }
                }
                foreach ($rel in (Get-LedgerArchiveRelativeFile -Root $root)) {
                    if ($rel -ne $script:LedgerArchiveChecksumFile -and -not $listed.Contains($rel)) {
                        & $addIssue $rel 'Unlisted' "$rel is not listed in checksums.txt."
                    }
                }
            }

            $manifest = @{}
            $manifestPath = Join-Path $root $script:LedgerArchiveManifestFile
            if (Test-Path -LiteralPath $manifestPath -PathType Leaf) {
                $manifest = Read-LedgerArchiveManifest -Path $manifestPath
            }
            if (-not $manifest.FiscalYear) {
                & $addIssue $script:LedgerArchiveManifestFile 'Malformed' 'manifest.txt is missing or has no FiscalYear.'
            }

            $integrityStatus = $null
            $archJournal = Join-Path $root 'journal'
            if ($manifest.FiscalYear -and (Test-Path -LiteralPath (Join-Path $archJournal $manifest.FiscalYear) -PathType Container)) {
                $params = @{ JournalPath = $archJournal; FiscalYear = $manifest.FiscalYear }
                if ($manifest.ChainHash) { $params.ExpectedHash = $manifest.ChainHash }
                $integrity = Test-LedgerIntegrity @params -WarningAction SilentlyContinue
                $integrityStatus = $integrity.Status
                foreach ($i in $integrity.Issues) {
                    & $addIssue "journal/$($manifest.FiscalYear)" 'Integrity' "$($i.Kind) $($i.Key): $($i.Problem). $($i.Detail)"
                }
            }

            $status = if ($issues.Count -eq 0) { 'Valid' } else { 'Invalid' }
            [pscustomobject]@{
                Path            = $full
                Journal         = $manifest.Journal
                FiscalYear      = $manifest.FiscalYear
                Preliminary     = $manifest.Preliminary -eq 'True'
                Created         = $manifest.Created
                Status          = $status
                IsValid         = $status -eq 'Valid'
                Files           = $listed.Count
                IntegrityStatus = $integrityStatus
                ChainHash       = $manifest.ChainHash
                Issues          = $issues.ToArray()
            }
        }
        finally {
            if ($temp -and (Test-Path -LiteralPath $temp)) { Remove-Item -LiteralPath $temp -Recurse -Force }
        }
    }
}