MacroModel opened issue #13544:
WASIp1 semantic ambiguity with overlapping preopened directories can break POSIX path equivalence
Summary
This is not a Wasmtime implementation bug. It is a semantic ambiguity that falls
out of the WASIp1 preopen model when it is exposed through POSIX-like APIs.
Wasmtime currently allows overlapping WASI preopened directory names such as:--dir /host/a::/ --dir /host/b::/libIf
/host/aalso contains a reallibsubdirectory, POSIX-like guests can observe
two different host directories for the same logical path:int root = open("/", O_RDONLY); int via_root = openat(root, "lib/marker.txt", O_RDONLY); int via_absolute = open("/lib/marker.txt", O_RDONLY);Under POSIX path semantics,
openat(open("/"), "lib/marker.txt")and
open("/lib/marker.txt")should resolve to the same object. With overlapping
WASIp1 preopens, they can resolve to different host directories.This is surprising for POSIX-like runtimes such as wasi-libc and Rust
std, and it
can cause users to believe a more specific preopen masks or overrides a subtree when
it does not.The goal of this issue is therefore not to report an incorrect implementation of
WASIp1. Rather, it is to document a confusing and potentially dangerous ambiguity
and to propose that Wasmtime provide a diagnostic or an explicit opt-in for this
configuration.Environment
Observed with a local Wasmtime build:
wasmtime 46.0.0 (3f3f222b7 2026-06-02)The behavior was also reproduced with an installed Wasmtime 35.0.0 binary.
Minimal directory setup
Create two different host directories. Directory
ais mounted at guest/.
Directorybis mounted at guest/lib. Directoryaalso has its ownlib
subdirectory:mkdir -p /tmp/wasi-overlap/a/lib mkdir -p /tmp/wasi-overlap/b printf 'from-a-lib\n' > /tmp/wasi-overlap/a/lib/marker.txt printf 'from-b-mounted-lib\n' > /tmp/wasi-overlap/b/marker.txtC/C++ wasi-libc reproduction
control.cc:#include <cerrno> #include <cstdio> #include <cstring> #include <fcntl.h> #include <unistd.h> static void read_path(const char* label, const char* path) { int fd = open(path, O_RDONLY); if (fd < 0) { std::printf("%s: open failed: %s\n", label, std::strerror(errno)); return; } char buf[128]; ssize_t n = read(fd, buf, sizeof(buf) - 1); if (n < 0) { std::printf("%s: read failed: %s\n", label, std::strerror(errno)); close(fd); return; } buf[n] = '\0'; std::printf("%s: %s\n", label, buf); close(fd); } static void read_openat(int root_fd, const char* label, const char* path) { int fd = openat(root_fd, path, O_RDONLY); if (fd < 0) { std::printf("%s: openat failed: %s\n", label, std::strerror(errno)); return; } char buf[128]; ssize_t n = read(fd, buf, sizeof(buf) - 1); if (n < 0) { std::printf("%s: read failed: %s\n", label, std::strerror(errno)); close(fd); return; } buf[n] = '\0'; std::printf("%s: %s\n", label, buf); close(fd); } int main() { int root_fd = open("/", O_RDONLY); if (root_fd < 0) { std::printf("open root: failed: %s\n", std::strerror(errno)); return 1; } std::printf("open root: ok\n"); read_path("open /lib/marker.txt", "/lib/marker.txt"); read_openat(root_fd, "open / then openat lib/marker.txt", "lib/marker.txt"); close(root_fd); return 0; }Example compile command:
clang++ -o control.wasm control.cc \ -Ofast -Wno-deprecated-ofast -s -flto -fuse-ld=lld \ -fno-rtti -fno-unwind-tables -fno-asynchronous-unwind-tables -fno-exceptions \ --target=wasm32-wasip1 \ --sysroot="/Users/liyinan/Documents/MacroModel/src/wasi-libc/build-mvp/sysroot" \ -std=c++26 \ -mno-bulk-memory -mno-bulk-memory-opt -mno-nontrapping-fptoint \ -mno-sign-ext -mno-mutable-globals -mno-multivalue \ -mno-reference-types -mno-call-indirect-overlongRun:
wasmtime run \ --dir /tmp/wasi-overlap/a::/ \ --dir /tmp/wasi-overlap/b::/lib \ control.wasmActual output:
open root: ok open /lib/marker.txt: from-b-mounted-lib open / then openat lib/marker.txt: from-a-libThe absolute path lookup reaches the
/libpreopen, while the directory-fd-relative
lookup reaches the reallibdirectory inside the/preopen.Rust reproduction
The same behavior can also be demonstrated with Rust
std. Theopen_athelper is
currently behind Rust's WASI extension feature, so this is best treated as an
auxiliary reproduction; the C/C++ wasi-libc case above is sufficient on its own.#![feature(wasi_ext)] use std::fs::{self, File, OpenOptions}; use std::io::{self, Read}; use std::os::wasi::fs::OpenOptionsExt; fn read(mut file: File) -> io::Result<String> { let mut s = String::new(); file.read_to_string(&mut s)?; Ok(s.trim().to_owned()) } fn main() -> io::Result<()> { let abs = fs::read_to_string("/lib/marker.txt")?.trim().to_owned(); let root = File::open("/")?; let via_root = read(OpenOptions::new().read(true).open_at(&root, "lib/marker.txt")?)?; println!("open /lib/marker.txt: {abs}"); println!("open / then openat lib/marker.txt: {via_root}"); println!("same: {}", abs == via_root); Ok(()) }Observed output:
open /lib/marker.txt: from-b-mounted-lib open / then openat lib/marker.txt: from-a-lib same: falseExpected behavior
For a POSIX-like guest, these two operations should be path-equivalent:
open("/lib/marker.txt", O_RDONLY) openat(open("/", O_RDONLY), "lib/marker.txt", O_RDONLY)Raw WASIp1 is capability-oriented and does not define a POSIX mount namespace.
However, when Wasmtime's CLI preopens are used by POSIX-like guests, users can
reasonably expect the configured guest paths to approximate a guest-visible
directory tree. In that context, overlapping guest paths should either preserve this
equivalence or be reported as ambiguous.Actual behavior
WASIp1 exposes each preopen as an independent directory capability. A POSIX-like
guest runtime resolves absolute paths by matching preopen names, so/lib/...can
select the more specific/libpreopen. However, once the guest has opened/, the
resulting directory descriptor is the host directory/host/a; a subsequent
openat(root_fd, "lib/...")is performed relative to that host directory and does
not re-consult the preopen table.This creates a split namespace:
guest /lib via absolute path -> /host/b guest / + relative lib via openat -> /host/a/libWhy this matters
This can violate application assumptions imported from POSIX:
- Path normalization and path decomposition are no longer equivalent.
Code that first opens a base directory and then uses
openatcan see different
files than code using the absolute path.Users may reasonably expect
--dir b::/libto overlay or mask/libunder the
root preopen, but it does not.A more specific preopen may give a false impression that a subtree has been
replaced, while the original subtree remains reachable through the parent
directory descriptor.This is not necessarily a sandbox escape, because the parent preopen already grants
access to/host/a. It is also not a Wasmtime correctness bug against WASIp1: the
raw WASIp1 model is based on independent directory capabilities. The problem is
that the resulting guest namespace is ambiguous and non-POSIX-like in a way that
can be hard to notice.Relevant implementation behavior
At a high level:
WasiCtxBuilder::preopened_dirrecords each(Dir, guest_path)pair independently.wasi:filesystem/preopens.get-directoriesreturns the preopens as a list.
descriptor.open-atopens relative to the specific directory descriptor passed by
the guest.There is no overlay mount table consulted when opening a path relative to an
already-opened directory descriptor.That behavior is consistent with capability-oriented WASIp1 handles. The ambiguity
appears when these handles are combined with POSIX-like absolute path APIs provided
by guest runtimes such as wasi-libc.Suggested mitigations
Option 1: Warn by default
Wasmtime could detect overlapping guest preopen paths and emit a warning as a
usability diagnostic, for example:warning: overlapping WASI preopened directories '/' and '/lib' may be ambiguous. POSIX-like guests may observe open("/lib") and openat(open("/"), "lib") resolving to different host directories.The check should operate on normalized guest paths:
- collapse repeated slashes,
- remove
.path components,- remove trailing slashes,
- compare path components rather than string prefixes.
For example, all of the following should be treated as the same guest path shape:
/lib //lib/// ./libThe warning should trigger when one guest preopen path is an ancestor of another
guest preopen path, such as:/ and /lib /usr and /usr/lib . and ./libIt is probably better to warn for any overlapping guest paths rather than only when
the parent host directory currently contains a matching child, because the host
directory contents can change after Wasmtime starts.Option 2: Reject by default, require explicit opt-in
A stricter and less surprising CLI behavior would be to reject overlapping guest
preopen paths unless the user explicitly allows them:error: overlapping WASI preopened directories '/' and '/lib' are ambiguous. Use --allow-overlapping-wasi-dirs to permit this behavior.Possible CLI names:
--allow-overlapping-wasi-dirs --wasi-overlapping-dirs=allow --wasi-dir-overlap=allowThis would make the WASIp1 ambiguity explicit while preserving compatibility for
users who intentionally rely on independent overl
[message truncated]
MacroModel edited issue #13544:
WASIp1 semantic ambiguity with overlapping preopened directories can break POSIX path equivalence
Summary
This is not a Wasmtime implementation bug. It is a semantic ambiguity that falls
out of the WASIp1 preopen model when it is exposed through POSIX-like APIs.
Wasmtime currently allows overlapping WASI preopened directory names such as:--dir /host/a::/ --dir /host/b::/libIf
/host/aalso contains a reallibsubdirectory, POSIX-like guests can observe
two different host directories for the same logical path:int root = open("/", O_RDONLY); int via_root = openat(root, "lib/marker.txt", O_RDONLY); int via_absolute = open("/lib/marker.txt", O_RDONLY);Under POSIX path semantics,
openat(open("/"), "lib/marker.txt")and
open("/lib/marker.txt")should resolve to the same object. With overlapping
WASIp1 preopens, they can resolve to different host directories.This is surprising for POSIX-like runtimes such as wasi-libc and Rust
std, and it
can cause users to believe a more specific preopen masks or overrides a subtree when
it does not.The goal of this issue is therefore not to report an incorrect implementation of
WASIp1. Rather, it is to document a confusing and potentially dangerous ambiguity
and to propose that Wasmtime provide a diagnostic or an explicit opt-in for this
configuration.Environment
Observed with a local Wasmtime build:
wasmtime 46.0.0 (3f3f222b7 2026-06-02)The behavior was also reproduced with an installed Wasmtime 35.0.0 binary.
Minimal directory setup
Create two different host directories. Directory
ais mounted at guest/.
Directorybis mounted at guest/lib. Directoryaalso has its ownlib
subdirectory:mkdir -p /tmp/wasi-overlap/a/lib mkdir -p /tmp/wasi-overlap/b printf 'from-a-lib\n' > /tmp/wasi-overlap/a/lib/marker.txt printf 'from-b-mounted-lib\n' > /tmp/wasi-overlap/b/marker.txtC/C++ wasi-libc reproduction
control.cc:#include <cerrno> #include <cstdio> #include <cstring> #include <fcntl.h> #include <unistd.h> static void read_path(const char* label, const char* path) { int fd = open(path, O_RDONLY); if (fd < 0) { std::printf("%s: open failed: %s\n", label, std::strerror(errno)); return; } char buf[128]; ssize_t n = read(fd, buf, sizeof(buf) - 1); if (n < 0) { std::printf("%s: read failed: %s\n", label, std::strerror(errno)); close(fd); return; } buf[n] = '\0'; std::printf("%s: %s\n", label, buf); close(fd); } static void read_openat(int root_fd, const char* label, const char* path) { int fd = openat(root_fd, path, O_RDONLY); if (fd < 0) { std::printf("%s: openat failed: %s\n", label, std::strerror(errno)); return; } char buf[128]; ssize_t n = read(fd, buf, sizeof(buf) - 1); if (n < 0) { std::printf("%s: read failed: %s\n", label, std::strerror(errno)); close(fd); return; } buf[n] = '\0'; std::printf("%s: %s\n", label, buf); close(fd); } int main() { int root_fd = open("/", O_RDONLY); if (root_fd < 0) { std::printf("open root: failed: %s\n", std::strerror(errno)); return 1; } std::printf("open root: ok\n"); read_path("open /lib/marker.txt", "/lib/marker.txt"); read_openat(root_fd, "open / then openat lib/marker.txt", "lib/marker.txt"); close(root_fd); return 0; }Example compile command:
clang++ -o control.wasm control.cc \ -Ofast -Wno-deprecated-ofast -s -flto -fuse-ld=lld \ -fno-rtti -fno-unwind-tables -fno-asynchronous-unwind-tables -fno-exceptions \ --target=wasm32-wasip1 \ --sysroot="/Users/MacroModel/Documents/MacroModel/src/wasi-libc/build-mvp/sysroot" \ -std=c++26 \ -mno-bulk-memory -mno-bulk-memory-opt -mno-nontrapping-fptoint \ -mno-sign-ext -mno-mutable-globals -mno-multivalue \ -mno-reference-types -mno-call-indirect-overlongRun:
wasmtime run \ --dir /tmp/wasi-overlap/a::/ \ --dir /tmp/wasi-overlap/b::/lib \ control.wasmActual output:
open root: ok open /lib/marker.txt: from-b-mounted-lib open / then openat lib/marker.txt: from-a-libThe absolute path lookup reaches the
/libpreopen, while the directory-fd-relative
lookup reaches the reallibdirectory inside the/preopen.Rust reproduction
The same behavior can also be demonstrated with Rust
std. Theopen_athelper is
currently behind Rust's WASI extension feature, so this is best treated as an
auxiliary reproduction; the C/C++ wasi-libc case above is sufficient on its own.#![feature(wasi_ext)] use std::fs::{self, File, OpenOptions}; use std::io::{self, Read}; use std::os::wasi::fs::OpenOptionsExt; fn read(mut file: File) -> io::Result<String> { let mut s = String::new(); file.read_to_string(&mut s)?; Ok(s.trim().to_owned()) } fn main() -> io::Result<()> { let abs = fs::read_to_string("/lib/marker.txt")?.trim().to_owned(); let root = File::open("/")?; let via_root = read(OpenOptions::new().read(true).open_at(&root, "lib/marker.txt")?)?; println!("open /lib/marker.txt: {abs}"); println!("open / then openat lib/marker.txt: {via_root}"); println!("same: {}", abs == via_root); Ok(()) }Observed output:
open /lib/marker.txt: from-b-mounted-lib open / then openat lib/marker.txt: from-a-lib same: falseExpected behavior
For a POSIX-like guest, these two operations should be path-equivalent:
open("/lib/marker.txt", O_RDONLY) openat(open("/", O_RDONLY), "lib/marker.txt", O_RDONLY)Raw WASIp1 is capability-oriented and does not define a POSIX mount namespace.
However, when Wasmtime's CLI preopens are used by POSIX-like guests, users can
reasonably expect the configured guest paths to approximate a guest-visible
directory tree. In that context, overlapping guest paths should either preserve this
equivalence or be reported as ambiguous.Actual behavior
WASIp1 exposes each preopen as an independent directory capability. A POSIX-like
guest runtime resolves absolute paths by matching preopen names, so/lib/...can
select the more specific/libpreopen. However, once the guest has opened/, the
resulting directory descriptor is the host directory/host/a; a subsequent
openat(root_fd, "lib/...")is performed relative to that host directory and does
not re-consult the preopen table.This creates a split namespace:
guest /lib via absolute path -> /host/b guest / + relative lib via openat -> /host/a/libWhy this matters
This can violate application assumptions imported from POSIX:
- Path normalization and path decomposition are no longer equivalent.
Code that first opens a base directory and then uses
openatcan see different
files than code using the absolute path.Users may reasonably expect
--dir b::/libto overlay or mask/libunder the
root preopen, but it does not.A more specific preopen may give a false impression that a subtree has been
replaced, while the original subtree remains reachable through the parent
directory descriptor.This is not necessarily a sandbox escape, because the parent preopen already grants
access to/host/a. It is also not a Wasmtime correctness bug against WASIp1: the
raw WASIp1 model is based on independent directory capabilities. The problem is
that the resulting guest namespace is ambiguous and non-POSIX-like in a way that
can be hard to notice.Relevant implementation behavior
At a high level:
WasiCtxBuilder::preopened_dirrecords each(Dir, guest_path)pair independently.wasi:filesystem/preopens.get-directoriesreturns the preopens as a list.
descriptor.open-atopens relative to the specific directory descriptor passed by
the guest.There is no overlay mount table consulted when opening a path relative to an
already-opened directory descriptor.That behavior is consistent with capability-oriented WASIp1 handles. The ambiguity
appears when these handles are combined with POSIX-like absolute path APIs provided
by guest runtimes such as wasi-libc.Suggested mitigations
Option 1: Warn by default
Wasmtime could detect overlapping guest preopen paths and emit a warning as a
usability diagnostic, for example:warning: overlapping WASI preopened directories '/' and '/lib' may be ambiguous. POSIX-like guests may observe open("/lib") and openat(open("/"), "lib") resolving to different host directories.The check should operate on normalized guest paths:
- collapse repeated slashes,
- remove
.path components,- remove trailing slashes,
- compare path components rather than string prefixes.
For example, all of the following should be treated as the same guest path shape:
/lib //lib/// ./libThe warning should trigger when one guest preopen path is an ancestor of another
guest preopen path, such as:/ and /lib /usr and /usr/lib . and ./libIt is probably better to warn for any overlapping guest paths rather than only when
the parent host directory currently contains a matching child, because the host
directory contents can change after Wasmtime starts.Option 2: Reject by default, require explicit opt-in
A stricter and less surprising CLI behavior would be to reject overlapping guest
preopen paths unless the user explicitly allows them:error: overlapping WASI preopened directories '/' and '/lib' are ambiguous. Use --allow-overlapping-wasi-dirs to permit this behavior.Possible CLI names:
--allow-overlapping-wasi-dirs --wasi-overlapping-dirs=allow --wasi-dir-overlap=allowThis would make the WASIp1 ambiguity explicit while preserving compatibility for
users who intentionally rely on independent ov
[message truncated]
MacroModel edited issue #13544:
WASIp1 semantic ambiguity with overlapping preopened directories can break POSIX path equivalence
Summary
This is not a Wasmtime implementation bug. It is a semantic ambiguity that falls
out of the WASIp1 preopen model when it is exposed through POSIX-like APIs.
Wasmtime currently allows overlapping WASI preopened directory names such as:--dir /host/a::/ --dir /host/b::/libIf
/host/aalso contains a reallibsubdirectory, POSIX-like guests can observe
two different host directories for the same logical path:int root = open("/", O_RDONLY); int via_root = openat(root, "lib/marker.txt", O_RDONLY); int via_absolute = open("/lib/marker.txt", O_RDONLY);Under POSIX path semantics,
openat(open("/"), "lib/marker.txt")and
open("/lib/marker.txt")should resolve to the same object. With overlapping
WASIp1 preopens, they can resolve to different host directories.This is surprising for POSIX-like runtimes such as wasi-libc and Rust
std, and it
can cause users to believe a more specific preopen masks or overrides a subtree when
it does not.The goal of this issue is therefore not to report an incorrect implementation of
WASIp1. Rather, it is to document a confusing and potentially dangerous ambiguity
and to propose that Wasmtime provide a diagnostic or an explicit opt-in for this
configuration.Environment
Observed with a local Wasmtime build:
wasmtime 46.0.0 (3f3f222b7 2026-06-02)The behavior was also reproduced with an installed Wasmtime 35.0.0 binary.
Minimal directory setup
Create two different host directories. Directory
ais mounted at guest/.
Directorybis mounted at guest/lib. Directoryaalso has its ownlib
subdirectory:mkdir -p /tmp/wasi-overlap/a/lib mkdir -p /tmp/wasi-overlap/b printf 'from-a-lib\n' > /tmp/wasi-overlap/a/lib/marker.txt printf 'from-b-mounted-lib\n' > /tmp/wasi-overlap/b/marker.txtC/C++ wasip1-libc reproduction
control.cc:#include <cerrno> #include <cstdio> #include <cstring> #include <fcntl.h> #include <unistd.h> static void read_path(const char* label, const char* path) { int fd = open(path, O_RDONLY); if (fd < 0) { std::printf("%s: open failed: %s\n", label, std::strerror(errno)); return; } char buf[128]; ssize_t n = read(fd, buf, sizeof(buf) - 1); if (n < 0) { std::printf("%s: read failed: %s\n", label, std::strerror(errno)); close(fd); return; } buf[n] = '\0'; std::printf("%s: %s\n", label, buf); close(fd); } static void read_openat(int root_fd, const char* label, const char* path) { int fd = openat(root_fd, path, O_RDONLY); if (fd < 0) { std::printf("%s: openat failed: %s\n", label, std::strerror(errno)); return; } char buf[128]; ssize_t n = read(fd, buf, sizeof(buf) - 1); if (n < 0) { std::printf("%s: read failed: %s\n", label, std::strerror(errno)); close(fd); return; } buf[n] = '\0'; std::printf("%s: %s\n", label, buf); close(fd); } int main() { int root_fd = open("/", O_RDONLY); if (root_fd < 0) { std::printf("open root: failed: %s\n", std::strerror(errno)); return 1; } std::printf("open root: ok\n"); read_path("open /lib/marker.txt", "/lib/marker.txt"); read_openat(root_fd, "open / then openat lib/marker.txt", "lib/marker.txt"); close(root_fd); return 0; }Example compile command:
clang++ -o control.wasm control.cc \ -Ofast -Wno-deprecated-ofast -s -flto -fuse-ld=lld \ -fno-rtti -fno-unwind-tables -fno-asynchronous-unwind-tables -fno-exceptions \ --target=wasm32-wasip1 \ --sysroot="/Users/MacroModel/Documents/MacroModel/src/wasi-libc/build-mvp/sysroot" \ -std=c++26 \ -mno-bulk-memory -mno-bulk-memory-opt -mno-nontrapping-fptoint \ -mno-sign-ext -mno-mutable-globals -mno-multivalue \ -mno-reference-types -mno-call-indirect-overlongRun:
wasmtime run \ --dir /tmp/wasi-overlap/a::/ \ --dir /tmp/wasi-overlap/b::/lib \ control.wasmActual output:
open root: ok open /lib/marker.txt: from-b-mounted-lib open / then openat lib/marker.txt: from-a-libThe absolute path lookup reaches the
/libpreopen, while the directory-fd-relative
lookup reaches the reallibdirectory inside the/preopen.Rust reproduction
The same behavior can also be demonstrated with Rust
std. Theopen_athelper is
currently behind Rust's WASI extension feature, so this is best treated as an
auxiliary reproduction; the C/C++ wasi-libc case above is sufficient on its own.#![feature(wasi_ext)] use std::fs::{self, File, OpenOptions}; use std::io::{self, Read}; use std::os::wasi::fs::OpenOptionsExt; fn read(mut file: File) -> io::Result<String> { let mut s = String::new(); file.read_to_string(&mut s)?; Ok(s.trim().to_owned()) } fn main() -> io::Result<()> { let abs = fs::read_to_string("/lib/marker.txt")?.trim().to_owned(); let root = File::open("/")?; let via_root = read(OpenOptions::new().read(true).open_at(&root, "lib/marker.txt")?)?; println!("open /lib/marker.txt: {abs}"); println!("open / then openat lib/marker.txt: {via_root}"); println!("same: {}", abs == via_root); Ok(()) }Observed output:
open /lib/marker.txt: from-b-mounted-lib open / then openat lib/marker.txt: from-a-lib same: falseExpected behavior
For a POSIX-like guest, these two operations should be path-equivalent:
open("/lib/marker.txt", O_RDONLY) openat(open("/", O_RDONLY), "lib/marker.txt", O_RDONLY)Raw WASIp1 is capability-oriented and does not define a POSIX mount namespace.
However, when Wasmtime's CLI preopens are used by POSIX-like guests, users can
reasonably expect the configured guest paths to approximate a guest-visible
directory tree. In that context, overlapping guest paths should either preserve this
equivalence or be reported as ambiguous.Actual behavior
WASIp1 exposes each preopen as an independent directory capability. A POSIX-like
guest runtime resolves absolute paths by matching preopen names, so/lib/...can
select the more specific/libpreopen. However, once the guest has opened/, the
resulting directory descriptor is the host directory/host/a; a subsequent
openat(root_fd, "lib/...")is performed relative to that host directory and does
not re-consult the preopen table.This creates a split namespace:
guest /lib via absolute path -> /host/b guest / + relative lib via openat -> /host/a/libWhy this matters
This can violate application assumptions imported from POSIX:
- Path normalization and path decomposition are no longer equivalent.
Code that first opens a base directory and then uses
openatcan see different
files than code using the absolute path.Users may reasonably expect
--dir b::/libto overlay or mask/libunder the
root preopen, but it does not.A more specific preopen may give a false impression that a subtree has been
replaced, while the original subtree remains reachable through the parent
directory descriptor.This is not necessarily a sandbox escape, because the parent preopen already grants
access to/host/a. It is also not a Wasmtime correctness bug against WASIp1: the
raw WASIp1 model is based on independent directory capabilities. The problem is
that the resulting guest namespace is ambiguous and non-POSIX-like in a way that
can be hard to notice.Relevant implementation behavior
At a high level:
WasiCtxBuilder::preopened_dirrecords each(Dir, guest_path)pair independently.wasi:filesystem/preopens.get-directoriesreturns the preopens as a list.
descriptor.open-atopens relative to the specific directory descriptor passed by
the guest.There is no overlay mount table consulted when opening a path relative to an
already-opened directory descriptor.That behavior is consistent with capability-oriented WASIp1 handles. The ambiguity
appears when these handles are combined with POSIX-like absolute path APIs provided
by guest runtimes such as wasi-libc.Suggested mitigations
Option 1: Warn by default
Wasmtime could detect overlapping guest preopen paths and emit a warning as a
usability diagnostic, for example:warning: overlapping WASI preopened directories '/' and '/lib' may be ambiguous. POSIX-like guests may observe open("/lib") and openat(open("/"), "lib") resolving to different host directories.The check should operate on normalized guest paths:
- collapse repeated slashes,
- remove
.path components,- remove trailing slashes,
- compare path components rather than string prefixes.
For example, all of the following should be treated as the same guest path shape:
/lib //lib/// ./libThe warning should trigger when one guest preopen path is an ancestor of another
guest preopen path, such as:/ and /lib /usr and /usr/lib . and ./libIt is probably better to warn for any overlapping guest paths rather than only when
the parent host directory currently contains a matching child, because the host
directory contents can change after Wasmtime starts.Option 2: Reject by default, require explicit opt-in
A stricter and less surprising CLI behavior would be to reject overlapping guest
preopen paths unless the user explicitly allows them:error: overlapping WASI preopened directories '/' and '/lib' are ambiguous. Use --allow-overlapping-wasi-dirs to permit this behavior.Possible CLI names:
--allow-overlapping-wasi-dirs --wasi-overlapping-dirs=allow --wasi-dir-overlap=allowThis would make the WASIp1 ambiguity explicit while preserving compatibility for
users who intentionally rely on independent
[message truncated]
MacroModel edited issue #13544:
WASI preopen semantic ambiguity with overlapping directories can break POSIX path equivalence
Summary
This is not a Wasmtime implementation bug. It is a semantic ambiguity that falls
out of the WASI preopen model when it is exposed through POSIX-like APIs.
The issue was first observed with WASIp1, but the same preopen/open-at structure
also exists in WASIp2 and WASIp3. Wasmtime currently allows overlapping WASI
preopened directory names such as:--dir /host/a::/ --dir /host/b::/libIf
/host/aalso contains a reallibsubdirectory, POSIX-like guests can observe
two different host directories for the same logical path:int root = open("/", O_RDONLY); int via_root = openat(root, "lib/marker.txt", O_RDONLY); int via_absolute = open("/lib/marker.txt", O_RDONLY);Under POSIX path semantics,
openat(open("/"), "lib/marker.txt")and
open("/lib/marker.txt")should resolve to the same object. With overlapping
WASI preopens, they can resolve to different host directories.This is surprising for POSIX-like runtimes such as wasi-libc and Rust
std, and it
can cause users to believe a more specific preopen masks or overrides a subtree when
it does not.The goal of this issue is therefore not to report an incorrect implementation of
WASI. Rather, it is to document a confusing and potentially dangerous ambiguity and
to propose that Wasmtime provide a diagnostic or an explicit opt-in for this
configuration.Environment
Observed with a local Wasmtime build:
wasmtime 46.0.0 (3f3f222b7 2026-06-02)The behavior was also reproduced with an installed Wasmtime 35.0.0 binary.
Minimal directory setup
Create two different host directories. Directory
ais mounted at guest/.
Directorybis mounted at guest/lib. Directoryaalso has its ownlib
subdirectory:mkdir -p /tmp/wasi-overlap/a/lib mkdir -p /tmp/wasi-overlap/b printf 'from-a-lib\n' > /tmp/wasi-overlap/a/lib/marker.txt printf 'from-b-mounted-lib\n' > /tmp/wasi-overlap/b/marker.txtC/C++ wasi-libc reproduction
control.cc:#include <cerrno> #include <cstdio> #include <cstring> #include <fcntl.h> #include <unistd.h> static void read_path(const char* label, const char* path) { int fd = open(path, O_RDONLY); if (fd < 0) { std::printf("%s: open failed: %s\n", label, std::strerror(errno)); return; } char buf[128]; ssize_t n = read(fd, buf, sizeof(buf) - 1); if (n < 0) { std::printf("%s: read failed: %s\n", label, std::strerror(errno)); close(fd); return; } buf[n] = '\0'; std::printf("%s: %s\n", label, buf); close(fd); } static void read_openat(int root_fd, const char* label, const char* path) { int fd = openat(root_fd, path, O_RDONLY); if (fd < 0) { std::printf("%s: openat failed: %s\n", label, std::strerror(errno)); return; } char buf[128]; ssize_t n = read(fd, buf, sizeof(buf) - 1); if (n < 0) { std::printf("%s: read failed: %s\n", label, std::strerror(errno)); close(fd); return; } buf[n] = '\0'; std::printf("%s: %s\n", label, buf); close(fd); } int main() { int root_fd = open("/", O_RDONLY); if (root_fd < 0) { std::printf("open root: failed: %s\n", std::strerror(errno)); return 1; } std::printf("open root: ok\n"); read_path("open /lib/marker.txt", "/lib/marker.txt"); read_openat(root_fd, "open / then openat lib/marker.txt", "lib/marker.txt"); close(root_fd); return 0; }Example compile command:
clang++ -o control.wasm control.cc \ -Ofast -Wno-deprecated-ofast -s -flto -fuse-ld=lld \ -fno-rtti -fno-unwind-tables -fno-asynchronous-unwind-tables -fno-exceptions \ --target=wasm32-wasip1 \ --sysroot="/Users/MacroModel/Documents/MacroModel/src/wasi-libc/build-mvp/sysroot" \ -std=c++26 \ -mno-bulk-memory -mno-bulk-memory-opt -mno-nontrapping-fptoint \ -mno-sign-ext -mno-mutable-globals -mno-multivalue \ -mno-reference-types -mno-call-indirect-overlongRun:
wasmtime run \ --dir /tmp/wasi-overlap/a::/ \ --dir /tmp/wasi-overlap/b::/lib \ control.wasmActual output:
open root: ok open /lib/marker.txt: from-b-mounted-lib open / then openat lib/marker.txt: from-a-libThe absolute path lookup reaches the
/libpreopen, while the directory-fd-relative
lookup reaches the reallibdirectory inside the/preopen.Scope across WASI Preview 1, 2, and 3
This ambiguity is not specific to one guest language or to a single Wasmtime
implementation path.WASIp1
The C/C++ wasi-libc reproduction above demonstrates the issue with the WASIp1
POSIX compatibility layer. A Ruststdreproduction is also included below.WASIp2
The same C++ source was also compiled with a
wasm32-wasip2sysroot and run as a
component. A Ruststdcomponent targetingwasm32-wasip2was also tested. Both
produced the same split:open root: ok open /lib/marker.txt: from-b-mounted-lib open / then openat lib/marker.txt: from-a-libThis is expected from the WASIp2 filesystem shape:
wasi:filesystem/preopens
returns a list of(descriptor, string)pairs, and
wasi:filesystem/types.descriptor.open-atopens relative to the specific
descriptor passed by the guest. A POSIX-like runtime that maps absolute paths to
preopen names can therefore observe the same split namespace.WASIp3
I do not have a complete
wasm32-wasip3libc sysroot available locally for a
libc/POSIX binary reproduction. However, a Rust WASIp3 component using WIT
bindings was tested. It directly usespreopens.get-directoriesand
descriptor.open-at, and it also observes the same split:preopen /lib + open_at marker.txt: from-b-mounted-lib preopen / + open_at lib/marker.txt: from-a-lib same: falseThis is expected from the WASIp3 filesystem shape:
preopens.get-directories
returns preopened descriptors and their names, anddescriptor.open-atopens
relative to a descriptor.Wasmtime's WASIp3 host implementation follows the same descriptor-relative shape.
Therefore, this class of ambiguity also exists in WASIp3 at the raw capability
level, and any POSIX-like WASIp3 runtime that exposes absolute paths over preopened
descriptors would inherit the same issue.In other words, this should be treated as a general WASI preopen semantic
ambiguity, originally observed in WASIp1 and reproduced in WASIp2, rather than as
a Wasmtime bug in a particular preview.Rust reproductions
The same behavior can also be demonstrated from Rust.
Rust
stdreproduction for WASIp1/WASIp2The
open_athelper is currently behind Rust's WASI extension feature. For
wasm32-wasip1, use#![feature(wasi_ext)]. Forwasm32-wasip2, Rust also
requires thewasip2feature gate, as shown below. This was tested with
RUSTC_BOOTSTRAP=1for local reproduction.#![feature(wasi_ext, wasip2)] use std::fs::{self, File, OpenOptions}; use std::io::{self, Read}; use std::os::wasi::fs::OpenOptionsExt; fn read(mut file: File) -> io::Result<String> { let mut s = String::new(); file.read_to_string(&mut s)?; Ok(s.trim().to_owned()) } fn main() -> io::Result<()> { let abs = fs::read_to_string("/lib/marker.txt")?.trim().to_owned(); let root = File::open("/")?; let via_root = read(OpenOptions::new().read(true).open_at(&root, "lib/marker.txt")?)?; println!("open /lib/marker.txt: {abs}"); println!("open / then openat lib/marker.txt: {via_root}"); println!("same: {}", abs == via_root); Ok(()) }Observed output:
open /lib/marker.txt: from-b-mounted-lib open / then openat lib/marker.txt: from-a-lib same: falseFor WASIp2 this was compiled with:
RUSTC_BOOTSTRAP=1 rustc --target wasm32-wasip2 rust-openat.rs -o rust-openat-p2.wasmRunning
rust-openat-p2.wasmwith the same overlapping preopens produced the same
output:open /lib/marker.txt: from-b-mounted-lib open / then openat lib/marker.txt: from-a-lib same: falseRust/WIT reproduction for WASIp3
Because I do not have a complete
wasm32-wasip3Ruststdtarget or libc sysroot
available locally, the WASIp3 Rust reproduction uses WIT bindings directly. This
demonstrates the raw WASIp3 capability-level ambiguity.The following is a reduced version of the Rust component used for the test:
use test_programs::p3::wasi; use test_programs::p3::wasi::filesystem::types::{ Descriptor, DescriptorFlags, OpenFlags, PathFlags, }; struct Component; test_programs::p3::export!(Component); impl test_programs::p3::exports::wasi::cli::run::Guest for Component { async fn run() -> Result<(), ()> { let preopens = wasi::filesystem::preopens::get_directories(); let mut root = None; let mut lib = None; for (dir, name) in preopens { match name.as_str() { "/" => root = Some(dir), "/lib" => lib = Some(dir), _ => {} } } let root = root.expect("missing / preopen"); let lib = lib.expect("missing /lib preopen"); let via_abs = read_file( &lib.open_at( PathFlags::empty(), "marker.txt".to_string(), OpenFlags::empty(), DescriptorFlags::READ, ) .await .expect("open marker through /lib preopen"), ) .await; let via_root = read_file( &root .open_at( PathFlags::empty(), "lib/marker.txt".to_string(), OpenFlags::empty(), DescriptorFlags::READ, ) .await .expect("open marker through root preopen") [message truncated]
MacroModel edited issue #13544:
WASI preopen ambiguity in POSIX-like runtimes can break path equivalence
Summary
This is not a Wasmtime implementation bug. It is a semantic ambiguity that falls
out of the WASI preopen model when it is exposed through POSIX-like APIs.
The issue was first observed with WASIp1, and it can also be reproduced with
WASIp2 when using POSIX-like guest runtimes such as wasi-libc or Ruststd.
Wasmtime currently allows overlapping WASI preopened directory names such as:--dir /host/a::/ --dir /host/b::/libIf
/host/aalso contains a reallibsubdirectory, POSIX-like guests can observe
two different host directories for the same logical path:int root = open("/", O_RDONLY); int via_root = openat(root, "lib/marker.txt", O_RDONLY); int via_absolute = open("/lib/marker.txt", O_RDONLY);Under POSIX path semantics,
openat(open("/"), "lib/marker.txt")and
open("/lib/marker.txt")should resolve to the same object. With overlapping
WASI preopens, they can resolve to different host directories.This is surprising for POSIX-like runtimes such as wasi-libc and Rust
std, and it
can cause users to believe a more specific preopen masks or overrides a subtree when
it does not.The goal of this issue is therefore not to report an incorrect implementation of
WASI. Rather, it is to document a confusing and potentially dangerous ambiguity and
to propose that Wasmtime provide a diagnostic or an explicit opt-in for this
configuration.Environment
Observed with a local Wasmtime build:
wasmtime 46.0.0 (3f3f222b7 2026-06-02)The behavior was also reproduced with an installed Wasmtime 35.0.0 binary.
Minimal directory setup
Create two different host directories. Directory
ais mounted at guest/.
Directorybis mounted at guest/lib. Directoryaalso has its ownlib
subdirectory:mkdir -p /tmp/wasi-overlap/a/lib mkdir -p /tmp/wasi-overlap/b printf 'from-a-lib\n' > /tmp/wasi-overlap/a/lib/marker.txt printf 'from-b-mounted-lib\n' > /tmp/wasi-overlap/b/marker.txtC/C++ wasi-libc reproduction
control.cc:#include <cerrno> #include <cstdio> #include <cstring> #include <fcntl.h> #include <unistd.h> static void read_path(const char* label, const char* path) { int fd = open(path, O_RDONLY); if (fd < 0) { std::printf("%s: open failed: %s\n", label, std::strerror(errno)); return; } char buf[128]; ssize_t n = read(fd, buf, sizeof(buf) - 1); if (n < 0) { std::printf("%s: read failed: %s\n", label, std::strerror(errno)); close(fd); return; } buf[n] = '\0'; std::printf("%s: %s\n", label, buf); close(fd); } static void read_openat(int root_fd, const char* label, const char* path) { int fd = openat(root_fd, path, O_RDONLY); if (fd < 0) { std::printf("%s: openat failed: %s\n", label, std::strerror(errno)); return; } char buf[128]; ssize_t n = read(fd, buf, sizeof(buf) - 1); if (n < 0) { std::printf("%s: read failed: %s\n", label, std::strerror(errno)); close(fd); return; } buf[n] = '\0'; std::printf("%s: %s\n", label, buf); close(fd); } int main() { int root_fd = open("/", O_RDONLY); if (root_fd < 0) { std::printf("open root: failed: %s\n", std::strerror(errno)); return 1; } std::printf("open root: ok\n"); read_path("open /lib/marker.txt", "/lib/marker.txt"); read_openat(root_fd, "open / then openat lib/marker.txt", "lib/marker.txt"); close(root_fd); return 0; }Example compile command:
clang++ -o control.wasm control.cc \ -Ofast -Wno-deprecated-ofast -s -flto -fuse-ld=lld \ -fno-rtti -fno-unwind-tables -fno-asynchronous-unwind-tables -fno-exceptions \ --target=wasm32-wasip1 \ --sysroot="/Users/MacroModel/Documents/MacroModel/src/wasi-libc/build-mvp/sysroot" \ -std=c++26 \ -mno-bulk-memory -mno-bulk-memory-opt -mno-nontrapping-fptoint \ -mno-sign-ext -mno-mutable-globals -mno-multivalue \ -mno-reference-types -mno-call-indirect-overlongRun:
wasmtime run \ --dir /tmp/wasi-overlap/a::/ \ --dir /tmp/wasi-overlap/b::/lib \ control.wasmActual output:
open root: ok open /lib/marker.txt: from-b-mounted-lib open / then openat lib/marker.txt: from-a-libThe absolute path lookup reaches the
/libpreopen, while the directory-fd-relative
lookup reaches the reallibdirectory inside the/preopen.Scope across WASI previews
This ambiguity is not specific to one guest language or to a single Wasmtime
implementation path. However, it is specifically about POSIX-like path APIs.WASIp1
The C/C++ wasi-libc reproduction above demonstrates the issue with the WASIp1
POSIX compatibility layer. A Ruststdreproduction is also included below.WASIp2
The same C++ source was also compiled with a
wasm32-wasip2sysroot and run as a
component. A Ruststdcomponent targetingwasm32-wasip2was also tested. Both
produced the same split:open root: ok open /lib/marker.txt: from-b-mounted-lib open / then openat lib/marker.txt: from-a-libThis is expected from the WASIp2 filesystem shape:
wasi:filesystem/preopens
returns a list of(descriptor, string)pairs, and
wasi:filesystem/types.descriptor.open-atopens relative to the specific
descriptor passed by the guest. A POSIX-like runtime that maps absolute paths to
preopen names can therefore observe the same split namespace.WASIp3
This issue does not claim a WASIp3 bug. The raw WASIp3 filesystem API is not a
POSIX API and does not itself provideopen("/")oropen("/lib")operations.
Opening one imported/preopened descriptor and then opening a relative path from it
is normal capability-oriented WASI behavior, not a POSIX path-equivalence problem.Therefore, WASIp3 is only relevant if a future POSIX-like WASIp3 guest runtime
introduces absolute-path APIs over preopened descriptors. Without such a POSIX-like
layer, the specific issue described here does not apply to WASIp3.Rust reproductions
The same behavior can also be demonstrated from Rust.
Rust
stdreproduction for WASIp1/WASIp2The
open_athelper is currently behind Rust's WASI extension feature. For
wasm32-wasip1, use#![feature(wasi_ext)]. Forwasm32-wasip2, Rust also
requires thewasip2feature gate, as shown below. This was tested with
RUSTC_BOOTSTRAP=1for local reproduction.#![feature(wasi_ext, wasip2)] use std::fs::{self, File, OpenOptions}; use std::io::{self, Read}; use std::os::wasi::fs::OpenOptionsExt; fn read(mut file: File) -> io::Result<String> { let mut s = String::new(); file.read_to_string(&mut s)?; Ok(s.trim().to_owned()) } fn main() -> io::Result<()> { let abs = fs::read_to_string("/lib/marker.txt")?.trim().to_owned(); let root = File::open("/")?; let via_root = read(OpenOptions::new().read(true).open_at(&root, "lib/marker.txt")?)?; println!("open /lib/marker.txt: {abs}"); println!("open / then openat lib/marker.txt: {via_root}"); println!("same: {}", abs == via_root); Ok(()) }Observed output:
open /lib/marker.txt: from-b-mounted-lib open / then openat lib/marker.txt: from-a-lib same: falseFor WASIp2 this was compiled with:
RUSTC_BOOTSTRAP=1 rustc --target wasm32-wasip2 rust-openat.rs -o rust-openat-p2.wasmRunning
rust-openat-p2.wasmwith the same overlapping preopens produced the same
output:open /lib/marker.txt: from-b-mounted-lib open / then openat lib/marker.txt: from-a-lib same: falseExpected behavior
For a POSIX-like guest, these two operations should be path-equivalent:
open("/lib/marker.txt", O_RDONLY) openat(open("/", O_RDONLY), "lib/marker.txt", O_RDONLY)Raw WASI is capability-oriented and does not define a POSIX mount namespace.
However, when Wasmtime's CLI preopens are used by POSIX-like guests, users can
reasonably expect the configured guest paths to approximate a guest-visible
directory tree. In that context, overlapping guest paths should either preserve this
equivalence or be reported as ambiguous.Actual behavior
WASI exposes each preopen as an independent directory capability. A POSIX-like
guest runtime resolves absolute paths by matching preopen names, so/lib/...can
select the more specific/libpreopen. However, once the guest has opened/, the
resulting directory descriptor is the host directory/host/a; a subsequent
openat(root_fd, "lib/...")is performed relative to that host directory and does
not re-consult the preopen table.This creates a split namespace:
guest /lib via absolute path -> /host/b guest / + relative lib via openat -> /host/a/libWhy this matters
This can violate application assumptions imported from POSIX:
- Path normalization and path decomposition are no longer equivalent.
Code that first opens a base directory and then uses
openatcan see different
files than code using the absolute path.Users may reasonably expect
--dir b::/libto overlay or mask/libunder the
root preopen, but it does not.A more specific preopen may give a false impression that a subtree has been
replaced, while the original subtree remains reachable through the parent
directory descriptor.This is not necessarily a sandbox escape, because the parent preopen already grants
access to/host/a. It is also not a Wasmtime correctness bug against WASI: the
raw WASI model is based on independent directory capabilities. The problem is that
the resulting guest namespace is ambiguous and non-POSIX-like in a way that can be
hard to notice.Relevant implementation behavior
At a high level:
WasiCtxBuilder::preopened_dirrecords each(Dir, guest_path)pair independently.- `wasi
[message truncated]
alexcrichton commented on issue #13544:
Thanks for the report, and this is a now-well-known design flaw with the preopens system in WASI (cc https://github.com/WebAssembly/WASI/issues/742). I agree it'd be reasonable for Wasmtime to have some sort of warning here as well. Figuring out the best path forward here is going to be tricky as we ideally want to move towards a world with just one preopen.
alexcrichton added the wasi label to Issue #13544.
Last updated: Jul 29 2026 at 05:03 UTC