From ab2fb87f1f331021694837ea63f47c8f350e600b Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Wed, 21 Oct 2026 14:04:12 +0000 Subject: [PATCH] fix(items): let an asterisk before a dot in -Filter cross every dot but the last An independent review found that the matcher of Get-ChildItem2 -Filter (commit 69cd8ed) bounded a star in front of a dot token at the FIRST dot of the name instead of the LAST one (Win32 DOS_STAR). The differential oracle of 69cd8ed used only 8.3-compliant names, which have at most one dot, so the first dot and the last dot are always the same position there and the bug could not show up. With a name that has two or more dots, such as "archive.tar.gz" or "jquery.min.js", the star stopped too early and the rest of the pattern could not reach the final segment of the name, so a pattern such as "*.?", "*.??", or "*.?.?" wrongly returned nothing for these names, although Windows PowerShell 5.1 returns them. What a user sees changing: -Filter patterns of the shape "a star followed by a dot" (including "*.?", "*.??", "*.???", a star in a multi-part pattern such as "a*.?" or "x.*.?") now also match names with two or more dots, the same as Windows PowerShell 5.1, in both editions. A name with at most one dot is unaffected (that case was already correct). Fixed by bounding the star at `name.LastIndexOf('.')` instead of the first dot at or after the current position, and corrected the two comments that described the old, narrower rule. Also added a discriminating row to the existing short-name test (`-Filter 'LONGFI~1*'` against a plain file named `LongFileName`, expected to return nothing): the three rows already in that test happen to give the same result whether the matcher reads the real name or the short name, so they could not have caught a regression to short-name matching; this row can. Tests (Tests\ItemCmdlets.Tests.ps1, "Get-ChildItem2" > "Recursion, type filters, and depth"): added a 9-row test with 10 names that have two or three dots (including the realistic ones from the review: archive.tar.gz, jquery.min.js, my.file.name.txt, v1.2.3, index.d.ts), expected values pinned from Windows PowerShell 5.1 with `fsutil file setshortname` (and, for one row, `fsutil 8dot3name strip`) so that a short name the volume assigns cannot also produce a match; and the LONGFI~1* row above. Both fail on the build of 69cd8ed and pass after this fix. The fix was checked with the reviewer's differential harness (C:\Temp\ntfs-rc9\review-probes-r3\diff5.ps1) against this build: 0 mismatches over two independent fixtures (732,630 real-name/pattern pairs with names of two or more dots, short names overridden with `fsutil file setshortname` so that a hit can only come from the real name), in addition to the 109,308 pairs of 8.3-compliant names checked for 69cd8ed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- NTFSSecurity/ItemCmdlets/GetChildItem2.cs | 31 +++++++++++---------- Tests/ItemCmdlets.Tests.ps1 | 34 ++++++++++++++++++++++- 2 files changed, 50 insertions(+), 15 deletions(-) diff --git a/NTFSSecurity/ItemCmdlets/GetChildItem2.cs b/NTFSSecurity/ItemCmdlets/GetChildItem2.cs index 313d6d9..39d8b21 100644 --- a/NTFSSecurity/ItemCmdlets/GetChildItem2.cs +++ b/NTFSSecurity/ItemCmdlets/GetChildItem2.cs @@ -401,13 +401,14 @@ namespace NTFSSecurity // 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. + // asterisk matches a run of characters (but not past the LAST dot in the name, which 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 @@ -521,17 +522,19 @@ namespace NTFSSecurity 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". + // for the dot token to account for: it may cross every dot in the name but the LAST one (Win32 + // DOS_STAR), which is why "Rep*." does not return "Report.txt" although a plain "*" could reach + // the end of the name by crossing the dot, and why "*.?" reaches the final segment of a name + // with two or more dots, such as "archive.tar.gz". A star with anything else after it, + // including another star, is free to cross every dot, 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) + var lastDot = name.LastIndexOf('.'); + if (lastDot >= ni) { - max = nextDot; + max = lastDot; } } diff --git a/Tests/ItemCmdlets.Tests.ps1 b/Tests/ItemCmdlets.Tests.ps1 index 8d8cb73..9a15360 100644 --- a/Tests/ItemCmdlets.Tests.ps1 +++ b/Tests/ItemCmdlets.Tests.ps1 @@ -166,9 +166,10 @@ Describe 'Get-ChildItem2' { @{ Filter = 'Page.ht?'; Expected = @('Page.htm') } @{ Filter = 'Page2.h*'; Expected = @('Page2.html') } @{ Filter = 'Page2.'; Expected = @() } + @{ Filter = 'LONGFI~1*'; Expected = @() } ) { $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'FilterNames' -Directory - foreach ($name in 'Page.htm', 'Page2.html') { + foreach ($name in 'Page.htm', 'Page2.html', 'LongFileName') { $file = Join-Path -Path $folder -ChildPath $name Assert-TestSandboxPath -Sandbox $sandbox -Path $file Set-Content -LiteralPath $file -Value $name @@ -344,6 +345,37 @@ Describe 'Get-ChildItem2' { ($result.Name | Sort-Object) -join ',' | Should -BeExactly (($Expected | Sort-Object) -join ',') } + # A star in front of a dot token may cross every dot in the name but the LAST one (Win32 DOS_STAR), not stop + # at the first one: with names that have two or more dots, a pattern such as "*.?" still has to reach the + # last dot before the dot token and the question mark account for the final segment. The 8.3-compliant + # names of the truth table above have at most one dot, where the first dot and the last dot are the same + # position, so they cannot tell the two rules apart; this fixture uses names with two or three dots + # instead, including the realistic ones from the WI. Expected values are pinned with the real name of each + # item, confirmed with `fsutil file setshortname` so that a short name the volume might otherwise assign + # cannot also produce a match. + It 'Should match -Filter "" on a name with two or more dots the way Windows PowerShell 5.1 does' -ForEach @( + @{ Filter = '*.?'; Expected = @('a.b.c', 'v1.2.3', 'x.y.z.w') } + @{ Filter = '*.??'; Expected = @('a.b.c', 'a.b.cd', 'archive.tar.gz', 'index.d.ts', 'jquery.min.js', 'my.file.tx', 'v1.2.3', 'x.y.z.w') } + @{ Filter = '*.???'; Expected = @('a.b.c', 'a.b.cd', 'archive.tar.gz', 'index.d.ts', 'jquery.min.js', 'my.file.name.txt', 'my.file.tx', 'v1.2.3', 'x.y.z.w') } + @{ Filter = '*.?.?'; Expected = @('a.b.c', 'v1.2.3', 'x.y.z.w') } + @{ Filter = '*.*.*'; Expected = @('a.b.c', 'a.b.cd', 'archive.tar.gz', 'index.d.ts', 'jquery.min.js', 'my.file.name.txt', 'my.file.tx', 'one.two.three', 'v1.2.3', 'x.y.z.w') } + @{ Filter = 'a*.?'; Expected = @('a.b.c') } + @{ Filter = 'a.b.?'; Expected = @('a.b.c') } + @{ Filter = 'a.b.??'; Expected = @('a.b.c', 'a.b.cd') } + @{ Filter = 'x.*.?'; Expected = @('x.y.z.w') } + ) { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'FilterMultiDot' -Directory + foreach ($name in 'a.b.c', 'a.b.cd', 'x.y.z.w', 'my.file.tx', 'one.two.three', 'archive.tar.gz', 'jquery.min.js', 'my.file.name.txt', 'v1.2.3', 'index.d.ts') { + $file = Join-Path -Path $folder -ChildPath $name + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + Set-Content -LiteralPath $file -Value $name + } + + $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 = @(