FITS.Psd1Utils.psm1
|
$MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = { Write-Verbose "FITS.Psd1Utils: module removed." } # ───────────────────────────────────────────────────────────────────────────── # Private helper — module scope, not re-created on every call # ───────────────────────────────────────────────────────────────────────────── function Format-PsdKey { param([string]$Key) if ($Key -match '\s') { return "`"$Key`"" } return $Key } # ───────────────────────────────────────────────────────────────────────────── # ConvertTo-Psd1String # ───────────────────────────────────────────────────────────────────────────── function ConvertTo-Psd1String { <# .SYNOPSIS Serialises a PowerShell object to a PSD1-compatible string. .DESCRIPTION Recursively converts hashtables, ordered dictionaries, arrays, PSCustomObjects and scalar values to a formatted PSD1 string. Keys that contain whitespace are automatically quoted. Strings are double-quoted with proper backtick escaping. Array elements are comma-separated; the last element has no trailing comma. .PARAMETER InputObject The object to serialise. .PARAMETER Depth Current indentation depth. Used internally for recursion; leave at 0 for the root call. .PARAMETER KeyPadWidth Fixed padding width for hashtable keys. When 0 (default), the width is calculated automatically from the longest key in each block. .EXAMPLE $config = [ordered]@{ Name = 'Test'; Active = $true; Tags = @('a','b') } ConvertTo-Psd1String -InputObject $config .EXAMPLE $config | ConvertTo-Psd1String | Set-Content -Path config.psd1 -Encoding UTF8 #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory, Position = 0, ValueFromPipeline)] $InputObject, [Parameter()] [int] $Depth = 0, [Parameter()] [int] $KeyPadWidth = 0 ) process { $pad = ' ' * $Depth $childPad = ' ' * ($Depth + 1) # ── Null ────────────────────────────────────────────────────────────── if ($null -eq $InputObject) { return '$null' } $type = $InputObject.GetType() # ── OrderedDictionary → [ordered]@{ } ─────────────────────────────── if ($type.Name -eq 'OrderedDictionary') { $keyWidth = if ($KeyPadWidth -gt 0) { $KeyPadWidth } else { ($InputObject.Keys | ForEach-Object { (Format-PsdKey $_).Length } | Measure-Object -Maximum).Maximum } $lines = @('[ordered]@{') foreach ($key in $InputObject.Keys) { $fKey = Format-PsdKey $key $val = ConvertTo-Psd1String -InputObject $InputObject[$key] -Depth ($Depth + 1) $lines += "$childPad$($fKey.PadRight($keyWidth)) = $val" } $lines += "$pad}" return $lines -join "`n" } # ── Any other IDictionary → @{ } ──────────────────────────────────── if ($type.GetInterfaces() -contains [System.Collections.IDictionary]) { $keyWidth = if ($KeyPadWidth -gt 0) { $KeyPadWidth } else { ($InputObject.Keys | ForEach-Object { (Format-PsdKey $_).Length } | Measure-Object -Maximum).Maximum } $lines = @('@{') foreach ($key in $InputObject.Keys) { $fKey = Format-PsdKey $key $val = ConvertTo-Psd1String -InputObject $InputObject[$key] -Depth ($Depth + 1) $lines += "$childPad$($fKey.PadRight($keyWidth)) = $val" } $lines += "$pad}" return $lines -join "`n" } # ── PSCustomObject → [ordered]@{ } ────────────────────────────────── if ($type.Name -eq 'PSCustomObject') { $props = $InputObject | Get-Member -MemberType NoteProperty $keyWidth = if ($KeyPadWidth -gt 0) { $KeyPadWidth } else { ($props.Name | ForEach-Object { (Format-PsdKey $_).Length } | Measure-Object -Maximum).Maximum } $lines = @('[ordered]@{') foreach ($prop in $props) { $fKey = Format-PsdKey $prop.Name $val = ConvertTo-Psd1String -InputObject $InputObject.$($prop.Name) -Depth ($Depth + 1) $lines += "$childPad$($fKey.PadRight($keyWidth)) = $val" } $lines += "$pad}" return $lines -join "`n" } # ── Array / IEnumerable → @( ) with comma separation ──────────────── if ($type.IsArray -or ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string])) { $items = @($InputObject) if ($items.Count -eq 0) { return '@()' } $lines = @('@(') for ($i = 0; $i -lt $items.Count; $i++) { $val = ConvertTo-Psd1String -InputObject $items[$i] -Depth ($Depth + 1) $comma = if ($i -lt $items.Count - 1) { ',' } else { '' } $lines += "$childPad$val$comma" } $lines += "$pad)" return $lines -join "`n" } # ── Boolean ─────────────────────────────────────────────────────────── if ($type.Name -eq 'Boolean') { return (if($InputObject){ '$true'} else {'$false'}) } # ── Numeric primitives ──────────────────────────────────────────────── if ($type.IsPrimitive -and $type.Name -notin @('Boolean', 'Char')) { return "$InputObject" } # ── Fallback: String — double-quoted with backtick escaping ─────────── # Escaping order matters: backtick first, then quote, then dollar $escaped = "$InputObject" -replace '`', '``' ` -replace '"', '`"' ` -replace '\$', '`$' return "`"$escaped`"" } } # ───────────────────────────────────────────────────────────────────────────── # Test-Psd1Content # ───────────────────────────────────────────────────────────────────────────── function Test-Psd1Content { <# .SYNOPSIS Validates PSD1/PS1 data file content using AST analysis before execution. .DESCRIPTION Parses the content using PowerShell's built-in AST parser without executing it, then walks the syntax tree looking for constructs that have no place in a pure data file: commands, method calls, scriptblock expressions, subexpressions, function definitions, and type casts to non-allowlisted types. Two modes: Default (strict) — pure data only: hashtables, arrays, literals, safe casts. -AllowVariables — additionally permits variable assignments and references, and the + operator. Required for composite-set files like CharacterSets.ps1 from FITS.StringUtils. .PARAMETER Content The string content to validate. .PARAMETER AllowVariables Permits variable assignments ($x = ...) and binary + expressions. Use for data files that build composite structures from named variables. .PARAMETER AllowedTypeNames Type names permitted in cast expressions like [ordered]@{} or [char]0x20. Defaults to a safe set of primitive and collection types. .OUTPUTS [bool] — $true if the content is considered safe, $false otherwise. .EXAMPLE $content = Get-Content config.psd1 -Raw if (Test-Psd1Content -Content $content) { $data = & ([scriptblock]::Create($content)) } .EXAMPLE # For CharacterSets.ps1 with variable assignments Test-Psd1Content -Content $content -AllowVariables #> [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory, Position = 0, ValueFromPipeline)] [string] $Content, [switch] $AllowVariables, [string[]] $AllowedTypeNames = @( 'ordered', 'char', 'string', 'string[]', 'int', 'int32', 'int64', 'long', 'uint32', 'uint64', 'double', 'decimal', 'float', 'single', 'bool', 'boolean', 'byte', 'sbyte' ) ) process { # ── Step 1: Parse without executing ─────────────────────────────────── $parseErrors = $null $tokens = $null $ast = [System.Management.Automation.Language.Parser]::ParseInput( $Content, [ref]$tokens, [ref]$parseErrors ) if ($parseErrors.Count -gt 0) { foreach ($e in $parseErrors) { Write-Warning "Parse error at line $($e.Extent.StartLineNumber): $($e.Message)" } return $false } # ── Step 2: Walk AST — collect all dangerous nodes ───────────────────── $allowedTypeSet = [System.Collections.Generic.HashSet[string]]::new( [string[]]($AllowedTypeNames | ForEach-Object { $_.ToLower() }), [System.StringComparer]::OrdinalIgnoreCase ) $violations = [System.Collections.Generic.List[string]]::new() $null = $ast.FindAll({ param($node) # Cmdlet / external command calls if ($node -is [System.Management.Automation.Language.CommandAst]) { $violations.Add("Command call at line $($node.Extent.StartLineNumber): $($node.Extent.Text)") return $false } # Method invocations: $obj.Method() or [Type]::StaticMethod() if ($node -is [System.Management.Automation.Language.InvocationExpressionAst]) { $violations.Add("Method invocation at line $($node.Extent.StartLineNumber): $($node.Extent.Text)") return $false } # Static/instance member access: $obj.Property or [Type]::Member if ($node -is [System.Management.Automation.Language.MemberExpressionAst]) { $violations.Add("Member access at line $($node.Extent.StartLineNumber): $($node.Extent.Text)") return $false } # Embedded scriptblocks: { ... } if ($node -is [System.Management.Automation.Language.ScriptBlockExpressionAst]) { $violations.Add("Scriptblock expression at line $($node.Extent.StartLineNumber)") return $false } # Function / filter definitions if ($node -is [System.Management.Automation.Language.FunctionDefinitionAst]) { $violations.Add("Function definition at line $($node.Extent.StartLineNumber): $($node.Name)") return $false } # $( ) subexpressions if ($node -is [System.Management.Automation.Language.SubExpressionAst]) { $violations.Add("Subexpression at line $($node.Extent.StartLineNumber)") return $false } # Expandable strings with live variable or expression parts: "hello $world" if ($node -is [System.Management.Automation.Language.ExpandableStringExpressionAst]) { $liveParts = $node.NestedExpressions | Where-Object { $_ -isnot [System.Management.Automation.Language.StringConstantExpressionAst] -and $_ -isnot [System.Management.Automation.Language.ConstantExpressionAst] } if ($liveParts) { $violations.Add("Variable expansion in string at line $($node.Extent.StartLineNumber): $($node.Extent.Text)") return $false } } # Type casts — only allowlisted types permitted if ($node -is [System.Management.Automation.Language.ConvertExpressionAst]) { $typeName = $node.Type.TypeName.FullName if (-not $allowedTypeSet.Contains($typeName)) { $violations.Add("Disallowed type cast [$typeName] at line $($node.Extent.StartLineNumber)") return $false } } # Variable assignments — blocked in strict mode if (-not $AllowVariables -and $node -is [System.Management.Automation.Language.AssignmentStatementAst]) { $violations.Add("Variable assignment at line $($node.Extent.StartLineNumber): $($node.Extent.Text)") return $false } # Variable references other than $true/$false/$null — blocked in strict mode if (-not $AllowVariables -and $node -is [System.Management.Automation.Language.VariableExpressionAst]) { $varName = $node.VariablePath.UserPath.ToLower() if ($varName -notin @('true', 'false', 'null')) { $violations.Add("Variable reference at line $($node.Extent.StartLineNumber): `$$($node.VariablePath.UserPath)") return $false } } # Binary expressions other than + — blocked even with -AllowVariables if ($node -is [System.Management.Automation.Language.BinaryExpressionAst]) { if ($node.Operator -ne [System.Management.Automation.Language.TokenKind]::Plus) { $violations.Add("Disallowed operator '$($node.Operator)' at line $($node.Extent.StartLineNumber)") return $false } } return $false }, $true) if ($violations.Count -gt 0) { foreach ($v in $violations) { Write-Warning "Sanitization: $v" } return $false } return $true } } # ───────────────────────────────────────────────────────────────────────────── # Read-Psd1File # ───────────────────────────────────────────────────────────────────────────── function Read-Psd1File { <# .SYNOPSIS Reads and optionally sanitizes a PSD1/PS1 data file, then executes it. .DESCRIPTION Convenience wrapper that combines Test-Psd1Content with the [scriptblock]::Create() execution pattern. By default, the content is sanitized before execution. Use -SkipSanitization only in fully trusted environments. .PARAMETER Path Path to the PSD1 or PS1 data file. .PARAMETER AllowVariables Passed through to Test-Psd1Content. Permits variable assignments and +. Required for files like CharacterSets.ps1 that build composite arrays. .PARAMETER SkipSanitization Bypasses Test-Psd1Content entirely. Not recommended. .EXAMPLE $config = Read-Psd1File -Path "$PSScriptRoot\Config\DFLConfig.psd1" .EXAMPLE $sets = Read-Psd1File -Path "$PSScriptRoot\Data\CharacterSets.ps1" -AllowVariables #> [CmdletBinding()] param( [Parameter(Mandatory, Position = 0)] [ValidateScript({ Test-Path $_ -PathType Leaf })] [string] $Path, [switch] $AllowVariables, [switch] $SkipSanitization ) $content = Get-Content -LiteralPath $Path -Encoding UTF8 -Raw if (-not $SkipSanitization) { $sanitizeParams = @{ Content = $content } if ($AllowVariables) { $sanitizeParams['AllowVariables'] = $true } if (-not (Test-Psd1Content @sanitizeParams)) { throw "Content of '$Path' failed sanitization check. Execution aborted." } Write-Verbose "Sanitization passed: $Path" } return & ([scriptblock]::Create($content)) } |