Skip to content

platform: add platform specific directory traversals - #217

Open
amaanq wants to merge 11 commits into
BurntSushi:masterfrom
amaanq:openat-traversal
Open

amaanq wants to merge 11 commits into
BurntSushi:masterfrom
amaanq:openat-traversal

Conversation

@amaanq

@amaanq amaanq commented Jul 7, 2026 •

Copy link
Copy Markdown

Problem

The ag/sys branch added the platform-specific directory readers, but
never finished hooking them into recursive traversal. Child directories
were still opened from their full path, which is where the long-path
and TOCTOU problems come from.

Solution

I've finished the walker so that it keeps each active parent directory
open and opens its children relative to it. On Unix, this uses openat, and
on Windows this uses NtOpenFile.

Directory entries are read with getdents or readdir on supported
Unix targets, and with GetFileInformationByHandleEx on Windows. On other Unix
targets, we fall back to std::fs::ReadDir. Note that sorting and max_open can
discard the parent handle, so those cases still fall back to opening the full path.

The changes I've added are split into seven commits. First, I restored the
path-based walker and added walkdir::FileType. Unix enumeration and
relative opens come next as separate commits, followed by the equivalent
Windows changes. The last commit just reuses the parent path buffer.

I also dropped BorrowedIter and its allocation benchmark. They aren't
needed for this change, but if you'd like them back please let me know and I'd
be happy to restore that.

Notes

There are a couple of changes worth pointing out. One is that DirEntry::file_type()
now returns walkdir::FileType, since the platform readers cannot construct
std::fs::FileType.

Secondly, DirEntry::metadata() on Windows now stats the entry
when called instead of returning metadata saved during enumeration.

Last, but not least, the MSRV is now Rust 1.71 because of windows-sys,
which is used because winapi does not expose the Nt* APIs used here.

LLM use

I used Codex (namely 5.5/5.6 sol) for the seven commits after that. They helped
mostly with code exploration and the implementation, though reviewing & testing
the code was done by me.

Testing

I've tested this on Linux, Windows on both gnu and msvc, FreeBSD, OpenBSD,
NetBSD, DragonFly, Solaris and macOS. (Thank you to cfarm :) )

(cc @Ericson2314)

This commit performs the unenviable task of implementing directory
traversal for Windows, Unix-like platforms and Linux (where Linux is
supported via the generic Unix implementation, but also has its own
specialized implementation that uses getdents64 directly).

The purpose of doing this is largely to control our costs more
explicitly, and to fix platform specific bugs. For costs, this largely
boils down to amortizing allocation. That is, instead of allocating
a fresh OsString for every entry, we can now read directory entries
into a previously used entry. Anecdotally, this leads to a 20-30%
performance improvement on listing a single large directory for *both*
Windows and Linux.

As for bugs, these mostly center around long file paths. For example,
on Unix, the typical maximum path length is 4096 bytes. There is no
way to avoid this using std's APIs, so we need to instead roll our own
based on file descriptors. There's also Windows, which has a maximum
file path length of only 260 chars, although this is trickier to fix.

There are basically two downsides to doing things this way:

1. This increases maintenance burden quite a bit, and this change will
   almost certainly introduce bugs on less well tested platforms.
2. It's not clear yet whether we will *also* need to hand-roll our own
   `Metadata` implementation. std provides no way to build the
   `Metadata` type from raw inputs. The key factor here is that std's
   `DirEntry::metadata` method benefits from using `fstatat` internally.
   If we don't re-roll our own `Metadata`, then we'll have to use
   `std::fs::metadata`, which does a normal `stat` call on the file
   path. `fstatat` should generally be faster. On the other hand, std's
   implementation strategy also means that file descriptors aren't
   closed until the last `DirEntry` is dropped, which is a bit sneaky.
   (And is also indicative of a bug in walkdir, since this means its
   "maximum opened fds" feature doesn't actually work.)

Unfortunate, but everything gets sacrificed at the alter of performance.
This moves the DirEntry and Error types out into their own separate
modules.

This is prep work to (hopefully) make the impending refactoring (or
more likely, rewrite) more palatable.
@BurntSushi

Copy link
Copy Markdown
Owner

Bumping MSRV is fine.

I have to ask: is the PR summary AI generated? If so, please write it in your own words. Same for commit messages.

@Ericson2314

Copy link
Copy Markdown

Thanks, @amaanq. I hope when this is done, we will have some valuable experience to inform std and the rust-lang/rust#120426 stabilization too!

@BurntSushi

Copy link
Copy Markdown
Owner

I took a quick skim here, and this PR is unfortunately really large. Or more precisely, each individual commit is very large. I'm fine with a big PR since I think it's necessary in this case, but breaking subsequent changes down on top of ag/sys is probably necessary for something like this to get through review and get merged.

Some statement about what is done with an LLM vs not would be helpful. But like, there are parts of this PR that stick out to me as sloppy. For example, this repo doesn't use Clippy, yet there are allow(clippy) things. I really do need PR bodies and commit messages to be written by a human in your own words.

amaanq added 7 commits July 15, 2026 13:48
The `ag/sys` branch leaves the new `WalkDir` implementation
unfinished. This brings back the existing path-based traversal without
using the new platform APIs yet.

This gives the later commits a working baseline.
The platform directory readers cannot construct `std::fs::FileType`.
We introduce `walkdir::FileType` while entries still come from `std`.
This keeps the public API change separate from changing traversal.
This switches supported Unix targets to `getdents` or `readdir`.
Directory opens are still path based in this commit.

Other Unix targets keep using `std::fs::ReadDir`. The build script
selects the backend from `CARGO_CFG_TARGET_OS`, which also makes
DragonFly cross builds use the correct `errno` shim.

Temporary `libc` and `cc` upper bounds keep this part of the stack
working on Rust 1.60. They are removed when the MSRV is bumped later.
Opening every child by its full path leaves traversal subject to
`PATH_MAX` and symlink swaps. We fix this by keeping each active
directory fd open and using `openat` for its children. `O_NOFOLLOW` is
used when links are disabled.

Sorting and `max_open` can discard the live parent handle. In those
cases, traversal still falls back to the full path.
`FindFirstFile` still relies on wildcard paths and gives us metadata
that cannot be turned back into `std::fs::Metadata`. We replace it
with `FILE_ID_BOTH_DIR_INFO` enumeration on an open directory handle.

`DirEntry::metadata` now stats on demand, as it does on Unix. Child
directories are still opened by path in this commit.

This is where `windows-sys` enters, so bump the MSRV to Rust 1.71 and
remove the temporary dependency bounds.
This switches Windows descent to `NtOpenFile` using the active parent
handle. We request `FILE_OPEN_REPARSE_POINT` when links are disabled
so a swapped final component is not followed.

Malformed or oversized UTF-16 names are rejected before constructing
a `UNICODE_STRING`.
Only the directory at the top of the stack needs its parent path. We
move a single `PathBuf` with the stack instead of cloning one into
every `DirList`.

This removes one allocation for each directory descent.
@amaanq
amaanq force-pushed the openat-traversal branch from 505a3ff to 2bd26bc Compare July 15, 2026 17:48
@amaanq

amaanq commented Jul 15, 2026

Copy link
Copy Markdown
Author

Some statement about what is done with an LLM vs not would be helpful. But like, there are parts of this PR that stick out to me as sloppy. For example, this repo doesn't use Clippy, yet there are allow(clippy) things. I really do need PR bodies and commit messages to be written by a human in your own words.

Yeah, of course. A good amount of the code was LLM generated, but I was in the loop the entire time & reviewed or edited all of it. As for the clippy allows, that was actually entirely done by me on purpose, since they stuck out on certain architectures only. Sorry about that :) it's just a force of habit to always run cargo clippy.

I've restructured the commits to be a bit smaller individually, so reviewing the PR commit by commit would be the way to go. Initially, the commit bodies were llm generated before, and the pr description, albeit edited by me. I've gone ahead and redone them myself now, and I apologize about not being upfront on the LLM usage right away. I look forward to your review and feedback

@amaanq

amaanq commented Jul 15, 2026

Copy link
Copy Markdown
Author

Also another question for you. Since the original branch ended with the recursive walker unfinished, my first commit restored the path-based traversal to get back to a working baseline and then the later commits replace that code piece by piece. Technically there could be less churn here by directly editing your commit beforehand to not be in a broken state. If you would like, I could fold the restoration into the progress commit so that the initial stack is in a working state and the following commits after are a bit cleaner.

@Ericson2314

Copy link
Copy Markdown

@BurntSushi How is this now?

In particular, what do you think of the "incrementally build up and then sudden switchover" strategy? It is not as good as incrementally introducing code that is live the entire time, of course, but we couldn't think of an alternative that still allowed for the commits to be smaller.

@BurntSushi

Copy link
Copy Markdown
Owner

I honestly don't know. I have to find time to review this and it might take quite some time. I agree this is probably the right way to go about it.

@ririsoft

Copy link
Copy Markdown

Hello,

Maintainer of async-walkdir here.

While running a security code review with opus-5-5 on the crate I went into the TOCTOU issue and implemented a fix for it following a similar approach as yours (see ririsoft/async-walkdir#22) using opus-5-5 with adversarial review from GPT-5.6-Sol. Whether my LLM run got inspiration from your PR, or LLMs got it that way already is left open for the debate :) ...

I went for optional and experimental opt-in of a secured implementation for async::WalkDir as I really did not want to break dependents crates. I will wait for official rust-lang std support before making async::WalkDir fully secured without a dedicated secure implementation required.

Just wanted to share the way I handled it in case it can help your here.

Note : I am not a big fan of maintaining my async-walkdir crate, and I believe most users can make their own with the sync WalkDir, to limit their dependencies, but as I receive issues or PRs from time to time, I keep maintain it for a while ...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants