powershellscriptblock

Invoke on a simple scriptblock with different behavior with PowershellDataFile / without


I am attempting to invoke a scriptblock in Powershell from a value within a PSD1 imported through Import-PowerShellDataFile.

Let's take the simplest expression (no data file) of executing a scriptblock within a hashtable. It works as expected.

$Config = @{
    test = {Write-output "Hello"}
}


$config.test.invoke()
#output: Hello

Now, if I add the following intermediary step, it fails

$tmpConfig = Import-PowerShellDataFile -Path tmpconfig.psd1
$tmpconfig.test.Invoke()
#Output: Write-Output "Hello"

I cannot find an explanation for the difference in behavior. Any idea what is happening there and how to remediate it ? I noticed that the imported from PowershellData file .ToString() output is wrapped in an additional pair of curly braces, which would essentially be $t ={{Write-Output 'Hello'}} and while I'd need to essentially invoke twice, I don't understand the context of why the PowershellDataFile double-wrap (or maybe it is something else) the scriptblock like that.

For reference, my output and the simplest script to view this pecular scenario playing out.

Output

# Imported from PowershellData
ConfigType : System.Collections.Hashtable
Value      : {Write-output "Hello"}
ValueType  : System.Management.Automation.ScriptBlock
Result     : Write-output "Hello"
ResultType : System.Management.Automation.ScriptBlock

# Not imported. 
ConfigType : System.Collections.Hashtable
Value      : Write-output "Hello"
ValueType  : System.Management.Automation.ScriptBlock
Result     : Hello
ResultType : System.String

Complete script to reproduce the issue



$tmpconfigstring = @'
@{
    test = { Write-output "Hello" }
}
'@ 

$tmpconfigstring | Out-File tmpconfig.psd1

$tmpConfig = Import-PowerShellDataFile -Path tmpconfig.psd1
$tmpconfig2 = ([scriptblock]::Create($tmpconfigstring)).InvokeReturnAsIs()

$Result = $tmpConfig.test.InvokeReturnAsIs()
$Result2 = $tmpconfig2.test.InvokeReturnAsIs()


[PSCustomObject]@{
    'ConfigType' = $tmpConfig.GetType() 
    'Value'      = $tmpConfig.test.ToString() 
    'ValueType'  = $tmpconfig.test.GetType()
    'Result'     = $Result
    'ResultType' = $Result.GetType()
} | fl 

[PSCustomObject]@{
    'ConfigType' = $tmpConfig2.GetType() 
    'Value'      = $tmpConfig2.test.ToString() 
    'ValueType'  = $tmpconfig2.test.GetType()
    'Result'     = $Result2
    'ResultType' = $Result2.GetType()
} | fl 

Solution

  • It is possible they're taking this path for security reasons, hinted out in the documentation:

    The Import-PowerShellDataFile cmdlet safely imports key-value pairs from hashtables defined in a .PSD1 file. The values could be imported using Invoke-Expression on the contents of the file. However, Invoke-Expression runs any code contained in the file.

    It is also possible this was just a mistake. In SafeValues.cs#L793-L796 they're creating a new scriptblock from the Extent:

    public object VisitScriptBlockExpression(ScriptBlockExpressionAst scriptBlockExpressionAst)
    {
        return ScriptBlock.Create(scriptBlockExpressionAst.Extent.Text);
    }
    

    Instead of accessing the ScriptBlockAst and getting a new scriptblock using GetScriptBlock():

    public object VisitScriptBlockExpression(ScriptBlockExpressionAst scriptBlockExpressionAst)
        => scriptBlockExpressionAst.ScriptBlock.GetScriptBlock();
    

    Then the result would be similar to what we get using Invoke-Expression:

    '@{ test = { 1 + 1 } }' | Out-File tmpconfig.psd1
    $tmpConfig = Get-Content .\tmpconfig.psd1 -Raw | Invoke-Expression
    $tmpConfig['test'].InvokeReturnAsIs() # 2
    

    If you want to dive deeper, they're using the Parser to get the hashtable from the psd1, see ImportPowerShellDataFile.cs#L60-L70.

    $ast = [System.Management.Automation.Language.Parser]::ParseFile(
        'tmpconfig.psd1',
        [ref] $null,
        [ref] $null)
    
    $hashAst = $ast.Find(
        { $args[0] -is [System.Management.Automation.Language.HashtableAst] }, $false)
    

    From here they use SafeGetValue() to get a hashtable from the HashtableAst:

    $hashtable = $hashAst.SafeGetValue()
    $hashtable['test'].InvokeReturnAsIs().InvokeReturnAsIs() # 2
    

    This code path also uses SafeGetValue() over the ScriptBlockExpressionAst to get the scriptblock from the dictionary entry value, which wraps the scriptblock in another scriptblock as was shown before:

    $sb = $hashAst.KeyValuePairs[0].Item2.PipelineElements[0].Expression.SafeGetValue()
    $sb.InvokeReturnAsIs().InvokeReturnAsIs() # 2