Skip to content

WASI stat-at ignores a trailing / or /. on macOS, Windows and FreeBSD #14359

Description

@fitzgen

Regression from 05f1d09903.

A filename's trailing / is ignored on macos, windows, and freebsd, which allows successfully stating some-file as some-file/ when it should return ENOTDIR.

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)
  )
)

Full LLM Report

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:

  1. 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.
  2. 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.
  3. 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:

  1. 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).
  2. 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.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugIncorrect behavior in the current implementation that needs fixing

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions