Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions .github/workflows/wasi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,18 +60,20 @@ jobs:
# Tests incompatible with WASI are annotated with
# #[cfg_attr(wasi_runner, ignore)] in the test source files.
# TODO: add integration tests for these tools as WASI support is extended:
# arch b2sum cat cksum cp csplit date dir dircolors fmt join
# arch b2sum cksum cp csplit date dir dircolors fmt join
# ls md5sum mkdir mv nproc pathchk pr printenv ptx pwd readlink
# realpath rm rmdir seq sha1sum sha224sum sha256sum sha384sum
# sha512sum shred sleep sort split tail touch tsort uname uniq
# vdir
# sha512sum shred sleep split tsort uname uniq vdir
UUTESTS_BINARY_PATH="$(pwd)/target/${{ matrix.job.target }}/debug/coreutils.wasm" \
UUTESTS_WASM_RUNNER=wasmtime \
cargo test --test tests -- \
test_base32:: test_base64:: test_basenc:: test_basename:: \
test_cat:: \
test_cp::test_cp_arg_symlink test_cp::test_cp_preserve_timestamps \
test_comm:: test_cut:: test_dirname:: test_echo:: \
test_expand:: test_factor:: test_false:: test_fold:: \
test_head:: test_link:: test_ln:: test_nl:: test_numfmt:: \
test_od:: test_paste:: test_printf:: test_shuf:: test_sum:: \
test_tee:: test_tr:: test_true:: test_truncate:: \
test_unexpand:: test_unlink:: test_wc:: test_yes::
test_od:: test_paste:: test_printf:: test_shuf:: test_sort:: \
test_sum:: test_tail:: test_tee:: test_touch:: test_tr:: \
test_true:: test_truncate:: test_unexpand:: test_unlink:: test_wc:: \
test_yes::
60 changes: 57 additions & 3 deletions docs/src/wasi-test-gaps.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
<!-- spell-checker:ignore tzdata preopen setrlimit NOFILE filestat kqueue sysinfo EISDIR ELOOP -->

# WASI integration test gaps

Tests annotated with `#[cfg_attr(wasi_runner, ignore = "...")]` are skipped when running integration tests against a WASI binary via wasmtime. This document tracks the reasons so that gaps in WASI support are visible in one place.
Expand All @@ -6,7 +8,7 @@ To find all annotated tests: `grep -rn 'wasi_runner, ignore' tests/`

## Tools not yet covered by integration tests

arch, b2sum, cat, cksum, cp, csplit, date, dir, dircolors, fmt, join, ls, md5sum, mkdir, mv, nproc, pathchk, pr, printenv, ptx, pwd, readlink, realpath, rm, rmdir, seq, sha1sum, sha224sum, sha256sum, sha384sum, sha512sum, shred, sleep, sort, split, tail, touch, tsort, uname, uniq, vdir, yes
arch, b2sum, cksum, cp, csplit, date, dir, dircolors, fmt, join, ls, md5sum, mkdir, mv, nproc, pathchk, pr, printenv, ptx, pwd, readlink, realpath, rm, rmdir, seq, sha1sum, sha224sum, sha256sum, sha384sum, sha512sum, shred, sleep, split, tsort, uname, uniq, vdir, yes

## WASI sandbox: host paths not visible

Expand All @@ -32,6 +34,58 @@ WASI does not support spawning child processes. Tests that shell out to other co

When stdin is a seekable file, wasmtime does not preserve the file position between the host and guest. Tests that validate stdin offset behavior after `head` reads are skipped.

## WASI: read_link on absolute paths fails under wasmtime via spawned test harness
## WASI: absolute symlink targets fail under wasmtime

Under wasmtime, symlinks whose stored target is an absolute guest path (for example `bar -> /foo`) fail in cases that work on POSIX: `readlink bar` exits 1 and `readlink -v bar` reports `Permission denied`, while the equivalent relative symlink (`bar -> foo`) succeeds. This reproduces even when `wasmtime` is invoked directly, so it is not specific to the cargo-test harness. `realpath` and symlink-heavy `cp` paths inherit the same limitation, and individual symptom tests under this umbrella are annotated with narrower reasons describing the observed errno mismatch.

## WASI: no Unix domain socket support

WASI does not support Unix domain sockets. Tests that create or read from `AF_UNIX` sockets are skipped.

## WASI: no locale data

The WASI sandbox does not ship locale data, so `setlocale`/`LC_ALL` have no effect and sorting falls back to byte comparison. Tests that depend on locale-aware collation or month-name translation are skipped.

## WASI: tail follow mode disabled

`tail -f` / `tail -F` (follow mode) requires change-notification mechanisms (`inotify`, `kqueue`) and signal handling that WASI does not provide, so follow is disabled on WASI and a warning is emitted. Tests that exercise follow behavior are skipped.

## WASI: cannot detect unsafe overwrite

`is_unsafe_overwrite` (used by `cat` to detect input-is-output situations) is stubbed to return `false` on WASI because the required `stat` / device-and-inode comparison is not available. Tests that assert this error path are skipped.

## WASI: pre-epoch timestamps not representable

WASI Preview 1 `Timestamp` is a `u64` nanosecond count since the Unix epoch, so `path_filestat_set_times` (and therefore `touch -t` with a two-digit year ≥ 69) cannot express dates before 1970. Tests that set pre-epoch timestamps are skipped.

## WASI: no timezone database

wasi-libc does not ship tzdata, so `TZ` is not honoured and timezone-dependent validation (e.g. `touch -t` rejecting a nonexistent local time during a DST transition) does not happen. Tests that rely on this are skipped.

## WASI: guest root is a writable preopen

The test harness maps the per-test working directory as the guest's `/`. That makes `/` writable inside the guest, so GNU-style protections against operating on the system root (e.g. `touch /` failing) cannot be exercised. It also means guest-visible absolute paths are rooted at `/`, not at the host tempdir. Tests that compare against host `canonicalize()` results or pass host absolute paths into the guest (for example some `cp --parents`, `readlink`, `realpath`, `pwd`, and `ls` cases) need guest-aware expectations or separate coverage. Tests that assert the root-protection behavior are skipped.

## WASI: `touch -` (stdout) unsupported

On WASI, `touch -` returns `UnsupportedPlatformFeature` because the guest cannot reliably locate the host file backing stdout. Tests that exercise `touch -` are skipped.

## WASI: rlimit/setrlimit not supported

WASI has no concept of per-process resource limits, so `setrlimit` (and the `rlimit` crate that wraps it) has no effect. Tests that set `RLIMIT_NOFILE` to verify behavior under restricted file-descriptor budgets are skipped.

## WASI: sysinfo/meminfo not available

WASI has no `sysinfo`/`/proc/meminfo` equivalent, so features that size buffers as a percentage of system memory (e.g. `sort -S 10%`) cannot resolve the limit and fail. Tests that exercise percentage-based sizing are skipped.

## WASI: errno/error-message mismatches

Several error paths surface different errno values (and therefore different error messages) through wasmtime than on POSIX. Observed cases:

- Opening a directory as a file returns `EBADF` rather than `EISDIR`.
- Redirecting a directory into stdin returns `ENOENT` rather than `EISDIR`.
- Filesystem permission errors surface as `ENOENT` rather than `EACCES`.
- Symlink-loop traversal does not reliably surface `ELOOP` ("Too many levels of symbolic links").
- Opening a symlink-to-directory does not reliably surface `EISDIR`.

`fs::read_link` on an absolute path inside the sandbox (e.g. `/file2`) returns `EPERM` when the WASI binary is launched through `std::process::Command` from the test harness, even though the same call works when wasmtime is invoked directly. This breaks `uucore::fs::canonicalize` for symlink sources, so tests that rely on following a symlink to compute a relative path are skipped.
Tests that assert specific error text for these paths are skipped.
6 changes: 5 additions & 1 deletion src/uu/cat/src/platform/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ pub use self::unix::is_safe_overwrite;
#[cfg(windows)]
pub use self::windows::is_safe_overwrite;

// WASI: no fstat-based device/inode checks available; assume safe.
// WASI: when stdout is inherited from a host file descriptor, wasmtime
// reports its fstat as all-zero (st_dev == st_ino == 0), so the dev/inode
// comparison against any input file descriptor can never match. There is
// no reliable way to detect unsafe overwrite here; assume safe rather than
// risk a spurious error.
#[cfg(target_os = "wasi")]
pub fn is_safe_overwrite<I, O>(_input: &I, _output: &O) -> bool {
true
Expand Down
3 changes: 3 additions & 0 deletions src/uu/cp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ windows-sys = { workspace = true, features = [
"Win32_Storage_FileSystem",
] }

[target.'cfg(target_os = "wasi")'.dependencies]
rustix = { workspace = true, features = ["fs"] }

[[bin]]
name = "cp"
path = "src/main.rs"
Expand Down
129 changes: 82 additions & 47 deletions src/uu/cp/src/cp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
//
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
// spell-checker:ignore (ToDO) copydir fiemap ftruncate linkgs lstat nlink nlinks pathbuf pwrite reflink strs xattrs symlinked deduplicated advcpmv nushell IRWXG IRWXO IRWXU IRWXUGO IRWXU IRWXG IRWXO IRWXUGO sflag
// spell-checker:ignore RDONLY futimens utimensat
// spell-checker:ignore (ToDO) copydir ficlone fiemap filestat ftruncate linkgs lstat nlink nlinks pathbuf pwrite reflink strs utimensat xattrs symlinked deduplicated advcpmv nushell IRWXG IRWXO IRWXU IRWXUGO IRWXU IRWXG IRWXO IRWXUGO sflag
// spell-checker:ignore RDONLY futimens

use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
Expand All @@ -21,6 +21,7 @@ use uucore::fsxattr::{copy_acls, copy_xattrs, copy_xattrs_skip_selinux};
use uucore::translate;

use clap::{Arg, ArgAction, ArgMatches, Command, builder::ValueParser, value_parser};
#[cfg(not(target_os = "wasi"))]
use filetime::FileTime;
use indicatif::{ProgressBar, ProgressStyle};
#[cfg(unix)]
Expand Down Expand Up @@ -1368,9 +1369,9 @@ fn parse_path_args(
/// Check if an error is ENOTSUP/EOPNOTSUPP (operation not supported).
/// This is used to suppress xattr errors on filesystems that don't support them.
fn is_enotsup_error(error: &CpError) -> bool {
#[cfg(unix)]
#[cfg(any(unix, target_os = "wasi"))]
const EOPNOTSUPP: i32 = libc::EOPNOTSUPP;
#[cfg(not(unix))]
#[cfg(not(any(unix, target_os = "wasi")))]
const EOPNOTSUPP: i32 = 95;

match error {
Expand Down Expand Up @@ -1780,6 +1781,66 @@ fn copy_extended_attrs(source: &Path, dest: &Path, skip_selinux: bool) -> CopyRe
Ok(())
}

/// Copy the access and modification timestamps from `source_metadata` onto `dest`.
/// If `dest` is a symlink, the symlink's own timestamps are set rather than the
/// target's.
///
/// On WASI this calls `rustix::fs::utimensat` directly because `filetime`'s
/// WASI backend panics in `from_last_{access,modification}_time`. `SystemTime`
/// values are converted to `Timespec` against `UNIX_EPOCH`, matching WASI's
/// `path_filestat_set_times` contract (unsigned nanosecond count — pre-epoch
/// source times can't be represented).
fn set_timestamps(source_metadata: &Metadata, dest: &Path) -> CopyResult<()> {
#[cfg(target_os = "wasi")]
{
use std::time::UNIX_EPOCH;
let to_timespec = |t: std::time::SystemTime| -> io::Result<rustix::fs::Timespec> {
let d = t
.duration_since(UNIX_EPOCH)
.map_err(|e| io::Error::new(io::ErrorKind::Unsupported, e))?;
Ok(rustix::fs::Timespec {
tv_sec: d.as_secs() as i64,
tv_nsec: d.subsec_nanos() as i32,
})
};
let timestamps = rustix::fs::Timestamps {
last_access: to_timespec(source_metadata.accessed()?)?,
last_modification: to_timespec(source_metadata.modified()?)?,
};
let flags = if dest.is_symlink() {
rustix::fs::AtFlags::SYMLINK_NOFOLLOW
} else {
rustix::fs::AtFlags::empty()
};
rustix::fs::utimensat(rustix::fs::CWD, dest, &timestamps, flags)
.map_err(io::Error::from)?;
Ok(())
}

#[cfg(not(target_os = "wasi"))]
{
let atime = FileTime::from_last_access_time(source_metadata);
let mtime = FileTime::from_last_modification_time(source_metadata);
#[cfg(unix)]
let no_open = {
let ft = source_metadata.file_type();
dest.is_symlink()
|| ft.is_fifo()
|| ft.is_socket()
|| ft.is_char_device()
|| ft.is_block_device()
};
#[cfg(not(unix))]
let no_open = dest.is_symlink();
if no_open {
filetime::set_symlink_file_times(dest, atime, mtime)?;
} else {
filetime::set_file_times(dest, atime, mtime)?;
}
Ok(())
}
}

/// Copy the specified attributes from one path to another.
/// If `skip_selinux_xattr` is true, the security.selinux xattr will not be copied
/// (used when -Z is specified to set the default context instead).
Expand Down Expand Up @@ -1888,32 +1949,8 @@ pub(crate) fn copy_attributes(
Ok(())
})?;

handle_preserve(attributes.timestamps, || -> CopyResult<()> {
let atime = FileTime::from_last_access_time(&source_metadata);
let mtime = FileTime::from_last_modification_time(&source_metadata);
// `set_file_times` opens the destination (O_RDONLY) before calling
// futimens; opening a FIFO or device with no peer blocks forever, and a
// socket cannot be opened at all. For symlinks and these special files
// use the path-based, no-follow variant, which sets the times via
// utimensat without opening.
#[cfg(unix)]
let no_open = {
let ft = source_metadata.file_type();
dest.is_symlink()
|| ft.is_fifo()
|| ft.is_socket()
|| ft.is_char_device()
|| ft.is_block_device()
};
#[cfg(not(unix))]
let no_open = dest.is_symlink();
if no_open {
filetime::set_symlink_file_times(dest, atime, mtime)?;
} else {
filetime::set_file_times(dest, atime, mtime)?;
}

Ok(())
handle_preserve(attributes.timestamps, || {
set_timestamps(&source_metadata, dest)
})?;

#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))]
Expand Down Expand Up @@ -1959,19 +1996,9 @@ pub(crate) fn copy_attributes(
fn symlink_file(
source: &Path,
dest: &Path,
#[cfg(not(target_os = "wasi"))] symlinked_files: &mut HashSet<FileInformation>,
#[cfg(target_os = "wasi")] _symlinked_files: &mut HashSet<FileInformation>,
symlinked_files: &mut HashSet<FileInformation>,
) -> CopyResult<()> {
#[cfg(target_os = "wasi")]
{
Err(CpError::IoErrContext(
io::Error::new(io::ErrorKind::Unsupported, "symlinks not supported"),
translate!("cp-error-cannot-create-symlink",
"dest" => get_filename(dest).unwrap_or("?").quote(),
"source" => get_filename(source).unwrap_or("?").quote()),
))
}
#[cfg(not(any(windows, target_os = "wasi")))]
#[cfg(unix)]
{
std::os::unix::fs::symlink(source, dest).map_err(|e| {
CpError::IoErrContext(
Expand All @@ -1993,13 +2020,21 @@ fn symlink_file(
)
})?;
}
#[cfg(not(target_os = "wasi"))]
#[cfg(target_os = "wasi")]
{
if let Ok(file_info) = FileInformation::from_path(dest, false) {
symlinked_files.insert(file_info);
}
Ok(())
rustix::fs::symlink(source, dest).map_err(|e| {
CpError::IoErrContext(
io::Error::from(e),
translate!("cp-error-cannot-create-symlink",
"dest" => get_filename(dest).unwrap_or("?").quote(),
"source" => get_filename(source).unwrap_or("?").quote()),
)
})?;
}
if let Ok(file_info) = FileInformation::from_path(dest, false) {
symlinked_files.insert(file_info);
}
Ok(())
}

fn context_for(src: &Path, dest: &Path) -> String {
Expand Down
4 changes: 3 additions & 1 deletion src/uu/sort/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ compare = { workspace = true }
itertools = { workspace = true }
memchr = { workspace = true }
rand = { workspace = true }
rayon = { workspace = true }
self_cell = { workspace = true }
tempfile = { workspace = true }
thiserror = { workspace = true }
Expand All @@ -47,6 +46,9 @@ foldhash = { workspace = true }
[target.'cfg(all(unix, not(any(target_os = "redox", target_os = "fuchsia", target_os = "haiku", target_os = "solaris", target_os = "illumos"))))'.dependencies]
rustix = { workspace = true, features = ["system", "process"] }

[target.'cfg(not(all(target_os = "wasi", not(target_feature = "atomics"))))'.dependencies]
rayon = { workspace = true }

[target.'cfg(not(any(target_os = "redox", target_os = "wasi")))'.dependencies]
ctrlc = { workspace = true }

Expand Down
14 changes: 14 additions & 0 deletions src/uu/sort/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
fn main() {
// Set a short alias for the WASI-without-threads configuration so that
// source files can use `#[cfg(wasi_no_threads)]` instead of the verbose
// `#[cfg(all(target_os = "wasi", not(target_feature = "atomics")))]`.
println!("cargo::rustc-check-cfg=cfg(wasi_no_threads)");

let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
let has_atomics = std::env::var("CARGO_CFG_TARGET_FEATURE")
.is_ok_and(|f| f.split(',').any(|feat| feat == "atomics"));

if target_os == "wasi" && !has_atomics {
println!("cargo::rustc-cfg=wasi_no_threads");
}
}
44 changes: 44 additions & 0 deletions src/uu/sort/src/check/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// This file is part of the uutils coreutils package.
//
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.

//! Check if a file is ordered.
//!
//! On most platforms this uses a multi-threaded reader. On WASI without
//! atomics, a synchronous variant is used instead. The two implementations
//! live in sibling modules and are selected via cfg at the module boundary.

use std::cmp::Ordering;
use std::ffi::OsStr;

use uucore::error::UResult;

use crate::{GlobalSettings, open};

#[cfg(not(wasi_no_threads))]
mod threaded;
#[cfg(not(wasi_no_threads))]
use threaded as runner;

#[cfg(wasi_no_threads)]
mod sync;
#[cfg(wasi_no_threads)]
use sync as runner;

/// Check if the file at `path` is ordered.
pub fn check(path: &OsStr, settings: &GlobalSettings) -> UResult<()> {
let max_allowed_cmp = if settings.unique {
Ordering::Less
} else {
Ordering::Equal
};
let file = open(path)?;
let chunk_size = if settings.buffer_size < 100 * 1024 {
settings.buffer_size
} else {
100 * 1024
};

runner::check(path, settings, max_allowed_cmp, file, chunk_size)
}
Loading
Loading