Skip to content

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

@MacroModel

Description

@MacroModel

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:

/lib
//lib///
./lib

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:

  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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    wasiIssues pertaining to WASI

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions