Modules/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1
|
function Resolve-IdleStepMetadataCatalog { [CmdletBinding()] param( [Parameter()] [AllowNull()] [object] $Providers ) # Metadata catalog maps Step.Type -> metadata hashtable. # # Trust boundary: # - The metadata catalog is a host-provided extension point, similar to StepRegistry. # - It is not loaded from workflow configuration. # - Workflows are data-only and must not contain executable code. # # Security / secure defaults: # - Only data-only metadata (hashtables with scalar/array values) are supported. # - ScriptBlock values are intentionally rejected to avoid arbitrary code execution. $catalog = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) # Helper: Validate a single capability identifier format. function Test-IdleCapabilityIdentifier { [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Capability, [Parameter(Mandatory)] [string] $StepType, [Parameter(Mandatory)] [string] $SourceName ) $cap = $Capability.Trim() if ([string]::IsNullOrWhiteSpace($cap)) { return } if ($cap -notmatch '^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z0-9]+)+$') { throw [System.ArgumentException]::new( "$SourceName entry for step type '$StepType' declares invalid capability '$cap'. Expected dot-separated segments like 'IdLE.Identity.Read'.", 'Providers' ) } } # Helper: Validate RequiredCapabilities value. function Test-IdleRequiredCapabilities { [CmdletBinding()] param( [Parameter()] [AllowNull()] [object] $Value, [Parameter(Mandatory)] [string] $StepType, [Parameter(Mandatory)] [string] $SourceName ) if ($null -eq $Value) { return } if ($Value -is [string]) { Test-IdleCapabilityIdentifier -Capability $Value -StepType $StepType -SourceName $SourceName return } # Explicitly reject dictionary/hashtable values; they are not valid capability lists. if ($Value -is [System.Collections.IDictionary] -or $Value -is [hashtable]) { throw [System.ArgumentException]::new( "$SourceName entry for step type '$StepType' has invalid RequiredCapabilities value. Expected string or string array.", 'Providers' ) } if ($Value -is [System.Collections.IEnumerable]) { foreach ($c in $Value) { if ($null -eq $c) { continue } if ($c -isnot [string]) { throw [System.ArgumentException]::new( "$SourceName entry for step type '$StepType' has invalid RequiredCapabilities value. Expected string or string array.", 'Providers' ) } Test-IdleCapabilityIdentifier -Capability $c -StepType $StepType -SourceName $SourceName } return } throw [System.ArgumentException]::new( "$SourceName entry for step type '$StepType' has invalid RequiredCapabilities value. Expected string or string array.", 'Providers' ) } # Helper: Validate metadata contains no ScriptBlocks (wrapper around Assert-IdleNoScriptBlock). function Assert-IdleStepMetadataNoScriptBlock { [CmdletBinding()] param( [Parameter(Mandatory)] [AllowNull()] [object] $Value, [Parameter(Mandatory)] [string] $Path, [Parameter(Mandatory)] [string] $StepType, [Parameter(Mandatory)] [string] $SourceName ) try { Assert-IdleNoScriptBlock -InputObject $Value -Path $Path } catch { # Rethrow with metadata-specific error message throw [System.ArgumentException]::new( "$SourceName entry for step type '$StepType' contains a ScriptBlock at '$Path'. ScriptBlocks are not allowed (data-only boundary).", 'Providers' ) } } # Helper: Get host-provided StepMetadata if available. function Get-IdleHostStepMetadata { [CmdletBinding()] param( [Parameter()] [AllowNull()] [object] $Providers ) if ($null -eq $Providers) { return $null } if ($Providers -is [hashtable] -and $Providers.ContainsKey('StepMetadata')) { return $Providers['StepMetadata'] } if ($Providers.PSObject.Properties.Name -contains 'StepMetadata') { return $Providers.StepMetadata } return $null } # Helper: Discover loaded step packs exporting Get-IdleStepMetadataCatalog. function Get-IdleStepPackModules { [CmdletBinding()] param() $loadedModules = Get-Module -Name 'IdLE.Steps.*' -All if ($null -eq $loadedModules) { return @() } $stepPackModules = @() foreach ($m in @($loadedModules)) { if ($null -ne $m.ExportedCommands -and $m.ExportedCommands.ContainsKey('Get-IdleStepMetadataCatalog')) { $stepPackModules += $m } } # Sort by module name for deterministic order return @($stepPackModules | Sort-Object -Property Name) } # Helper: Merge step pack catalog with duplicate detection. function Merge-IdleStepPackCatalog { [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable] $Target, [Parameter(Mandatory)] [hashtable] $Source, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $SourceModuleName, [Parameter(Mandatory)] [hashtable] $StepTypeOwners ) foreach ($key in $Source.Keys) { if ($null -eq $key -or [string]::IsNullOrWhiteSpace([string]$key)) { throw [System.ArgumentException]::new("$SourceModuleName contains an empty step type key.", 'Providers') } $value = $Source[$key] if ($value -isnot [hashtable]) { throw [System.ArgumentException]::new( "$SourceModuleName entry for step type '$key' must be a hashtable (metadata object).", 'Providers' ) } # Validate metadata shape (data-only, no ScriptBlocks). foreach ($metaKey in $value.Keys) { $metaValue = $value[$metaKey] # Recursively validate no ScriptBlocks anywhere in metadata Assert-IdleStepMetadataNoScriptBlock -Value $metaValue -Path $metaKey -StepType $key -SourceName $SourceModuleName if ($metaKey -eq 'RequiredCapabilities') { Test-IdleRequiredCapabilities -Value $metaValue -StepType $key -SourceName $SourceModuleName } } # Check for duplicates across step packs if ($StepTypeOwners.ContainsKey([string]$key)) { $existingOwner = $StepTypeOwners[[string]$key] $errorMessage = "DuplicateStepTypeMetadata: Step type '$key' is defined in both '$existingOwner' and '$SourceModuleName'. " + "Step packs must own unique step types." throw [System.InvalidOperationException]::new($errorMessage) } # Register ownership and add to catalog $StepTypeOwners[[string]$key] = $SourceModuleName $Target[[string]$key] = $value } } # 1) Discover and merge step pack catalogs (deterministic order). $stepTypeOwners = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) $stepPackModules = Get-IdleStepPackModules foreach ($module in $stepPackModules) { $catalogFunction = $module.ExportedCommands['Get-IdleStepMetadataCatalog'] if ($null -ne $catalogFunction) { $stepPackCatalog = & $catalogFunction if ($null -ne $stepPackCatalog -and $stepPackCatalog -is [hashtable]) { Merge-IdleStepPackCatalog -Target $catalog -Source $stepPackCatalog -SourceModuleName $module.Name -StepTypeOwners $stepTypeOwners } } } # 2) Apply host-provided StepMetadata as supplement-only (no overrides). $hostMetadata = Get-IdleHostStepMetadata -Providers $Providers if ($null -ne $hostMetadata) { if ($hostMetadata -isnot [hashtable]) { throw [System.ArgumentException]::new('Providers.StepMetadata must be a hashtable that maps Step.Type to a metadata object (hashtable).', 'Providers') } foreach ($key in $hostMetadata.Keys) { if ($null -eq $key -or [string]::IsNullOrWhiteSpace([string]$key)) { throw [System.ArgumentException]::new('Providers.StepMetadata contains an empty step type key.', 'Providers') } # Check if this step type already exists in step pack catalog (no override allowed) if ($catalog.ContainsKey([string]$key)) { $existingOwner = $stepTypeOwners[[string]$key] $errorMessage = "DuplicateStepTypeMetadata: Step type '$key' is already defined in step pack '$existingOwner'. " + "Host metadata (Providers.StepMetadata) can only supplement with new step types, not override existing ones." throw [System.InvalidOperationException]::new($errorMessage) } $value = $hostMetadata[$key] if ($value -isnot [hashtable]) { throw [System.ArgumentException]::new( "Providers.StepMetadata entry for step type '$key' must be a hashtable (metadata object).", 'Providers' ) } # Validate metadata shape (data-only, no ScriptBlocks). foreach ($metaKey in $value.Keys) { $metaValue = $value[$metaKey] # Recursively validate no ScriptBlocks anywhere in metadata Assert-IdleStepMetadataNoScriptBlock -Value $metaValue -Path $metaKey -StepType $key -SourceName 'Providers.StepMetadata' if ($metaKey -eq 'RequiredCapabilities') { Test-IdleRequiredCapabilities -Value $metaValue -StepType $key -SourceName 'Providers.StepMetadata' } } # Add host supplement $catalog[[string]$key] = $value $stepTypeOwners[[string]$key] = 'Host' } } return $catalog } |