Private/Assert-HDTRunStateDocument.ps1

function Assert-HDTRunStateDocument {
    <#
        .SYNOPSIS
            Validates a parsed state.json against the state document contract.
 
        .DESCRIPTION
            The engine's own validator, and the one that actually runs in WinPE:
            Test-Json does not exist under Windows PowerShell 5.1, so
            schemas/state.schema.json is a gate for the console and CI while this
            is the gate for a deployment. The two must agree on every document -
            a state file a console calls valid and the engine rejects would fail
            on the machine that has already been wiped.
 
            It throws on the first violation and returns nothing otherwise. Every
            failure is a terminating error built by New-HDTErrorRecord, so it
            names the file, carries the file as its TargetObject and reports
            HDTConfigurationError - "fail fast and point at the
            file".
 
            The rules, in the order they are checked:
 
              document not null; an object; schemaVersion present, an integer,
                        and not newer than this engine
              identity runId and sequenceId present and non-empty
              lifecycle status in Running/Succeeded/Failed; phase in
                        WinPE/FullOS; leg an integer >= 1; seq an integer >= 0;
                        stepIndex an integer >= 1; pauseOnError a boolean
              stamps startedUtc and updatedUtc present, and STRINGS - a raw
                        datetime here would mean somebody serialised one, which
                        renders as \/Date(...)\/ under 5.1
              variable present and an object
              step present and a list; every step has an integer index >= 1,
                        a non-empty name and type, a status in the set, an
                        integer attempt >= 0 and a boolean resumable
              autoLogon present, with a boolean armed
 
            The document arrives from ConvertFrom-Json, so it is a
            PSCustomObject tree rather than a dictionary, and every presence
            check goes through PSObject.Properties. That also keeps the function
            correct under Set-StrictMode -Version Latest, where reading an absent
            property throws instead of returning null.
 
        .PARAMETER Document
            The parsed document. $null is accepted and reported as an empty file
            rather than crashing.
 
        .PARAMETER Path
            The file the document came from. Used for the message and the
            TargetObject; this function reads nothing.
 
        .OUTPUTS
            None. It throws or it returns nothing.
 
        .EXAMPLE
            Assert-HDTRunStateDocument -Document (ConvertFrom-Json -InputObject $text) -Path $path
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [AllowNull()]
        [object] $Document,

        [Parameter(Mandatory = $true, Position = 1)]
        [ValidateNotNullOrEmpty()]
        [string] $Path
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    $supportedSchemaVersion = 1

    # ConvertFrom-Json hands back a PSCustomObject tree; presence is a property
    # question, not a key question, and it must be asked before the read.
    $hasProperty = {
        param($Object, [string] $Name)

        if ($null -eq $Object) {
            return $false
        }

        return (@($Object.PSObject.Properties | ForEach-Object { $_.Name }) -contains $Name)
    }

    $isInteger = {
        param($Value)

        return (($Value -is [int]) -or ($Value -is [long]) -or ($Value -is [int16]) -or ($Value -is [byte]))
    }

    # -- the document ---------------------------------------------------------

    if ($null -eq $Document) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message 'the file is empty. A state document must declare schemaVersion, runId and a step list.'))
    }

    if (($Document -is [string]) -or ($Document -is [System.Collections.IList])) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message ("the document must be a JSON object, but it is a {0}." -f $Document.GetType().Name)))
    }

    if (-not (& $hasProperty $Document 'schemaVersion')) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message 'schemaVersion is missing. Every HDT document declares one; this engine understands schemaVersion 1.'))
    }

    $schemaVersion = $Document.schemaVersion
    if (-not (& $isInteger $schemaVersion)) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message ("schemaVersion must be an integer, but it is '{0}'." -f $schemaVersion)))
    }

    $supported = $false
    try {
        $supported = Test-HDTSchemaVersion -SchemaVersion ([int] $schemaVersion) -Supported $supportedSchemaVersion
    } catch {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message ("schemaVersion {0} is not a valid schema version. It must be 1 or greater." -f $schemaVersion)))
    }

    if (-not $supported) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message ("schemaVersion {0} is newer than this engine understands (schemaVersion {1}). Upgrade the engine rather than the state document." -f $schemaVersion, $supportedSchemaVersion)))
    }

    # -- identity and lifecycle -----------------------------------------------

    foreach ($name in @('runId', 'sequenceId')) {
        if (-not (& $hasProperty $Document $name)) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("{0} is missing. A state document records it for every run." -f $name)))
        }

        $value = $Document.$name
        if (-not ($value -is [string]) -or [string]::IsNullOrWhiteSpace([string] $value)) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("{0} must be a non-empty string." -f $name)))
        }
    }

    # A timestamp is WRITTEN as a round-trip string, and the schema requires one.
    # It is READ back as either: ConvertFrom-Json under pwsh 7 rehydrates an
    # ISO 8601 string into a [datetime], and under Windows PowerShell 5.1 it does
    # not. Both are the same instant, so both are accepted here - rejecting the
    # [datetime] would make this validator disagree with the schema on exactly the
    # documents the schema accepts, which is the one thing it must never do.
    foreach ($name in @('startedUtc', 'updatedUtc')) {
        if (-not (& $hasProperty $Document $name)) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("{0} is missing. A state document stamps it for every run." -f $name)))
        }

        $value = $Document.$name
        $isStamp = ($value -is [datetime]) -or (($value -is [string]) -and -not [string]::IsNullOrWhiteSpace([string] $value))

        if (-not $isStamp) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("{0} must be a non-empty timestamp, written in the round-trip 'o' format." -f $name)))
        }
    }

    $enumeration = @{ status = @('Running', 'Succeeded', 'Failed'); phase = @('WinPE', 'FullOS') }
    foreach ($name in @('status', 'phase')) {
        if (-not (& $hasProperty $Document $name)) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("{0} is missing. It must be one of {1}." -f $name, ($enumeration[$name] -join ', '))))
        }

        if ($enumeration[$name] -notcontains [string] $Document.$name) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("{0} is '{1}', which is not one of {2}." -f $name, $Document.$name, ($enumeration[$name] -join ', '))))
        }
    }

    $minimum = @{ leg = 1; seq = 0; stepIndex = 1 }
    foreach ($name in @('leg', 'seq', 'stepIndex')) {
        if (-not (& $hasProperty $Document $name)) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("{0} is missing. A state document records it for every run." -f $name)))
        }

        $value = $Document.$name
        if (-not (& $isInteger $value) -or ([long] $value -lt $minimum[$name])) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("{0} must be an integer of at least {1}, but it is '{2}'." -f $name, $minimum[$name], $value)))
        }
    }

    if (-not (& $hasProperty $Document 'pauseOnError')) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message 'pauseOnError is missing. It records whether a failure drops to a prompt.'))
    }

    if (-not ($Document.pauseOnError -is [bool])) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message ("pauseOnError must be a boolean, but it is '{0}'." -f $Document.pauseOnError)))
    }

    # -- variable -------------------------------------------------------------

    if (-not (& $hasProperty $Document 'variable')) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message 'variable is missing. A state document carries the resolved variables, even when there are none.'))
    }

    if ($null -eq $Document.variable -or ($Document.variable -is [System.Collections.IList])) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message 'variable must be an object mapping variable name to value.'))
    }

    # -- step -----------------------------------------------------------------

    if (-not (& $hasProperty $Document 'step')) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message 'step is missing. A state document carries one record per flattened step.'))
    }

    if (-not ($Document.step -is [System.Collections.IList])) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message 'step must be a list of step records.'))
    }

    $stepStatus = @('Pending', 'Running', 'Completed', 'Failed', 'Skipped')
    $position = 0

    foreach ($step in @($Document.step)) {
        $position++
        $locator = 'step {0}' -f $position

        if ($null -eq $step -or ($step -is [string]) -or ($step -is [System.Collections.IList])) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("{0}: a step record must be an object." -f $locator)))
        }

        if (-not (& $hasProperty $step 'index')) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("{0}: index is missing. The index is how a resume finds the next step to run." -f $locator)))
        }

        if (-not (& $isInteger $step.index) -or ([long] $step.index -lt 1)) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("{0}: index must be a 1-based integer, but it is '{1}'." -f $locator, $step.index)))
        }

        $locator = "step {0} (index {1})" -f $position, $step.index

        foreach ($name in @('name', 'type')) {
            if (-not (& $hasProperty $step $name)) {
                $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                            -Message ("{0}: {1} is missing." -f $locator, $name)))
            }

            if (-not ($step.$name -is [string]) -or [string]::IsNullOrWhiteSpace([string] $step.$name)) {
                $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                            -Message ("{0}: {1} must be a non-empty string." -f $locator, $name)))
            }
        }

        if (-not (& $hasProperty $step 'status')) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("{0}: status is missing. It must be one of {1}." -f $locator, ($stepStatus -join ', '))))
        }

        if ($stepStatus -notcontains [string] $step.status) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("{0}: status is '{1}', which is not one of {2}." -f $locator, $step.status, ($stepStatus -join ', '))))
        }

        if (-not (& $hasProperty $step 'attempt')) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("{0}: attempt is missing. It counts how many times the step has been started." -f $locator)))
        }

        if (-not (& $isInteger $step.attempt) -or ([long] $step.attempt -lt 0)) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("{0}: attempt must be an integer of at least 0, but it is '{1}'." -f $locator, $step.attempt)))
        }

        if (-not (& $hasProperty $step 'resumable')) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("{0}: resumable is missing. An interrupted step is re-run only if it declares one." -f $locator)))
        }

        if (-not ($step.resumable -is [bool])) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("{0}: resumable must be a boolean, but it is '{1}'." -f $locator, $step.resumable)))
        }
    }

    # -- autoLogon ------------------------------------------------------------

    if (-not (& $hasProperty $Document 'autoLogon')) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message 'autoLogon is missing. The boot reconcile tears down exactly what this block records was armed.'))
    }

    if (-not (& $hasProperty $Document.autoLogon 'armed')) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message 'autoLogon.armed is missing. Without it the boot reconcile cannot tell an armed machine from a torn-down one.'))
    }

    if (-not ($Document.autoLogon.armed -is [bool])) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message ("autoLogon.armed must be a boolean, but it is '{0}'." -f $Document.autoLogon.armed)))
    }
}