Private/Assert-HDTWizardDocument.ps1
|
function Assert-HDTWizardDocument { <# .SYNOPSIS Validates Scripts\UI\wizard.yaml - the pages the technician wizard asks, and in what order. .DESCRIPTION MDT'S DeployWiz_Definition_ENU.xml, IN HDT'S YAML. MDT lists one <Pane id= reference= /> per screen with a <Condition> deciding whether it appears, and PSD's Classic_Theme_Definitions_en-US.xml lists the same panes the same way. This document is that file, and the condition is reduced to the thing every MDT condition actually tests: a Skip variable. A SHARE WITH NO wizard.yaml HAS NO WIZARD, and that is the property the whole design hangs off. Every image built before this existed deploys with nobody present, exactly as it did; a site turns the wizard on by authoring pages, not by remembering to turn it off. So the document being ABSENT is never an error - only a document that is present and wrong. IT IS AUTHORED CONTENT ON A SHARE, AND IS VALIDATED LIKE ONE. The reference names markup relative to Scripts\UI: never rooted, never climbing out with '..', and always .xaml. A definition file that could name C:\Windows\... or a .ps1 would be a file on a share that reads - or runs - whatever it likes on every machine that deploys. A RULE OR A SPLITTER THIS ENGINE DOES NOT IMPLEMENT IS REFUSED, not ignored. A control that silently never validates looks, on a bench, like a wizard that accepts anything - and the value on the other side of it is a machine's identity. EVERY VARIABLE IT SETS IS HDT-PREFIXED, because everything the resolution engine carries is (DESIGN 3), and a definition on a share must not be able to set anything else. .PARAMETER Document The parsed document. .PARAMETER Path Where it came from, named in every message. .INPUTS None. This command does not accept pipeline input. .OUTPUTS None. It throws on the first problem. .EXAMPLE Assert-HDTWizardDocument -Document $document -Path 'C:\Share\Scripts\UI\wizard.yaml' #> # $Path IS used - by the $fail closure below, which names it in every # message. The analyzer does not follow a parameter into a closure. [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Used inside the $fail closure, which PSReviewUnusedParameter does not follow.')] [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' # KEPT IN STEP WITH schemas/wizard.schema.json BY A CONTRACT TEST, which # compares the schema's own "required" array with this list. $requiredRootKey = @('schemaVersion', 'pages') $knownRootKey = @('schemaVersion', 'title', 'pages') $knownPageKey = @('id', 'title', 'heading', 'subheading', 'reference', 'skip', 'validate', 'summary', 'collect') $knownCollectKey = @('control', 'variable', 'property', 'isSecret', 'optional', 'split', 'splitVariable', 'splitDefaultFrom') $knownProperty = @('Text', 'SelectedValue', 'Password', 'IsChecked') $knownRule = @('ComputerName') $knownSplit = @('AccountName') $fail = { param([string] $Message) throw (New-HDTErrorRecord -Path $Path -Category InvalidData -Message $Message) } if ($null -eq $Document) { & $fail 'the wizard definition is empty. It declares schemaVersion and pages.' } if (-not ($Document -is [System.Collections.IDictionary])) { & $fail 'the wizard definition is not a mapping. It declares schemaVersion and pages.' } foreach ($key in $requiredRootKey) { if (-not $Document.Contains($key)) { & $fail ("{0} is missing. A wizard definition declares {1}." -f $key, ($requiredRootKey -join ', ')) } } foreach ($key in @($Document.Keys)) { if ($knownRootKey -notcontains $key) { & $fail ("'{0}' is not a key a wizard definition carries. The keys are {1}." -f $key, ($knownRootKey -join ', ')) } } # THE SAME GATE EVERY OTHER DOCUMENT USES, so a version this engine cannot # read is refused the same way and with the same advice. $supportedSchemaVersion = 1 $schemaVersion = $Document['schemaVersion'] if (-not (($schemaVersion -is [int]) -or ($schemaVersion -is [long]))) { & $fail ("schemaVersion must be an integer, but it is '{0}'." -f $schemaVersion) } $supported = $false try { $supported = Test-HDTSchemaVersion -SchemaVersion ([int] $schemaVersion) -Supported $supportedSchemaVersion } catch { & $fail ("schemaVersion {0} is not a valid schema version. It must be 1 or greater." -f $schemaVersion) } if (-not $supported) { & $fail ("schemaVersion {0} is newer than this engine understands (schemaVersion {1}). Upgrade the engine rather than the wizard definition." -f $schemaVersion, $supportedSchemaVersion) } $pages = $Document['pages'] if ($null -eq $pages) { $pages = @() } if ($pages -is [System.Collections.IDictionary] -or -not ($pages -is [System.Collections.IEnumerable]) -or $pages -is [string]) { & $fail 'pages is not a list. It is one entry per screen, in the order they are asked.' } $seenId = @() $summaryPage = @() foreach ($page in @($pages)) { if (-not ($page -is [System.Collections.IDictionary])) { & $fail 'a page is not a mapping. Each one declares at least id and reference.' } foreach ($key in @('id', 'reference')) { if (-not $page.Contains($key) -or [string]::IsNullOrWhiteSpace([string] $page[$key])) { & $fail ("a page is missing {0}. Each page declares id and reference - MDT's <Pane id= reference= />." -f $key) } } $id = [string] $page['id'] foreach ($key in @($page.Keys)) { if ($knownPageKey -notcontains $key) { & $fail ("'{0}' is not a key the page '{1}' can carry. The keys are {2}." -f $key, $id, ($knownPageKey -join ', ')) } } if ($id -notmatch '^[A-Za-z0-9_-]{1,64}$') { & $fail ("the page id '{0}' is not a name. Letters, digits, hyphen and underscore, up to 64." -f $id) } # THE ID IS WHAT A SKIP VARIABLE AND A SUMMARY ROW ARE KEYED ON. if ($seenId -contains $id) { & $fail ("the page id '{0}' is declared twice. Each page is named once." -f $id) } $seenId += $id $reference = [string] $page['reference'] # SEE THE HEADER: this is authored content on a share. if ($reference -match '\.\.' -or $reference -match '^[A-Za-z]:' -or $reference.StartsWith('\') -or $reference.StartsWith('/')) { & $fail ("the page '{0}' references '{1}', which is not inside Scripts\UI. A reference is relative, never rooted, and never climbs out with '..'." -f $id, $reference) } if ($reference -notmatch '\.xaml$') { & $fail ("the page '{0}' references '{1}', which is not markup. A page is a .xaml file." -f $id, $reference) } if ($page.Contains('skip') -and -not [string]::IsNullOrWhiteSpace([string] $page['skip'])) { $skip = [string] $page['skip'] if ($skip -notmatch '^HDT[A-Za-z0-9]{1,61}$') { & $fail ("the page '{0}' is skipped by '{1}', which is not an HDT variable. Everything the engine resolves is HDT-prefixed." -f $id, $skip) } } # -- what it validates ------------------------------------------------ if ($page.Contains('validate') -and $null -ne $page['validate']) { $validate = $page['validate'] if (-not ($validate -is [System.Collections.IDictionary])) { & $fail ("the page '{0}' declares validate, which is not a mapping. It names a control and a rule." -f $id) } foreach ($key in @('control', 'rule')) { if (-not $validate.Contains($key) -or [string]::IsNullOrWhiteSpace([string] $validate[$key])) { & $fail ("the page '{0}' declares validate with no {1}." -f $id, $key) } } if ($knownRule -notcontains [string] $validate['rule']) { & $fail ("the page '{0}' declares the validation rule '{1}', which this engine does not implement. The rules are {2}." -f $id, [string] $validate['rule'], ($knownRule -join ', ')) } } # -- whether it is the summary ---------------------------------------- if ($page.Contains('summary') -and $null -ne $page['summary']) { $summary = $page['summary'] if (-not ($summary -is [System.Collections.IDictionary])) { & $fail ("the page '{0}' declares summary, which is not a mapping." -f $id) } if (-not $summary.Contains('rowControl') -or [string]::IsNullOrWhiteSpace([string] $summary['rowControl'])) { & $fail ("the page '{0}' declares summary with no rowControl to fill." -f $id) } $summaryPage += $id } # -- what it collects -------------------------------------------------- if ($page.Contains('collect') -and $null -ne $page['collect']) { foreach ($collect in @($page['collect'])) { if (-not ($collect -is [System.Collections.IDictionary])) { & $fail ("the page '{0}' declares a collect entry that is not a mapping." -f $id) } foreach ($key in @($collect.Keys)) { if ($knownCollectKey -notcontains $key) { & $fail ("'{0}' is not a key a collect entry on page '{1}' can carry. The keys are {2}." -f $key, $id, ($knownCollectKey -join ', ')) } } foreach ($key in @('control', 'variable')) { if (-not $collect.Contains($key) -or [string]::IsNullOrWhiteSpace([string] $collect[$key])) { & $fail ("a collect entry on page '{0}' is missing {1}." -f $id, $key) } } if ([string] $collect['variable'] -notmatch '^HDT[A-Za-z0-9]{1,61}$') { & $fail ("the page '{0}' collects into '{1}', which is not an HDT variable. Everything the engine resolves is HDT-prefixed (DESIGN 3)." -f $id, [string] $collect['variable']) } if ($collect.Contains('property') -and $knownProperty -notcontains [string] $collect['property']) { & $fail ("the page '{0}' reads '{1}' from a control, which the host cannot read. The properties are {2}." -f $id, [string] $collect['property'], ($knownProperty -join ', ')) } if ($collect.Contains('split') -and -not [string]::IsNullOrWhiteSpace([string] $collect['split'])) { if ($knownSplit -notcontains [string] $collect['split']) { & $fail ("the page '{0}' declares the splitter '{1}', which this engine does not implement. The splitters are {2}." -f $id, [string] $collect['split'], ($knownSplit -join ', ')) } if (-not $collect.Contains('splitVariable') -or [string]::IsNullOrWhiteSpace([string] $collect['splitVariable'])) { & $fail ("the page '{0}' splits a value and declares no splitVariable to put the other half in." -f $id) } } } } } # ONE FILE TO HAND OVER, so one page that hands it over. if (@($summaryPage).Count -gt 1) { & $fail ("more than one page declares summary: {0}. There is one rules.yaml to hand a technician." -f ($summaryPage -join ', ')) } } |