Stream: git-wasmtime

Topic: wasmtime / issue #14359 WASI `stat-at` ignores a trailing...


view this post on Zulip Wasmtime GitHub notifications bot (Sep 19 2026 at 15:18):

fitzgen opened issue #14359:

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

</details>

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:

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/ope
[message truncated]

view this post on Zulip Wasmtime GitHub notifications bot (Sep 19 2026 at 15:18):

fitzgen added the bug label to Issue #14359.

view this post on Zulip Wasmtime GitHub notifications bot (Sep 19 2026 at 15:19):

fitzgen commented on issue #14359:

cc @alexcrichton


Last updated: Sep 20 2026 at 18:08 UTC