Private/Assert-HDTOperatingSystemDocument.ps1

function Assert-HDTOperatingSystemDocument {
    <#
        .SYNOPSIS
            Validates a parsed os.yaml against the authoring
            rules.
 
        .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/os.schema.json is a gate for the console, editors and CI
            while this is the gate for a deployment.
 
            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 authoring rules, in the order they are checked:
 
              document not empty; a mapping; only the ten known keys;
                         schemaVersion present, an integer, and not newer than
                         this engine (delegated to Test-HDTSchemaVersion)
              identity id present and matching ^[A-Za-z0-9][A-Za-z0-9_.-]*$;
                         name present; type wim or ffu; architecture, if given,
                         one of x86, x64, arm64; sourcePath present
              images present, a list, not empty; each a mapping with only the
                         six known keys, a positive integer index no other image
                         carries, and a non-empty name
              default defaultIndex, if given, names an index that exists
 
            TWO OF THOSE CLOSE A SCHEMA BLIND SPOT rather than duplicating it.
            JSON Schema draft-07 has no cross-field reference, so it cannot check
            defaultIndex against the images array; and uniqueItems compares WHOLE
            items, so it cannot express "no two images share an index" unless the
            duplicated entries are identical in every field. Both are listed in
            tests/contract/OsSchema.Contract.Tests.ps1 with a fixture each.
 
            THE ID IS PATTERN-CHECKED BECAUSE IT BECOMES A FOLDER NAME. It is the
            ChildPath of Get-HDTWorkspacePath -Kind OperatingSystems, so a
            separator or a '..' in it is a directory traversal into the share.
 
        .PARAMETER Document
            The parsed document, as returned by ConvertFrom-HDTYaml. $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-HDTOperatingSystemDocument -Document (ConvertFrom-HDTYaml -Yaml $text -Path $path) -Path $path
 
        .NOTES
            The locator in a message is the IMAGE INDEX or the KEY, not a line
            number: the YAML parser does not carry line information onto the
            object graph it returns, so after parsing there is no honest line to
            report. Only ConvertFrom-HDTYaml, which still holds the parser's own
            exception, can name a line.
    #>

    [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
    $allowedRootKey = @('schemaVersion', 'id', 'name', 'description', 'folder', 'type',
        'architecture', 'sourcePath', 'importedUtc', 'defaultIndex', 'images')
    $allowedImageKey = @('index', 'name', 'description', 'edition', 'sizeBytes', 'version')
    $allowedType = @('wim', 'ffu')
    $allowedArchitecture = @('x86', 'x64', 'arm64')

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

    if ($null -eq $Document) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message 'the file is empty. An operating system document must declare schemaVersion, id, name, type, sourcePath and at least one image.'))
    }

    if (-not ($Document -is [System.Collections.IDictionary])) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message ("the document must be a mapping with schemaVersion, id, name, type, sourcePath and images keys, but it is a {0}." -f $Document.GetType().Name)))
    }

    foreach ($key in @($Document.Keys)) {
        if ($allowedRootKey -notcontains [string] $key) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("'{0}' is not a key an operating system document may declare. The allowed keys are {1}." -f $key, ($allowedRootKey -join ', '))))
        }
    }

    if (-not $Document.Contains('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 (($schemaVersion -is [int]) -or ($schemaVersion -is [long]))) {
        $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 workspace." -f $schemaVersion, $supportedSchemaVersion)))
    }

    # -- the identity ---------------------------------------------------------

    $id = ''
    if ($Document.Contains('id')) { $id = [string] $Document['id'] }

    if ([string]::IsNullOrWhiteSpace($id)) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message 'id is missing. The id is the folder name under OperatingSystems\ and the key a sequence names, so a catalog entry without one cannot be referred to.'))
    }

    if ($id -notmatch '^[A-Za-z0-9][A-Za-z0-9_.-]*$') {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message ("id '{0}' is not a legal operating system id. It becomes a folder name under OperatingSystems\, so it must start with a letter or a digit and hold only letters, digits, underscore, dot and hyphen." -f $id)))
    }

    $name = ''
    if ($Document.Contains('name')) { $name = [string] $Document['name'] }

    if ([string]::IsNullOrWhiteSpace($name)) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message 'name is missing. The name is what an administrator reads in the console and in a log line.'))
    }

    $type = ''
    if ($Document.Contains('type')) { $type = [string] $Document['type'] }

    if ($allowedType -notcontains $type) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message ("type '{0}' is not an image type HDT applies. The types are {1}: a wim is applied with DISM /Apply-Image and an ffu with DISM /Apply-Ffu, and there is no third apply path." -f $type, ($allowedType -join ', '))))
    }

    if ($Document.Contains('architecture')) {
        $architecture = [string] $Document['architecture']

        if ($allowedArchitecture -notcontains $architecture) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("architecture '{0}' is not an architecture HDT deploys. The architectures are {1}." -f $architecture, ($allowedArchitecture -join ', '))))
        }
    }

    $sourcePath = ''
    if ($Document.Contains('sourcePath')) { $sourcePath = [string] $Document['sourcePath'] }

    if ([string]::IsNullOrWhiteSpace($sourcePath)) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message 'sourcePath is missing. It names the image file, relative to the operating system folder unless it is rooted.'))
    }

    # -- the images -----------------------------------------------------------

    if (-not $Document.Contains('images')) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message 'the images key is missing. An operating system declares the indices its image file carries, read from the image rather than typed by hand.'))
    }

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

    if (@($image).Count -eq 0) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message 'the images list is empty. An operating system with no image is an operating system nothing can apply.'))
    }

    $seenIndex = New-Object -TypeName System.Collections.ArrayList
    $position = 0

    foreach ($current in @($image)) {
        $position++
        $locator = 'image {0}' -f $position

        if (-not ($current -is [System.Collections.IDictionary])) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("{0}: an image must be a mapping with an index and a name." -f $locator)))
        }

        foreach ($key in @($current.Keys)) {
            if ($allowedImageKey -notcontains [string] $key) {
                $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                            -Message ("{0}: '{1}' is not a key an image may declare. The allowed keys are {2}." -f $locator, $key, ($allowedImageKey -join ', '))))
            }
        }

        if (-not $current.Contains('index')) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("{0}: index is missing. An image is applied by its index, so every image declares one." -f $locator)))
        }

        $index = $current['index']
        if (-not (($index -is [int]) -or ($index -is [long]))) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("{0}: index must be an integer, but it is '{1}'." -f $locator, $index)))
        }

        if ([int] $index -lt 1) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("{0}: index {1} is not a valid image index. Image indices are 1-based; index 0 names no image." -f $locator, $index)))
        }

        if ($seenIndex -contains [int] $index) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("{0}: two images declare index {1}, so naming that index does not identify one of them. Image indices must be unique." -f $locator, $index)))
        }
        [void] $seenIndex.Add([int] $index)

        $imageName = ''
        if ($current.Contains('name')) { $imageName = [string] $current['name'] }

        if ([string]::IsNullOrWhiteSpace($imageName)) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("{0}: name is missing. An image is selectable by name, so every image declares one." -f $locator)))
        }
    }

    # -- the default ----------------------------------------------------------

    if ($Document.Contains('defaultIndex')) {
        $defaultIndex = $Document['defaultIndex']

        if (-not (($defaultIndex -is [int]) -or ($defaultIndex -is [long]))) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("defaultIndex must be an integer, but it is '{0}'." -f $defaultIndex)))
        }

        if ($seenIndex -notcontains [int] $defaultIndex) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("defaultIndex {0} names an index no image carries. The indices this image file declares are {1}." -f $defaultIndex, (@($seenIndex) -join ', '))))
        }
    }
}