Stream: git-wasmtime

Topic: wasmtime / issue #13544 WASIp1 semantic ambiguity with ov...


view this post on Zulip Wasmtime GitHub notifications bot (Jun 03 2026 at 06:59):

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::/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
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 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/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-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.

Rust reproduction

The same behavior can also be demonstrated with Rust std. The open_at helper 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: 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 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 /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:

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:

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:

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 WASIp1 ambiguity explicit while preserving compatibility for
users who intentionally rely on independent overl
[message truncated]

view this post on Zulip Wasmtime GitHub notifications bot (Jun 03 2026 at 07:01):

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::/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
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 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.

Rust reproduction

The same behavior can also be demonstrated with Rust std. The open_at helper 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: 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 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 /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:

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:

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:

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 WASIp1 ambiguity explicit while preserving compatibility for
users who intentionally rely on independent ov
[message truncated]

view this post on Zulip Wasmtime GitHub notifications bot (Jun 03 2026 at 07:12):

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::/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
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 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++ 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-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.

Rust reproduction

The same behavior can also be demonstrated with Rust std. The open_at helper 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: 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 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 /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:

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:

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:

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 WASIp1 ambiguity explicit while preserving compatibility for
users who intentionally rely on independent
[message truncated]

view this post on Zulip Wasmtime GitHub notifications bot (Jun 03 2026 at 07:37):

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

I do not have a complete wasm32-wasip3 libc sysroot available locally for a
libc/POSIX binary reproduction. However, a Rust WASIp3 component using WIT
bindings was tested. It directly uses preopens.get-directories and
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: false

This is expected from the WASIp3 filesystem shape: preopens.get-directories
returns preopened descriptors and their names, and descriptor.open-at opens
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 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

Rust/WIT reproduction for WASIp3

Because I do not have a complete wasm32-wasip3 Rust std target 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]

view this post on Zulip Wasmtime GitHub notifications bot (Jun 03 2026 at 07:53):

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

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:

view this post on Zulip Wasmtime GitHub notifications bot (Jun 03 2026 at 15:23):

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.

view this post on Zulip Wasmtime GitHub notifications bot (Jun 03 2026 at 15:23):

alexcrichton added the wasi label to Issue #13544.


Last updated: Jul 29 2026 at 05:03 UTC