Public/Get-HDTBootstrapConfiguration.ps1

function Get-HDTBootstrapConfiguration {
    <#
        .SYNOPSIS
            Reads bootstrap.json - the file in the boot image that says where the
            content is and which provider reaches it.
 
        .DESCRIPTION
            THE ONLY FILE THAT TELLS THE ENGINE WHERE IT IS. It is written into
            the image at X:\HDT\bootstrap.json by Update-HDTBootImage (05-04) and
            read here, before anything else exists: no share, no workspace, no
            variables, no rules. Everything the engine does afterwards follows
            from it.
 
            The shape:
 
              {
                "schemaVersion": 1,
                "workspaceId": "HDT-LAB",
                "provider": "Smb",
                "deployRoot": "\\\\server\\HdtShare",
                "contentMarker": "rules.yaml",
                "sequenceId": "",
                "credential": { "username": "...", "protected": "..." },
                "promptForCredential": false,
                "logLevel": "Info",
                "buildId": "...",
                "builtUtc": "..."
              }
 
            EVERY REFUSAL IS A SENTENCE NAMING THE FILE, never a raw
            ConvertFrom-Json exception. This document is read on a machine with
            nobody at the keyboard, and its failure is the last thing anyone will
            ever see about the run - "Invalid JSON primitive" names no file, no
            key and no fix.
 
            THE RULES:
 
              - provider outside Smb|Local is refused, naming the file, the value
                and the two legal names;
              - deployRoot missing or empty is refused;
              - provider Smb with a deployRoot that is not UNC is refused, naming
                both. Volume-relative is a LOCAL idea and does not weaken this;
              - credential absent AND promptForCredential false AND provider Smb
                is refused, because that image cannot authenticate and the fact
                is decidable at build time;
              - promptForCredential true means the credential block may be
                absent, and the caller stops for a human;
              - provider Local with a VOLUME-RELATIVE deployRoot (\Share) is
                legal, and it is the form a boot image should carry. In the lab
                WinPE gave the content disk C: and the RAM disk X:, so a letter
                written at build time is a guess about a machine that has not
                booted yet. Resolve-HDTDeployRoot turns it into a real path at
                boot; this reader accepts it and hands it on;
              - provider Local with a ROOTED deployRoot is legal too - it is what
                a build host uses - and it is not an error for it to be absent at
                boot, because the resolver falls back to the probe;
              - sequenceId empty is legal: the sequence then comes from the rules,
                which is the answer to "which task sequence does this
                machine get";
              - contentMarker defaults to rules.yaml, the rules file, which is
                what identifies a workspace root when the volume is discovered
                rather than configured.
 
            THE PROTECTED SECRET IS NOT A PROPERTY. The result is written into
            RESULT.json and into log records, so a property holding the share
            password would put it on the share. The credential is built on demand
            by GetCredential(), from a value closed over rather than carried.
 
            It reads through an injected IFileSystem, never Get-Content, so the
            whole path is provable under Pester with no boot image.
 
        .PARAMETER Path
            The bootstrap document. X:\HDT\bootstrap.json in WinPE.
 
        .PARAMETER FileSystem
            An IFileSystem. Defaults to the real adapter.
 
        .INPUTS
            None. This command does not accept pipeline input.
 
        .OUTPUTS
            System.Management.Automation.PSCustomObject with SchemaVersion,
            WorkspaceId, Provider, DeployRoot, ContentMarker, SequenceId,
            PromptForCredential, Skip, LogLevel, UserName, HasCredential,
            BuildId, BuiltUtc and Path, plus a GetCredential() ScriptMethod.
 
            Skip carries Welcome, StaticIp, DeployRoot and Credential, each
            $true, $false, or $null for a rule the image did not state.
            Get-HDTWizardSkip is what turns those into a decision.
 
        .EXAMPLE
            $bootstrap = Get-HDTBootstrapConfiguration -Path 'X:\HDT\bootstrap.json'
            $bootstrap.DeployRoot
 
        .EXAMPLE
            $bootstrap = Get-HDTBootstrapConfiguration -Path $path -FileSystem $fs
            $provider = New-HDTContentProvider -Provider $bootstrap.Provider `
                -Root $resolved.Path -Credential $bootstrap.GetCredential()
 
            The two commands that turn a boot image into a connected machine.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [ValidateNotNullOrEmpty()]
        [string] $Path,

        [Parameter()]
        [AllowNull()]
        [object] $FileSystem
    )

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

    if ($null -eq $FileSystem) {
        $FileSystem = New-HDTFileSystem
    }

    if (-not $FileSystem.TestPath($Path)) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message 'there is no bootstrap document here. Update-HDTBootImage writes one into the boot image; without it the engine has no way to know where its content is.' `
                    -Category ObjectNotFound))
    }

    $text = $FileSystem.ReadAllText($Path)

    if ([string]::IsNullOrWhiteSpace($text)) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message 'the bootstrap document is empty. Rebuild the boot image with Update-HDTBootImage.'))
    }

    try {
        # Assigned first, wrapped second: under Windows PowerShell 5.1
        # ConvertFrom-Json does not enumerate a top-level array (helpers README
        # F12), and it bit 04-04 twice.
        $document = ConvertFrom-Json -InputObject $text
    } catch {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message ("the bootstrap document could not be read as JSON: {0}. Rebuild the boot image with Update-HDTBootImage." -f $_.Exception.Message)))
    }

    if ($null -eq $document) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message 'the bootstrap document parsed to nothing. Rebuild the boot image with Update-HDTBootImage.'))
    }

    $valueOf = {
        param([string] $Name, [object] $Default)

        if ($null -eq $document.PSObject.Properties[$Name]) {
            return $Default
        }

        $raw = $document.$Name
        if ($null -eq $raw) {
            return $Default
        }

        return $raw
    }

    $schemaVersion = [int] (& $valueOf 'schemaVersion' 1)
    $workspaceId = [string] (& $valueOf 'workspaceId' '')
    $provider = [string] (& $valueOf 'provider' '')
    $deployRoot = [string] (& $valueOf 'deployRoot' '')
    $contentMarker = [string] (& $valueOf 'contentMarker' 'rules.yaml')
    $sequenceId = [string] (& $valueOf 'sequenceId' '')
    $logLevel = [string] (& $valueOf 'logLevel' 'Info')
    $buildId = [string] (& $valueOf 'buildId' '')
    $prompt = [bool] (& $valueOf 'promptForCredential' $false)

    # THE TWO ENGINES DISAGREE ABOUT builtUtc, and it is not a detail: under
    # pwsh 7 ConvertFrom-Json coerces an ISO 8601 string to [datetime], under
    # Windows PowerShell 5.1 it does not. Casting the pwsh 7 result to [string]
    # yields '08/13/2026 09:14:22' - a machine-local rendering of a timestamp
    # that RESULT.json is meant to carry verbatim. Round-tripped explicitly, both
    # engines produce the same sentence.
    $rawBuilt = & $valueOf 'builtUtc' ''
    $builtUtc = [string] $rawBuilt
    if ($rawBuilt -is [datetime]) {
        $builtUtc = $rawBuilt.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ',
            [System.Globalization.CultureInfo]::InvariantCulture)
    }

    if ([string]::IsNullOrWhiteSpace($contentMarker)) {
        $contentMarker = 'rules.yaml'
    }

    if ([string]::IsNullOrWhiteSpace($logLevel)) {
        $logLevel = 'Info'
    }

    if (-not (Test-HDTSchemaVersion -SchemaVersion $schemaVersion -Supported 1)) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message ("the bootstrap document declares schemaVersion {0}, which this engine does not understand. It supports schemaVersion 1." -f $schemaVersion)))
    }

    if (@('Smb', 'Local') -notcontains $provider) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message ("provider '{0}' is not a transport HDT can build. The provider must be Smb or Local." -f $provider)))
    }

    # A MISSING deployRoot IS A QUESTION, NOT A MALFORMED DOCUMENT, and this
    # used to throw. The refusal was in the wrong place: an image with no share
    # still boots, still reaches the Welcome screen, and still has a technician
    # in front of it who can type one. Throwing here is what stopped that
    # screen from ever opening, so the only person who could fix it was never
    # asked - Get-HDTWizardSkip raises HDTDeployRootHint instead.
    #
    # NOTHING IS SILENTLY EXCUSED. An empty share that nobody fills in fails at
    # connect time, loudly, which is where a share that is wrong rather than
    # absent has always failed.
    #
    # The shape checks below still apply to a share that IS stated - a
    # deployRoot present and wrong is still a malformed document.
    if (-not [string]::IsNullOrWhiteSpace($deployRoot) -and
        $provider -eq 'Smb' -and -not $deployRoot.StartsWith('\\')) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message ("provider is Smb and deployRoot '{0}' is not a UNC path. An Smb deployRoot names a share (\\server\share); a volume-relative or drive-qualified root is a Local idea and the transports are not interchangeable." -f $deployRoot)))
    }

    # -- the skip block, and ABSENT IS NOT false --------------------------
    #
    # MDT's Bootstrap.ini carries SkipBDDWelcome and CustomSettings.ini carries
    # every other Skip*, for a structural reason rather than a historical one:
    # the Welcome screen runs BEFORE the share is reachable, so a rule about it
    # cannot live on the share. This is HDT's in-image half of the same split
    # (.planning/WPF-FIRST.md, W2).
    #
    # A rule the image did not state comes back as $null, NOT $false. Every
    # image built before this block existed has no skip block at all, and it is
    # Get-HDTWizardSkip's defaults - not this reader - that turn "said nothing"
    # into the unattended path. A reader that flattened absent to false would
    # make that decision here, silently, and in the wrong place.
    #
    # A key nobody knows is ignored rather than refused: a newer builder writing
    # a fifth rule must not stop an older engine from deploying.
    $skip = [ordered] @{
        Welcome    = $null
        StaticIp   = $null
        DeployRoot = $null
        Credential = $null
    }

    if ($null -ne $document.PSObject.Properties['skip'] -and $null -ne $document.skip) {
        foreach ($pair in @(
                @{ Property = 'Welcome'; Key = 'welcome' },
                @{ Property = 'StaticIp'; Key = 'staticIp' },
                @{ Property = 'DeployRoot'; Key = 'deployRoot' },
                @{ Property = 'Credential'; Key = 'credential' })) {

            $key = [string] $pair.Key
            if ($null -ne $document.skip.PSObject.Properties[$key] -and $null -ne $document.skip.$key) {
                $skip[[string] $pair.Property] = [bool] $document.skip.$key
            }
        }
    }

    $userName = ''
    $protected = ''
    if ($null -ne $document.PSObject.Properties['credential'] -and $null -ne $document.credential) {
        $credential = $document.credential

        if ($null -ne $credential.PSObject.Properties['username']) { $userName = [string] $credential.username }
        if ($null -ne $credential.PSObject.Properties['protected']) { $protected = [string] $credential.protected }
    }

    $hasCredential = -not ([string]::IsNullOrWhiteSpace($userName) -or [string]::IsNullOrWhiteSpace($protected))

    if ($provider -eq 'Smb' -and -not $hasCredential -and -not $prompt) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message 'this boot image was built without a credential and without promptForCredential, so a machine booting it can neither authenticate to the share nor ask anybody. Rebuild it with Set-HDTShareCredential, or with -PromptForCredential if the image is meant to stop for a human.' `
                    -Category AuthenticationError))
    }

    # UNPROTECTED EAGERLY, so a corrupt blob fails HERE - naming this file - and
    # not four steps later inside a provider. The plain value is closed over by
    # GetCredential() rather than carried as a property: this object is written
    # into RESULT.json and into log records.
    $plain = ''
    if ($hasCredential) {
        try {
            $plain = Unprotect-HDTShareSecret -Protected $protected
        } catch {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("the embedded credential could not be decoded: {0}. Rebuild the boot image with Update-HDTBootImage." -f $_.Exception.Message)))
        }
    }

    # -- the certificates, in the image's own letters -------------------------
    #
    # NAMED HERE AS X:\HDT\Certs\..., NOT AS THE SHARE NAMES THEM. The share
    # path is where the build read the file FROM; by the time anything reads
    # this document the file is inside the image, and the share may not be
    # reachable yet - which is the whole reason the certificates are imported
    # before wpeinit.
    #
    # THE PASSWORD IS UNPROTECTED EAGERLY AND CLOSED OVER, exactly as the
    # credential's is: this object goes into RESULT.json and into log records,
    # so a plain property would put a private key's password in both.

    # THE TIME ZONE THE IMAGE WAS BUILT WITH. startnet.cmd already applied it to
    # WinPE's own clock; this is the copy the deployed machine's unattend gets,
    # so one choice on the Windows PE window covers both.
    $timeZone = ''
    if ($null -ne $document.PSObject.Properties['timeZone']) {
        $timeZone = [string] $document.timeZone
    }

    $rootCertificate = New-Object -TypeName System.Collections.ArrayList
    $clientCertificate = ''
    $certificateProtected = ''

    if ($null -ne $document.PSObject.Properties['certificate'] -and $null -ne $document.certificate) {
        $certificate = $document.certificate

        if ($null -ne $certificate.PSObject.Properties['root']) {
            foreach ($current in @($certificate.root)) {
                if ([string]::IsNullOrWhiteSpace([string] $current)) { continue }
                [void] $rootCertificate.Add([string] $current)
            }
        }

        if ($null -ne $certificate.PSObject.Properties['client']) {
            $clientCertificate = [string] $certificate.client
        }

        if ($null -ne $certificate.PSObject.Properties['protected']) {
            $certificateProtected = [string] $certificate.protected
        }
    }

    $certificatePassword = ''
    if (-not [string]::IsNullOrWhiteSpace($certificateProtected)) {
        try {
            $certificatePassword = Unprotect-HDTShareSecret -Protected $certificateProtected
        } catch {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("the embedded certificate password could not be decoded: {0}. Rebuild the boot image with Update-HDTBootImage." -f $_.Exception.Message)))
        }
    }

    $result = [pscustomobject] ([ordered] @{
            SchemaVersion       = $schemaVersion
            WorkspaceId         = $workspaceId
            Provider            = $provider
            DeployRoot          = $deployRoot
            ContentMarker       = $contentMarker
            SequenceId          = $sequenceId
            PromptForCredential = $prompt
            Skip                = [pscustomobject] $skip
            LogLevel            = $logLevel
            UserName            = $userName
            HasCredential       = $hasCredential
            TimeZone            = $timeZone
            RootCertificate     = [string[]] @($rootCertificate)
            ClientCertificate   = $clientCertificate
            BuildId             = $buildId
            BuiltUtc            = $builtUtc
            Path                = $Path
        })

    # GetNewClosure captures $userName and $plain; nothing inside the method
    # calls a module-private command, so the closure's session state does not
    # have to reach back into the module.
    $result | Add-Member -MemberType ScriptMethod -Name GetCredential -Value {
        if ([string]::IsNullOrWhiteSpace($userName) -or [string]::IsNullOrEmpty($plain)) {
            return $null
        }

        $secure = New-Object -TypeName System.Security.SecureString
        foreach ($character in $plain.ToCharArray()) { $secure.AppendChar($character) }
        $secure.MakeReadOnly()

        return (New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $userName, $secure)
    }.GetNewClosure()

    # THE SAME TREATMENT FOR THE SAME REASON. Plain text, because the caller
    # hands it straight to an X509Certificate2 constructor, which takes one.
    $result | Add-Member -MemberType ScriptMethod -Name GetCertificatePassword -Value {
        return [string] $certificatePassword
    }.GetNewClosure()

    return $result
}