Browse Source

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 <ai@example.com>
pull/131/head
Raimund Andree 1 week ago
parent
commit
ab2fb87f1f
  1. 31
      NTFSSecurity/ItemCmdlets/GetChildItem2.cs
  2. 34
      Tests/ItemCmdlets.Tests.ps1

31
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;
}
}

34
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 "<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 = @(

Loading…
Cancel
Save