From 5a5cd150545e494c3d1d7d59afa14f7ff8ac726c Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Tue, 4 Aug 2026 10:44:43 -0700 Subject: [PATCH] mount: report . and .. from windows directories (#10556) * mount: report . and .. from windows directories WinFsp strips the dot entries for the root itself and expects every other directory to report them, the way a real NTFS enumeration does: its dirctl test asserts a subdirectory's first two entries are "." and ".." and that a hundred files enumerate as 102 entries. Dropping them unconditionally is what fails querydir_test. The Go test that guarded the old behaviour went with it: os.File.Readdir filters dot entries itself, so it could never have observed either way. * mount: give the windows dot entries their directory type The readdir fills an attribute block only for real children, so "." and ".." arrived with a zeroed one and were reported with mode 0. Windows refuses to enumerate a directory whose first entry is not marked as a directory, which is the assertion querydir_test fails on with STATUS_OBJECT_NAME_NOT_FOUND. They now carry the type the readdir already knew. The explorer walk also names any unexpected entry rather than only counting, so a dot entry leaking through reads differently from a missing file. --- .github/workflows/mount-windows.yml | 9 ++++-- test/winfsp-conformance/known_failures.txt | 7 ++--- test/winfsp/semantics_test.go | 29 ------------------ weed/mount/winfsp/fs_windows.go | 34 ++++++++++------------ 4 files changed, 26 insertions(+), 53 deletions(-) diff --git a/.github/workflows/mount-windows.yml b/.github/workflows/mount-windows.yml index bf2235cdc..985f8c85c 100644 --- a/.github/workflows/mount-windows.yml +++ b/.github/workflows/mount-windows.yml @@ -165,8 +165,13 @@ jobs: Write-Host "::group::explorer-style walk" New-Item -ItemType Directory -Force -Path S:\walk | Out-Null 1..200 | ForEach-Object { Set-Content -Path "S:\walk\f$_.txt" -Value "line $_" } - $count = (Get-ChildItem S:\walk | Measure-Object).Count - if ($count -ne 200) { throw "listed $count files, expected 200" } + $names = @(Get-ChildItem S:\walk | ForEach-Object { $_.Name }) + if ($names.Count -ne 200) { + # Name the strays: a dot entry surfacing here is a different problem + # from a missing or duplicated file. + $unexpected = $names | Where-Object { $_ -notmatch '^f\d+\.txt$' } + throw "listed $($names.Count) entries, expected 200; unexpected: $($unexpected -join ', ')" + } $body = Get-Content S:\walk\f42.txt if ($body -ne 'line 42') { throw "unexpected content: $body" } Copy-Item S:\walk\f42.txt S:\walk\copy.txt diff --git a/test/winfsp-conformance/known_failures.txt b/test/winfsp-conformance/known_failures.txt index 2b179ba7d..436d1e160 100644 --- a/test/winfsp-conformance/known_failures.txt +++ b/test/winfsp-conformance/known_failures.txt @@ -73,10 +73,9 @@ setfileinfo_test # Directory enumeration # --------------------- -# Marker-based resumption and the buffer-overflow path, neither of which the -# hand-written suite reaches. -querydir_test -querydir_buffer_overflow_test +# querydir_namelen only: checkName caps at 255 bytes while WinFsp counts 255 +# characters. The other two are expected to pass now that "." and ".." are +# reported for non-root directories, which is what WinFsp enumerates. querydir_namelen_test # Name length diff --git a/test/winfsp/semantics_test.go b/test/winfsp/semantics_test.go index 3260bdbb7..67c36b8b7 100644 --- a/test/winfsp/semantics_test.go +++ b/test/winfsp/semantics_test.go @@ -270,35 +270,6 @@ func TestAwkwardNames(t *testing.T) { } } -// Windows enumerates a directory without "." and "..", and shows whatever the -// filesystem reports, so they must not reach it. os.ReadDir filters them, so -// this reads the handle the way Windows tooling does. -func TestNoDotEntriesInListing(t *testing.T) { - dir := testRoot(t) - for i := 0; i < 3; i++ { - if err := os.WriteFile(filepath.Join(dir, fmt.Sprintf("f%d", i)), []byte("x"), 0644); err != nil { - t.Fatalf("write: %v", err) - } - } - f, err := os.Open(dir) - if err != nil { - t.Fatalf("open dir: %v", err) - } - defer f.Close() - names, err := f.Readdirnames(-1) - if err != nil { - t.Fatalf("readdirnames: %v", err) - } - for _, name := range names { - if name == "." || name == ".." { - t.Errorf("listing includes %q", name) - } - } - if len(names) != 3 { - t.Fatalf("listing has %d entries (%v), want 3", len(names), names) - } -} - func TestDeepDirectoryNesting(t *testing.T) { dir := testRoot(t) deep := dir diff --git a/weed/mount/winfsp/fs_windows.go b/weed/mount/winfsp/fs_windows.go index cc9814aca..f5ab7f212 100644 --- a/weed/mount/winfsp/fs_windows.go +++ b/weed/mount/winfsp/fs_windows.go @@ -593,6 +593,7 @@ type readdirSink struct { names []string offsets []uint64 inodes []uint64 + modes []uint32 attrs []*fuse.EntryOut limit int @@ -600,31 +601,20 @@ type readdirSink struct { // dot entries still moves the enumeration along. lastOffset uint64 seen int - - // discard absorbs the attributes of an entry that is being dropped; the - // raw filesystem fills the block after handing it back. - discard fuse.EntryOut -} - -// The kernel expects readdir to report "." and "..", but Windows enumerates a -// directory without them and shows whatever it is given, so they are dropped -// rather than surfaced as two extra children. -func isDotEntry(name string) bool { - return name == "." || name == ".." } +// WinFsp strips "." and ".." for the root directory itself and expects every +// other directory to report them, the way a real NTFS enumeration does. func (s *readdirSink) AddEntry(entry fuse.DirEntry) bool { if len(s.names) >= s.limit { return false } s.seen++ s.lastOffset = entry.Off - if isDotEntry(entry.Name) { - return true - } s.names = append(s.names, entry.Name) s.offsets = append(s.offsets, entry.Off) s.inodes = append(s.inodes, entry.Ino) + s.modes = append(s.modes, entry.Mode) s.attrs = append(s.attrs, nil) return true } @@ -635,13 +625,11 @@ func (s *readdirSink) AddEntryPlus(entry fuse.DirEntry) *fuse.EntryOut { } s.seen++ s.lastOffset = entry.Off - if isDotEntry(entry.Name) { - return &s.discard - } out := &fuse.EntryOut{} s.names = append(s.names, entry.Name) s.offsets = append(s.offsets, entry.Off) s.inodes = append(s.inodes, entry.Ino) + s.modes = append(s.modes, entry.Mode) s.attrs = append(s.attrs, out) return out } @@ -676,9 +664,19 @@ func (w *WinFS) Readdir(path string, fill func(name string, stat *cgofuse.Stat_t for i, name := range sink.names { var stat cgofuse.Stat_t var statp *cgofuse.Stat_t - if attr := sink.attrs[i]; attr != nil { + if attr := sink.attrs[i]; attr != nil && attr.Attr.Mode != 0 { w.attrToStat(&attr.Attr, &stat) statp = &stat + } else if sink.modes[i] != 0 { + // "." and ".." are reported without attributes: the readdir + // only fills a block for real children. Windows still needs + // their type, and enumerating a directory whose first entry is + // not marked as one fails outright. + stat.Mode = sink.modes[i] + stat.Ino = sink.inodes[i] + stat.Nlink = 1 + stat.Uid, stat.Gid = w.uid, w.gid + statp = &stat } if filled && !fill(name, statp, int64(sink.offsets[i])) { filled = false