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 Rust std.
Wasmtime currently allows overlapping WASI preopened directory names such as:
--dir /host/a::/
--dir /host/b::/lib
If /host/a also contains a real lib subdirectory, 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 a is mounted at guest /.
Directory b is mounted at guest /lib. Directory a also has its own lib
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.txt
C/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-overlong
Run:
wasmtime run \
--dir /tmp/wasi-overlap/a::/ \
--dir /tmp/wasi-overlap/b::/lib \
control.wasm
Actual output:
open root: ok
open /lib/marker.txt: from-b-mounted-lib
open / then openat lib/marker.txt: from-a-lib
The absolute path lookup reaches the /lib preopen, while the directory-fd-relative
lookup reaches the real lib directory 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 Rust std reproduction is also included below.
WASIp2
The same C++ source was also compiled with a wasm32-wasip2 sysroot and run as a
component. A Rust std component targeting wasm32-wasip2 was 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-lib
This is expected from the WASIp2 filesystem shape: wasi:filesystem/preopens
returns a list of (descriptor, string) pairs, and
wasi:filesystem/types.descriptor.open-at opens 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 provide open("/") or open("/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 std reproduction for WASIp1/WASIp2
The open_at helper is currently behind Rust's WASI extension feature. For
wasm32-wasip1, use #![feature(wasi_ext)]. For wasm32-wasip2, Rust also
requires the wasip2 feature gate, as shown below. This was tested with
RUSTC_BOOTSTRAP=1 for 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: false
For WASIp2 this was compiled with:
RUSTC_BOOTSTRAP=1 rustc --target wasm32-wasip2 rust-openat.rs -o rust-openat-p2.wasm
Running rust-openat-p2.wasm with 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: false
Expected 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 /lib preopen. 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/lib
Why 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
openat can see different
files than code using the absolute path.
- Users may reasonably expect
--dir b::/lib to overlay or mask /lib under 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_dir records each (Dir, guest_path) pair independently.
wasi:filesystem/preopens.get-directories returns the preopens as a list.
descriptor.open-at opens 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 WASI 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:
The 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 ./lib
It 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=allow
This would make the WASI ambiguity explicit while preserving compatibility for
users who intentionally rely on independent overlapping capabilities.
For backward compatibility, Wasmtime could first introduce a warning and later
consider making overlap rejection the default.
Option 3: Full virtual overlay semantics
Wasmtime could attempt to implement a virtual guest mount namespace where opening
lib through the root descriptor returns the /lib preopen when applicable.
This would preserve POSIX path equivalence more closely, but it is likely more
complex because directory descriptors would need to retain virtual guest-path
context and consult an overlay table on relative opens. It may also be less aligned
with WASI's capability-oriented model, so it should not be required to consider the
current behavior "correct" under WASI.
Given that complexity, warning or rejecting overlapping guest paths seems like the
simplest and clearest mitigation.
Preferred resolution
My preference would be:
- Add detection for overlapping guest preopen paths.
- Emit a warning by default in the CLI.
- Add an explicit command-line option to allow/silence overlapping preopens.
- Consider rejecting overlaps by default in a future release if maintainers agree
that preserving POSIX-like expectations is more important than permissive
overlapping capabilities.
This would make the WASI preopen semantic ambiguity visible to users and prevent
accidental reliance on a guest namespace that does not behave like POSIX.
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 Rust
std.Wasmtime currently allows overlapping WASI preopened directory names such as:
If
/host/aalso contains a reallibsubdirectory, POSIX-like guests can observetwo different host directories for the same logical path:
Under POSIX path semantics,
openat(open("/"), "lib/marker.txt")andopen("/lib/marker.txt")should resolve to the same object. With overlappingWASI preopens, they can resolve to different host directories.
This is surprising for POSIX-like runtimes such as wasi-libc and Rust
std, and itcan 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:
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/.Directory
bis mounted at guest/lib. Directoryaalso has its ownlibsubdirectory:
C/C++ wasi-libc reproduction
control.cc: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:
Actual output:
The absolute path lookup reaches the
/libpreopen, while the directory-fd-relativelookup reaches the real
libdirectory 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 Rust
stdreproduction is also included below.WASIp2
The same C++ source was also compiled with a
wasm32-wasip2sysroot and run as acomponent. A Rust
stdcomponent targetingwasm32-wasip2was also tested. Bothproduced the same split:
This is expected from the WASIp2 filesystem shape:
wasi:filesystem/preopensreturns a list of
(descriptor, string)pairs, andwasi:filesystem/types.descriptor.open-atopens relative to the specificdescriptor 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 provide
open("/")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. Forwasm32-wasip1, use#![feature(wasi_ext)]. Forwasm32-wasip2, Rust alsorequires the
wasip2feature gate, as shown below. This was tested withRUSTC_BOOTSTRAP=1for local reproduction.Observed output:
For WASIp2 this was compiled with:
Running
rust-openat-p2.wasmwith the same overlapping preopens produced the sameoutput:
Expected behavior
For a POSIX-like guest, these two operations should be path-equivalent:
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/...canselect the more specific
/libpreopen. However, once the guest has opened/, theresulting directory descriptor is the host directory
/host/a; a subsequentopenat(root_fd, "lib/...")is performed relative to that host directory and doesnot re-consult the preopen table.
This creates a split namespace:
Why this matters
This can violate application assumptions imported from POSIX:
openatcan see differentfiles than code using the absolute path.
--dir b::/libto overlay or mask/libunder theroot preopen, but it does not.
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: theraw 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:filesystem/preopens.get-directoriesreturns the preopens as a list.descriptor.open-atopens relative to the specific directory descriptor passed bythe guest.
already-opened directory descriptor.
That behavior is consistent with capability-oriented WASI 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:
The check should operate on normalized guest paths:
.path components,For example, all of the following should be treated as the same guest path shape:
The warning should trigger when one guest preopen path is an ancestor of another
guest preopen path, such as:
It 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:
Possible CLI names:
This would make the WASI ambiguity explicit while preserving compatibility for
users who intentionally rely on independent overlapping capabilities.
For backward compatibility, Wasmtime could first introduce a warning and later
consider making overlap rejection the default.
Option 3: Full virtual overlay semantics
Wasmtime could attempt to implement a virtual guest mount namespace where opening
libthrough the root descriptor returns the/libpreopen when applicable.This would preserve POSIX path equivalence more closely, but it is likely more
complex because directory descriptors would need to retain virtual guest-path
context and consult an overlay table on relative opens. It may also be less aligned
with WASI's capability-oriented model, so it should not be required to consider the
current behavior "correct" under WASI.
Given that complexity, warning or rejecting overlapping guest paths seems like the
simplest and clearest mitigation.
Preferred resolution
My preference would be:
that preserving POSIX-like expectations is more important than permissive
overlapping capabilities.
This would make the WASI preopen semantic ambiguity visible to users and prevent
accidental reliance on a guest namespace that does not behave like POSIX.