From 522fd7fa06244cea94e2537cac2b63b4f0366e34 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Wed, 21 Oct 2026 12:29:56 +0000 Subject: [PATCH] fix(items): match -Filter of Get-ChildItem2 with the dot rules of Windows PowerShell 5.1 Get-ChildItem2 -Filter now applies a single matcher, in the cmdlet, that follows the Win32 "DOS" wildcard rules Windows PowerShell 5.1 applies to Get-ChildItem -Filter, on the real name of the item, in both editions (Decision 27, item 4, option B). Before this change the pattern was matched twice: once by the AlphaFS enumeration and once by the cmdlet with a WildcardPattern that read a dot as an ordinary character, so the two editions, and Get-ChildItem itself, disagreed on patterns with a dot. AlphaFS now always enumerates with "*", and the new matcher decides alone. What a user sees changing: - "Report.*" also returns "Report", which has no dot (previously only names with a dot matched a pattern ending in ".*"). - A pattern that ends in a dot, such as "Report." or "Rep*.", now returns the names without a dot, instead of nothing. - "*.*.*" now returns every item, the same as "*.*" and a plain "*", instead of only names with two literal dots. - Breaking: an empty -Filter ('') now returns every item, like no filter at all, instead of nothing. This matches the policy of Windows PowerShell 5.1 for every other dot pattern; a script that relied on an empty -Filter returning nothing should pass -Filter '*' explicitly to keep that behavior from before. - A single dot ('.') as -Filter also returns every item, matching Get-ChildItem. - Get-ChildItem2 deliberately no longer follows PowerShell 7's own Get-ChildItem -Filter for a pattern that ends in a dot (which there returns nothing) or for "*.?" (which there returns every item): it follows the Windows PowerShell 5.1 rules in both editions instead, so the two editions of Get-ChildItem2 agree with each other. - Unaffected: -Filter matching by the real name and not the 8.3 short name, brackets and backticks as ordinary characters, case-insensitive matching with the invariant culture, the null-Filter error, and every other parameter and its interaction with -Filter. The matcher was derived and checked against a differential oracle of real Windows PowerShell 5.1 Get-ChildItem -Filter runs: two independent sets of 8.3-compliant names (22 and 19 names) against 54,654 generated patterns each (alphabet a, b, c, ., *, ? up to length 6; the remaining 1,333 of the 55,987 generated patterns per set are refused by the .NET enumeration API itself for ending in ".." and are not part of either product's -Filter semantics), with zero differences in both sets. Tests (Tests\ItemCmdlets.Tests.ps1, "Get-ChildItem2" > "Recursion, type filters, and depth"): - Changed the dot-rules table (pins the new values for "Report.*", "Report.", "Rep*.", "*.", and an empty -Filter) and the dedicated "*.*.*" test (now expects every item), both of which pinned the old, changed-on-purpose behavior. - Added three rows to the existing short-name-sensitive test, to pin that the new matcher still reads the real name, not the short name, for the trailing-dot and "X.*" rule families. - Added a 36-row truth table (22 fixed 8.3-compliant names, patterns from every family the WI calls out) with the literal result of Windows PowerShell 5.1 as static, hand-verified data; the test runs no Get-ChildItem of its own. - Added -Recurse with a selective -Filter across three folder levels, a dot -Filter with -File and -Directory, an empty -Filter, and a -Filter on a -Path that names a file. Docs\Cmdlets\Get-ChildItem2.md: restated the -Filter parameter description and the matching note in NOTES with the Windows PowerShell 5.1 policy and the deliberate PowerShell 7 difference, and removed the sentences that described the dot rules as a limitation or a mismatch with Get-ChildItem in Windows PowerShell 5.1. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Docs/Cmdlets/Get-ChildItem2.md | 4 +- NTFSSecurity/ItemCmdlets/GetChildItem2.cs | 213 ++++++++++++++++++++-- Tests/ItemCmdlets.Tests.ps1 | 147 +++++++++++++-- 3 files changed, 334 insertions(+), 30 deletions(-) diff --git a/Docs/Cmdlets/Get-ChildItem2.md b/Docs/Cmdlets/Get-ChildItem2.md index 52275af..dccbb2e 100644 --- a/Docs/Cmdlets/Get-ChildItem2.md +++ b/Docs/Cmdlets/Get-ChildItem2.md @@ -142,7 +142,7 @@ Accept wildcard characters: False ### -Filter -Specifies a name pattern that an item must match to be returned. The pattern supports the asterisk and the question mark as wildcard characters, an asterisk for any number of characters and a question mark for exactly one, and the match ignores case. Any other character stands for itself; a bracket is an ordinary character, so `Report[1].txt` returns the file of that name. As for `Get-ChildItem`, a pattern of an asterisk, a dot, and an asterisk returns every item, also an item without a dot in its name. The default value is `*`, which returns every item. The pattern is applied to the name of each item, not to its path, and during a recursive listing it restricts only the returned items; the cmdlet still descends into every subfolder. +Specifies a name pattern that an item must match to be returned. The pattern follows the wildcard rules that Windows PowerShell 5.1 applies to `Get-ChildItem -Filter`, in both editions, on the real name of the item, not its 8.3 short name: an asterisk matches a run of characters, a question mark matches one character that is not a dot (nothing where a dot or the end of the name follows), and a dot also matches nothing where only wildcards or the end of the pattern follow, so that `Report.*` also returns `Report`, which has no dot, and `*.*` returns every item. The match ignores case and is not affected by the culture of the session. Any other character stands for itself, including a bracket, so `Report[1].txt` returns the file of that name. PowerShell 7 deliberately differs here from the built-in `Get-ChildItem`, whose own `-Filter` returns nothing for a pattern that ends in a dot and returns every item for `*.?`; `Get-ChildItem2` follows the Windows PowerShell 5.1 rules in both editions instead. The default value is `*`, which returns every item, and an empty value also returns every item. The pattern is applied to the name of each item, not to its path, and during a recursive listing it restricts only the returned items; the cmdlet still descends into every subfolder. ```yaml Type: String @@ -319,7 +319,7 @@ Before 5.0.0, a `-Path` value that points to a file stopped the cmdlet with an ` Before 5.0.0, `-Filter` read a bracket as the start of a character class, so a file with brackets in its name, such as `Report[1].txt`, was not returned for its name, and a pattern of an asterisk, a dot, and an asterisk dropped the items without a dot in their names, most folders among them. -The names are matched twice, by the enumeration of the AlphaFS library and by the cmdlet, which reads a dot as an ordinary character, and their rules for a dot can differ from those of `Get-ChildItem`: a pattern such as `Report.*` does not return the file `Report`, which has no dot, a pattern that ends in a dot returns nothing, and an empty value returns nothing. Only the pattern of an asterisk, a dot, and an asterisk is treated as a single asterisk. +The cmdlet enumerates broadly and matches each name once, with the Win32 "DOS" wildcard rules that Windows PowerShell 5.1 applies to `Get-ChildItem -Filter`, in both editions (Decision 27, item 4): `Report.*` also returns `Report`, which has no dot, a pattern that ends in a dot returns the names without a dot, and an empty value returns every item, like `*`. PowerShell 7 itself differs from Windows PowerShell 5.1 here: its own `Get-ChildItem -Filter` returns nothing for a pattern that ends in a dot and returns every item for `*.?`. `Get-ChildItem2` follows the Windows PowerShell 5.1 rules in both editions instead of the edition it runs in, so this is a deliberate difference from the built-in `Get-ChildItem` in PowerShell 7 for these two pattern shapes. Before 5.0.0, a `break`, a `continue`, or a `throw` in a later command of the pipeline did not end the cmdlet for an item below the first folder, also when the later command took the error of a folder that the cmdlet cannot read, for example with `2>&1`. diff --git a/NTFSSecurity/ItemCmdlets/GetChildItem2.cs b/NTFSSecurity/ItemCmdlets/GetChildItem2.cs index 66ad109..313d6d9 100644 --- a/NTFSSecurity/ItemCmdlets/GetChildItem2.cs +++ b/NTFSSecurity/ItemCmdlets/GetChildItem2.cs @@ -26,8 +26,6 @@ namespace NTFSSecurity private bool identifyHardLinks = false; private int? depth; - WildcardPattern wildcard = null; - System.Reflection.MethodInfo modeMethodInfo = null; [Parameter(Position = 1, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] @@ -146,15 +144,9 @@ namespace NTFSSecurity paths = new List() { GetCurrentLocation() }; } - // Only * and ? are wildcards, like in the pattern that the enumeration matches; a bracket or a backtick stands - // for itself. Before 5.0.0, [1] was read as a character class, so a file with brackets in its name was not - // returned for its name. The enumeration returns every item for *.* as Windows does, so the comparison does - // too; with the dot as an ordinary character, it would drop the items without a dot, most folders. - // CultureInvariant: like the enumeration (see MoveNextInvariant), the comparison would otherwise treat I and i as - // different letters with tr-TR and drop items that the enumeration returned. - var pattern = filter == "*.*" ? "*" : filter; - wildcard = new WildcardPattern(pattern.Replace("`", "``").Replace("[", "`[").Replace("]", "`]"), WildcardOptions.Compiled | WildcardOptions.IgnoreCase | WildcardOptions.CultureInvariant); - + // The names are matched by DosWildcard, not by the enumeration (see WriteFileSystem, which always asks + // AlphaFS for "*"), so there is nothing to pre-compile here: DosWildcard reads the raw value of -Filter + // at the time each name is compared. modeMethodInfo = typeof(FileSystemCodeMembers).GetMethod("Mode"); getFileSystemModeProperty = (bool)((Hashtable)MyInvocation.MyCommand.Module.PrivateData)["GetFileSystemModeProperty"]; @@ -211,7 +203,10 @@ namespace NTFSSecurity IEnumerator files; using (new InvariantCultureScope()) { - files = di.EnumerateDirectories(filter, global::System.IO.SearchOption.TopDirectoryOnly).GetEnumerator(); + // The enumeration applies no pattern of its own; DosWildcard decides in + // WriteFileSystemInfoCollection, on the real name, with the dot rules of Windows + // PowerShell 5.1. + files = di.EnumerateDirectories("*", global::System.IO.SearchOption.TopDirectoryOnly).GetEnumerator(); } WriteFileSystemInfoCollection(files); @@ -221,7 +216,7 @@ namespace NTFSSecurity IEnumerator files; using (new InvariantCultureScope()) { - files = di.EnumerateFiles(filter, global::System.IO.SearchOption.TopDirectoryOnly).GetEnumerator(); + files = di.EnumerateFiles("*", global::System.IO.SearchOption.TopDirectoryOnly).GetEnumerator(); } WriteFileSystemInfoCollection(files); @@ -231,7 +226,7 @@ namespace NTFSSecurity IEnumerator files; using (new InvariantCultureScope()) { - files = di.EnumerateFileSystemInfos(filter, global::System.IO.SearchOption.TopDirectoryOnly).GetEnumerator(); + files = di.EnumerateFileSystemInfos("*", global::System.IO.SearchOption.TopDirectoryOnly).GetEnumerator(); } WriteFileSystemInfoCollection(files); @@ -304,7 +299,7 @@ namespace NTFSSecurity while (MoveNextInvariant(fileSystemInfos)) { FileSystemInfo current = (FileSystemInfo)fileSystemInfos.Current; - if (!wildcard.IsMatch(current.Name)) + if (!DosWildcard.IsMatch(current.Name, filter)) { continue; } @@ -401,5 +396,193 @@ namespace NTFSSecurity global::System.Threading.Thread.CurrentThread.CurrentCulture = previous; } } + + // Matches a -Filter pattern against the real name of an item with the Win32 "DOS" wildcard rules that + // Windows PowerShell 5.1 applies to Get-ChildItem -Filter, in both editions, so that the two editions and + // the short-name based matching that a past fix removed do not disagree. The rules come from + // FsRtlIsNameInExpression with the Win32 translation of a DOS pattern (DOS_STAR, DOS_QM, DOS_DOT): an + // asterisk matches a run of characters (but not past a dot that a following dot token still has to + // account for); a question mark matches one character that is not a dot, or nothing where the name has + // no more characters left or the next one is a dot; and a dot matches a literal dot in the name, or + // nothing where the name has no more characters left, which is why "Report.*" also returns "Report" and + // "Report." returns only the names without a dot. Comparisons are case-insensitive with the invariant + // culture, never the thread culture, matching the enumeration (see InvariantCultureScope). Every + // character other than an asterisk or a question mark is ordinary, including a bracket and a backtick. + private static class DosWildcard + { + private enum TokenKind + { + Literal, + Star, + QuestionMark, + Dot + } + + private struct Token + { + public TokenKind Kind; + public char Char; + } + + public static bool IsMatch(string name, string pattern) + { + // An empty value matches every item, like a plain asterisk; Get-ChildItem2 keeps its own default + // of "*" for -Filter, but once a caller passes an explicit empty string this is the policy. + if (pattern.Length == 0) + { + return true; + } + + // A single dot is not a meaningful base/extension split, so Get-ChildItem treats it the same as + // no filter at all, in both editions. + if (pattern == ".") + { + return true; + } + + var tokens = Tokenize(pattern); + var matched = new Dictionary(); + return Match(name, 0, tokens, 0, matched); + } + + private static List Tokenize(string pattern) + { + var tokens = new List(); + + // A run of one or more dots that reaches the end of the pattern collapses into a single DOS_DOT: + // "Report..." and "Report." both ask for "Report" with nothing left of the name to account for. + // A dot elsewhere becomes a DOS_DOT only when the single next character is an asterisk or a + // question mark; otherwise it is an ordinary character that requires a literal dot in the name. + var trailingDotsFrom = pattern.Length; + while (trailingDotsFrom > 0 && pattern[trailingDotsFrom - 1] == '.') + { + trailingDotsFrom--; + } + var hasTrailingDotRun = trailingDotsFrom < pattern.Length; + + for (var i = 0; i < pattern.Length; i++) + { + if (hasTrailingDotRun && i == trailingDotsFrom) + { + tokens.Add(new Token { Kind = TokenKind.Dot, Char = '.' }); + break; + } + + var c = pattern[i]; + if (c == '*') + { + tokens.Add(new Token { Kind = TokenKind.Star }); + } + else if (c == '?') + { + tokens.Add(new Token { Kind = TokenKind.QuestionMark }); + } + else if (c == '.') + { + var next = (i + 1 < pattern.Length) ? pattern[i + 1] : '\0'; + var qualifies = (i + 1 >= pattern.Length) || next == '*' || next == '?'; + tokens.Add(new Token { Kind = qualifies ? TokenKind.Dot : TokenKind.Literal, Char = '.' }); + } + else + { + tokens.Add(new Token { Kind = TokenKind.Literal, Char = c }); + } + } + return tokens; + } + + // Matches the name, starting at name position ni, against the tokens from token position ti. The + // dictionary memoizes (ti, ni) pairs, because a star token tries every name position from ni onward + // and the same (ti, ni) pair can otherwise be re-examined many times over for a pattern with several + // stars. + private static bool Match(string name, int ni, List tokens, int ti, Dictionary matched) + { + var key = ((long)ti << 32) | (uint)ni; + bool cached; + if (matched.TryGetValue(key, out cached)) + { + return cached; + } + + var result = MatchCore(name, ni, tokens, ti, matched); + matched[key] = result; + return result; + } + + private static bool MatchCore(string name, int ni, List tokens, int ti, Dictionary matched) + { + if (ti == tokens.Count) + { + return ni == name.Length; + } + + var tok = tokens[ti]; + + if (tok.Kind == TokenKind.Star) + { + // A star that is directly followed by a dot (a DOS_DOT or an ordinary dot) must leave that dot + // for the dot token to account for: it cannot run past the next dot in the name, which is why + // "Rep*." does not return "Report.txt" although a plain "*" could reach the end of the name by + // crossing the dot. A star with anything else after it, including another star, is free to + // cross dots, which is why "**." returns every item and "R*t" returns "Report.txt". + var max = name.Length; + if (ti + 1 < tokens.Count && tokens[ti + 1].Kind == TokenKind.Dot) + { + var nextDot = name.IndexOf('.', ni); + if (nextDot >= 0) + { + max = nextDot; + } + } + + for (var k = ni; k <= max; k++) + { + if (Match(name, k, tokens, ti + 1, matched)) + { + return true; + } + } + return false; + } + + if (ni == name.Length) + { + // A question mark or a dot can match nothing at the end of the name; anything else needs a + // character that is not there. + if (tok.Kind == TokenKind.QuestionMark || tok.Kind == TokenKind.Dot) + { + return Match(name, ni, tokens, ti + 1, matched); + } + return false; + } + + if (tok.Kind == TokenKind.QuestionMark) + { + // A question mark does not match a dot; it matches nothing there instead, leaving the dot for + // a later token. + if (name[ni] == '.') + { + return Match(name, ni, tokens, ti + 1, matched); + } + return Match(name, ni + 1, tokens, ti + 1, matched); + } + + if (tok.Kind == TokenKind.Dot) + { + if (name[ni] == '.') + { + return Match(name, ni + 1, tokens, ti + 1, matched); + } + return false; + } + + if (char.ToUpperInvariant(tok.Char) == char.ToUpperInvariant(name[ni])) + { + return Match(name, ni + 1, tokens, ti + 1, matched); + } + + return false; + } + } } } diff --git a/Tests/ItemCmdlets.Tests.ps1 b/Tests/ItemCmdlets.Tests.ps1 index e6a1e4a..8d8cb73 100644 --- a/Tests/ItemCmdlets.Tests.ps1 +++ b/Tests/ItemCmdlets.Tests.ps1 @@ -122,6 +122,23 @@ Describe 'Get-ChildItem2' { Assert-TestSandboxPath -Sandbox $sandbox -Path (@($grandchild) + @($paths)) New-Item -ItemType Directory -Path $grandchild -Force | Out-Null foreach ($path in $paths) { Set-Content -LiteralPath $path -Value 'Tree' } + + # The fixture for the -Filter truth table: 18 files and 4 folders, all 8.3-compliant (at most one dot, + # up to 8 characters of base name and 3 of extension, no spaces), so each real name is also its own + # short name and AlphaFS enumerates the same identity that the matcher compares. + $filterTruthTableFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'FilterTruthTable' -Directory + $truthTableFiles = 'a', 'ab', 'abc', 'a.b', 'ab.c', 'abc.de', 'a.bcd', 'abcdefgh', 'abcdefgh.ijk', 'x.y', 'a1.b2', 'bca', 'cab.cd', 'ccc', 'c.c', 'bcabcabc', 'a2.b1', '1cb' + $truthTableDirs = 'bdir', 'bdir.cd', 'fold', 'fo.ld' + foreach ($name in $truthTableFiles) { + $path = Join-Path -Path $filterTruthTableFolder -ChildPath $name + Assert-TestSandboxPath -Sandbox $sandbox -Path $path + Set-Content -LiteralPath $path -Value $name + } + foreach ($name in $truthTableDirs) { + $path = Join-Path -Path $filterTruthTableFolder -ChildPath $name + Assert-TestSandboxPath -Sandbox $sandbox -Path $path + New-Item -ItemType Directory -Path $path | Out-Null + } } It 'Should return the exact tree for ' -ForEach @( @@ -146,6 +163,9 @@ Describe 'Get-ChildItem2' { @{ Filter = '*.htm'; Expected = @('Page.htm') } @{ Filter = 'Page?.html'; Expected = @('Page2.html') } @{ Filter = 'PAGE*'; Expected = @('Page.htm', 'Page2.html') } + @{ Filter = 'Page.ht?'; Expected = @('Page.htm') } + @{ Filter = 'Page2.h*'; Expected = @('Page2.html') } + @{ Filter = 'Page2.'; Expected = @() } ) { $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'FilterNames' -Directory foreach ($name in 'Page.htm', 'Page2.html') { @@ -229,9 +249,12 @@ Describe 'Get-ChildItem2' { ($result.Name | Sort-Object) -join ',' | Should -BeExactly 'NoExtension,NoExtensionFolder,Page.htm' } - # The enumeration returns every item for *.*.*, as Get-ChildItem does. The cmdlet compares each name with the whole - # pattern again, so that the dots of the pattern are characters of the name, as the help says. - It 'Should return only the items that match the whole pattern for -Filter *.*.*' { + # The cmdlet now has one matcher, not two (Decision 27, item 4): every dot in -Filter whose single next + # character is an asterisk or a question mark, or that has nothing after it, is a DOS_DOT, and *.*.* has + # two such dots, so it returns every item, the same as *.* and a plain *. Before this change, the cmdlet + # compared the name with the pattern a second time with the dots read as ordinary characters, so only a + # name with two literal dots matched. + It 'Should return every item for -Filter *.*.*, also the ones with one dot or none' { $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'FilterDots' -Directory foreach ($name in 'Page.htm', 'NoExtension', 'Two.dots.txt') { $file = Join-Path -Path $folder -ChildPath $name @@ -241,20 +264,20 @@ Describe 'Get-ChildItem2' { $result = @(Get-ChildItem2 -Path $folder -Filter '*.*.*' -ErrorAction Stop) - ($result.Name -join ',') | Should -BeExactly 'Two.dots.txt' + ($result.Name | Sort-Object) -join ',' | Should -BeExactly 'NoExtension,Page.htm,Two.dots.txt' } - # The names are matched twice, by the enumeration and by the cmdlet, and the rules for a dot differ from those of - # Get-ChildItem, where Report.* also returns Report. The documentation lists this as a limitation; these cases pin - # it, so that a change of the rules is a decision. Report* is the control: without a dot in the pattern, the names - # without a dot are returned. + # The names are matched once, by the cmdlet, with the Win32 "DOS" wildcard rules that Windows PowerShell 5.1 + # applies to Get-ChildItem -Filter, in both editions (Decision 27, item 4): Report.* also returns Report, + # which has no dot, and a trailing dot asks for the names without a dot. Report* is the control: without a + # dot in the pattern, the names with and without a dot are both returned, as before. It 'Should return for -Filter ""' -ForEach @( - @{ Filter = 'Report.*'; Expected = 'Report.txt'; Outcome = 'only the names with a dot' } + @{ Filter = 'Report.*'; Expected = 'Report,Report.txt'; Outcome = 'the names with and without a dot' } @{ Filter = 'Report*'; Expected = 'Report,Report.txt'; Outcome = 'the names with and without a dot' } - @{ Filter = 'Report.'; Expected = ''; Outcome = 'nothing' } - @{ Filter = 'Rep*.'; Expected = ''; Outcome = 'nothing' } - @{ Filter = '*.'; Expected = ''; Outcome = 'nothing' } - @{ Filter = ''; Expected = ''; Outcome = 'nothing' } + @{ Filter = 'Report.'; Expected = 'Report'; Outcome = 'only the name without a dot' } + @{ Filter = 'Rep*.'; Expected = 'Report'; Outcome = 'only the name without a dot' } + @{ Filter = '*.'; Expected = 'Other,Report'; Outcome = 'the names without a dot' } + @{ Filter = ''; Expected = 'Other,Report,Report.txt'; Outcome = 'every item, like no filter at all' } ) { $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'FilterDotRules' -Directory foreach ($name in 'Report', 'Report.txt', 'Other') { @@ -268,6 +291,104 @@ Describe 'Get-ChildItem2' { ($result.Name | Sort-Object) -join ',' | Should -BeExactly $Expected } + # A fixed truth table, pinned with the literal result of Windows PowerShell 5.1's Get-ChildItem on 8.3-compliant + # names (at most one dot, up to 8 characters of base name and 3 of extension, no spaces), whose short name is + # its own real name, so AlphaFS and the cmdlet agree on which name they compare. The expected values are data, + # not the output of a run of Get-ChildItem: this test is its own oracle. + It 'Should match -Filter "" the way Windows PowerShell 5.1 does' -ForEach @( + @{ Filter = '*'; Expected = @('1cb', 'a', 'a.b', 'a.bcd', 'a1.b2', 'a2.b1', 'ab', 'ab.c', 'abc', 'abc.de', 'abcdefgh', 'abcdefgh.ijk', 'bca', 'bcabcabc', 'bdir', 'bdir.cd', 'c.c', 'cab.cd', 'ccc', 'fo.ld', 'fold', 'x.y') } + @{ Filter = '*.*'; Expected = @('1cb', 'a', 'a.b', 'a.bcd', 'a1.b2', 'a2.b1', 'ab', 'ab.c', 'abc', 'abc.de', 'abcdefgh', 'abcdefgh.ijk', 'bca', 'bcabcabc', 'bdir', 'bdir.cd', 'c.c', 'cab.cd', 'ccc', 'fo.ld', 'fold', 'x.y') } + @{ Filter = '*.'; Expected = @('1cb', 'a', 'ab', 'abc', 'abcdefgh', 'bca', 'bcabcabc', 'bdir', 'ccc', 'fold') } + @{ Filter = 'a.*'; Expected = @('a', 'a.b', 'a.bcd') } + @{ Filter = 'ab.*'; Expected = @('ab', 'ab.c') } + @{ Filter = 'a.'; Expected = @('a') } + @{ Filter = 'ab.'; Expected = @('ab') } + @{ Filter = 'a*.'; Expected = @('a', 'ab', 'abc', 'abcdefgh') } + @{ Filter = 'ab*.'; Expected = @('ab', 'abc', 'abcdefgh') } + @{ Filter = 'c*.'; Expected = @('ccc') } + @{ Filter = '*.b'; Expected = @('a.b') } + @{ Filter = '*.c?'; Expected = @('ab.c', 'bdir.cd', 'c.c', 'cab.cd') } + @{ Filter = '*.b?'; Expected = @('a.b', 'a1.b2', 'a2.b1') } + @{ Filter = '*.?'; Expected = @('1cb', 'a', 'a.b', 'ab', 'ab.c', 'abc', 'abcdefgh', 'bca', 'bcabcabc', 'bdir', 'c.c', 'ccc', 'fold', 'x.y') } + @{ Filter = '*.??'; Expected = @('1cb', 'a', 'a.b', 'a1.b2', 'a2.b1', 'ab', 'ab.c', 'abc', 'abc.de', 'abcdefgh', 'bca', 'bcabcabc', 'bdir', 'bdir.cd', 'c.c', 'cab.cd', 'ccc', 'fo.ld', 'fold', 'x.y') } + @{ Filter = '?'; Expected = @('a') } + @{ Filter = '??'; Expected = @('a', 'ab') } + @{ Filter = 'a?'; Expected = @('a', 'ab') } + @{ Filter = 'b?'; Expected = @() } + @{ Filter = 'ab?'; Expected = @('ab', 'abc') } + @{ Filter = 'a?.'; Expected = @('a', 'ab') } + @{ Filter = 'bc?'; Expected = @('bca') } + @{ Filter = '?.?'; Expected = @('a', 'a.b', 'c.c', 'x.y') } + @{ Filter = 'c.?'; Expected = @('c.c') } + @{ Filter = '*a*'; Expected = @('a', 'a.b', 'a.bcd', 'a1.b2', 'a2.b1', 'ab', 'ab.c', 'abc', 'abc.de', 'abcdefgh', 'abcdefgh.ijk', 'bca', 'bcabcabc', 'cab.cd') } + @{ Filter = '*c*'; Expected = @('1cb', 'a.bcd', 'ab.c', 'abc', 'abc.de', 'abcdefgh', 'abcdefgh.ijk', 'bca', 'bcabcabc', 'bdir.cd', 'c.c', 'cab.cd', 'ccc') } + @{ Filter = 'a*.*'; Expected = @('a', 'a.b', 'a.bcd', 'a1.b2', 'a2.b1', 'ab', 'ab.c', 'abc', 'abc.de', 'abcdefgh', 'abcdefgh.ijk') } + @{ Filter = 'bca*.'; Expected = @('bca', 'bcabcabc') } + @{ Filter = '*.*.*'; Expected = @('1cb', 'a', 'a.b', 'a.bcd', 'a1.b2', 'a2.b1', 'ab', 'ab.c', 'abc', 'abc.de', 'abcdefgh', 'abcdefgh.ijk', 'bca', 'bcabcabc', 'bdir', 'bdir.cd', 'c.c', 'cab.cd', 'ccc', 'fo.ld', 'fold', 'x.y') } + @{ Filter = '.*'; Expected = @() } + @{ Filter = '*..*'; Expected = @() } + @{ Filter = 'abc'; Expected = @('abc') } + @{ Filter = 'cba'; Expected = @() } + @{ Filter = 'a.b.'; Expected = @('a.b') } + @{ Filter = 'ab.c.'; Expected = @('ab.c') } + @{ Filter = '*b.'; Expected = @('1cb', 'a.b', 'ab') } + @{ Filter = 'cab*'; Expected = @('cab.cd') } + @{ Filter = 'a??'; Expected = @('a', 'ab', 'abc') } + @{ Filter = 'c.c'; Expected = @('c.c') } + @{ Filter = 'ca?'; Expected = @() } + ) { + $folder = $filterTruthTableFolder + + $result = @(Get-ChildItem2 -Path $folder -Filter $Filter -Force -ErrorAction Stop) + + ($result.Name | Sort-Object) -join ',' | Should -BeExactly (($Expected | Sort-Object) -join ',') + } + + It 'Should apply a -Filter at every level of a recursive listing, directories included' { + $recurseFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'FilterRecurse' -Directory + $paths = @( + 'A.txt', 'B.log', + 'Sub\C.txt', 'Sub\D.log', + 'Sub\Sub2\E.txt', 'Sub\Sub2\F.log' + ) | ForEach-Object -Process { Join-Path -Path $recurseFolder -ChildPath $_ } + Assert-TestSandboxPath -Sandbox $sandbox -Path $paths + New-Item -ItemType Directory -Path (Join-Path -Path $recurseFolder -ChildPath 'Sub\Sub2') -Force | Out-Null + foreach ($path in $paths) { Set-Content -LiteralPath $path -Value 'Content' } + + $result = @(Get-ChildItem2 -Path $recurseFolder -Recurse -Filter '*.txt' -ErrorAction Stop) + + ($result.Name | Sort-Object) -join ',' | Should -BeExactly 'A.txt,C.txt,E.txt' + } + + It 'Should apply a dot -Filter together with -File and with -Directory' { + $folder = $filterTruthTableFolder + + $files = @(Get-ChildItem2 -Path $folder -File -Filter '*.' -Force -ErrorAction Stop) + $directories = @(Get-ChildItem2 -Path $folder -Directory -Filter '*.' -Force -ErrorAction Stop) + + ($files.Name | Sort-Object) -join ',' | Should -BeExactly '1cb,a,ab,abc,abcdefgh,bca,bcabcabc,ccc' + ($directories.Name | Sort-Object) -join ',' | Should -BeExactly 'bdir,fold' + } + + It 'Should return every item for an empty -Filter, like no filter at all' { + $folder = $filterTruthTableFolder + + $result = @(Get-ChildItem2 -Path $folder -Filter '' -Force -ErrorAction Stop) + + $result | Should -HaveCount 22 + } + + It 'Should apply -Filter to a -Path that is a file' { + $file = Join-Path -Path $tree -ChildPath 'Root.txt' + + $matching = @(Get-ChildItem2 -Path $file -Filter '*.txt' -ErrorAction Stop) + $other = @(Get-ChildItem2 -Path $file -Filter '*.log' -ErrorAction Stop) + + $matching | Should -HaveCount 1 + $matching[0].FullName | Should -Be $file + $other | Should -BeNullOrEmpty + } + It 'Should reject a null -Filter' { { Get-ChildItem2 -Path $tree -Filter $null -ErrorAction Stop } | Should -Throw -ErrorId 'ParameterArgumentValidationError,NTFSSecurity.GetChildItem2' -ExpectedMessage "*'Filter'*"