Private/Get-StaticColorScriptOutput.ps1
|
function ConvertTo-StaticColorScriptEvaluationResult { [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [bool]$Success, [Parameter()] [AllowNull()] [object]$Value ) return [pscustomobject]@{ Success = $Success Value = $Value } } function Resolve-StaticColorScriptExpandedString { [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [System.Management.Automation.Language.ExpandableStringExpressionAst]$Expression, [Parameter(Mandatory)] [System.Collections.Generic.IDictionary[string, object]]$Variables ) if ($Expression.NestedExpressions.Count -eq 0) { try { return ConvertTo-StaticColorScriptEvaluationResult -Success $true -Value ([string]$Expression.SafeGetValue()) } catch { return ConvertTo-StaticColorScriptEvaluationResult -Success $false } } $tokenPatterns = [System.Collections.Generic.Dictionary[string, string]]::new( [System.StringComparer]::Ordinal) $replacementTokens = [System.Collections.Generic.List[string]]::new() $replacementVariableNames = [System.Collections.Generic.List[string]]::new() for ($index = 0; $index -lt $Expression.NestedExpressions.Count; $index++) { $nestedExpression = $Expression.NestedExpressions[$index] $variableExpression = $nestedExpression -as [System.Management.Automation.Language.VariableExpressionAst] if ($nestedExpression -is [System.Management.Automation.Language.SubExpressionAst]) { $subStatements = @($nestedExpression.SubExpression.Statements) $subPipeline = if ($subStatements.Count -eq 1) { $subStatements[0] -as [System.Management.Automation.Language.PipelineAst] } else { $null } $subCommandExpression = if ($subPipeline -and $subPipeline.PipelineElements.Count -eq 1 -and -not $subPipeline.Background) { $subPipeline.PipelineElements[0] -as [System.Management.Automation.Language.CommandExpressionAst] } else { $null } if (-not $subCommandExpression -or $subCommandExpression.Redirections.Count -ne 0) { return ConvertTo-StaticColorScriptEvaluationResult -Success $false } $variableExpression = $subCommandExpression.Expression -as [System.Management.Automation.Language.VariableExpressionAst] } if (-not $variableExpression -or -not $variableExpression.VariablePath.IsUnqualified -or $variableExpression.Splatted) { return ConvertTo-StaticColorScriptEvaluationResult -Success $false } $variableName = $variableExpression.VariablePath.UserPath $simpleTokenText = '$' + $variableName $bracedTokenText = '${' + $variableName + '}' $subExpressionTokenText = '$(' + $variableExpression.Extent.Text + ')' $tokenText = $nestedExpression.Extent.Text $supportedTokenText = ( $tokenText -ceq $simpleTokenText -or $tokenText -ceq $bracedTokenText -or $tokenText -ceq $subExpressionTokenText) if ([string]::IsNullOrWhiteSpace($variableName) -or $variableName -notmatch '^[A-Za-z_][A-Za-z0-9_]*$' -or -not $supportedTokenText -or -not $Variables.ContainsKey($variableName)) { return ConvertTo-StaticColorScriptEvaluationResult -Success $false } $variableValue = $Variables[$variableName] if ($variableValue -isnot [string] -and $variableValue -isnot [char]) { return ConvertTo-StaticColorScriptEvaluationResult -Success $false } if (-not $tokenPatterns.ContainsKey($tokenText)) { $hasExplicitBoundary = ( $tokenText.StartsWith('$(', [System.StringComparison]::Ordinal) -or $tokenText.StartsWith('${', [System.StringComparison]::Ordinal)) $tokenBoundary = if ($hasExplicitBoundary) { '' } else { '(?![A-Za-z0-9_])' } $tokenPatterns.Add($tokenText, ([regex]::Escape($tokenText) + $tokenBoundary)) } $replacementTokens.Add($tokenText) $replacementVariableNames.Add($variableName) } $orderedTokenTexts = @($tokenPatterns.Keys | Sort-Object -Property { $_.Length } -Descending) $orderedTokenPatterns = @($orderedTokenTexts | ForEach-Object { $tokenPatterns[$_] }) $tokenRegex = [regex]::new( ('(?:' + ($orderedTokenPatterns -join '|') + ')'), [System.Text.RegularExpressions.RegexOptions]::CultureInvariant) $template = [string]$Expression.Value $templateMatches = $tokenRegex.Matches($template) if ($templateMatches.Count -ne $replacementTokens.Count) { # If the decoded string contains an escaped occurrence of the same variable token, # source offsets no longer map to the decoded template. Fail closed instead of # guessing which occurrence PowerShell would expand. return ConvertTo-StaticColorScriptEvaluationResult -Success $false } $builder = New-Object System.Text.StringBuilder $templateOffset = 0 for ($index = 0; $index -lt $templateMatches.Count; $index++) { $match = $templateMatches[$index] if ($match.Value -cne $replacementTokens[$index]) { return ConvertTo-StaticColorScriptEvaluationResult -Success $false } $null = $builder.Append($template.Substring($templateOffset, $match.Index - $templateOffset)) $null = $builder.Append([string]$Variables[$replacementVariableNames[$index]]) $templateOffset = $match.Index + $match.Length } $null = $builder.Append($template.Substring($templateOffset)) return ConvertTo-StaticColorScriptEvaluationResult -Success $true -Value $builder.ToString() } function Resolve-StaticColorScriptExpression { [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [System.Management.Automation.Language.ExpressionAst]$Expression, [Parameter(Mandatory)] [System.Collections.Generic.IDictionary[string, object]]$Variables ) if ($Expression -is [System.Management.Automation.Language.StringConstantExpressionAst]) { return ConvertTo-StaticColorScriptEvaluationResult -Success $true -Value ([string]$Expression.Value) } if ($Expression -is [System.Management.Automation.Language.ExpandableStringExpressionAst]) { return Resolve-StaticColorScriptExpandedString -Expression $Expression -Variables $Variables } if ($Expression -is [System.Management.Automation.Language.VariableExpressionAst]) { $variableName = $Expression.VariablePath.UserPath if (-not $Expression.VariablePath.IsUnqualified -or $Expression.Splatted -or [string]::IsNullOrWhiteSpace($variableName) -or $variableName -notmatch '^[A-Za-z_][A-Za-z0-9_]*$' -or -not $Variables.ContainsKey($variableName)) { return ConvertTo-StaticColorScriptEvaluationResult -Success $false } return ConvertTo-StaticColorScriptEvaluationResult -Success $true -Value $Variables[$variableName] } if ($Expression -is [System.Management.Automation.Language.ConvertExpressionAst]) { $typeName = $Expression.Type.TypeName.FullName $allowedCharType = ( [string]::Equals($typeName, 'char', [System.StringComparison]::OrdinalIgnoreCase) -or [string]::Equals($typeName, 'System.Char', [System.StringComparison]::OrdinalIgnoreCase)) $constant = $Expression.Child -as [System.Management.Automation.Language.ConstantExpressionAst] if (-not $allowedCharType -or -not $constant) { return ConvertTo-StaticColorScriptEvaluationResult -Success $false } $numericTypes = @( [byte], [sbyte], [int16], [uint16], [int32], [uint32], [int64], [uint64]) if ($null -eq $constant.Value -or $constant.Value.GetType() -notin $numericTypes) { return ConvertTo-StaticColorScriptEvaluationResult -Success $false } try { $codePoint = [System.Convert]::ToInt64($constant.Value, [System.Globalization.CultureInfo]::InvariantCulture) if ($codePoint -lt [char]::MinValue -or $codePoint -gt [char]::MaxValue) { return ConvertTo-StaticColorScriptEvaluationResult -Success $false } return ConvertTo-StaticColorScriptEvaluationResult -Success $true -Value ([char]$codePoint) } catch { return ConvertTo-StaticColorScriptEvaluationResult -Success $false } } if ($Expression -is [System.Management.Automation.Language.BinaryExpressionAst] -and $Expression.Operator -eq [System.Management.Automation.Language.TokenKind]::Plus) { $left = Resolve-StaticColorScriptExpression -Expression $Expression.Left -Variables $Variables if (-not $left.Success -or $left.Value -isnot [string]) { return ConvertTo-StaticColorScriptEvaluationResult -Success $false } $right = Resolve-StaticColorScriptExpression -Expression $Expression.Right -Variables $Variables if (-not $right.Success -or $right.Value -isnot [string]) { return ConvertTo-StaticColorScriptEvaluationResult -Success $false } return ConvertTo-StaticColorScriptEvaluationResult -Success $true -Value ($left.Value + $right.Value) } return ConvertTo-StaticColorScriptEvaluationResult -Success $false } function Get-StaticColorScriptOutput { <# .SYNOPSIS Extracts output from a colorscript that uses a provably static subset of PowerShell. .DESCRIPTION Parses the script without executing it and interprets only a deliberately narrow AST subset used by bundled static colorscripts: simple variable assignments, literal strings, [char] constants, previously assigned variable interpolation, string concatenation, and one or more Write-Host statements. Any command execution, member access, subexpression, control flow, redirection, scoped or unknown variable, nontrivial subexpression, unsupported cast, or other syntax fails closed so the caller retains isolated process execution. #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [string]$ScriptPath ) $notAvailable = [pscustomobject]@{ Available = $false Content = '' } if ([string]::IsNullOrWhiteSpace($ScriptPath) -or -not (Test-Path -LiteralPath $ScriptPath -PathType Leaf)) { return $notAvailable } try { # Windows PowerShell 5.1 treats UTF-8 files without a BOM as the active ANSI code page # when ParseFile is used. Read as UTF-8 explicitly so adjacent Unicode artwork cannot be # misparsed as part of a variable name. $source = [System.IO.File]::ReadAllText($ScriptPath, [System.Text.Encoding]::UTF8) $tokens = $null $parseErrors = $null $ast = [System.Management.Automation.Language.Parser]::ParseInput( $source, $ScriptPath, [ref]$tokens, [ref]$parseErrors) if ($parseErrors.Count -gt 0 -or $ast.ParamBlock -or $ast.BeginBlock -or $ast.ProcessBlock -or $ast.DynamicParamBlock -or ($ast.PSObject.Properties['CleanBlock'] -and $ast.CleanBlock) -or $ast.UsingStatements.Count -gt 0 -or $ast.Attributes.Count -gt 0) { return $notAvailable } $statements = @($ast.EndBlock.Statements) if ($statements.Count -eq 0) { return $notAvailable } $variables = [System.Collections.Generic.Dictionary[string, object]]::new( [System.StringComparer]::OrdinalIgnoreCase) $output = New-Object System.Text.StringBuilder $outputStatementSeen = $false foreach ($statement in $statements) { if ($statement -is [System.Management.Automation.Language.AssignmentStatementAst]) { if ($outputStatementSeen -or $statement.Operator -ne [System.Management.Automation.Language.TokenKind]::Equals -or $statement.Left -isnot [System.Management.Automation.Language.VariableExpressionAst] -or -not $statement.Left.VariablePath.IsUnqualified -or $statement.Left.Splatted) { return $notAvailable } $variableName = $statement.Left.VariablePath.UserPath $right = $statement.Right -as [System.Management.Automation.Language.CommandExpressionAst] if ([string]::IsNullOrWhiteSpace($variableName) -or $variableName -notmatch '^[A-Za-z_][A-Za-z0-9_]*$' -or $variables.ContainsKey($variableName) -or -not $right -or $right.Redirections.Count -ne 0) { return $notAvailable } $evaluation = Resolve-StaticColorScriptExpression -Expression $right.Expression -Variables $variables if (-not $evaluation.Success -or ($evaluation.Value -isnot [string] -and $evaluation.Value -isnot [char])) { return $notAvailable } $variables.Add($variableName, $evaluation.Value) continue } $pipeline = $statement -as [System.Management.Automation.Language.PipelineAst] if (-not $pipeline -or $pipeline.PipelineElements.Count -ne 1 -or $pipeline.Background) { return $notAvailable } $command = $pipeline.PipelineElements[0] -as [System.Management.Automation.Language.CommandAst] if (-not $command -or $command.InvocationOperator -ne [System.Management.Automation.Language.TokenKind]::Unknown -or -not [string]::Equals($command.GetCommandName(), 'Write-Host', [System.StringComparison]::OrdinalIgnoreCase) -or $command.Redirections.Count -ne 0 -or $command.CommandElements.Count -gt 2) { return $notAvailable } $outputStatementSeen = $true if ($command.CommandElements.Count -eq 2) { $argument = $command.CommandElements[1] -as [System.Management.Automation.Language.ExpressionAst] if (-not $argument) { return $notAvailable } $evaluation = Resolve-StaticColorScriptExpression -Expression $argument -Variables $variables if (-not $evaluation.Success -or ($evaluation.Value -isnot [string] -and $evaluation.Value -isnot [char])) { return $notAvailable } $null = $output.Append([string]$evaluation.Value) } $null = $output.Append([Environment]::NewLine) } if (-not $outputStatementSeen) { return $notAvailable } # Preserve the literal ANSI payload. The public rendering boundary is responsible for # removing it only when the caller explicitly requests -NoAnsiOutput. return [pscustomobject]@{ Available = $true Content = $output.ToString() } } catch { Write-Verbose ("Static colorscript extraction failed for {0}: {1}" -f $ScriptPath, $_.Exception.Message) return $notAvailable } } |