bytecodealliance / bytecodealliance/wasmtime

WASIp1 semantic ambiguity with overlapping preopened directories can break POSIX path equivalence

Open
#13,544 1 comment 0 reactions 0 assignees View on GitHub
wasi
Dominant language
Rust
Stars
18.6k
Forks
1.8k
Avg merge
1d 18h
Merged PRs (30d)
126

Description

# 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:

```text
--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:

```c
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:

```text
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:

```sh
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`:

```cpp
#include
#include
#include
#include
#include

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:

```sh
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:

```sh
wasmtime run \
--dir /tmp/wasi-overlap/a::/ \
--dir /tmp/wasi-overlap/b::/lib \
control.wasm
```

Actual output:

```text
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:

```text
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.

```rust
#![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 {
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:

```text
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:

```sh
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:

```text
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:

```c
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:

```text
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:

```text
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:

```text
/lib
//lib///
./lib
```

The warning should trigger when one guest preopen path is an ancestor of another
guest preopen path, such as:

```text
/ 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:

```text
error: overlapping WASI preopened directories '/' and '/lib' are ambiguous.
Use --allow-overlapping-wasi-dirs to permit this behavior.
```

Possible CLI names:

```text
--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:

1. Add detection for overlapping guest preopen paths.
2. Emit a warning by default in the CLI.
3. Add an explicit command-line option to allow/silence overlapping preopens.
4. 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.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.