Private/FileTransfer/Assert-VmFileBulkEntry.ps1

<#
.SYNOPSIS
    Validates a bulk-form file entry inside a VM 'files' array.
 
.DESCRIPTION
    Extracted from Assert-VmFilesField so the public dispatcher stays
    small. Bulk entries target Copy-VmFilesByPattern. Rules:
      - All entry sub-fields must be in -AllowedSubFields (the fixed
        bulk allow-list owned by Assert-VmFilesField). Unknown
        sub-fields throw - catches typos like 'recursive' or
        'targetdir'.
      - 'pattern' is required, non-empty string. Existence is NOT
        checked: globs are time-varying and the resolver
        re-evaluates them on every provision run, so a zero-match
        surfaces there - still before any SSH I/O.
      - 'targetDir' is required, non-empty string, absolute Linux
        path.
      - 'recurse' and 'preserveRelativePath' are optional booleans.
 
.PARAMETER EntryCtx
    Error-message prefix identifying this entry (e.g.
    "VM 'node-01': files[2]"). Built by the caller.
 
.PARAMETER Entry
    The PSCustomObject parsed from the JSON entry.
 
.PARAMETER AllowedSubFields
    Fixed bulk allow-list passed in by the dispatcher. Not
    consumer-overridable: the bulk form's sub-field set IS the
    contract with Copy-VmFilesByPattern.
#>

function Assert-VmFileBulkEntry {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string]   $EntryCtx,
        [Parameter(Mandatory)] [object]   $Entry,
        [Parameter(Mandatory)] [string[]] $AllowedSubFields
    )

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

    if (-not $Entry.PSObject.Properties['pattern']) {
        throw "$EntryCtx is missing required sub-field 'pattern'."
    }
    if ($Entry.pattern -isnot [string] -or [string]::IsNullOrWhiteSpace($Entry.pattern)) {
        throw "$EntryCtx.pattern must be a non-empty string (host glob)."
    }

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

    if ($Entry.PSObject.Properties['recurse'] -and $Entry.recurse -isnot [bool]) {
        throw "$EntryCtx.recurse must be a boolean."
    }
    if ($Entry.PSObject.Properties['preserveRelativePath'] -and
        $Entry.preserveRelativePath -isnot [bool]) {
        throw "$EntryCtx.preserveRelativePath must be a boolean."
    }
}