diff --git a/Engine/Helper.cs b/Engine/Helper.cs index ae5858587..cbd8d0cd3 100644 --- a/Engine/Helper.cs +++ b/Engine/Helper.cs @@ -1853,6 +1853,188 @@ public Version GetPSVersion() return psVersionTable == null ? null : psVersionTable.PSVersion; } + /// + /// Evaluates all statically evaluable, side-effect-free expressions under an + /// expression AST to return a value. + /// Throws if an expression cannot be safely evaluated. + /// Attempts to replicate the GetSafeValue() method on PowerShell AST methods from PSv5. + /// + /// The expression AST to try to evaluate. + /// The .NET value represented by the PowerShell expression. + public static object GetSafeValueFromExpressionAst(ExpressionAst exprAst) + { + switch (exprAst) + { + case ConstantExpressionAst constExprAst: + // Note, this parses top-level command invocations as bareword strings + // However, forbidding this causes hashtable parsing to fail + // It is probably not worth the complexity to isolate this case + return constExprAst.Value; + + case VariableExpressionAst varExprAst: + // $true and $false are VariableExpressionAsts, so look for them here + switch (varExprAst.VariablePath.UserPath.ToLowerInvariant()) + { + case "true": + return true; + + case "false": + return false; + + case "null": + return null; + + default: + throw CreateInvalidDataExceptionFromAst(varExprAst); + } + + case ArrayExpressionAst arrExprAst: + + // Most cases are handled by the inner array handling, + // but we may have an empty array + if (arrExprAst.SubExpression?.Statements == null) + { + throw CreateInvalidDataExceptionFromAst(arrExprAst); + } + + if (arrExprAst.SubExpression.Statements.Count == 0) + { + return new object[0]; + } + + var listComponents = new List(); + // Arrays can either be array expressions (1, 2, 3) or array literals with statements @(1 `n 2 `n 3) + // Or they can be a combination of these + // We go through each statement (line) in an array and read the whole subarray + // This will also mean that @(1; 2) is parsed as an array of two elements, but there's not much point defending against this + foreach (StatementAst statement in arrExprAst.SubExpression.Statements) + { + if (!(statement is PipelineAst pipelineAst)) + { + throw CreateInvalidDataExceptionFromAst(arrExprAst); + } + + ExpressionAst pipelineExpressionAst = pipelineAst.GetPureExpression(); + if (pipelineExpressionAst == null) + { + throw CreateInvalidDataExceptionFromAst(arrExprAst); + } + + object arrayValue = GetSafeValueFromExpressionAst(pipelineExpressionAst); + // We might hit arrays like @(\n1,2,3\n4,5,6), which the parser sees as two statements containing array expressions + if (arrayValue is object[] subArray) + { + listComponents.AddRange(subArray); + continue; + } + + listComponents.Add(arrayValue); + } + return listComponents.ToArray(); + + + case ArrayLiteralAst arrLiteralAst: + return GetSafeValuesFromArrayAst(arrLiteralAst); + + case HashtableAst hashtableAst: + return GetSafeValueFromHashtableAst(hashtableAst); + + default: + // Other expression types are too complicated or fundamentally unsafe + throw CreateInvalidDataExceptionFromAst(exprAst); + } + } + + /// + /// Create a hashtable value from a PowerShell AST representing one, + /// provided that the PowerShell expression is statically evaluable and safe. + /// + /// The PowerShell representation of the hashtable value. + /// The Hashtable as a hydrated .NET value. + internal static Hashtable GetSafeValueFromHashtableAst(HashtableAst hashtableAst) + { + if (hashtableAst == null) + { + throw new ArgumentNullException(nameof(hashtableAst)); + } + + if (hashtableAst.KeyValuePairs == null) + { + throw CreateInvalidDataExceptionFromAst(hashtableAst); + } + + var hashtable = new Hashtable(); + foreach (Tuple entry in hashtableAst.KeyValuePairs) + { + // Get the key + object key = GetSafeValueFromExpressionAst(entry.Item1); + if (key == null) + { + throw CreateInvalidDataExceptionFromAst(entry.Item1); + } + + // Get the value + ExpressionAst valueExprAst = (entry.Item2 as PipelineAst)?.GetPureExpression(); + if (valueExprAst == null) + { + throw CreateInvalidDataExceptionFromAst(entry.Item2); + } + + // Add the key/value entry into the hydrated hashtable + hashtable[key] = GetSafeValueFromExpressionAst(valueExprAst); + } + + return hashtable; + } + + /// + /// Process a PowerShell array literal with statically evaluable/safe contents + /// into a .NET value. + /// + /// The PowerShell array AST to turn into a value. + /// The .NET value represented by PowerShell syntax. + private static object[] GetSafeValuesFromArrayAst(ArrayLiteralAst arrLiteralAst) + { + if (arrLiteralAst == null) + { + throw new ArgumentNullException(nameof(arrLiteralAst)); + } + + if (arrLiteralAst.Elements == null) + { + throw CreateInvalidDataExceptionFromAst(arrLiteralAst); + } + + var elements = new List(); + foreach (ExpressionAst exprAst in arrLiteralAst.Elements) + { + elements.Add(GetSafeValueFromExpressionAst(exprAst)); + } + + return elements.ToArray(); + } + + private static InvalidDataException CreateInvalidDataExceptionFromAst(Ast ast) + { + if (ast == null) + { + throw new ArgumentNullException(nameof(ast)); + } + + return CreateInvalidDataException(ast.Extent); + } + + private static InvalidDataException CreateInvalidDataException(IScriptExtent extent) + { + return new InvalidDataException(string.Format( + CultureInfo.CurrentCulture, + Strings.WrongValueFormat, + extent.StartLineNumber, + extent.StartColumnNumber, + extent.File ?? "")); + } + + #endregion Methods } diff --git a/Engine/Settings.cs b/Engine/Settings.cs index 506733cc2..b13f9405b 100644 --- a/Engine/Settings.cs +++ b/Engine/Settings.cs @@ -475,7 +475,7 @@ private void parseSettingsFile(string settingsFilePath) { // ideally we should use HashtableAst.SafeGetValue() but since // it is not available on PSv3, we resort to our own narrow implementation. - hashtable = GetSafeValueFromHashtableAst(hashTableAst); + hashtable = Helper.GetSafeValueFromHashtableAst(hashTableAst); } catch (InvalidOperationException e) { @@ -494,187 +494,6 @@ private void parseSettingsFile(string settingsFilePath) parseSettingsHashtable(hashtable); } - /// - /// Evaluates all statically evaluable, side-effect-free expressions under an - /// expression AST to return a value. - /// Throws if an expression cannot be safely evaluated. - /// Attempts to replicate the GetSafeValue() method on PowerShell AST methods from PSv5. - /// - /// The expression AST to try to evaluate. - /// The .NET value represented by the PowerShell expression. - private static object GetSafeValueFromExpressionAst(ExpressionAst exprAst) - { - switch (exprAst) - { - case ConstantExpressionAst constExprAst: - // Note, this parses top-level command invocations as bareword strings - // However, forbidding this causes hashtable parsing to fail - // It is probably not worth the complexity to isolate this case - return constExprAst.Value; - - case VariableExpressionAst varExprAst: - // $true and $false are VariableExpressionAsts, so look for them here - switch (varExprAst.VariablePath.UserPath.ToLowerInvariant()) - { - case "true": - return true; - - case "false": - return false; - - case "null": - return null; - - default: - throw CreateInvalidDataExceptionFromAst(varExprAst); - } - - case ArrayExpressionAst arrExprAst: - - // Most cases are handled by the inner array handling, - // but we may have an empty array - if (arrExprAst.SubExpression?.Statements == null) - { - throw CreateInvalidDataExceptionFromAst(arrExprAst); - } - - if (arrExprAst.SubExpression.Statements.Count == 0) - { - return new object[0]; - } - - var listComponents = new List(); - // Arrays can either be array expressions (1, 2, 3) or array literals with statements @(1 `n 2 `n 3) - // Or they can be a combination of these - // We go through each statement (line) in an array and read the whole subarray - // This will also mean that @(1; 2) is parsed as an array of two elements, but there's not much point defending against this - foreach (StatementAst statement in arrExprAst.SubExpression.Statements) - { - if (!(statement is PipelineAst pipelineAst)) - { - throw CreateInvalidDataExceptionFromAst(arrExprAst); - } - - ExpressionAst pipelineExpressionAst = pipelineAst.GetPureExpression(); - if (pipelineExpressionAst == null) - { - throw CreateInvalidDataExceptionFromAst(arrExprAst); - } - - object arrayValue = GetSafeValueFromExpressionAst(pipelineExpressionAst); - // We might hit arrays like @(\n1,2,3\n4,5,6), which the parser sees as two statements containing array expressions - if (arrayValue is object[] subArray) - { - listComponents.AddRange(subArray); - continue; - } - - listComponents.Add(arrayValue); - } - return listComponents.ToArray(); - - - case ArrayLiteralAst arrLiteralAst: - return GetSafeValuesFromArrayAst(arrLiteralAst); - - case HashtableAst hashtableAst: - return GetSafeValueFromHashtableAst(hashtableAst); - - default: - // Other expression types are too complicated or fundamentally unsafe - throw CreateInvalidDataExceptionFromAst(exprAst); - } - } - - /// - /// Process a PowerShell array literal with statically evaluable/safe contents - /// into a .NET value. - /// - /// The PowerShell array AST to turn into a value. - /// The .NET value represented by PowerShell syntax. - private static object[] GetSafeValuesFromArrayAst(ArrayLiteralAst arrLiteralAst) - { - if (arrLiteralAst == null) - { - throw new ArgumentNullException(nameof(arrLiteralAst)); - } - - if (arrLiteralAst.Elements == null) - { - throw CreateInvalidDataExceptionFromAst(arrLiteralAst); - } - - var elements = new List(); - foreach (ExpressionAst exprAst in arrLiteralAst.Elements) - { - elements.Add(GetSafeValueFromExpressionAst(exprAst)); - } - - return elements.ToArray(); - } - - /// - /// Create a hashtable value from a PowerShell AST representing one, - /// provided that the PowerShell expression is statically evaluable and safe. - /// - /// The PowerShell representation of the hashtable value. - /// The Hashtable as a hydrated .NET value. - private static Hashtable GetSafeValueFromHashtableAst(HashtableAst hashtableAst) - { - if (hashtableAst == null) - { - throw new ArgumentNullException(nameof(hashtableAst)); - } - - if (hashtableAst.KeyValuePairs == null) - { - throw CreateInvalidDataExceptionFromAst(hashtableAst); - } - - var hashtable = new Hashtable(); - foreach (Tuple entry in hashtableAst.KeyValuePairs) - { - // Get the key - object key = GetSafeValueFromExpressionAst(entry.Item1); - if (key == null) - { - throw CreateInvalidDataExceptionFromAst(entry.Item1); - } - - // Get the value - ExpressionAst valueExprAst = (entry.Item2 as PipelineAst)?.GetPureExpression(); - if (valueExprAst == null) - { - throw CreateInvalidDataExceptionFromAst(entry.Item2); - } - - // Add the key/value entry into the hydrated hashtable - hashtable[key] = GetSafeValueFromExpressionAst(valueExprAst); - } - - return hashtable; - } - - private static InvalidDataException CreateInvalidDataExceptionFromAst(Ast ast) - { - if (ast == null) - { - throw new ArgumentNullException(nameof(ast)); - } - - return CreateInvalidDataException(ast.Extent); - } - - private static InvalidDataException CreateInvalidDataException(IScriptExtent extent) - { - return new InvalidDataException(string.Format( - CultureInfo.CurrentCulture, - Strings.WrongValueFormat, - extent.StartLineNumber, - extent.StartColumnNumber, - extent.File ?? "")); - } - private static bool IsBuiltinSettingPreset(object settingPreset) { var preset = settingPreset as string; diff --git a/Rules/UseShouldProcessCorrectly.cs b/Rules/UseShouldProcessCorrectly.cs index e08ca098a..74b330a60 100644 --- a/Rules/UseShouldProcessCorrectly.cs +++ b/Rules/UseShouldProcessCorrectly.cs @@ -299,48 +299,101 @@ private bool SupportsShouldProcess(string cmdName) return false; } - var cmdInfo = Helper.Instance.GetCommandInfo(cmdName); + CommandInfo cmdInfo = Helper.Instance.GetCommandInfo(cmdName); if (cmdInfo == null) { return false; } - var cmdletInfo = cmdInfo as CmdletInfo; - if (cmdletInfo == null) + switch (cmdInfo) { - // check if it is of functioninfo type - var funcInfo = cmdInfo as FunctionInfo; - if (funcInfo != null - && funcInfo.CmdletBinding - && funcInfo.ScriptBlock != null - && funcInfo.ScriptBlock.Attributes != null) - { - foreach (var attr in funcInfo.ScriptBlock.Attributes) + case CmdletInfo cmdletInfo: + return cmdletInfo.ImplementingType.GetCustomAttribute(inherit: true).SupportsShouldProcess; + + case FunctionInfo functionInfo: + try { - var cmdletBindingAttr = attr as CmdletBindingAttribute; - if (cmdletBindingAttr != null) + if (!functionInfo.CmdletBinding + || functionInfo.ScriptBlock?.Attributes == null) + { + break; + } + + foreach (CmdletBindingAttribute cmdletBindingAttribute in functionInfo.ScriptBlock.Attributes.OfType()) { - return cmdletBindingAttr.SupportsShouldProcess; + return cmdletBindingAttribute.SupportsShouldProcess; + } + } + catch + { + // functionInfo.ScriptBlock.Attributes may throw if it cannot resolve an attribute type + // Instead we fall back to AST analysis + // See: https://github.com/PowerShell/PSScriptAnalyzer/issues/1217 + if (TryGetShouldProcessValueFromAst(functionInfo, out bool hasShouldProcessSet)) + { + return hasShouldProcessSet; } } - } - return false; + break; } - var attributes = cmdletInfo.ImplementingType.GetTypeInfo().GetCustomAttributes( - typeof(System.Management.Automation.CmdletCommonMetadataAttribute), - true); + return false; + } + + /// + /// Attempt to find whether a function has SupportsShouldProcess set based on its AST. + /// + /// The function info object referring to the function. + /// True if SupportsShouldProcess is set, false if not. Value is not valid if this method returns false. + /// True if a value for SupportsShouldProcess was found, false otherwise. + private bool TryGetShouldProcessValueFromAst(FunctionInfo functionInfo, out bool hasShouldProcessSet) + { + // Get the body of the function + ScriptBlockAst functionBodyAst = (ScriptBlockAst)functionInfo.ScriptBlock.Ast.Find(ast => ast is ScriptBlockAst, searchNestedScriptBlocks: false); - foreach (var attr in attributes) + // Go through attributes on the parameter block, since this is where [CmdletBinding()] will be + foreach (AttributeAst attributeAst in functionBodyAst.ParamBlock.Attributes) { - var cmdletAttribute = attr as System.Management.Automation.CmdletAttribute; - if (cmdletAttribute != null) + // We're looking for [CmdletBinding()] + if (!attributeAst.TypeName.FullName.Equals("CmdletBinding", StringComparison.OrdinalIgnoreCase)) { - return cmdletAttribute.SupportsShouldProcess; + continue; + } + + foreach (NamedAttributeArgumentAst namedArgumentAst in attributeAst.NamedArguments) + { + // We want [CmdletBinding(SupportsShouldProcess)] + if (!namedArgumentAst.ArgumentName.Equals("SupportsShouldProcess", StringComparison.OrdinalIgnoreCase)) + + { + continue; + } + + // [CmdletBinding(SupportsShouldProcess)] is the same as [CmdletBinding(SupportsShouldProcess = $true)] + if (namedArgumentAst.ExpressionOmitted) + { + hasShouldProcessSet = true; + return true; + } + + // Otherwise try to get the value assigned to the parameter, and assume false if value cannot be determined + try + { + hasShouldProcessSet = LanguagePrimitives.IsTrue( + Helper.GetSafeValueFromExpressionAst( + namedArgumentAst.Argument)); + } + catch + { + hasShouldProcessSet = false; + } + + return true; } } + hasShouldProcessSet = false; return false; } diff --git a/Tests/Engine/Settings.tests.ps1 b/Tests/Engine/Settings.tests.ps1 index 4a5969cb1..cfd814274 100644 --- a/Tests/Engine/Settings.tests.ps1 +++ b/Tests/Engine/Settings.tests.ps1 @@ -251,8 +251,6 @@ Describe "Settings Class" { @{ Expr = ';)' } ) - $gsvMethod = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Settings].GetMethod('GetSafeValueFromExpressionAst', [System.Reflection.BindingFlags]'nonpublic,static') - function ShouldBeDeeplyEqual { param( @@ -294,7 +292,7 @@ Describe "Settings Class" { { @(,$inputVal) | ShouldBeDeeplyEqual -To $To[$i] continue - } + } $inputVal | ShouldBeDeeplyEqual -To $To[$i] } return @@ -311,7 +309,7 @@ Describe "Settings Class" { $exprAst = [System.Management.Automation.Language.Parser]::ParseInput($Expr, [ref]$null, [ref]$null) $exprAst = $exprAst.Find({$args[0] -is [System.Management.Automation.Language.ExpressionAst]}, $true) - $gsvVal = $gsvMethod.Invoke($null, @($exprAst)) + $gsvVal = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Helper]::GetSafeValueFromExpressionAst($exprAst) $gsvVal | Should -Be $pwshVal } @@ -323,7 +321,7 @@ Describe "Settings Class" { $exprAst = [System.Management.Automation.Language.Parser]::ParseInput($Expr, [ref]$null, [ref]$null) $exprAst = $exprAst.Find({$args[0] -is [System.Management.Automation.Language.ExpressionAst]}, $true) - $gsvVal = $gsvMethod.Invoke($null, @($exprAst)) + $gsvVal = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Helper]::GetSafeValueFromExpressionAst($exprAst) # Need to test the type like this so that the pipeline doesn't unwrap the type, @@ -340,7 +338,7 @@ Describe "Settings Class" { $exprAst = [System.Management.Automation.Language.Parser]::ParseInput($Expr, [ref]$null, [ref]$null) $exprAst = $exprAst.Find({$args[0] -is [System.Management.Automation.Language.ExpressionAst]}, $true) - $gsvVal = $gsvMethod.Invoke($null, @($exprAst)) + $gsvVal = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Helper]::GetSafeValueFromExpressionAst($exprAst) $gsvVal | Should -BeOfType [hashtable] $gsvVal | ShouldBeDeeplyEqual -To $pwshVal @@ -358,7 +356,7 @@ Describe "Settings Class" { $expectedError = 'ArgumentNullException' } - { $gsvVal = $gsvMethod.Invoke($null, @($exprAst)) } | Should -Throw -ErrorId $expectedError + { $gsvVal = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Helper]::GetSafeValueFromExpressionAst($exprAst) } | Should -Throw -ErrorId $expectedError } } }