diff --git a/Rules/Strings.resx b/Rules/Strings.resx index 260214967..c7fd0f8da 100644 --- a/Rules/Strings.resx +++ b/Rules/Strings.resx @@ -1224,4 +1224,16 @@ AvoidUsingAllowUnencryptedAuthentication + + Use a single ValueFromPipeline parameter per parameter set + + + Use at most a single ValueFromPipeline parameter per parameter set to avoid undefined or unexpected behaviour. + + + Multiple parameters ({0}) in parameter set '{1}' are marked as ValueFromPipeline. Only one parameter per parameter set should accept pipeline input. + + + UseSingleValueFromPipelineParameter + \ No newline at end of file diff --git a/Rules/UseSingleValueFromPipelineParameter.cs b/Rules/UseSingleValueFromPipelineParameter.cs new file mode 100644 index 000000000..be123566b --- /dev/null +++ b/Rules/UseSingleValueFromPipelineParameter.cs @@ -0,0 +1,188 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Management.Automation.Language; +#if !CORECLR +using System.ComponentModel.Composition; +#endif + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules +{ +#if !CORECLR + [Export(typeof(IScriptRule))] +#endif + + /// + /// Rule that identifies parameter blocks with multiple parameters in + /// the same parameter set that are marked as ValueFromPipeline=true, which + /// can cause undefined behavior. + /// + public class UseSingleValueFromPipelineParameter : IScriptRule + { + private const string AllParameterSetsName = "__AllParameterSets"; + + /// + /// Analyzes the PowerShell AST for parameter sets with multiple ValueFromPipeline parameters. + /// + /// The PowerShell Abstract Syntax Tree to analyze. + /// The name of the file being analyzed (for diagnostic reporting). + /// A collection of diagnostic records for each violating parameter. + public IEnumerable AnalyzeScript(Ast ast, string fileName) + { + if (ast == null) + { + yield break; + } + // Find all param blocks that have a Parameter attribute with + // ValueFromPipeline set to true. + var paramBlocks = ast.FindAll(testAst => testAst is ParamBlockAst, true) + .Where(paramBlock => paramBlock.FindAll( + attributeAst => attributeAst is AttributeAst attr && + ParameterAttributeAstHasValueFromPipeline(attr), + true + ).Any()); + + foreach (var paramBlock in paramBlocks) + { + // Find all parameter declarations in the current param block + // Convert the generic ast objects into ParameterAst Objects + // For each ParameterAst, find all it's attributes that have + // ValueFromPipeline set to true (either explicitly or + // implicitly). Flatten the results into a single collection of + // Annonymous objects relating the parameter with it's attribute + // and then group them by parameter set name. + // + // + // https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_parameter_sets?#reserved-parameter-set-name + // + // The default parameter set name is '__AllParameterSets'. + // Not specifying a parameter set name and using the parameter + // set name '__AllParameterSets' are equivalent, so we shouldn't + // treat them like they're different just because one is an + // empty string and the other is not. + // + // Filter the list to only keep parameter sets that have more + // than one ValueFromPipeline parameter. + var parameterSetGroups = paramBlock.FindAll(n => n is ParameterAst, true) + .Cast() + .SelectMany(parameter => parameter.FindAll( + a => a is AttributeAst attr && ParameterAttributeAstHasValueFromPipeline(attr), + true + ).Cast().Select(attr => new { Parameter = parameter, Attribute = attr })) + .GroupBy(item => GetParameterSetForAttribute(item.Attribute) ?? AllParameterSetsName) + .Where(group => group.Count() > 1); + + + foreach (var group in parameterSetGroups) + { + // __AllParameterSets being the default name is...obscure. + // Instead we'll show the user "default". It's more than + // likely the user has not specified a parameter set name, + // so default will make sense. If they have used 'default' + // as their parameter set name, then we're still correct. + var parameterSetName = group.Key == AllParameterSetsName ? "default" : group.Key; + + // Create a concatenated string of parameter names that + // conflict in this parameter set + var parameterNames = string.Join(", ", group.Select(item => item.Parameter.Name.VariablePath.UserPath)); + + // We emit a diagnostic record for each offending parameter + // attribute in the parameter set so it's obvious where all the + // occurrences are. + foreach (var item in group) + { + var message = string.Format(CultureInfo.CurrentCulture, + Strings.UseSingleValueFromPipelineParameterError, + parameterNames, + parameterSetName); + + yield return new DiagnosticRecord( + message, + item.Attribute.Extent, + GetName(), + DiagnosticSeverity.Warning, + fileName, + parameterSetName); + } + } + } + } + + /// + /// Returns whether the specified AttributeAst represents a Parameter attribute + /// that has the ValueFromPipeline named argument set to true (either explicitly or + /// implicitly). + /// + /// The Parameter attribute to examine. + /// Whether the attribute has the ValueFromPipeline named argument set to true. + private static bool ParameterAttributeAstHasValueFromPipeline(AttributeAst attributeAst) + { + // Exit quickly if the attribute is null, has no named arguments, or + // is not a parameter attribute. + if (attributeAst?.NamedArguments == null || + !string.Equals(attributeAst.TypeName?.Name, "Parameter", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return attributeAst.NamedArguments + .OfType() + .Any(namedArg => string.Equals( + namedArg?.ArgumentName, + "ValueFromPipeline", + StringComparison.OrdinalIgnoreCase + // Helper.Instance.GetNamedArgumentAttributeValue handles both explicit ($true) + // and implicit (no value specified) ValueFromPipeline declarations + ) && Helper.Instance.GetNamedArgumentAttributeValue(namedArg)); + } + + /// + /// Gets the ParameterSetName value from a Parameter attribute. + /// + /// The Parameter attribute to examine. + /// The parameter set name, or null if not found or empty. + private static string GetParameterSetForAttribute(AttributeAst attributeAst) + { + // Exit quickly if the attribute is null, has no named arguments, or + // is not a parameter attribute. + if (attributeAst?.NamedArguments == null || + !string.Equals(attributeAst.TypeName.Name, "Parameter", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return attributeAst.NamedArguments + .OfType() + .Where(namedArg => string.Equals( + namedArg?.ArgumentName, + "ParameterSetName", + StringComparison.OrdinalIgnoreCase + )) + .Select(namedArg => namedArg?.Argument) + .OfType() + .Select(stringConstAst => stringConstAst?.Value) + .FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)); + } + + public string GetCommonName() => Strings.UseSingleValueFromPipelineParameterCommonName; + + public string GetDescription() => Strings.UseSingleValueFromPipelineParameterDescription; + + public string GetName() => string.Format( + CultureInfo.CurrentCulture, + Strings.NameSpaceFormat, + GetSourceName(), + Strings.UseSingleValueFromPipelineParameterName); + + public RuleSeverity GetSeverity() => RuleSeverity.Warning; + + public string GetSourceName() => Strings.SourceName; + + public SourceType GetSourceType() => SourceType.Builtin; + } +} \ No newline at end of file diff --git a/Tests/Engine/GetScriptAnalyzerRule.tests.ps1 b/Tests/Engine/GetScriptAnalyzerRule.tests.ps1 index 8d61c1c7f..c3b744803 100644 --- a/Tests/Engine/GetScriptAnalyzerRule.tests.ps1 +++ b/Tests/Engine/GetScriptAnalyzerRule.tests.ps1 @@ -63,7 +63,7 @@ Describe "Test Name parameters" { It "get Rules with no parameters supplied" { $defaultRules = Get-ScriptAnalyzerRule - $expectedNumRules = 70 + $expectedNumRules = 71 if ($PSVersionTable.PSVersion.Major -le 4) { # for PSv3 PSAvoidGlobalAliases is not shipped because diff --git a/Tests/Rules/UseSingleValueFromPipelineParameter.Tests.ps1 b/Tests/Rules/UseSingleValueFromPipelineParameter.Tests.ps1 new file mode 100644 index 000000000..77acb0898 --- /dev/null +++ b/Tests/Rules/UseSingleValueFromPipelineParameter.Tests.ps1 @@ -0,0 +1,326 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +BeforeAll { + $ruleName = 'PSUseSingleValueFromPipelineParameter' +} + +Describe 'UseSingleValueFromPipelineParameter' { + + Context 'When multiple parameters have ValueFromPipeline in same parameter set' { + + It "Should flag explicit ValueFromPipeline=`$true in default parameter set" { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ValueFromPipeline=$true)] + $InputObject, + + [Parameter(ValueFromPipeline=$true)] + $AnotherParam + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule $ruleName + $violations.Count | Should -Be 2 + $violations[0].Message | Should -Match "Multiple parameters \(InputObject, AnotherParam\) in parameter set 'default'" + } + + It 'Should flag implicit ValueFromPipeline in default parameter set' { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ValueFromPipeline)] + $InputObject, + + [Parameter(ValueFromPipeline)] + $SecondParam + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule $ruleName + $violations.Count | Should -Be 2 + } + + It 'Should flag mixed explicit and implicit ValueFromPipeline' { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ValueFromPipeline=$true)] + $InputObject, + + [Parameter(ValueFromPipeline)] + $SecondParam + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule $ruleName + $violations.Count | Should -Be 2 + } + + It 'Should flag multiple parameters in named parameter set' { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ValueFromPipeline=$true, ParameterSetName='MySet')] + $InputObject, + + [Parameter(ValueFromPipeline=$true, ParameterSetName='MySet')] + $SecondParam + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule $ruleName + $violations.Count | Should -Be 2 + $violations[0].Message | Should -Match "parameter set 'MySet'" + } + + It 'Should flag three parameters in same parameter set' { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ValueFromPipeline=$true)] + $First, + + [Parameter(ValueFromPipeline=$true)] + $Second, + + [Parameter(ValueFromPipeline=$true)] + $Third + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule $ruleName + $violations.Count | Should -Be 3 + $violations[0].Message | Should -Match 'Multiple parameters \(First, Second, Third\)' + } + } + + Context 'When parameters are in different parameter sets' { + + It 'Should not flag parameters in different parameter sets' { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ValueFromPipeline=$true, ParameterSetName='Set1')] + $InputObject1, + + [Parameter(ValueFromPipeline=$true, ParameterSetName='Set2')] + $InputObject2 + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule $ruleName + $violations.Count | Should -Be 0 + } + + It 'Should handle mix of named and default parameter sets correctly' { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ValueFromPipeline=$true)] + $DefaultSetParam, + + [Parameter(ValueFromPipeline=$true, ParameterSetName='NamedSet')] + $NamedSetParam + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule $ruleName + $violations.Count | Should -Be 0 + } + } + + Context 'When only one parameter has ValueFromPipeline' { + + It 'Should not flag single ValueFromPipeline parameter' { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ValueFromPipeline=$true)] + $InputObject, + + [Parameter()] + $OtherParam + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule $ruleName + $violations.Count | Should -Be 0 + } + } + + Context 'When ValueFromPipeline is explicitly set to false' { + + It "Should not flag parameters with ValueFromPipeline=`$false" { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ValueFromPipeline=$false)] + $InputObject, + + [Parameter(ValueFromPipeline=$false)] + $AnotherParam + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule $ruleName + $violations.Count | Should -Be 0 + } + + It 'Should only flag the true ValueFromPipeline parameter' { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ValueFromPipeline=$true)] + $TrueParam, + + [Parameter(ValueFromPipeline=$false)] + $FalseParam, + + [Parameter()] + $NoValueParam + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule $ruleName + $violations.Count | Should -Be 0 + } + } + + Context 'When non-Parameter attributes have ValueFromPipeline property' { + + It 'Should not flag custom attributes with ValueFromPipeline property' { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ValueFromPipeline=$true)] + [CustomAttribute(ValueFromPipeline=$true)] + $InputObject, + + [CustomAttribute(ValueFromPipeline=$true)] + $NonPipelineParam + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule $ruleName + $violations.Count | Should -Be 0 + } + + It 'Should not flag ValidateSet with ValueFromPipeline property' { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ValueFromPipeline=$true)] + [ValidateSet('Value1', 'Value2', ValueFromPipeline=$true)] + $InputObject, + + [ValidateSet('Value1', 'Value2', ValueFromPipeline=$true)] + $NonPipelineParam + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule $ruleName + $violations.Count | Should -Be 0 + } + } + + Context 'When there are no Parameter attributes' { + + It 'Should not flag functions without Parameter attributes' { + $scriptDefinition = @' +function Test-Function { + param( + $InputObject, + $AnotherParam + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule $ruleName + $violations.Count | Should -Be 0 + } + + It 'Should not flag functions with only non-ValueFromPipeline Parameter attributes' { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(Mandatory=$true)] + $InputObject, + + [Parameter(Position=0)] + $AnotherParam + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule $ruleName + $violations.Count | Should -Be 0 + } + } + + Context 'Complex parameter set scenarios' { + + It 'Should flag violations in multiple parameter sets independently' { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ValueFromPipeline=$true, ParameterSetName='Set1')] + $Set1Param1, + + [Parameter(ValueFromPipeline=$true, ParameterSetName='Set1')] + $Set1Param2, + + [Parameter(ValueFromPipeline=$true, ParameterSetName='Set2')] + $Set2Param1, + + [Parameter(ValueFromPipeline=$true, ParameterSetName='Set2')] + $Set2Param2 + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule $ruleName + $violations.Count | Should -Be 4 # 2 violations per parameter set, each parameter gets flagged + + # Check that both parameter sets are mentioned in violations + $violationMessages = $violations.Message -join ' ' + $violationMessages | Should -Match "parameter set 'Set1'" + $violationMessages | Should -Match "parameter set 'Set2'" + } + + It 'Should handle __AllParameterSets parameter set name correctly' { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ValueFromPipeline=$true, ParameterSetName='__AllParameterSets')] + $ExplicitAllSets, + + [Parameter(ValueFromPipeline=$true)] + $ImplicitAllSets + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule $ruleName + $violations.Count | Should -Be 2 + $violations[0].Message | Should -Match "parameter set 'default'" + } + } + + Context 'Suppression scenarios' { + + It 'Should be suppressible by parameter set name' { + $scriptDefinition = @' +function Test-Function { + [Diagnostics.CodeAnalysis.SuppressMessage('PSUseSingleValueFromPipelineParameter', 'MySet')] + param( + [Parameter(ValueFromPipeline=$true, ParameterSetName='MySet')] + $InputObject, + + [Parameter(ValueFromPipeline=$true, ParameterSetName='MySet')] + $AnotherParam + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule $ruleName + $violations.Count | Should -Be 0 + } + } +} \ No newline at end of file diff --git a/docs/Rules/README.md b/docs/Rules/README.md index 06f27d2da..6871355b5 100644 --- a/docs/Rules/README.md +++ b/docs/Rules/README.md @@ -75,6 +75,7 @@ The PSScriptAnalyzer contains the following rule definitions. | [UseProcessBlockForPipelineCommand](./UseProcessBlockForPipelineCommand.md) | Warning | Yes | | | [UsePSCredentialType](./UsePSCredentialType.md) | Warning | Yes | | | [UseShouldProcessForStateChangingFunctions](./UseShouldProcessForStateChangingFunctions.md) | Warning | Yes | | +| [UseSingleValueFromPipelineParameter](./UseSingleValueFromPipelineParameter.md) | Warning | Yes | | | [UseSingularNouns](./UseSingularNouns.md) | Warning | Yes | Yes | | [UseSupportsShouldProcess](./UseSupportsShouldProcess.md) | Warning | Yes | | | [UseToExportFieldsInManifest](./UseToExportFieldsInManifest.md) | Warning | Yes | | diff --git a/docs/Rules/UseSingleValueFromPipelineParameter.md b/docs/Rules/UseSingleValueFromPipelineParameter.md new file mode 100644 index 000000000..9cac391f5 --- /dev/null +++ b/docs/Rules/UseSingleValueFromPipelineParameter.md @@ -0,0 +1,101 @@ +--- +description: Use at most a single ValueFromPipeline parameter per parameter set. +ms.date: 08/08/2025 +ms.topic: reference +title: UseSingleValueFromPipelineParameter +--- +# UseSingleValueFromPipelineParameter + +**Severity Level: Warning** + +## Description + +Parameter sets should have at most one parameter marked as +`ValueFromPipeline=true`. + +This rule identifies functions where multiple parameters within the same +parameter set have `ValueFromPipeline` set to `true` (either explicitly or +implicitly). + +## How + +Ensure that only one parameter per parameter set accepts pipeline input by +value. If you need multiple parameters to accept different types of pipeline +input, use separate parameter sets. + +## Example + +### Wrong + +```powershell +function Process-Data { + [CmdletBinding()] + param( + [Parameter(ValueFromPipeline)] + [string]$InputData, + + [Parameter(ValueFromPipeline)] + [string]$ProcessingMode + ) + + process { + Write-Output "$ProcessingMode`: $InputData" + } +} +``` + + +### Correct + +```powershell +function Process-Data { + [CmdletBinding()] + param( + [Parameter(ValueFromPipeline)] + [string]$InputData, + + [Parameter(Mandatory)] + [string]$ProcessingMode + ) + process { + Write-Output "$ProcessingMode`: $InputData" + } +} +``` +## Suppression + +To suppress this rule for a specific parameter set, use the `SuppressMessage` +attribute with the parameter set name: + +```powershell +function Process-Data { + [Diagnostics.CodeAnalysis.SuppressMessage('PSUseSingleValueFromPipelineParameter', 'MyParameterSet')] + [CmdletBinding()] + param( + [Parameter(ValueFromPipeline, ParameterSetName='MyParameterSet')] + [string]$InputData, + + [Parameter(ValueFromPipeline, ParameterSetName='MyParameterSet')] + [string]$ProcessingMode + ) + process { + Write-Output "$ProcessingMode`: $InputData" + } +} +``` + +For the default parameter set, use `'default'` as the suppression target: + +```powershell +[Diagnostics.CodeAnalysis.SuppressMessage('PSUseSingleValueFromPipelineParameter', 'default')] +``` + +## Notes + +- This rule applies to both explicit `ValueFromPipeline=$true` and implicit + `ValueFromPipeline` (which is the same as using `=$true`) +- Parameters with `ValueFromPipeline=$false` are not flagged by this rule +- The rule correctly handles the default parameter set (`__AllParameterSets`) + and named parameter sets +- Different parameter sets can each have their own single `ValueFromPipeline` + parameter without triggering this rule