Repository navigation
glob: pass pattern component as NtQueryDirectoryFile FileName filter on Windows #28489
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -201,6 +201,10 @@ pub fn NewIterator(comptime use_windows_ospath: bool) type { | |
| end_index: usize, | ||
| first: bool, | ||
| name_data: if (use_windows_ospath) [257]u16 else [513]u8, | ||
| /// Optional kernel-side wildcard filter passed to NtQueryDirectoryFile. | ||
| /// Evaluated by FsRtlIsNameInExpression (case-insensitive, supports `*` and `?`). | ||
| /// Only honored on the first call (RestartScan=TRUE); sticky for the handle lifetime. | ||
| name_filter: ?[]const u16 = null, | ||
|
|
||
| const Self = @This(); | ||
|
|
||
|
|
@@ -220,6 +224,16 @@ pub fn NewIterator(comptime use_windows_ospath: bool) type { | |
| @memset(&self.buf, 0); | ||
| } | ||
|
|
||
| var filter_us: w.UNICODE_STRING = undefined; | ||
| const filter_ptr: ?*w.UNICODE_STRING = if (self.name_filter) |f| blk: { | ||
| filter_us = .{ | ||
| .Length = @intCast(f.len * 2), | ||
| .MaximumLength = @intCast(f.len * 2), | ||
| .Buffer = @constCast(f.ptr), | ||
| }; | ||
| break :blk &filter_us; | ||
| } else null; | ||
|
|
||
| const rc = w.ntdll.NtQueryDirectoryFile( | ||
| self.dir.cast(), | ||
| null, | ||
|
|
@@ -230,7 +244,7 @@ pub fn NewIterator(comptime use_windows_ospath: bool) type { | |
| self.buf.len, | ||
| .FileDirectoryInformation, | ||
| w.FALSE, | ||
| null, | ||
| filter_ptr, | ||
| if (self.first) @as(w.BOOLEAN, w.TRUE) else @as(w.BOOLEAN, w.FALSE), | ||
| ); | ||
|
|
||
|
|
@@ -252,7 +266,9 @@ pub fn NewIterator(comptime use_windows_ospath: bool) type { | |
| }; | ||
| } | ||
|
|
||
| if (rc == .NO_MORE_FILES) { | ||
| // NO_SUCH_FILE is returned on the first call when a FileName filter | ||
| // matches nothing; NO_MORE_FILES on subsequent calls. Both mean "done". | ||
| if (rc == .NO_MORE_FILES or rc == .NO_SUCH_FILE) { | ||
| bun.sys.syslog("NtQueryDirectoryFile({f}) = {s}", .{ self.dir, @tagName(rc) }); | ||
| self.end_index = self.index; | ||
| return .{ .result = null }; | ||
|
Comment on lines
266
to
274
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 The new rc == .NO_SUCH_FILE branch (line 271) is dead code in its primary intended scenario because the if (io.Information == 0) early-return at line 252 fires first. When NtQueryDirectoryFile returns STATUS_NO_SUCH_FILE (filter matched nothing on first call), the kernel writes 0 bytes to the buffer so io.Information == 0, causing the earlier guard to return null with a misleading syslog message of = 0 rather than = NO_SUCH_FILE. The fix would be to reorder the checks so status codes are evaluated before the io.Information == 0 guard. Extended reasoning...When NtQueryDirectoryFile returns STATUS_NO_SUCH_FILE on the first call (because a FileName filter matched no entries), the NT kernel writes zero bytes to the output buffer, setting io.Information to 0. This is standard NT behavior for synchronous file handles as Bun uses. The check order in the Windows next() function is: (1) if (io.Information == 0) fires at line 252 returning null with syslog = 0, then (2) self.index/end_index are set, (3) INVALID_PARAMETER check, and finally (4) the new NO_MORE_FILES or NO_SUCH_FILE check at line 271. When STATUS_NO_SUCH_FILE is returned, step 1 fires immediately because io.Information == 0, so the new explicit NO_SUCH_FILE check at step 4 is never reached. The io.Information == 0 guard is a reasonable early-exit optimization, but it was placed before the status-code checks. Since STATUS_NO_SUCH_FILE always (in the primary case) results in io.Information == 0, the io.Information path absorbs the return before the newly added explicit status check can run. Functional correctness is unaffected — both paths return null. However, the debug syslog emits NtQueryDirectoryFile(fd) = 0 instead of = NO_SUCH_FILE, which is misleading when diagnosing why a filtered glob found no results. The PR description specifically claims this PR handles STATUS_NO_SUCH_FILE, but the actual handling was already provided by the pre-existing io.Information == 0 path. The refutation argues this is intentional defensive programming for the hypothetical case where a filesystem driver returns STATUS_NO_SUCH_FILE with io.Information > 0. While theoretically possible with non-standard drivers, it is undocumented and not observed in practice. By contrast, the analogous NO_MORE_FILES check IS reachable in normal operation when the last batch of entries partially fills the buffer. The NO_SUCH_FILE case lacks this justification since it only occurs on the very first call. The defensive-programming argument is weak here because the code already correctly handles NO_SUCH_FILE via the io.Information == 0 path. Step-by-step proof: (1) Glob pattern *.target used; computeNtFilter returns .target as NT filter. (2) NtQueryDirectoryFile called with RestartScan=TRUE and FileName=.target. (3) No files match; kernel writes 0 bytes to buffer, sets io.Information = 0, returns STATUS_NO_SUCH_FILE. (4) self.first = false is set. (5) if (io.Information == 0) is true, so syslog prints NtQueryDirectoryFile(fd) = 0 and return .{ .result = null } is executed. (6) Lines 268-274 (the NO_SUCH_FILE check) are never reached. (7) Syslog shows = 0 rather than = NO_SUCH_FILE. The fix is to move the status-code checks (NO_MORE_FILES, NO_SUCH_FILE, INVALID_PARAMETER) before the io.Information == 0 guard, or merge the NO_SUCH_FILE branch into the io.Information == 0 path so both the correct syslog message and the early return are emitted together. |
||
|
|
@@ -449,6 +465,11 @@ pub fn NewWrappedIterator(comptime path_type: PathType) type { | |
| }, | ||
| }; | ||
| } | ||
|
|
||
| pub fn setNameFilter(self: *Self, filter: ?[]const u16) void { | ||
| if (comptime !bun.Environment.isWindows) return; | ||
| self.iter.name_filter = filter; | ||
| } | ||
| }; | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -137,6 +137,10 @@ pub const SyscallAccessor = struct { | |
| pub inline fn iterate(dir: Handle) DirIter { | ||
| return .{ .value = DirIterator.WrappedIterator.init(dir.value) }; | ||
| } | ||
|
|
||
| pub inline fn setNameFilter(self: *DirIter, filter: ?[]const u16) void { | ||
| self.value.setNameFilter(filter); | ||
| } | ||
| }; | ||
|
|
||
| pub fn open(path: [:0]const u8) !Maybe(Handle) { | ||
|
|
@@ -435,6 +439,8 @@ pub fn GlobWalker_( | |
| /// directory being iterated on. | ||
| fds_open: if (count_fds) usize else u0 = 0, | ||
|
|
||
| nt_filter_buf: if (isWindows) [256]u16 else void = if (isWindows) undefined else {}, | ||
|
|
||
| pub fn init(this: *Iterator) !Maybe(void) { | ||
| log("Iterator init pattern={s}", .{this.walker.pattern}); | ||
| var was_absolute = false; | ||
|
|
@@ -702,13 +708,49 @@ pub fn GlobWalker_( | |
| log("Transition(dirpath={s}, component_idx={d})", .{ dir_path, component_idx }); | ||
|
|
||
| this.iter_state.directory.fd = fd; | ||
| const iterator = Accessor.DirIter.iterate(fd); | ||
| var iterator = Accessor.DirIter.iterate(fd); | ||
| if (comptime isWindows) { | ||
| if (@hasDecl(Accessor.DirIter, "setNameFilter")) { | ||
| iterator.setNameFilter(this.computeNtFilter(component_idx)); | ||
| } | ||
| } | ||
| this.iter_state.directory.iter = iterator; | ||
| this.iter_state.directory.iter_closed = false; | ||
|
|
||
| return .success; | ||
| } | ||
|
|
||
| /// Compute an optional NtQueryDirectoryFile FileName filter for the current | ||
| /// pattern component. The kernel filter is used purely as a pre-filter; | ||
| /// matchPatternImpl still runs on every returned entry for correctness | ||
| /// (case sensitivity, 8.3 aliases, etc). We only emit a filter when the | ||
| /// NT match is guaranteed to be a superset of the glob match. | ||
| fn computeNtFilter(this: *Iterator, component_idx: u32) ?[]const u16 { | ||
| if (comptime !isWindows) return null; | ||
|
|
||
| const comp = &this.walker.patternComponents.items[component_idx]; | ||
| switch (comp.syntax_hint) { | ||
| // `*` and `**` match everything; a filter gains nothing and for `**` | ||
| // would incorrectly hide subdirectories we need to recurse into. | ||
| .Single, .Double, .Dot, .DotBack => return null, | ||
| else => {}, | ||
| } | ||
|
|
||
| const slice = comp.patternSlice(this.walker.pattern); | ||
| if (slice.len == 0 or slice.len > this.nt_filter_buf.len) return null; | ||
|
|
||
| // Only `*` and literals are safe to lower. Reject anything NT cannot | ||
| // express (`[` `{` `\` `!`) or where NT semantics under-match glob | ||
| // (`?` matches one UTF-16 code unit, glob matches one codepoint). | ||
| // `<` `>` `"` are NT wildcards; treating them as literals would over-match, | ||
| // but they are invalid in Windows filenames so such a pattern never matches | ||
| // anyway. | ||
|
Comment on lines
+735
to
+747
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 The size guard in Extended reasoning...The BugIn if (slice.len == 0 or slice.len > this.nt_filter_buf.len) return null;
Code Path
Why the existing guard is incorrect (but safe)For all valid Unicode, the number of UTF-16 code units is always ≤ the number of UTF-8 bytes:
This means the guard can never cause a buffer overflow — if Step-by-step ProofConsider a glob pattern component made entirely of Cyrillic characters, each encoded as a 2-byte UTF-8 sequence (e.g.,
For a 200-character Cyrillic pattern: ImpactPurely a missed optimization. FixCompare against the actual UTF-16 length before conversion. One approach: const utf16_len = bun.strings.utf16Len(slice);
if (utf16_len == 0 or utf16_len > this.nt_filter_buf.len) return null;Alternatively, run the conversion first and check the result length, discarding it if it exceeds the buffer. |
||
| if (bun.strings.indexOfAny(slice, "?[{\\!<>\"") != null) return null; | ||
|
|
||
| const wide = bun.strings.convertUTF8toUTF16InBuffer(&this.nt_filter_buf, slice); | ||
| return wide; | ||
| } | ||
|
|
||
| pub fn next(this: *Iterator) !Maybe(?MatchedPath) { | ||
| while (true) { | ||
| switch (this.iter_state) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is byte length?