Skip to content

Treat the WASM/WASI build target as cfg(unix) so filesystem tools need no per-tool mode-bit patches #271

Description

@NathanFlurry

Summary

We build coreutils (ls, stat, chmod, …) for wasm32-wasip1. Because that
target is not cfg(unix), every crate's #[cfg(unix)] filesystem code —
the code that reads real st_mode permission bits — is compiled out, and the
#[cfg(not(unix))] fallback (which fabricates permissions from a single
readonly boolean) is compiled in. To get native-Linux-accurate output we've
had to hand-patch each tool to bypass that fallback and fetch mode bits from the
sidecar via a host_fs.path_mode import (landed in #268 / #269).

This issue tracks the larger question: should we make the WASM build target
present as cfg(unix) (a Linux-like target) so these tools "just work" with no
per-tool patches
, instead of maintaining a growing pile of #[cfg(target_os = "wasi")] shims?

Why a non-Linux target breaks filesystem tools

uucore has two implementations of display_permissions, cfg-gated:

#[cfg(unix)]                       // reads REAL bits
pub fn display_permissions(md, ...) -> String {
    display_permissions_unix(md.mode() as mode_t, ...)   // st_mode
}

#[cfg(not(unix))]                  // WASI lands HERE — fabricates
pub fn display_permissions(md, display_file_type) -> String {
    let write = if md.permissions().readonly() { '-' } else { 'w' };
    format!("{file_type}r{write}xr{write}xr{write}x")     // r & x HARDCODED on
}

Upstream root cause (uutils/coreutils, uucore 0.5.0):
https://github.com/uutils/coreutils/blob/main/src/uucore/src/lib/features/fs.rs

Consequences of being cfg(not(unix)):

  • md.mode() isn't even callable — std::os::unix::fs::MetadataExt doesn't
    exist in the WASI std.
  • Rust's own std::os::wasi::fs::MetadataExt deliberately exposes
    dev/ino/nlink/size/atim/… but no mode(), because WASI preview1's
    filestat struct has no mode field. So the bits are absent at the std layer
    regardless of cfg.
  • Net effect: ls -l printed -rwxrwxrwx (or -r-xr-xr-x if readonly) for
    every file — a uniform string derived from one boolean.

Note this is degradation, not deliberate stripping: the tools have "WASI
support," it's just a lossy fallback nobody wired to a host that has the bits.

Evidence: the per-tool workarounds this forces (all at 8145b07)

Each of these exists only because the target isn't cfg(unix):

  • ls — inject a host_fs.path_mode import + mode_for_path and swap
    display_permissionsdisplay_permissions_unix:
    +#[cfg(target_os = "wasi")]
    +use uucore::fs::mode_t;
    +
    +#[cfg(target_os = "wasi")]
    +mod wasi_host_fs {
    + use std::env;
    +
    + use super::{Metadata, Path};
    +
    + mod host_fs {
    + #[link(wasm_import_module = "host_fs")]
    + unsafe extern "C" {
    + pub fn path_mode(
    + fd: u32,
    + path_ptr: *const u8,
    + path_len: u32,
    + follow_symlinks: u32,
    + ) -> u32;
    + }
    + }
    +
    + fn fallback_mode(metadata: &Metadata) -> u32 {
    + if metadata.is_dir() { 0o040755 } else { 0o100644 }
    + }
    +
    + fn raw_mode_for_path(path: &Path, follow_symlinks: bool) -> u32 {
    + let Some(path_str) = path.to_str() else {
    + return 0;
    + };
    + unsafe {
    + host_fs::path_mode(
    + 3,
    + path_str.as_ptr(),
    + path_str.len() as u32,
    + if follow_symlinks { 1 } else { 0 },
    + )
    + }
    + }
    +
    + pub fn mode_for_path(path: &Path, metadata: &Metadata, follow_symlinks: bool) -> u32 {
    + let mode = raw_mode_for_path(path, follow_symlinks);
    + if mode != 0 {
    + mode
    + } else if path.is_relative() {
    + env::current_dir()
    + .ok()
    + .map(|cwd| raw_mode_for_path(&cwd.join(path), follow_symlinks))
    + .filter(|mode| *mode != 0)
    + .unwrap_or_else(|| fallback_mode(metadata))
    + } else {
    + fallback_mode(metadata)
    + }
    + }
    +}
    mod dired;
    use dired::{DiredOutput, is_dired_arg_present};
    @@ -2982,7 +3036,15 @@
    let is_acl_set = false;
  • stat:
    https://github.com/rivet-dev/secure-exec/blob/8145b07ebcd1f6553a417a009091c53d7610a04f/registry/native/patches/crates/uu_stat/0001-wasi-metadata-compat.patch
  • chmod:
    https://github.com/rivet-dev/secure-exec/blob/8145b07ebcd1f6553a417a009091c53d7610a04f/registry/native/patches/crates/uu_chmod/0001-wasi-compat.patch
  • First-party shims hit the same wall — which and the shell builtins each
    carry their own #[cfg(target_os = "wasi")] host_fs extern:
    #[cfg(target_os = "wasi")]
    mod host_fs {
    #[link(wasm_import_module = "host_fs")]
    unsafe extern "C" {
    // Signature must match the sidecar host_fs.path_mode
    // (dir_fd, path_ptr, path_len, follow_symlinks).
    pub fn path_mode(
    dir_fd: u32,
    path_ptr: *const u8,
    path_len: u32,
    follow_symlinks: u32,
    ) -> u32;
    }
    }
    fn print_usage<W: Write>(out: &mut W) -> io::Result<()> {
    writeln!(out, "Usage: which [-a] name [...]")
    }
    fn is_executable_path(path: &Path) -> bool {
    let Ok(metadata) = fs::metadata(path) else {
    return false;
    };
    metadata.is_file() && executable_mode_bits(path, &metadata)
    }
    #[cfg(unix)]
    fn executable_mode_bits(_path: &Path, metadata: &fs::Metadata) -> bool {
    (metadata.mode() & 0o111) != 0
    }
    #[cfg(target_os = "wasi")]
    fn executable_mode_bits(path: &Path, _metadata: &fs::Metadata) -> bool {
    let path_string = path.to_string_lossy();
    let bytes = path_string.as_bytes();
    let Ok(path_len) = u32::try_from(bytes.len()) else {
    return false;
    };
    // dir_fd 3 = cwd preopen; absolute paths ignore it.
    let mode = unsafe { host_fs::path_mode(3, bytes.as_ptr(), path_len, 1) };
    (mode & 0o111) != 0
    }

    #[cfg(target_os = "wasi")]
    mod host_fs {
    #[link(wasm_import_module = "host_fs")]
    unsafe extern "C" {
    // Signature must match the sidecar host_fs.path_mode
    // (dir_fd, path_ptr, path_len, follow_symlinks).
    pub fn path_mode(
    dir_fd: u32,
    path_ptr: *const u8,
    path_len: u32,
    follow_symlinks: u32,
    ) -> u32;
    }
    }
  • And the C side already has the mirror-image fix in wasi-libc (which Rust can't
    reach, because Rust std bypasses libc stat() on wasip1):
    https://github.com/rivet-dev/secure-exec/blob/8145b07ebcd1f6553a417a009091c53d7610a04f/registry/native/patches/wasi-libc/0016-host-fs-mode-and-chmod.patch

Every new fs-touching Rust tool we add will need another such patch.

Options

A. Force cfg(unix) on the current target (cheap, does NOT work)

Passing --cfg unix via RUSTFLAGS flips the cfg on our crates, but the
#[cfg(unix)] path does use std::os::unix::fs::MetadataExt, which the
precompiled WASI std doesn't contain → unresolved import. And even if it
resolved, the WASI Metadata's underlying filestat has no st_mode to
return. Rejected.

B. Custom Linux-like target spec + -Z build-std (the real "treat it like native")

Define a target derived from wasm32-wasip1 with target-family = ["unix", …]
and recompile std/core so Rust's fs routes through the libc-backed unix fs
backend
(calls libc stat(), reads st_mode). Then the wasi-libc mode patch
(0016) flows up into Rust automatically and all #[cfg(unix)] tool code works —
no per-tool patches. Blast radius, however, is large:

  • Nightly + build-std on every build; per-Rust-version maintenance of a bespoke
    target.
  • cfg(unix) flips the entire dep graph, not just perms — signals, process,
    users/groups (getpwuid), termios, mmap, net — much of which calls libc
    symbols WASI libc doesn't implement → link/ENOSYS breakage to chase.
  • The fs backend (target_os = "wasi") and MetadataExt (target_family = unix) are gated on different cfgs and don't cleanly compose; effectively a
    std fork.

C. Move the runtime to WASI preview2 / wasi:filesystem (the clean long-term fix)

preview2 carries richer metadata than preview1's filestat. Migrating (or
upstreaming a wasi MetadataExt::mode() backed by an extended filestat) would
let these tools read real bits natively and let us delete all the patches —
without pretending to be unix.

Ask / decision needed

Decide between: (B) invest in a Linux-like custom target + build-std so fs tools
need zero patches, vs. (C) target preview2, vs. (status quo) keep adding small
per-tool host_fs patches. Leaning C long-term; B is the "make it native" ask
but has broad blast radius. Capturing so the tradeoff is explicit before the
patch pile grows.

Related PRs: #268 (filesystem native-parity: wasi-libc + sidecar host_fs),
#269 (coreutils stat/chmod/ls real permission bits).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions