Public/Assert-VmFilesField.ps1

<#
.SYNOPSIS
    Validates the shape of a 'files' array on a VM definition.
 
.DESCRIPTION
    Performs the shared shape checks for a 'files' array:
      - 'files' may be absent (returns silently).
      - When present, must be a JSON array (PSCustomObject or string
        is rejected).
      - Each entry must be a JSON object.
      - Each entry may only contain sub-fields listed in
        -AllowedSubFields. Unknown sub-fields throw - catches typos
        like 'src' or 'dest'.
      - 'source' is required, non-empty string, and must exist on the
        host at validation time. Existence is checked here, not at
        copy time, so an operator typo fails fast before any VM work
        begins.
      - 'target' is required, non-empty string, and must be an
        absolute Linux path (starts with '/'). Windows-style and
        relative targets are rejected.
 
    Additional per-entry rules can be supplied via -PostEntryValidator:
    a scriptblock that receives ($entry, $context) and throws on any
    violation.
 
.PARAMETER Vm
    The parsed VM definition object (the same object the rest of
    the schema sees).
 
.PARAMETER AllowedSubFields
    Strict allow-list for sub-fields on each entry. Defaults to
    @('source', 'target'). Pass a wider list when the schema is
    being extended (e.g. @('source', 'target', 'owner')).
 
.PARAMETER PostEntryValidator
    Optional scriptblock invoked once per entry after all shared
    checks pass. Receives ($entry, $context). Should throw on
    violation.
 
.PARAMETER PostEntryValidatorContext
    Optional value passed as the second argument to -PostEntryValidator.
    Use it to hand the validator any data it needs from the surrounding
    schema.
 
.EXAMPLE
    # Default shape, no extras.
    Assert-VmFilesField -Vm $vm
 
.EXAMPLE
    # Allow 'owner', require it, and validate against a known set.
    $context = @{ KnownUsers = $vm.users.name }
    Assert-VmFilesField `
        -Vm $vm `
        -AllowedSubFields @('source', 'target', 'owner') `
        -PostEntryValidator {
            param($entry, $context)
            if (-not $entry.PSObject.Properties['owner']) {
                throw "files[*].owner is required."
            }
            if ($entry.owner -notin $context.KnownUsers) {
                throw "files[*].owner '$($entry.owner)' is not a known user."
            }
        } `
        -PostEntryValidatorContext $context
#>

function Assert-VmFilesField {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object] $Vm,

        [Parameter()]
        [string[]] $AllowedSubFields = @('source', 'target'),

        [Parameter()]
        [scriptblock] $PostEntryValidator,

        [Parameter()]
        [object] $PostEntryValidatorContext
    )

    if (-not $Vm.PSObject.Properties['files']) {
        return
    }

    $vmName = if ($Vm.PSObject.Properties['vmName']) { $Vm.vmName } else { '(unknown)' }
    $ctx    = "VM '$vmName': files"

    $files = $Vm.files

    if ($null -eq $files -or -not ($files -is [System.Collections.IEnumerable]) -or
        $files -is [string]) {
        throw "$ctx must be a JSON array of file entries."
    }

    # 'source' and 'target' are always required and never overridable
    # by the AllowedSubFields parameter - those two fields ARE the
    # contract.
    $i = 0
    foreach ($entry in $files) {
        $entryCtx = "$ctx[$i]"
        $i++

        if ($null -eq $entry -or
            $entry -isnot [System.Management.Automation.PSCustomObject]) {
            throw "$entryCtx must be a JSON object."
        }

        foreach ($prop in $entry.PSObject.Properties) {
            if ($prop.Name -notin $AllowedSubFields) {
                throw "$entryCtx has unknown sub-field '$($prop.Name)'. Allowed sub-fields: $($AllowedSubFields -join ', ')."
            }
        }

        if (-not $entry.PSObject.Properties['source']) {
            throw "$entryCtx is missing required sub-field 'source'."
        }
        if ($entry.source -isnot [string] -or [string]::IsNullOrWhiteSpace($entry.source)) {
            throw "$entryCtx.source must be a non-empty string (host path)."
        }
        if (-not (Test-Path -LiteralPath $entry.source)) {
            throw "$entryCtx.source path does not exist on the host: '$($entry.source)'."
        }

        if (-not $entry.PSObject.Properties['target']) {
            throw "$entryCtx is missing required sub-field 'target'."
        }
        if ($entry.target -isnot [string] -or [string]::IsNullOrWhiteSpace($entry.target)) {
            throw "$entryCtx.target must be a non-empty string (absolute Linux path)."
        }
        if ($entry.target -notmatch '^/') {
            throw "$entryCtx.target must be an absolute Linux path starting with '/' (got '$($entry.target)')."
        }

        # Called after the shared checks pass so the validator can
        # assume source / target are already valid and only reason
        # about the fields the caller introduced.
        if ($null -ne $PostEntryValidator) {
            & $PostEntryValidator $entry $PostEntryValidatorContext
        }
    }
}