From e75bc15953989b4b3409a8c041b5306d258e70ab Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Wed, 21 Oct 2026 14:15:14 +0000 Subject: [PATCH] fix(access): complete quoted and wildcard words of the five parameters The completers for AccessType, AuditFlags, InheritanceFlags, PropagationFlags, and AppliesTo returned nothing for a quoted word (-AccessType "D) or a wildcard word (-AppliesTo *Only, -AccessType D*), which rc8's built-in enum-parameter completion still offered. PowerShell auto-closes an unterminated quote before it reaches the completer, so the word a completer receives for -AccessType "D is "D", not D. EnumArgumentCompletion.Complete now strips a leading quote and its matching trailing quote from the word before matching, matches the enumeration's names with WildcardPattern(word + "*", WildcardOptions.IgnoreCase) the way PowerShell's own enum completion does, and, when the word was quoted, returns each match in the same quote character (CompletionText 'FilesOnly' for a leading single quote). An unquoted, non-wildcard word is unaffected: appending "*" to it only turns an exact prefix match into a prefix-wildcard match. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- NTFSSecurity/EnumArgumentCompleters.cs | 27 ++++++++++++++++++---- Tests/ParameterSets.Access.Tests.ps1 | 31 ++++++++++++++++++++++++++ Tests/ParameterSets.Audit.Tests.ps1 | 27 ++++++++++++++++++++++ 3 files changed, 81 insertions(+), 4 deletions(-) diff --git a/NTFSSecurity/EnumArgumentCompleters.cs b/NTFSSecurity/EnumArgumentCompleters.cs index 87604d6..927d1ea 100644 --- a/NTFSSecurity/EnumArgumentCompleters.cs +++ b/NTFSSecurity/EnumArgumentCompleters.cs @@ -13,21 +13,40 @@ namespace NTFSSecurity internal static class EnumArgumentCompletion { /// - /// Returns the names of the enumeration that start with the word the user already typed, compared without - /// regard to case, in the order Enum.GetNames returns them. + /// Returns the names of the enumeration that match the word the user already typed, compared without + /// regard to case, in the order Enum.GetNames returns them. A leading quote is stripped before matching, + /// together with the matching trailing quote that PowerShell adds to a word it completes while the quote + /// is still open, and every match is returned quoted the same way, so that completing inside an open quote + /// keeps the command line valid. The word itself may already contain a wildcard, such as "*Only" or "D*", + /// the same as PowerShell completes a parameter of an enumeration type. /// /// The enumeration to complete the names of. /// The word the user already typed; empty or null completes every name. internal static IEnumerable Complete(Type enumType, string wordToComplete) { var word = wordToComplete ?? string.Empty; + char? quote = null; + + if (word.Length > 0 && (word[0] == '\'' || word[0] == '"')) + { + quote = word[0]; + word = word.Substring(1); + + if (word.Length > 0 && word[word.Length - 1] == quote.Value) + { + word = word.Substring(0, word.Length - 1); + } + } + + var pattern = new WildcardPattern(word + "*", WildcardOptions.IgnoreCase); var results = new List(); foreach (var name in Enum.GetNames(enumType)) { - if (name.StartsWith(word, StringComparison.OrdinalIgnoreCase)) + if (pattern.IsMatch(name)) { - results.Add(new CompletionResult(name, name, CompletionResultType.ParameterValue, name)); + var completionText = quote.HasValue ? quote.Value + name + quote.Value : name; + results.Add(new CompletionResult(completionText, name, CompletionResultType.ParameterValue, name)); } } diff --git a/Tests/ParameterSets.Access.Tests.ps1 b/Tests/ParameterSets.Access.Tests.ps1 index c3fd1c9..11189f1 100644 --- a/Tests/ParameterSets.Access.Tests.ps1 +++ b/Tests/ParameterSets.Access.Tests.ps1 @@ -1420,6 +1420,17 @@ Describe 'Invalid and valid values of AccessType, InheritanceFlags, PropagationF @{ Cmdlet = $cmdlet; Parameter = 'PropagationFlags'; EnumTypeName = 'System.Security.AccessControl.PropagationFlags' } @{ Cmdlet = $cmdlet; Parameter = 'AppliesTo'; EnumTypeName = 'Security2.ApplyTo' } } + + # R1-4: a quoted or a wildcard word completed in rc8 (a typed parameter of an enumeration); the first + # EnumArgumentCompleters.cs returned nothing for either shape. Each ExpectedName has no sibling name that + # also starts with it (ThisFolderOnly has no ThisFolderOnlyOneLevel), so the unquoted and quoted forms match + # it alone; the wildcard only needs to contain it among the matches. + $accessCompletionFormCases = @( + @{ Parameter = 'AccessType'; Word = 'D'; ExpectedName = 'Deny' } + @{ Parameter = 'InheritanceFlags'; Word = 'Container'; ExpectedName = 'ContainerInherit' } + @{ Parameter = 'PropagationFlags'; Word = 'Inh'; ExpectedName = 'InheritOnly' } + @{ Parameter = 'AppliesTo'; Word = 'ThisFolderO'; ExpectedName = 'ThisFolderOnly' } + ) } Context 'A bad value bound from the pipeline' { @@ -1753,6 +1764,26 @@ Describe 'Invalid and valid values of AccessType, InheritanceFlags, PropagationF It ' should declare - as [object]' -ForEach $accessCompleterCases { (Get-Command -Name $Cmdlet).Parameters[$Parameter].ParameterType | Should -Be ([object]) } + + It 'Should complete Add-NTFSAccess - for the unquoted, single-quoted, double-quoted, and wildcard forms of ' -ForEach $accessCompletionFormCases { + $base = 'Add-NTFSAccess -Path C:\x -Account a -AccessRights Read -{0} ' -f $Parameter + + $unquoted = $base + $Word + $r1 = [System.Management.Automation.CommandCompletion]::CompleteInput($unquoted, $unquoted.Length, $null) + @($r1.CompletionMatches.CompletionText) | Should -Contain $ExpectedName + + $singleQuoted = $base + "'" + $Word + $r2 = [System.Management.Automation.CommandCompletion]::CompleteInput($singleQuoted, $singleQuoted.Length, $null) + @($r2.CompletionMatches.CompletionText) | Should -Contain ("'" + $ExpectedName + "'") + + $doubleQuoted = $base + '"' + $Word + $r3 = [System.Management.Automation.CommandCompletion]::CompleteInput($doubleQuoted, $doubleQuoted.Length, $null) + @($r3.CompletionMatches.CompletionText) | Should -Contain ('"' + $ExpectedName + '"') + + $wildcard = $base + '*' + $ExpectedName.Substring($ExpectedName.Length - 3) + $r4 = [System.Management.Automation.CommandCompletion]::CompleteInput($wildcard, $wildcard.Length, $null) + @($r4.CompletionMatches.CompletionText) | Should -Contain $ExpectedName + } } } diff --git a/Tests/ParameterSets.Audit.Tests.ps1 b/Tests/ParameterSets.Audit.Tests.ps1 index fb2cb0d..f3c6868 100644 --- a/Tests/ParameterSets.Audit.Tests.ps1 +++ b/Tests/ParameterSets.Audit.Tests.ps1 @@ -1092,6 +1092,13 @@ Describe 'Invalid and valid values of AuditFlags, InheritanceFlags, PropagationF @{ Cmdlet = $cmdlet; Parameter = 'PropagationFlags'; EnumTypeName = 'System.Security.AccessControl.PropagationFlags' } @{ Cmdlet = $cmdlet; Parameter = 'AppliesTo'; EnumTypeName = 'Security2.ApplyTo' } } + + # R1-4: AuditFlags is the one completer that ParameterSets.Access.Tests.ps1 does not already cover with the + # quoted and wildcard forms (it shares the same completer code for InheritanceFlags, PropagationFlags, and + # AppliesTo, so those are pinned there). + $auditCompletionFormCases = @( + @{ Parameter = 'AuditFlags'; Word = 'F'; ExpectedName = 'Failure' } + ) } Context 'A bad value bound from the pipeline' { @@ -1413,5 +1420,25 @@ Describe 'Invalid and valid values of AuditFlags, InheritanceFlags, PropagationF It ' should declare - as [object]' -ForEach $auditCompleterCases { (Get-Command -Name $Cmdlet).Parameters[$Parameter].ParameterType | Should -Be ([object]) } + + It 'Should complete Add-NTFSAudit - for the unquoted, single-quoted, double-quoted, and wildcard forms of ' -ForEach $auditCompletionFormCases { + $base = 'Add-NTFSAudit -Path C:\x -Account a -AccessRights Read -{0} ' -f $Parameter + + $unquoted = $base + $Word + $r1 = [System.Management.Automation.CommandCompletion]::CompleteInput($unquoted, $unquoted.Length, $null) + @($r1.CompletionMatches.CompletionText) | Should -Contain $ExpectedName + + $singleQuoted = $base + "'" + $Word + $r2 = [System.Management.Automation.CommandCompletion]::CompleteInput($singleQuoted, $singleQuoted.Length, $null) + @($r2.CompletionMatches.CompletionText) | Should -Contain ("'" + $ExpectedName + "'") + + $doubleQuoted = $base + '"' + $Word + $r3 = [System.Management.Automation.CommandCompletion]::CompleteInput($doubleQuoted, $doubleQuoted.Length, $null) + @($r3.CompletionMatches.CompletionText) | Should -Contain ('"' + $ExpectedName + '"') + + $wildcard = $base + '*' + $ExpectedName.Substring($ExpectedName.Length - 3) + $r4 = [System.Management.Automation.CommandCompletion]::CompleteInput($wildcard, $wildcard.Length, $null) + @($r4.CompletionMatches.CompletionText) | Should -Contain $ExpectedName + } } }