Skip to content
Open
28 changes: 14 additions & 14 deletions .github/workflows/wasi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ jobs:
UTILS=$(./util/show-utils.sh | tr ' ' '\n' | grep -vE "^($EXCLUDE)$" | sed 's/^/-p uu_/' | tr '\n' ' ')
cargo test --target ${{ matrix.job.target }} --no-default-features $UTILS
- name: Run integration tests via wasmtime
if: matrix.job.target == 'wasm32-wasip1'
env:
RUSTFLAGS: ${{ matrix.job.rust-flags }}
run: |
Expand All @@ -59,19 +58,20 @@ jobs:
# Run host-compiled integration tests against the WASI binary.
# 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
# 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
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_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_arch:: test_b2sum:: test_base32:: test_base64:: test_basenc:: \
test_basename:: test_cat:: test_cksum:: test_comm:: test_cp:: \
test_csplit:: test_cut:: test_date:: test_dir:: test_dircolors:: \
test_dirname:: test_echo:: test_expand:: test_factor:: test_false:: \
test_fmt:: test_fold:: test_head:: test_join:: test_link:: test_ln:: \
test_ls:: test_md5sum:: test_mkdir:: test_mv:: test_nl:: test_nproc:: \
test_numfmt:: test_od:: test_paste:: test_pathchk:: test_pr:: \
test_printenv:: test_printf:: test_ptx:: test_pwd:: test_readlink:: \
test_realpath:: test_rm:: test_rmdir:: test_seq:: test_sha1sum:: \
test_sha224sum:: test_sha256sum:: test_sha384sum:: test_sha512sum:: \
test_shred:: test_shuf:: test_sleep:: test_sort:: test_split:: \
test_sum:: test_tail:: test_tee:: test_touch:: test_tr:: test_true:: \
test_truncate:: test_tsort:: test_uname:: test_unexpand:: test_uniq:: \
test_unlink:: test_vdir:: test_wc:: test_yes::
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,7 @@ unexpected_cfgs = { level = "warn", check-cfg = [
'cfg(fuzzing)',
'cfg(target_os, values("cygwin"))',
'cfg(wasi_runner)',
'cfg(wasip2_runner)',
] }
unused_qualifications = "warn"

Expand Down
6 changes: 5 additions & 1 deletion docs/src/wasi-test-gaps.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 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.
Tests annotated with `#[cfg_attr(wasi_runner, ignore = "...")]` or `#[cfg_attr(wasip2_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.

To find all annotated tests: `grep -rn 'wasi_runner, ignore' tests/`

Expand Down Expand Up @@ -35,3 +35,7 @@ When stdin is a seekable file, wasmtime does not preserve the file position betw
## WASI: read_link on absolute paths fails under wasmtime via spawned test harness

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

## WASI Preview2: exit with code has not been implemented

Until the [wasi:cli/exit#exit-with-code](https://github.com/WebAssembly/WASI/blob/a1fc383d01eabaf3fac01de03c0ab1a01bfdd099/proposals/cli/wit/exit.wit#L16) will be made available in Rust stable toolchain, we will have to consider error exit code only as 1.
22 changes: 20 additions & 2 deletions src/uu/cat/src/cat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,17 @@ fn cat_path(path: &OsString, options: &OutputOptions, state: &mut OutputState) -
#[cfg(unix)]
InputType::Socket => Err(CatError::NoSuchDeviceOrAddress),
_ => {
let file = File::open(path)?;
let file = File::open(path).map_err(|e| {
match e.raw_os_error() {
// WASI: opening a directory may return EBADF instead of EISDIR
#[cfg(target_os = "wasi")]
Some(8) => CatError::IsDirectory,
// WASI: Check if this might be a symlink loop error
#[cfg(target_os = "wasi")]
Some(63) => CatError::TooManySymlinks,
Some(_) | None => CatError::from(e),
}
})?;
if !is_safe_overwrite(&file, &io::stdout()) {
return Err(CatError::OutputIsInput);
}
Expand Down Expand Up @@ -443,12 +453,20 @@ fn get_input_type(path: &OsString) -> CatResult<InputType> {
Ok(md) => md.file_type(),
Err(e) => {
if let Some(raw_error) = e.raw_os_error() {
// WASI: opening a directory may return EBADF instead of EISDIR
#[cfg(target_os = "wasi")]
if raw_error == 8 {
return Err(CatError::IsDirectory);
}

// On Unix-like systems, the error code for "Too many levels of symbolic links" is 40 (ELOOP).
// we want to provide a proper error message in this case.
#[cfg(not(any(target_os = "macos", target_os = "freebsd")))]
#[cfg(not(any(target_os = "macos", target_os = "freebsd", target_os = "wasi")))]
let too_many_symlink_code = 40;
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
let too_many_symlink_code = 62;
#[cfg(target_os = "wasi")]
let too_many_symlink_code = 63;
if raw_error == too_many_symlink_code {
return Err(CatError::TooManySymlinks);
}
Expand Down
2 changes: 1 addition & 1 deletion src/uu/cp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ walkdir = { workspace = true }
indicatif = { workspace = true }
thiserror = { workspace = true }
fluent = { workspace = true }
rustix = { workspace = true }
rustix = { workspace = true, features = ["fs"] }

[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies]
selinux = { workspace = true, optional = true }
Expand Down
72 changes: 52 additions & 20 deletions src/uu/cp/src/cp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1373,7 +1373,18 @@ fn is_enotsup_error(error: &CpError) -> bool {
const EOPNOTSUPP: i32 = 95;

match error {
CpError::IoErr(e) | CpError::IoErrContext(e, _) => e.raw_os_error() == Some(EOPNOTSUPP),
CpError::IoErr(e) | CpError::IoErrContext(e, _) => {
let raw = e.raw_os_error();
// WASI's sandbox has no chmod/chown syscalls at all (not merely an
// unsupported combination of flags), so `fs::set_permissions` and
// friends always fail with ENOSYS there. Treat that the same as
// EOPNOTSUPP for optional preservation.
#[cfg(target_os = "wasi")]
if raw == Some(libc::ENOSYS) {
return true;
}
raw == Some(EOPNOTSUPP)
}
_ => false,
}
}
Expand Down Expand Up @@ -1902,8 +1913,28 @@ pub(crate) fn copy_attributes(
})?;

handle_preserve(attributes.timestamps, || -> CopyResult<()> {
let atime = FileTime::from_last_access_time(&source_metadata);
let mtime = FileTime::from_last_modification_time(&source_metadata);
// `filetime::FileTime::from_last_{access,modification}_time` panics on
// WASI (the `filetime` crate has no WASI-specific backend and falls
// back to its unimplemented generic wasm one). `Metadata::accessed`/
// `modified` are stable and WASI-backed, so use those instead there.
#[cfg(target_os = "wasi")]
let (atime, mtime) = (
FileTime::from(
source_metadata
.accessed()
.map_err(|e| CpError::IoErrContext(e, context.to_owned()))?,
),
FileTime::from(
source_metadata
.modified()
.map_err(|e| CpError::IoErrContext(e, context.to_owned()))?,
),
);
#[cfg(not(target_os = "wasi"))]
let (atime, mtime) = (
FileTime::from_last_access_time(&source_metadata),
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
Expand Down Expand Up @@ -1972,18 +2003,8 @@ 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")))]
{
std::os::unix::fs::symlink(source, dest).map_err(|e| {
Expand All @@ -1995,6 +2016,20 @@ fn symlink_file(
)
})?;
}
// `std::os::unix::fs::symlink` is unavailable on WASI (`std::os::wasi` is
// nightly-only), but `rustix::fs::symlink` works on stable for both
// wasip1 and wasip2 (same approach `ln` already uses).
#[cfg(target_os = "wasi")]
{
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()),
)
})?;
}
#[cfg(windows)]
{
std::os::windows::fs::symlink_file(source, dest).map_err(|e| {
Expand All @@ -2006,13 +2041,10 @@ fn symlink_file(
)
})?;
}
#[cfg(not(target_os = "wasi"))]
{
if let Ok(file_info) = FileInformation::from_path(dest, false) {
symlinked_files.insert(file_info);
}
Ok(())
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
7 changes: 7 additions & 0 deletions src/uu/csplit/src/csplit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ impl<T: BufRead> Iterator for LinesWithNewlines<T> {
match self.inner.read_until(b'\n', &mut v) {
Ok(0) => None,
Ok(_) => Some(ret(v)),
// WASI: reading from a directory opened as a file returns EBADF
// instead of EISDIR.
#[cfg(target_os = "wasi")]
Err(e) if e.raw_os_error() == Some(8) => Some(Err(io::Error::new(
ErrorKind::IsADirectory,
"Is a directory",
))),
Err(e) => Some(Err(e)),
}
}
Expand Down
3 changes: 3 additions & 0 deletions src/uu/date/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ uucore = { workspace = true, features = ["parser", "i18n-datetime"] }
libc = { workspace = true }
rustix = { workspace = true, features = ["time"] }

[target.'cfg(target_os = "wasi")'.dependencies]
libc = { workspace = true }

[target.'cfg(windows)'.dependencies]
windows-sys = { workspace = true, features = [
"Win32_Foundation",
Expand Down
18 changes: 17 additions & 1 deletion src/uu/date/src/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1130,7 +1130,23 @@ fn parse_date<S: AsRef<str>>(
}
}

#[cfg(not(any(unix, windows)))]
#[cfg(target_os = "wasi")]
/// Returns the resolution of the system's realtime clock.
///
/// `rustix::time::clock_getres` excludes WASI, so call `libc::clock_getres`
/// (available on WASI) directly instead.
fn get_clock_resolution() -> Timestamp {
let timespec = unsafe {
let mut timespec: libc::timespec = std::mem::zeroed();
libc::clock_getres(libc::CLOCK_REALTIME, &mut timespec);
timespec
};

#[allow(clippy::unnecessary_cast, reason = "needed for 32 bit target")]
Timestamp::constant(timespec.tv_sec as _, timespec.tv_nsec as _)
}

#[cfg(not(any(unix, windows, target_os = "wasi")))]
fn get_clock_resolution() -> Timestamp {
unimplemented!("getting clock resolution not implemented (unsupported target)");
}
Expand Down
4 changes: 2 additions & 2 deletions src/uu/ls/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -783,13 +783,13 @@ impl Config {
let group = !options.get_flag(options::NO_GROUP)
&& !options.get_flag(options::format::LONG_NO_GROUP);
let owner = !options.get_flag(options::format::LONG_NO_OWNER);
#[cfg(unix)]
#[cfg(any(unix, target_os = "wasi"))]
let numeric_uid_gid = options.get_flag(options::format::LONG_NUMERIC_UID_GID);
LongFormat {
author,
group,
owner,
#[cfg(unix)]
#[cfg(any(unix, target_os = "wasi"))]
numeric_uid_gid,
}
};
Expand Down
17 changes: 13 additions & 4 deletions src/uu/ls/src/display.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ pub(crate) struct LongFormat {
pub(crate) author: bool,
pub(crate) group: bool,
pub(crate) owner: bool,
#[cfg(unix)]
#[cfg(any(unix, target_os = "wasi"))]
pub(crate) numeric_uid_gid: bool,
}

Expand Down Expand Up @@ -667,12 +667,21 @@ fn display_group<'a>(
}

#[cfg(not(unix))]
fn display_uname(_metadata: &Metadata, _config: &Config, _uid_cache: &mut ()) -> &'static str {
"somebody"
fn display_uname(_metadata: &Metadata, config: &Config, _uid_cache: &mut ()) -> &'static str {
// No uid to report on this platform; with `-n` fall back to "0" so the
// output still looks numeric, matching the intent of --numeric-uid-gid.
if config.long.numeric_uid_gid {
"0"
} else {
"somebody"
}
}

#[cfg(not(unix))]
fn display_group(_metadata: &Metadata, _config: &Config, _gid_cache: &mut ()) -> &'static str {
fn display_group(_metadata: &Metadata, config: &Config, _gid_cache: &mut ()) -> &'static str {
if config.long.numeric_uid_gid {
return "0";
}
"somegroup"
}

Expand Down
41 changes: 38 additions & 3 deletions src/uu/rm/src/rm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,17 @@ fn show_removal_error(error: io::Error, path: &Path) -> bool {
if error.kind() == io::ErrorKind::PermissionDenied {
show_error!("cannot remove {}: Permission denied", path.quote());
} else {
#[cfg(target_os = "wasi")]
if let Some(raw_error) = error.raw_os_error() {
// WASI: opening a directory may return EBADF instead of EISDIR
if raw_error == 8 {
show_error!(
"{}",
RmError::CannotRemoveIsDirectory(path.as_os_str().to_os_string())
);
return true;
}
}
let e =
error.map_err_context(|| translate!("rm-error-cannot-remove", "file" => path.quote()));
show_error!("{e}");
Expand Down Expand Up @@ -604,11 +615,17 @@ fn is_readable_metadata(metadata: &Metadata) -> bool {
}

/// Whether the given file or directory is readable.
#[cfg(any(not(unix), target_os = "redox"))]
#[cfg(all(any(not(unix), target_os = "redox"), not(target_os = "wasi")))]
fn is_readable(_path: &Path) -> bool {
true
}

/// WASI has no `PermissionsExt`, so probe readability by opening the directory.
#[cfg(target_os = "wasi")]
fn is_readable(path: &Path) -> bool {
fs::read_dir(path).is_ok()
}

#[cfg(unix)]
fn is_writable_metadata(metadata: &Metadata) -> bool {
let mode = metadata.permissions().mode();
Expand Down Expand Up @@ -1028,9 +1045,27 @@ fn handle_writable_directory(path: &Path, options: &Options, metadata: &Metadata
}
}

#[cfg(target_os = "wasi")]
fn handle_writable_directory(path: &Path, options: &Options, _metadata: &Metadata) -> bool {
let stdin_ok = options.__presume_input_tty.unwrap_or(false) || stdin().is_terminal();

// Try to read the directory to check if it's accessible
let is_accessible = fs::read_dir(path).is_ok();

match (stdin_ok, is_accessible, options.interactive) {
(false, _, InteractiveMode::PromptProtected) => true,
(false, false, InteractiveMode::Never) => true,
(_, false, _) => prompt_yes!(
"attempt removal of inaccessible directory {}?",
path.quote()
),
(_, _, InteractiveMode::Always) => prompt_yes!("remove directory {}?", path.quote()),
(_, _, _) => true,
}
}

// I have this here for completeness but it will always return "remove directory {}" because metadata.permissions().readonly() only works for file not directories
#[cfg(not(windows))]
#[cfg(not(unix))]
#[cfg(not(any(windows, unix, target_os = "wasi")))]
fn handle_writable_directory(path: &Path, options: &Options, _metadata: &Metadata) -> bool {
if options.interactive == InteractiveMode::Always {
prompt_yes!("remove directory {}?", path.quote())
Expand Down
Loading
Loading