1+ // Copyright (c) Microsoft Corporation. All rights reserved.
2+ // Licensed under the MIT License.
3+
4+ using Microsoft . Windows . PowerShell . ScriptAnalyzer . Generic ;
5+ using System ;
6+ using System . Collections . Generic ;
7+ using System . Globalization ;
8+ using System . Linq ;
9+ using System . Management . Automation . Language ;
10+ #if ! CORECLR
11+ using System . ComponentModel . Composition ;
12+ #endif
13+
14+ namespace Microsoft . Windows . PowerShell . ScriptAnalyzer . BuiltinRules
15+ {
16+ #if ! CORECLR
17+ [ Export ( typeof ( IScriptRule ) ) ]
18+ #endif
19+
20+ /// <summary>
21+ /// Rule that identifies parameter blocks with multiple parameters in
22+ /// the same parameter set that are marked as ValueFromPipeline=true, which
23+ /// can cause undefined behavior.
24+ /// </summary>
25+ public class UseSingleValueFromPipelineParameter : IScriptRule
26+ {
27+ private const string AllParameterSetsName = "__AllParameterSets" ;
28+
29+ /// <summary>
30+ /// Analyzes the PowerShell AST for parameter sets with multiple ValueFromPipeline parameters.
31+ /// </summary>
32+ /// <param name="ast">The PowerShell Abstract Syntax Tree to analyze.</param>
33+ /// <param name="fileName">The name of the file being analyzed (for diagnostic reporting).</param>
34+ /// <returns>A collection of diagnostic records for each violating parameter.</returns>
35+ public IEnumerable < DiagnosticRecord > AnalyzeScript ( Ast ast , string fileName )
36+ {
37+ if ( ast == null )
38+ {
39+ yield break ;
40+ }
41+ // Find all param blocks that have a Parameter attribute with
42+ // ValueFromPipeline set to true.
43+ var paramBlocks = ast . FindAll ( testAst => testAst is ParamBlockAst , true )
44+ . Where ( paramBlock => paramBlock . FindAll (
45+ attributeAst => attributeAst is AttributeAst attr &&
46+ ParameterAttributeAstHasValueFromPipeline ( attr ) ,
47+ true
48+ ) . Any ( ) ) ;
49+
50+ foreach ( var paramBlock in paramBlocks )
51+ {
52+ // Find all parameter declarations in the current param block
53+ // Convert the generic ast objects into ParameterAst Objects
54+ // For each ParameterAst, find all it's attributes that have
55+ // ValueFromPipeline set to true (either explicitly or
56+ // implicitly). Flatten the results into a single collection of
57+ // Annonymous objects relating the parameter with it's attribute
58+ // and then group them by parameter set name.
59+ //
60+ //
61+ // https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_parameter_sets?#reserved-parameter-set-name
62+ //
63+ // The default parameter set name is '__AllParameterSets'.
64+ // Not specifying a parameter set name and using the parameter
65+ // set name '__AllParameterSets' are equivalent, so we shouldn't
66+ // treat them like they're different just because one is an
67+ // empty string and the other is not.
68+ //
69+ // Filter the list to only keep parameter sets that have more
70+ // than one ValueFromPipeline parameter.
71+ var parameterSetGroups = paramBlock . FindAll ( n => n is ParameterAst , true )
72+ . Cast < ParameterAst > ( )
73+ . SelectMany ( parameter => parameter . FindAll (
74+ a => a is AttributeAst attr && ParameterAttributeAstHasValueFromPipeline ( attr ) ,
75+ true
76+ ) . Cast < AttributeAst > ( ) . Select ( attr => new { Parameter = parameter , Attribute = attr } ) )
77+ . GroupBy ( item => GetParameterSetForAttribute ( item . Attribute ) ?? AllParameterSetsName )
78+ . Where ( group => group . Count ( ) > 1 ) ;
79+
80+
81+ foreach ( var group in parameterSetGroups )
82+ {
83+ // __AllParameterSets being the default name is...obscure.
84+ // Instead we'll show the user "default". It's more than
85+ // likely the user has not specified a parameter set name,
86+ // so default will make sense. If they have used 'default'
87+ // as their parameter set name, then we're still correct.
88+ var parameterSetName = group . Key == AllParameterSetsName ? "default" : group . Key ;
89+
90+ // Create a concatenated string of parameter names that
91+ // conflict in this parameter set
92+ var parameterNames = string . Join ( ", " , group . Select ( item => item . Parameter . Name . VariablePath . UserPath ) ) ;
93+
94+ // We emit a diagnostic record for each offending parameter
95+ // attribute in the parameter set so it's obvious where all the
96+ // occurrences are.
97+ foreach ( var item in group )
98+ {
99+ var message = string . Format ( CultureInfo . CurrentCulture ,
100+ Strings . UseSingleValueFromPipelineParameterError ,
101+ parameterNames ,
102+ parameterSetName ) ;
103+
104+ yield return new DiagnosticRecord (
105+ message ,
106+ item . Attribute . Extent ,
107+ GetName ( ) ,
108+ DiagnosticSeverity . Warning ,
109+ fileName ,
110+ parameterSetName ) ;
111+ }
112+ }
113+ }
114+ }
115+
116+ /// <summary>
117+ /// Returns whether the specified AttributeAst represents a Parameter attribute
118+ /// that has the ValueFromPipeline named argument set to true (either explicitly or
119+ /// implicitly).
120+ /// </summary>
121+ /// <param name="attributeAst">The Parameter attribute to examine.</param>
122+ /// <returns>Whether the attribute has the ValueFromPipeline named argument set to true.</returns>
123+ private static bool ParameterAttributeAstHasValueFromPipeline ( AttributeAst attributeAst )
124+ {
125+ // Exit quickly if the attribute is null, has no named arguments, or
126+ // is not a parameter attribute.
127+ if ( attributeAst ? . NamedArguments == null ||
128+ ! string . Equals ( attributeAst . TypeName ? . Name , "Parameter" , StringComparison . OrdinalIgnoreCase ) )
129+ {
130+ return false ;
131+ }
132+
133+ return attributeAst . NamedArguments
134+ . OfType < NamedAttributeArgumentAst > ( )
135+ . Any ( namedArg => string . Equals (
136+ namedArg ? . ArgumentName ,
137+ "ValueFromPipeline" ,
138+ StringComparison . OrdinalIgnoreCase
139+ // Helper.Instance.GetNamedArgumentAttributeValue handles both explicit ($true)
140+ // and implicit (no value specified) ValueFromPipeline declarations
141+ ) && Helper . Instance . GetNamedArgumentAttributeValue ( namedArg ) ) ;
142+ }
143+
144+ /// <summary>
145+ /// Gets the ParameterSetName value from a Parameter attribute.
146+ /// </summary>
147+ /// <param name="attributeAst">The Parameter attribute to examine.</param>
148+ /// <returns>The parameter set name, or null if not found or empty.</returns>
149+ private static string GetParameterSetForAttribute ( AttributeAst attributeAst )
150+ {
151+ // Exit quickly if the attribute is null, has no named arguments, or
152+ // is not a parameter attribute.
153+ if ( attributeAst ? . NamedArguments == null ||
154+ ! string . Equals ( attributeAst . TypeName . Name , "Parameter" , StringComparison . OrdinalIgnoreCase ) )
155+ {
156+ return null ;
157+ }
158+
159+ return attributeAst . NamedArguments
160+ . OfType < NamedAttributeArgumentAst > ( )
161+ . Where ( namedArg => string . Equals (
162+ namedArg ? . ArgumentName ,
163+ "ParameterSetName" ,
164+ StringComparison . OrdinalIgnoreCase
165+ ) )
166+ . Select ( namedArg => namedArg ? . Argument )
167+ . OfType < StringConstantExpressionAst > ( )
168+ . Select ( stringConstAst => stringConstAst ? . Value )
169+ . FirstOrDefault ( value => ! string . IsNullOrWhiteSpace ( value ) ) ;
170+ }
171+
172+ public string GetCommonName ( ) => Strings . UseSingleValueFromPipelineParameterCommonName ;
173+
174+ public string GetDescription ( ) => Strings . UseSingleValueFromPipelineParameterDescription ;
175+
176+ public string GetName ( ) => Strings . UseSingleValueFromPipelineParameterName ;
177+
178+ public RuleSeverity GetSeverity ( ) => RuleSeverity . Warning ;
179+
180+ public string GetSourceName ( ) => Strings . SourceName ;
181+
182+ public SourceType GetSourceType ( ) => SourceType . Builtin ;
183+ }
184+ }
0 commit comments