From d29b36553ac4b8047362b3a898f1870e89ab396b Mon Sep 17 00:00:00 2001 From: kzrnm Date: Sat, 18 Oct 2025 00:55:56 +0900 Subject: [PATCH 1/2] Wrap value of StringConstantExpressionAst --- .../engine/NativeCommandParameterBinder.cs | 46 ++++++++++--------- .../engine/lang/parserutils.cs | 9 +++- .../engine/parser/Compiler.cs | 12 +++++ .../engine/runtime/ScriptBlockToPowerShell.cs | 11 +++++ 4 files changed, 56 insertions(+), 22 deletions(-) diff --git a/src/System.Management.Automation/engine/NativeCommandParameterBinder.cs b/src/System.Management.Automation/engine/NativeCommandParameterBinder.cs index 4115546a758..52394af0b68 100644 --- a/src/System.Management.Automation/engine/NativeCommandParameterBinder.cs +++ b/src/System.Management.Automation/engine/NativeCommandParameterBinder.cs @@ -93,35 +93,39 @@ internal void BindParameters(Collection parameters) if (parameter.ArgumentSpecified) { - // If this is the verbatim argument marker, we don't pass it on to the native command. - // We do need to remember it though - we'll expand environment variables in subsequent args. object argValue = parameter.ArgumentValue; - if (string.Equals("--%", argValue as string, StringComparison.OrdinalIgnoreCase)) - { - sawVerbatimArgumentMarker = true; - continue; - } if (argValue != AutomationNull.Value && argValue != UnboundParameter.Value) { - // ArrayLiteralAst is used to reconstruct the correct argument, e.g. - // windbg -k com:port=\\devbox\pipe\debug,pipe,resets=0,reconnect // The parser produced an array of strings but marked the parameter so we // can properly reconstruct the correct command line. - bool usedQuotes = false; - ArrayLiteralAst arrayLiteralAst = null; - switch (parameter?.ArgumentAst) + bool usedQuotes; + if (argValue is PSObject { BaseObject: string baseString } psObject && psObject.Properties[ParserOps.StringConstantType]?.Value is StringConstantType stp) { - case StringConstantExpressionAst sce: - usedQuotes = sce.StringConstantType != StringConstantType.BareWord; - break; - case ExpandableStringExpressionAst ese: - usedQuotes = ese.StringConstantType != StringConstantType.BareWord; - break; - case ArrayLiteralAst ala: - arrayLiteralAst = ala; - break; + usedQuotes = stp != StringConstantType.BareWord; + argValue = baseString; } + else + { + usedQuotes = parameter.ArgumentAst switch + { + StringConstantExpressionAst sce => sce.StringConstantType != StringConstantType.BareWord, + ExpandableStringExpressionAst ese => ese.StringConstantType != StringConstantType.BareWord, + _ => false, + }; + } + + // If this is the verbatim argument marker, we don't pass it on to the native command. + // We do need to remember it though - we'll expand environment variables in subsequent args. + if (string.Equals("--%", argValue as string, StringComparison.OrdinalIgnoreCase)) + { + sawVerbatimArgumentMarker = true; + continue; + } + + // ArrayLiteralAst is used to reconstruct the correct argument, e.g. + // windbg -k com:port=\\devbox\pipe\debug,pipe,resets=0,reconnect + ArrayLiteralAst arrayLiteralAst = parameter.ArgumentAst as ArrayLiteralAst; AppendOneNativeArgument(Context, parameter, argValue, arrayLiteralAst, sawVerbatimArgumentMarker, usedQuotes); } diff --git a/src/System.Management.Automation/engine/lang/parserutils.cs b/src/System.Management.Automation/engine/lang/parserutils.cs index 184c82c6815..798b2b383ad 100644 --- a/src/System.Management.Automation/engine/lang/parserutils.cs +++ b/src/System.Management.Automation/engine/lang/parserutils.cs @@ -11,7 +11,6 @@ using System.Management.Automation.Language; using System.Management.Automation.Runspaces; using System.Runtime.CompilerServices; -using System.Runtime.Serialization; using System.Text.RegularExpressions; using Dbg = System.Management.Automation.Diagnostics; @@ -232,6 +231,7 @@ public enum SplitOptions internal static class ParserOps { internal const string MethodNotFoundErrorId = "MethodNotFound"; + internal const string StringConstantType = "StringConstantType"; /// /// Construct the various caching structures used by the runtime routines... @@ -291,6 +291,13 @@ internal static PSObject WrappedNumber(object data, string text) return wrapped; } + internal static PSObject WrappedString(string text, StringConstantType stringConstantType) + { + var wrapped = new PSObject(text); + wrapped.Properties.Add(new PSNoteProperty(StringConstantType, stringConstantType), true); + return wrapped; + } + /// /// A helper routine that turns the argument object into an /// integer. It handles PSObject and conversions. diff --git a/src/System.Management.Automation/engine/parser/Compiler.cs b/src/System.Management.Automation/engine/parser/Compiler.cs index 5ce6a6c6dc8..e430487fbe8 100644 --- a/src/System.Management.Automation/engine/parser/Compiler.cs +++ b/src/System.Management.Automation/engine/parser/Compiler.cs @@ -366,6 +366,9 @@ internal static class CachedReflectionInfo internal static readonly MethodInfo ParserOps_UnarySplitOperator = typeof(ParserOps).GetMethod(nameof(ParserOps.UnarySplitOperator), StaticFlags); + internal static readonly MethodInfo ParserOps_WrappedString = + typeof(ParserOps).GetMethod(nameof(ParserOps.WrappedString), StaticFlags); + internal static readonly ConstructorInfo Pipe_ctor = typeof(Pipe).GetConstructor(InstanceFlags, null, CallingConventions.Standard, new Type[] { typeof(List) }, null); @@ -4248,6 +4251,10 @@ private Expression GetCommandArgumentExpression(CommandElementAst element) return Expression.Constant(ParserOps.WrappedNumber(constElement.Value, commandArgumentText)); } } + else if (constElement is StringConstantExpressionAst stringConstant) + { + return Expression.Constant(ParserOps.WrappedString(stringConstant.Value, stringConstant.StringConstantType)); + } var result = Compile(element); if (result.Type == typeof(object[])) @@ -4258,6 +4265,11 @@ private Expression GetCommandArgumentExpression(CommandElementAst element) { result = Expression.Call(CachedReflectionInfo.PipelineOps_CheckAutomationNullInCommandArgument, result); } + else if (element is ExpandableStringExpressionAst expandableString) + { + Debug.Assert(result.Type == typeof(string)); + result = Expression.Call(CachedReflectionInfo.ParserOps_WrappedString, result, Expression.Constant(expandableString.StringConstantType)); + } return result; } diff --git a/src/System.Management.Automation/engine/runtime/ScriptBlockToPowerShell.cs b/src/System.Management.Automation/engine/runtime/ScriptBlockToPowerShell.cs index defd87662e8..87dec903d39 100644 --- a/src/System.Management.Automation/engine/runtime/ScriptBlockToPowerShell.cs +++ b/src/System.Management.Automation/engine/runtime/ScriptBlockToPowerShell.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Diagnostics; using System.Globalization; using System.Linq; using System.Management.Automation.Language; @@ -811,6 +812,10 @@ private void ConvertCommand(CommandAst commandAst, bool isTrustedInput) argument = ParserOps.WrappedNumber(argument, commandArgumentText); } } + else if (constantExprAst is StringConstantExpressionAst stringConstant) + { + argument = ParserOps.WrappedString(stringConstant.Value, stringConstant.StringConstantType); + } else { if (!isTrustedInput) @@ -832,6 +837,12 @@ private void ConvertCommand(CommandAst commandAst, bool isTrustedInput) { argument = GetExpressionValue(exprAst, isTrustedInput); } + + if (ast is ExpandableStringExpressionAst expandableStringExpressionAst) + { + Debug.Assert(argument is string); + argument = ParserOps.WrappedString(argument as string, expandableStringExpressionAst.StringConstantType); + } } _powershell.AddArgument(argument); From cd908b64d3fae4931ee9d54128fb99b35ed5a04c Mon Sep 17 00:00:00 2001 From: kzrnm Date: Sat, 18 Oct 2025 03:24:10 +0900 Subject: [PATCH 2/2] Update splat tests --- .../NativeUnixGlobbing.Tests.ps1 | 135 ++++++++++++++---- .../NativeWindowsTildeExpansion.Tests.ps1 | 66 +++++++-- 2 files changed, 156 insertions(+), 45 deletions(-) diff --git a/test/powershell/Language/Scripting/NativeExecution/NativeUnixGlobbing.Tests.ps1 b/test/powershell/Language/Scripting/NativeExecution/NativeUnixGlobbing.Tests.ps1 index ae12076fed3..0dfd0f4d288 100644 --- a/test/powershell/Language/Scripting/NativeExecution/NativeUnixGlobbing.Tests.ps1 +++ b/test/powershell/Language/Scripting/NativeExecution/NativeUnixGlobbing.Tests.ps1 @@ -13,6 +13,8 @@ Describe 'Native UNIX globbing tests' -tags "CI" { $defaultParamValues = $PSDefaultParameterValues.Clone() $PSDefaultParameterValues["it:skip"] = $IsWindows + $HomeDir = $ExecutionContext.SessionState.Provider.Get("FileSystem").Home + $Tilde = "~" } AfterAll { @@ -74,39 +76,82 @@ Describe 'Native UNIX globbing tests' -tags "CI" { /bin/echo $arg | Should -BeExactly $arg } $quoteTests = @( - @{arg = '"*"'}, - @{arg = "'*'"} + @{arg = '"*"'; expectedArg = "*"} + @{arg = "'*'"; expectedArg = "*"} + @{arg = '"$TESTDRIVE/*"'; expectedArg = "$TESTDRIVE/*"} ) It 'Should not expand quoted strings: ' -TestCases $quoteTests { - param($arg) - Invoke-Expression "/bin/echo $arg" | Should -BeExactly '*' + param($arg, $expectedArg) + Invoke-Expression "testexe -echoargs $arg" | Should -BeExactly "Arg 0 is <$expectedArg>" } - # Splat tests are skipped because they should work, but don't. - # Supporting this scenario would require adding a NoteProperty - # to each quoted string argument - maybe not worth it, and maybe - # an argument for another way to suppress globbing. - It 'Should not expand quoted strings via splat array: ' -TestCases $quoteTests -Skip { - param($arg) + It 'Should not expand quoted strings via splat array: ' -TestCases $quoteTests { + param($arg, $expectedArg) - function Invoke-Echo + function Invoke-TestExe { - /bin/echo @args + testexe @args } - Invoke-Expression "Invoke-Echo $arg" | Should -BeExactly '*' + Invoke-Expression "Invoke-TestExe -echoargs $arg" | Should -BeExactly "Arg 0 is <$expectedArg>" } - It 'Should not expand quoted strings via splat hash: ' -TestCases $quoteTests -Skip { - param($arg) + It 'Should not expand quoted strings via splat hash: ' -TestCases $quoteTests { + param($arg, $expectedArg) function Invoke-Echo($quotedArg) { - /bin/echo @PSBoundParameters + testexe -echoargs @PSBoundParameters } - Invoke-Expression "Invoke-Echo -quotedArg:$arg" | Should -BeExactly "-quotedArg:*" + Invoke-Expression "Invoke-Echo -quotedArg:$arg" | Should -BeExactly "Arg 0 is <-quotedArg:$expectedArg>" # When specifing a space after the parameter, the space is removed when splatting. # This behavior is debatable, but it's worth adding this test anyway to detect # a change in behavior. - Invoke-Expression "Invoke-Echo -quotedArg: $arg" | Should -BeExactly "-quotedArg:*" + Invoke-Expression "Invoke-Echo -quotedArg: $arg" | Should -BeExactly "Arg 0 is <-quotedArg:$expectedArg>" + } + It 'Should expand strings via splat array' { + function Invoke-TestExe + { + $args.Length | Should -Be 2 + testexe @args + } + Invoke-TestExe -echoargs $TESTDRIVE/*.txt | Should -BeExactly @( + "Arg 0 is <$TESTDRIVE/abc.txt>" + "Arg 1 is <$TESTDRIVE/bbb.txt>" + "Arg 2 is <$TESTDRIVE/cbb.txt>" + ) + } + It 'Should keep its literal meaning when splatted' { + function Invoke-TestExe + { + testexe @args + } + Invoke-TestExe -echoargs $TESTDRIVE/*.txt "$TESTDRIVE/*.txt" '$TESTDRIVE/*.txt' | Should -BeExactly @( + "Arg 0 is <$TESTDRIVE/abc.txt>" + "Arg 1 is <$TESTDRIVE/bbb.txt>" + "Arg 2 is <$TESTDRIVE/cbb.txt>" + "Arg 3 is <$TESTDRIVE/*.txt>" + 'Arg 4 is <$TESTDRIVE/*.txt>' + ) + } + It 'Should not expand quoted strings via splat hash' { + function Invoke-EchoArgs($quotedArg) + { + $PSBoundParameters.Length | Should -Be 1 + testexe -echoargs @PSBoundParameters + } + Invoke-EchoArgs -quotedArg:$TESTDRIVE/*.txt | Should -BeExactly @( + "Arg 0 is <-quotedArg:$TESTDRIVE/abc.txt>" + "Arg 1 is <-quotedArg:$TESTDRIVE/bbb.txt>" + "Arg 2 is <-quotedArg:$TESTDRIVE/cbb.txt>" + ) + + # When specifing a space after the parameter, the space is removed when splatting. + # This behavior is debatable, but it's worth adding this test anyway to detect + # a change in behavior. + Invoke-EchoArgs -quotedArg: $TESTDRIVE/*.txt | Should -BeExactly @( + "Arg 0 is <-quotedArg:$TESTDRIVE/abc.txt>" + "Arg 1 is <-quotedArg:$TESTDRIVE/bbb.txt>" + "Arg 2 is <-quotedArg:$TESTDRIVE/cbb.txt>" + ) } # Test the behavior in non-filesystem drives It 'Should not expand patterns on non-filesystem drives' { @@ -119,17 +164,45 @@ Describe 'Native UNIX globbing tests' -tags "CI" { (/bin/ls $TESTDRIVE/foo*.txt).Length | Should -Be 2 } # Test ~ expansion - It 'Tilde should be replaced by the filesystem provider home directory' { - /bin/echo ~ | Should -BeExactly ($ExecutionContext.SessionState.Provider.Get("FileSystem").Home) - } - # Test ~ expansion with a path fragment (e.g. ~/foo) - It '~/foo should be replaced by the /foo' { - /bin/echo ~/foo | Should -BeExactly "$($ExecutionContext.SessionState.Provider.Get("FileSystem").Home)/foo" - } - It '~ should not be replaced when quoted' { - /bin/echo '~' | Should -BeExactly '~' - /bin/echo "~" | Should -BeExactly '~' - /bin/echo '~/foo' | Should -BeExactly '~/foo' - /bin/echo "~/foo" | Should -BeExactly '~/foo' - } + It '~ should be replaced by the filesystem provider home directory ' -testCases @( + @{arg = '~'; Expected = $HomeDir } + @{arg = '$Tilde'; Expected = $HomeDir } + @{arg = '~/foo'; Expected = "$HomeDir/foo" } + @{arg = '$Tilde/foo'; Expected = "$HomeDir/foo" } + ) { + param($arg, $Expected) + Invoke-Expression "testexe -echoargs $arg" | Should -BeExactly "Arg 0 is <$Expected>" + } + It '~ should not be replaced when quoted ' -testCases @( + @{arg = "'~'"; Expected = "~" } + @{arg = "'~/foo'"; Expected = "~/foo" } + @{arg = '"~"'; Expected = "~" } + @{arg = '"~/foo"'; Expected = "~/foo" } + @{arg = '"$Tilde"'; Expected = "~" } + @{arg = '"$Tilde/foo"'; Expected = "~/foo" } + ) { + param($arg, $Expected) + Invoke-Expression "testexe -echoargs $arg" | Should -BeExactly "Arg 0 is <$Expected>" + } + It '~ should keep its literal meaning when splatted '-testCases @( + @{ + splattingArgs = @' +~ ~/foo '~' "~" '~/foo' "~/foo" +'@; + Expected = @("$HomeDir", "$HomeDir/foo", "~", "~", "~/foo", "~/foo") + } + @{ + splattingArgs = @' +$Tilde $Tilde/foo "$Tilde" "$Tilde/foo" +'@; + Expected = @("$HomeDir", "$HomeDir/foo", "~", "~/foo") + } + ) { + param($splattingArgs, $Expected) + function Invoke-TestExe { + testexe @args + } + + Invoke-Expression "Invoke-TestExe -echoargs $splattingArgs" | Should -BeExactly @($Expected | ForEach-Object { $i = 0 } { "Arg {0} is <$_>" -f $i++ } ) + } } diff --git a/test/powershell/Language/Scripting/NativeExecution/NativeWindowsTildeExpansion.Tests.ps1 b/test/powershell/Language/Scripting/NativeExecution/NativeWindowsTildeExpansion.Tests.ps1 index 04156099fa4..b18d3d4c2a7 100644 --- a/test/powershell/Language/Scripting/NativeExecution/NativeWindowsTildeExpansion.Tests.ps1 +++ b/test/powershell/Language/Scripting/NativeExecution/NativeWindowsTildeExpansion.Tests.ps1 @@ -6,6 +6,8 @@ Describe 'Native Windows tilde expansion tests' -tags "CI" { $originalDefaultParams = $PSDefaultParameterValues.Clone() $PSDefaultParameterValues["it:skip"] = -Not $IsWindows $EnabledExperimentalFeatures.Contains('PSNativeWindowsTildeExpansion') | Should -BeTrue + $HomeDir = $ExecutionContext.SessionState.Provider.Get("FileSystem").Home + $Tilde = "~" } AfterAll { @@ -13,20 +15,56 @@ Describe 'Native Windows tilde expansion tests' -tags "CI" { } # Test ~ expansion - It 'Tilde should be replaced by the filesystem provider home directory' { - cmd /c echo ~ | Should -BeExactly ($ExecutionContext.SessionState.Provider.Get("FileSystem").Home) + It '~ should be replaced by the filesystem provider home directory ' -testCases @( + @{arg = '~'; Expected = $HomeDir } + @{arg = '$Tilde'; Expected = $HomeDir } + @{arg = '~/foo'; Expected = "$HomeDir/foo" } + @{arg = '~\foo'; Expected = "$HomeDir\foo" } + @{arg = '$Tilde/foo'; Expected = "$HomeDir/foo" } + @{arg = '$Tilde\foo'; Expected = "$HomeDir\foo" } + ) { + param($arg, $Expected) + Invoke-Expression "cmd /c echo $arg" | Should -BeExactly $Expected + Invoke-Expression "testexe -echoargs $arg" | Should -BeExactly "Arg 0 is <$Expected>" } - # Test ~ expansion with a path fragment (e.g. ~/foo) - It '~/foo should be replaced by the /foo' { - cmd /c echo ~/foo | Should -BeExactly "$($ExecutionContext.SessionState.Provider.Get("FileSystem").Home)/foo" - cmd /c echo ~\foo | Should -BeExactly "$($ExecutionContext.SessionState.Provider.Get("FileSystem").Home)\foo" + It '~ should not be replaced when quoted ' -testCases @( + @{arg = "'~'"; Expected = "~" } + @{arg = "'~/foo'"; Expected = "~/foo" } + @{arg = "'~\foo'"; Expected = "~\foo" } + @{arg = '"~"'; Expected = "~" } + @{arg = '"~/foo"'; Expected = "~/foo" } + @{arg = '"~\foo"'; Expected = "~\foo" } + @{arg = '"$Tilde"'; Expected = "~" } + @{arg = '"$Tilde/foo"'; Expected = "~/foo" } + @{arg = '"$Tilde\foo"'; Expected = "~\foo" } + ) { + param($arg, $Expected) + Invoke-Expression "cmd /c echo $arg" | Should -BeExactly $Expected + Invoke-Expression "testexe -echoargs $arg" | Should -BeExactly "Arg 0 is <$Expected>" + } + It '~ should keep its literal meaning when splatted '-testCases @( + @{ + splattingArgs = @' +~ ~/foo ~\foo '~' "~" '~/foo' "~/foo" '~\foo' "~\foo" +'@; + Expected = @("$HomeDir", "$HomeDir/foo", "$HomeDir\foo", "~", "~", "~/foo", "~/foo", "~\foo", "~\foo") + } + @{ + splattingArgs = @' +$Tilde $Tilde/foo $Tilde\foo "$Tilde" "$Tilde/foo" "$Tilde\foo" +'@; + Expected = @("$HomeDir", "$HomeDir/foo", "$HomeDir\foo", "~", "~/foo", "~\foo") + } + ) { + param($splattingArgs, $Expected) + function Invoke-Cmd { + cmd @args + } + function Invoke-TestExe { + testexe @args + } + + Invoke-Expression "Invoke-Cmd /c echo $splattingArgs" | Should -BeExactly ($Expected -join ' ') + Invoke-Expression "Invoke-TestExe -echoargs $splattingArgs" | Should -BeExactly @($Expected | ForEach-Object { $i = 0 } { "Arg {0} is <$_>" -f $i++ } ) } - It '~ should not be replaced when quoted' { - cmd /c echo '~' | Should -BeExactly '~' - cmd /c echo "~" | Should -BeExactly '~' - cmd /c echo '~/foo' | Should -BeExactly '~/foo' - cmd /c echo "~/foo" | Should -BeExactly '~/foo' - cmd /c echo '~\foo' | Should -BeExactly '~\foo' - cmd /c echo "~\foo" | Should -BeExactly '~\foo' - } }