fitzgen opened issue #14359:
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
$ 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
statslash.wat<details>
;; Repro: WASIp1 `path_filestat_get` ignores a trailing slash / trailing ;; "/." when the path is a single component and symlinks are not followed. ;; ;; Run with: ;; mkdir -p root/dir && echo hi > root/file && ln -s file root/link ;; wasmtime run --dir=root::/d statslash.wat ;; ;; Each line prints the errno returned (54 == ENOTDIR, 00 == success). The ;; `link` lines additionally print the `filetype` byte from the returned ;; `filestat` (07 == symbolic_link, 04 == regular_file, 03 == directory, ;; 99 == buffer untouched because the call failed). (module (import "wasi_snapshot_preview1" "path_filestat_get" (func $stat (param i32 i32 i32 i32 i32) (result i32))) (import "wasi_snapshot_preview1" "path_open" (func $open (param i32 i32 i32 i32 i32 i64 i64 i32 i32) (result i32))) (import "wasi_snapshot_preview1" "fd_write" (func $write (param i32 i32 i32 i32) (result i32))) (memory (export "memory") 1) (data (i32.const 1024) "stat 'file' nofollow: ") (data (i32.const 1056) "file") (data (i32.const 1064) "stat 'file/' nofollow: ") (data (i32.const 1096) "file/") (data (i32.const 1104) "stat 'file/' follow: ") (data (i32.const 1136) "stat 'file/.' nofollow: ") (data (i32.const 1168) "file/.") (data (i32.const 1176) "stat 'file/.' follow: ") (data (i32.const 1208) "stat 'link' nofollow: ") (data (i32.const 1240) "link") (data (i32.const 1248) "stat 'link/' nofollow: ") (data (i32.const 1280) "link/") (data (i32.const 1288) "stat 'link/' follow: ") (data (i32.const 1320) "stat 'dir/' nofollow: ") (data (i32.const 1352) "dir/") (data (i32.const 1360) "open 'file/' nofollow: ") (data (i32.const 2048) " filetype=") ;; Memory map: 0..64 filestat buf, 128 iovec, 136 nwritten, 144 opened fd, ;; 160 scratch digits, 1024+ string pool. (func $print (param $p i32) (param $n i32) (i32.store (i32.const 128) (local.get $p)) (i32.store (i32.const 132) (local.get $n)) (drop (call $write (i32.const 1) (i32.const 128) (i32.const 1) (i32.const 136)))) (func $num (param $v i32) (i32.store8 (i32.const 160) (i32.add (i32.const 48) (i32.div_u (local.get $v) (i32.const 10)))) (i32.store8 (i32.const 161) (i32.add (i32.const 48) (i32.rem_u (local.get $v) (i32.const 10)))) (call $print (i32.const 160) (i32.const 2))) (func $nl (i32.store8 (i32.const 162) (i32.const 10)) (call $print (i32.const 162) (i32.const 1))) (func (export "_start") (call $print (i32.const 1024) (i32.const 25)) (i64.store (i32.const 16) (i64.const 0x63)) (call $num (call $stat (i32.const 3) (i32.const 0) (i32.const 1056) (i32.const 4) (i32.const 0))) (call $nl) (call $print (i32.const 1064) (i32.const 25)) (i64.store (i32.const 16) (i64.const 0x63)) (call $num (call $stat (i32.const 3) (i32.const 0) (i32.const 1096) (i32.const 5) (i32.const 0))) (call $nl) (call $print (i32.const 1104) (i32.const 25)) (i64.store (i32.const 16) (i64.const 0x63)) (call $num (call $stat (i32.const 3) (i32.const 1) (i32.const 1096) (i32.const 5) (i32.const 0))) (call $nl) (call $print (i32.const 1136) (i32.const 25)) (i64.store (i32.const 16) (i64.const 0x63)) (call $num (call $stat (i32.const 3) (i32.const 0) (i32.const 1168) (i32.const 6) (i32.const 0))) (call $nl) (call $print (i32.const 1176) (i32.const 25)) (i64.store (i32.const 16) (i64.const 0x63)) (call $num (call $stat (i32.const 3) (i32.const 1) (i32.const 1168) (i32.const 6) (i32.const 0))) (call $nl) (call $print (i32.const 1208) (i32.const 25)) (i64.store (i32.const 16) (i64.const 0x63)) (call $num (call $stat (i32.const 3) (i32.const 0) (i32.const 1240) (i32.const 4) (i32.const 0))) (call $print (i32.const 2048) (i32.const 11)) (call $num (i32.load8_u (i32.const 16))) (call $nl) (call $print (i32.const 1248) (i32.const 25)) (i64.store (i32.const 16) (i64.const 0x63)) (call $num (call $stat (i32.const 3) (i32.const 0) (i32.const 1280) (i32.const 5) (i32.const 0))) (call $print (i32.const 2048) (i32.const 11)) (call $num (i32.load8_u (i32.const 16))) (call $nl) (call $print (i32.const 1288) (i32.const 25)) (i64.store (i32.const 16) (i64.const 0x63)) (call $num (call $stat (i32.const 3) (i32.const 1) (i32.const 1280) (i32.const 5) (i32.const 0))) (call $print (i32.const 2048) (i32.const 11)) (call $num (i32.load8_u (i32.const 16))) (call $nl) (call $print (i32.const 1320) (i32.const 25)) (i64.store (i32.const 16) (i64.const 0x63)) (call $num (call $stat (i32.const 3) (i32.const 0) (i32.const 1352) (i32.const 4) (i32.const 0))) (call $nl) (call $print (i32.const 1360) (i32.const 25)) (call $num (call $open (i32.const 3) (i32.const 0) (i32.const 1096) (i32.const 5) (i32.const 0) (i64.const 0x1fffffff) (i64.const 0x1fffffff) (i32.const 0) (i32.const 144))) (call $nl) ) )</details>
Full LLM Report
<details>
Audit metadata
Date 2026-09-18 Wasmtime commit audited 7ad2e732ab9ca8665d3cdd91f9c395315eeafc81Regressing commit 05f1d09903500fdc7d17ab335a32d1144b6baa42("Continue to reorganizefilesystem::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
05f1d09903hoisted a Linux-only "single component" fast path out of
rustix/linux/fs/stat_impl.rsinto 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 fromPath::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, andpath_filestat_getin WASIp1) now
returns success for paths such as"file/","file/."and"link/"where
it previously — and correctly — returnedENOTDIR. For"link/"it goes
further and reportsfiletype = symbolic-linkfor 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 outsidestart.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::statwas 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 theopenat2/O_PATHpath.Android (
rustix/linux/fs/mod.rs) —manually::stat, no fast path.FreeBSD (
rustix/freebsd/fs/stat_impl.rs) —statatwith
AT_RESOLVE_BENEATHon the unmodifiedpath, 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::statdoes implement the rule;Context::newsets
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_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/")and
lstat("file/")both fail withENOTDIRon Linux and macOS. WASI inherits
this:wasi:filesystem/types.stat-atis specified in terms of
fstatat/lstatsemantics andwasmtime's own
`crates/wasi/src/filesystem/primitives/manually/ope
[message truncated]
fitzgen added the bug label to Issue #14359.
fitzgen commented on issue #14359:
cc @alexcrichton
Last updated: Sep 20 2026 at 18:08 UTC