$ mkdir -p root/dir && echo hi > root/file && ln -s file root/link
$ cargo build -p wasmtime-cli --bin wasmtime
$ ./target/debug/wasmtime run --dir=root::/d reports/005-wasi-stat-trailing-slash/statslash.wat
Details
Audit metadata
|
|
| Date |
2026-09-18 |
| Wasmtime commit audited |
7ad2e732ab9ca8665d3cdd91f9c395315eeafc81 |
| Regressing commit |
05f1d09903500fdc7d17ab335a32d1144b6baa42 ("Continue to reorganize filesystem::primitives", #14345, 2026-09-16) |
| Host OS |
macOS 15.7.9 (Darwin 24.6.0, build 24G830) |
| Host arch |
aarch64 (arm64) |
| Model performing audit |
Claude Opus 5 (claude-opus-5) |
Summary
05f1d09903 hoisted a Linux-only "single component" fast path out of
rustix/linux/fs/stat_impl.rs into the new platform-agnostic
filesystem::primitives::stat. The fast path was previously reachable only
on Linux; it now runs on every platform.
The fast path calls stat_unchecked(start, component, FollowSymlinks::No)
with the path re-derived from Path::components(). Path::components()
normalizes a trailing / and a trailing /. away, so the trailing-component
marker that POSIX (and hence WASI) uses to require the path to name a
directory is silently discarded.
Consequently, on macOS, Windows, FreeBSD and Android, WASI's stat-at
(wasi:filesystem/types.stat-at, and path_filestat_get in WASIp1) now
returns success for paths such as "file/", "file/." and "link/" where
it previously — and correctly — returned ENOTDIR. For "link/" it goes
further and reports filetype = symbolic-link for a path that cannot name a
symlink at all.
The commit message states "The goal of this commit is to have no behavior
change", so this is unintentional.
This is a correctness / WASI-conformance bug rather than a sandbox escape:
the fast path only triggers for a path consisting of exactly one
Component::Normal, which cannot resolve outside start.
The code
crates/wasi/src/filesystem/primitives/mod.rs:307-326 (at
7ad2e732ab):
pub(crate) fn stat(start: &fs::File, path: &Path, follow: FollowSymlinks) -> io::Result<Metadata> {
// Optimization: if path has exactly one component and it's not ".." or
// anything non-normal and we're not following symlinks we can go straight
// to `stat_unchecked`, which can be faster than various paths below.
if follow == FollowSymlinks::No {
let mut components = path.components();
if let Some(Component::Normal(component)) = components.next() {
if components.next().is_none() {
return stat_unchecked(start, component.as_ref(), FollowSymlinks::No);
}
}
}
#[cfg(any(target_os = "freebsd", target_os = "android", target_os = "linux",))]
if let Some(stat) = sys::stat_fast(start, path, follow)? {
return Ok(stat);
}
manually::stat(start, path, follow)
}
Before the commit, primitives::stat was a straight re-export of the
per-platform implementation
(05f1d09903~1:crates/wasi/src/filesystem/primitives/mod.rs:74):
pub(crate) use sys::stat_impl as stat;
and the platform implementations were:
- Linux (
rustix/linux/fs/stat_impl.rs) — contained the identical
single-component fast path, followed by the openat2/O_PATH path.
- Android (
rustix/linux/fs/mod.rs) — manually::stat, no fast path.
- FreeBSD (
rustix/freebsd/fs/stat_impl.rs) — statat with
AT_RESOLVE_BENEATH on the unmodified path, so the kernel saw the
trailing slash. No fast path.
- Windows (
windows/fs/mod.rs:31) — manually::stat, no fast path.
- Everything else, including macOS (
rustix/fs/mod.rs) —
manually::stat, no fast path.
manually::stat does implement the rule; Context::new sets
dir_required = path_has_trailing_slash(path) and
follow_with_dot = trailing_dot | trailing_dotdot
(crates/wasi/src/filesystem/primitives/manually/open.rs), and the
last-component handler rejects a non-directory:
} else if ctx.dir_required {
return Err(errors::is_not_directory());
}
The new fast path runs before both sys::stat_fast and manually::stat,
so it takes priority on every platform.
Why this is wrong
POSIX requires a pathname with a trailing slash to resolve as if it ended in
/., i.e. the final component must be a directory; stat("file/") and
lstat("file/") both fail with ENOTDIR on Linux and macOS. WASI inherits
this: wasi:filesystem/types.stat-at is specified in terms of
fstatat/lstat semantics and wasmtime's own
crates/wasi/src/filesystem/primitives/manually/open.rs implements the rule
deliberately.
Three concrete consequences on the affected platforms:
stat-at("file/", path-flags = {}) succeeds and reports
type = regular-file, so a guest that uses a trailing slash to test
whether a name is a directory gets the wrong answer.
stat-at("file/.", path-flags = {}) likewise succeeds. manually::stat
treats a trailing /. specially (follow_with_dot), so this case also
loses the "follow the symlink even in nofollow mode" behavior.
stat-at("link/", path-flags = {}) succeeds and reports
type = symbolic-link, which is not a value the path can legally produce:
a trailing slash forces symlink resolution, and the resolved target is a
regular file, so the correct result is ENOTDIR.
The runtime is also now internally inconsistent in two visible ways:
stat-at with symlink-follow set still returns ENOTDIR (it takes the
manually::stat path), so the same path gets two different answers
depending only on the flag.
open-at("file/", ...) still correctly returns ENOTDIR, because
primitives::open was not given the fast path.
Both are visible in the reproduction output below.
Reproduction
statslash.wat in this directory is a WASIp1 module that calls
path_filestat_get (and one path_open for contrast) on a fixture directory
and prints each errno as two decimal digits. 54 is
__WASI_ERRNO_NOTDIR, 00 is success. For the link cases it also prints
the filetype byte from the returned filestat (07 = symbolic_link,
04 = regular_file, 03 = directory, 99 = buffer untouched because
the call failed).
$ mkdir -p root/dir && echo hi > root/file && ln -s file root/link
$ cargo build -p wasmtime-cli --bin wasmtime
$ ./target/debug/wasmtime run --dir=root::/d reports/005-wasi-stat-trailing-slash/statslash.wat
gen.py is the generator that produced statslash.wat; it is included so
the case list can be extended.
Observed
Both binaries are debug builds of wasmtime-cli on this host.
=== PRE-COMMIT (05f1d09903~1 = 358ee7665b) ===
stat 'file' nofollow: 00
stat 'file/' nofollow: 54
stat 'file/' follow: 54
stat 'file/.' nofollow: 54
stat 'file/.' follow: 54
stat 'link' nofollow: 00 filetype=07
stat 'link/' nofollow: 54 filetype=99
stat 'link/' follow: 54 filetype=99
stat 'dir/' nofollow: 00
open 'file/' nofollow: 54
=== HEAD (7ad2e732ab) ===
stat 'file' nofollow: 00
stat 'file/' nofollow: 00 <-- regression, expected 54
stat 'file/' follow: 54
stat 'file/.' nofollow: 00 <-- regression, expected 54
stat 'file/.' follow: 54
stat 'link' nofollow: 00 filetype=07
stat 'link/' nofollow: 00 filetype=07 <-- regression, expected 54
stat 'link/' follow: 54 filetype=99
stat 'dir/' nofollow: 00
open 'file/' nofollow: 54
The pre-commit binary was built from a clean worktree at 05f1d09903~1
(358ee7665b, "Add a public method to grow the per-store GC heap (#14341)").
Bisected to the fast path
Applying only this change to 7ad2e732ab restores every pre-commit result
(54 54 54 ... 54 54), confirming the fast path is the sole cause:
--- a/crates/wasi/src/filesystem/primitives/mod.rs
+++ b/crates/wasi/src/filesystem/primitives/mod.rs
@@ -308,7 +308,7 @@
- if follow == FollowSymlinks::No {
+ if false && follow == FollowSymlinks::No {
Affected platforms
| Platform |
Before 05f1d09903 |
At 7ad2e732ab |
| macOS (Tier 1, x86-64 and aarch64) |
ENOTDIR |
success |
| Windows (Tier 1, x86-64) |
ENOTDIR |
success |
| FreeBSD |
ENOTDIR (kernel saw the slash) |
success |
| Android |
ENOTDIR |
success |
| Linux (Tier 1) |
success |
success (pre-existing) |
Only the macOS behavior was verified by execution on this host; the others
follow from the #[cfg] structure shown above.
Linux already had this bug via its own copy of the fast path, so on Linux
this is long-standing rather than new. That also explains why CI did not
catch the regression: the in-tree trailing-slash tests
(crates/wasi/src/filesystem/primitives/tests/fs_additional.rs:
trailing_slash, trailing_slash_in_dir, file_with_trailing_slashdot)
exercise open, not stat, and open still behaves correctly.
Suggested fix
Either of:
- Reject the fast path when the trailing-component marker is significant,
e.g. guard it with !path_has_trailing_slash(path) and a check for a
trailing ./.. — the helpers already exist in
primitives::{unix,windows}::dir_utils. Note that Path::components()
cannot be used to detect either condition, since it normalizes both away;
the raw OsStr bytes must be inspected (which is what
path_has_trailing_slash does).
- Drop the fast path entirely and let
stat_fast/manually::stat handle
every case. On Linux this costs one openat2 + fstat instead of one
fstatat, but it would also fix the pre-existing Linux divergence.
A regression test for stat (not just open) with "file/", "file/."
and "link/" would be worth adding alongside the existing open tests in
fs_additional.rs.
Regression from
05f1d09903.A filename's trailing
/is ignored on macos, windows, and freebsd, which allows successfully statingsome-fileassome-file/when it should returnENOTDIR.Reproduction
statslash.watDetails
Full LLM Report
Details
Audit metadata
7ad2e732ab9ca8665d3cdd91f9c395315eeafc8105f1d09903500fdc7d17ab335a32d1144b6baa42("Continue to reorganizefilesystem::primitives", #14345, 2026-09-16)aarch64(arm64)claude-opus-5)Summary
05f1d09903hoisted a Linux-only "single component" fast path out ofrustix/linux/fs/stat_impl.rsinto the new platform-agnosticfilesystem::primitives::stat. The fast path was previously reachable onlyon Linux; it now runs on every platform.
The fast path calls
stat_unchecked(start, component, FollowSymlinks::No)with the path re-derived from
Path::components().Path::components()normalizes a trailing
/and a trailing/.away, so the trailing-componentmarker that POSIX (and hence WASI) uses to require the path to name a
directory is silently discarded.
Consequently, on macOS, Windows, FreeBSD and Android, WASI's
stat-at(
wasi:filesystem/types.stat-at, andpath_filestat_getin WASIp1) nowreturns success for paths such as
"file/","file/."and"link/"whereit previously — and correctly — returned
ENOTDIR. For"link/"it goesfurther and reports
filetype = symbolic-linkfor a path that cannot name asymlink at all.
The commit message states "The goal of this commit is to have no behavior
change", so this is unintentional.
This is a correctness / WASI-conformance bug rather than a sandbox escape:
the fast path only triggers for a path consisting of exactly one
Component::Normal, which cannot resolve outsidestart.The code
crates/wasi/src/filesystem/primitives/mod.rs:307-326(at7ad2e732ab):Before the commit,
primitives::statwas a straight re-export of theper-platform implementation
(
05f1d09903~1:crates/wasi/src/filesystem/primitives/mod.rs:74):and the platform implementations were:
rustix/linux/fs/stat_impl.rs) — contained the identicalsingle-component fast path, followed by the
openat2/O_PATHpath.rustix/linux/fs/mod.rs) —manually::stat, no fast path.rustix/freebsd/fs/stat_impl.rs) —statatwithAT_RESOLVE_BENEATHon the unmodifiedpath, so the kernel saw thetrailing slash. No fast path.
windows/fs/mod.rs:31) —manually::stat, no fast path.rustix/fs/mod.rs) —manually::stat, no fast path.manually::statdoes implement the rule;Context::newsetsdir_required = path_has_trailing_slash(path)andfollow_with_dot = trailing_dot | trailing_dotdot(
crates/wasi/src/filesystem/primitives/manually/open.rs), and thelast-component handler rejects a non-directory:
The new fast path runs before both
sys::stat_fastandmanually::stat,so it takes priority on every platform.
Why this is wrong
POSIX requires a pathname with a trailing slash to resolve as if it ended in
/., i.e. the final component must be a directory;stat("file/")andlstat("file/")both fail withENOTDIRon Linux and macOS. WASI inheritsthis:
wasi:filesystem/types.stat-atis specified in terms offstatat/lstatsemantics andwasmtime's owncrates/wasi/src/filesystem/primitives/manually/open.rsimplements the ruledeliberately.
Three concrete consequences on the affected platforms:
stat-at("file/", path-flags = {})succeeds and reportstype = regular-file, so a guest that uses a trailing slash to testwhether a name is a directory gets the wrong answer.
stat-at("file/.", path-flags = {})likewise succeeds.manually::stattreats a trailing
/.specially (follow_with_dot), so this case alsoloses the "follow the symlink even in nofollow mode" behavior.
stat-at("link/", path-flags = {})succeeds and reportstype = symbolic-link, which is not a value the path can legally produce:a trailing slash forces symlink resolution, and the resolved target is a
regular file, so the correct result is
ENOTDIR.The runtime is also now internally inconsistent in two visible ways:
stat-atwithsymlink-followset still returnsENOTDIR(it takes themanually::statpath), so the same path gets two different answersdepending only on the flag.
open-at("file/", ...)still correctly returnsENOTDIR, becauseprimitives::openwas not given the fast path.Both are visible in the reproduction output below.
Reproduction
statslash.watin this directory is a WASIp1 module that callspath_filestat_get(and onepath_openfor contrast) on a fixture directoryand prints each
errnoas two decimal digits.54is__WASI_ERRNO_NOTDIR,00is success. For thelinkcases it also printsthe
filetypebyte from the returnedfilestat(07=symbolic_link,04=regular_file,03=directory,99= buffer untouched becausethe call failed).
gen.pyis the generator that producedstatslash.wat; it is included sothe case list can be extended.
Observed
Both binaries are debug builds of
wasmtime-clion this host.The pre-commit binary was built from a clean worktree at
05f1d09903~1(
358ee7665b, "Add a public method to grow the per-store GC heap (#14341)").Bisected to the fast path
Applying only this change to
7ad2e732abrestores every pre-commit result(
54 54 54 ... 54 54), confirming the fast path is the sole cause:Affected platforms
05f1d099037ad2e732abENOTDIRENOTDIRENOTDIR(kernel saw the slash)ENOTDIROnly the macOS behavior was verified by execution on this host; the others
follow from the
#[cfg]structure shown above.Linux already had this bug via its own copy of the fast path, so on Linux
this is long-standing rather than new. That also explains why CI did not
catch the regression: the in-tree trailing-slash tests
(
crates/wasi/src/filesystem/primitives/tests/fs_additional.rs:trailing_slash,trailing_slash_in_dir,file_with_trailing_slashdot)exercise
open, notstat, andopenstill behaves correctly.Suggested fix
Either of:
e.g. guard it with
!path_has_trailing_slash(path)and a check for atrailing
./..— the helpers already exist inprimitives::{unix,windows}::dir_utils. Note thatPath::components()cannot be used to detect either condition, since it normalizes both away;
the raw
OsStrbytes must be inspected (which is whatpath_has_trailing_slashdoes).stat_fast/manually::stathandleevery case. On Linux this costs one
openat2+fstatinstead of onefstatat, but it would also fix the pre-existing Linux divergence.A regression test for
stat(not justopen) with"file/","file/."and
"link/"would be worth adding alongside the existingopentests infs_additional.rs.