From 3e717b3be6ab72919b289a340f8e49f1e8ecdec2 Mon Sep 17 00:00:00 2001 From: Eduardo Rodrigues <16357187+eduardomourar@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:44:19 +0100 Subject: [PATCH 1/5] uucore: normalize wasi errno quirks to posix errorkind WASI reports directory errors and broken pipes with different errno values than Unix. Add `wasi_normalize_open_error`, `wasi_normalize_read_error` and `wasi_is_broken_pipe` to map them to the Unix `ErrorKind`. The functions do nothing on other targets. --- src/uucore/src/lib/mods/error.rs | 42 ++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/uucore/src/lib/mods/error.rs b/src/uucore/src/lib/mods/error.rs index 270bdcb8832..15e0549f18d 100644 --- a/src/uucore/src/lib/mods/error.rs +++ b/src/uucore/src/lib/mods/error.rs @@ -509,6 +509,48 @@ pub fn strip_errno(err: &std::io::Error) -> String { msg } +/// Maps the WASI `EBADF` error for a directory to `ErrorKind::IsADirectory`. +/// +/// WASI returns `EBADF` where Unix returns `EISDIR`. This function does nothing on other targets. +pub fn wasi_normalize_open_error(err: std::io::Error) -> std::io::Error { + #[cfg(target_os = "wasi")] + if err.raw_os_error() == Some(8) { + return std::io::Error::new(std::io::ErrorKind::IsADirectory, "Is a directory"); + } + err +} + +/// Maps the WASI error for reading a directory to `ErrorKind::IsADirectory`. +/// +/// The errno depends on the preview version and on who opened the descriptor. +/// WASI defers the directory check from `open` to the first `read`. +/// This function does nothing on other targets. +pub fn wasi_normalize_read_error(err: std::io::Error) -> std::io::Error { + #[cfg(all(target_os = "wasi", target_env = "p1"))] + if matches!(err.raw_os_error(), Some(31) | Some(8)) { + return std::io::Error::new(std::io::ErrorKind::IsADirectory, "Is a directory"); + } + #[cfg(all(target_os = "wasi", target_env = "p2"))] + if matches!(err.raw_os_error(), Some(29) | Some(8)) { + return std::io::Error::new(std::io::ErrorKind::IsADirectory, "Is a directory"); + } + err +} + +/// Returns true if `err` is a broken pipe on the current platform. +/// +/// WASI preview 2 reports a broken pipe as raw `EIO`. +pub fn wasi_is_broken_pipe(err: &std::io::Error) -> bool { + if err.kind() == std::io::ErrorKind::BrokenPipe { + return true; + } + #[cfg(all(target_os = "wasi", target_env = "p2"))] + if err.raw_os_error() == Some(29) { + return true; + } + false +} + /// Enables the conversion from [`std::io::Error`] to [`UError`] and from [`std::io::Result`] to /// [`UResult`]. pub trait FromIo { From 49da440d88feb80dc85102921164771a2dfc07c3 Mon Sep 17 00:00:00 2001 From: Eduardo Rodrigues <16357187+eduardomourar@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:56:34 +0100 Subject: [PATCH 2/5] feat(wasi): apply wasi errno normalization across utilities Use the new `uucore::error` helpers in cat, dd, rm, wc, tee, sort, seq, tr, tsort and split. These utilities now report `IsADirectory` on WASI instead of a raw errno. seq also detects the preview 2 broken pipe. Add WASI fallbacks for APIs that WASI does not provide: the `dd` progress reporter and alarm without threads, `OwnedFileDescriptorOrHandle` without descriptor duplication, and directory checks in rm and tsort. --- src/uu/cat/src/cat.rs | 23 ++++- src/uu/dd/src/dd.rs | 147 +++++++++++++++++++----------- src/uu/dd/src/progress.rs | 63 ++++++++++++- src/uu/rm/src/rm.rs | 42 ++++++++- src/uu/seq/src/seq.rs | 2 +- src/uu/sort/src/sort.rs | 4 +- src/uu/split/src/platform/wasi.rs | 10 +- src/uu/tee/src/tee.rs | 1 + src/uu/tr/src/operation.rs | 5 +- src/uu/tr/src/simd.rs | 5 +- src/uu/tsort/Cargo.toml | 4 + src/uu/tsort/src/tsort.rs | 12 +-- src/uu/wc/src/wc.rs | 5 +- src/uucore/src/lib/mods/io.rs | 17 +++- 14 files changed, 264 insertions(+), 76 deletions(-) diff --git a/src/uu/cat/src/cat.rs b/src/uu/cat/src/cat.rs index 81ad827368d..5dece231868 100644 --- a/src/uu/cat/src/cat.rs +++ b/src/uu/cat/src/cat.rs @@ -393,7 +393,15 @@ 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| { + let e = uucore::error::wasi_normalize_open_error(e); + match e.kind() { + ErrorKind::IsADirectory => CatError::IsDirectory, + #[cfg(target_os = "wasi")] + _ if e.raw_os_error() == Some(32) => CatError::TooManySymlinks, + _ => CatError::from(e), + } + })?; if !is_safe_overwrite(&file, &io::stdout()) { return Err(CatError::OutputIsInput); } @@ -456,13 +464,24 @@ fn get_input_type(path: &OsString) -> CatResult { let ft = match metadata(path) { Ok(md) => md.file_type(), Err(e) => { + let e = uucore::error::wasi_normalize_open_error(e); + if e.kind() == ErrorKind::IsADirectory { + return Err(CatError::IsDirectory); + } if let Some(raw_error) = e.raw_os_error() { // 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_vendor = "apple", target_os = "freebsd")))] + #[cfg(not(any( + target_vendor = "apple", + target_os = "freebsd", + target_os = "wasi" + )))] let too_many_symlink_code = 40; #[cfg(any(target_vendor = "apple", target_os = "freebsd"))] let too_many_symlink_code = 62; + // wasi-libc's ELOOP is 32, unlike the 63 used by some other targets. + #[cfg(target_os = "wasi")] + let too_many_symlink_code = 32; if raw_error == too_many_symlink_code { return Err(CatError::TooManySymlinks); } diff --git a/src/uu/dd/src/dd.rs b/src/uu/dd/src/dd.rs index 24f769bfcc6..5bb6644f702 100644 --- a/src/uu/dd/src/dd.rs +++ b/src/uu/dd/src/dd.rs @@ -19,7 +19,7 @@ use blocks::Converter; use datastructures::{ConversionMode, IConvFlags, IFlags, OConvFlags, OFlags, options}; use parseargs::Parser; use progress::ProgUpdateType; -use progress::{ProgUpdate, ReadStat, StatusLevel, WriteStat, gen_prog_updater}; +use progress::{ProgUpdate, ProgressReporter, ReadStat, StatusLevel, WriteStat}; #[cfg(target_os = "linux")] use progress::{check_and_reset_sigusr1, install_sigusr1_handler}; use uucore::io::OwnedFileDescriptorOrHandle; @@ -28,22 +28,24 @@ use uucore::translate; use std::cmp; use std::env; use std::ffi::OsString; -#[cfg(unix)] +#[cfg(any(unix, target_os = "wasi"))] use std::fs::Metadata; use std::fs::{File, OpenOptions}; use std::io::{self, Read, Seek, SeekFrom, Write}; +#[cfg(any(unix, target_os = "wasi"))] +use std::os::fd::{AsRawFd, FromRawFd}; +#[cfg(unix)] +use std::os::unix::fs::FileTypeExt; #[cfg(any(target_os = "linux", target_os = "android"))] use std::os::unix::fs::OpenOptionsExt; -#[cfg(unix)] -use std::os::unix::{ - fs::FileTypeExt, - io::{AsRawFd, FromRawFd}, -}; #[cfg(windows)] use std::os::windows::{fs::MetadataExt, io::AsHandle}; use std::path::Path; +#[cfg(not(target_os = "wasi"))] use std::sync::atomic::AtomicU8; -use std::sync::{Arc, atomic::Ordering::Relaxed, mpsc}; +#[cfg(not(target_os = "wasi"))] +use std::sync::{Arc, atomic::Ordering::Relaxed}; +#[cfg(not(target_os = "wasi"))] use std::thread; use std::time::{Duration, Instant}; @@ -86,14 +88,26 @@ struct Settings { /// the first caller each interval will yield true. /// /// When all instances are dropped the background thread will exit on the next interval. +#[cfg(not(target_os = "wasi"))] pub struct Alarm { trigger: Arc, } +/// A timer which triggers on a given interval. +/// +/// WASI has no threads, so [`Alarm::get_trigger`] polls the clock instead. +#[cfg(target_os = "wasi")] +pub struct Alarm { + interval: Duration, + last_trigger: std::cell::Cell, + signalled: std::cell::Cell, +} + pub const ALARM_TRIGGER_NONE: u8 = 0; pub const ALARM_TRIGGER_TIMER: u8 = 1; pub const ALARM_TRIGGER_SIGNAL: u8 = 2; +#[cfg(not(target_os = "wasi"))] impl Alarm { /// use to construct alarm timer with duration pub fn with_interval(interval: Duration) -> Self { @@ -126,6 +140,37 @@ impl Alarm { } } +#[cfg(target_os = "wasi")] +impl Alarm { + pub fn with_interval(interval: Duration) -> Self { + Self { + interval, + last_trigger: std::cell::Cell::new(Instant::now()), + signalled: std::cell::Cell::new(false), + } + } + + pub fn manual_trigger(&self) { + self.signalled.set(true); + } + + pub fn get_trigger(&self) -> u8 { + if self.signalled.replace(false) { + return ALARM_TRIGGER_SIGNAL; + } + let now = Instant::now(); + if now.duration_since(self.last_trigger.get()) >= self.interval { + self.last_trigger.set(now); + return ALARM_TRIGGER_TIMER; + } + ALARM_TRIGGER_NONE + } + + pub fn get_interval(&self) -> Duration { + self.interval + } +} + /// A number in blocks or bytes /// /// Some values (seek, skip, iseek, oseek) can have values either in blocks or in bytes. @@ -212,14 +257,14 @@ impl AlignedBuf { /// fine-grained access to reading from stdin. enum Source { /// Input from stdin. - #[cfg(not(unix))] + #[cfg(not(any(unix, target_os = "wasi")))] Stdin(io::Stdin), /// Input from a file. File(File), /// Input from stdin, opened from its file descriptor. - #[cfg(unix)] + #[cfg(any(unix, target_os = "wasi"))] StdinFile(File), /// Input from a named pipe, also known as a FIFO. @@ -235,7 +280,9 @@ impl Source { /// the [`File`] parameter. You can use this instead of /// `Source::Stdin` to allow reading from stdin without consuming /// the entire contents of stdin when this process terminates. - #[cfg(unix)] + /// + /// This also avoids the read-ahead buffering of `io::Stdin`. + #[cfg(any(unix, target_os = "wasi"))] fn stdin_as_file() -> Self { let fd = io::stdin().as_raw_fd(); let f = unsafe { File::from_raw_fd(fd) }; @@ -244,7 +291,7 @@ impl Source { fn skip(&mut self, n: u64, ibs: usize) -> io::Result { match self { - #[cfg(not(unix))] + #[cfg(not(any(unix, target_os = "wasi")))] Self::Stdin(stdin) => { let m = uucore::io::read_and_discard(stdin, n, ibs)?; if m < n { @@ -255,8 +302,9 @@ impl Source { } Ok(m) } - #[cfg(unix)] + #[cfg(any(unix, target_os = "wasi"))] Self::StdinFile(f) => { + #[cfg(unix)] if let Ok(Some(len)) = try_get_len_of_block_device(f) && len < n { @@ -342,10 +390,13 @@ impl Source { impl Read for Source { fn read(&mut self, buf: &mut [u8]) -> io::Result { match self { - #[cfg(not(unix))] + #[cfg(not(any(unix, target_os = "wasi")))] Self::Stdin(stdin) => stdin.read(buf), - Self::File(f) => f.read(buf), - #[cfg(unix)] + // WASI defers the directory check from open to the first read. + Self::File(f) => f + .read(buf) + .map_err(uucore::error::wasi_normalize_open_error), + #[cfg(any(unix, target_os = "wasi"))] Self::StdinFile(f) => f.read(buf), #[cfg(unix)] Self::Fifo(f) => f.read(buf), @@ -383,11 +434,11 @@ impl<'a> Input<'a> { _ => Source::Stdin(io::stdin()), } }; - #[cfg(all(not(unix), not(windows)))] + #[cfg(not(any(unix, windows, target_os = "wasi")))] let mut src = Source::Stdin(io::stdin()); - #[cfg(unix)] + #[cfg(any(unix, target_os = "wasi"))] let mut src = Source::stdin_as_file(); - #[cfg(unix)] + #[cfg(any(unix, target_os = "wasi"))] if let Source::StdinFile(f) = &src && settings.iflags.directory && !f.metadata()?.is_dir() @@ -415,6 +466,14 @@ impl<'a> Input<'a> { opts.custom_flags(libc_flags); } + // WASI resolves an empty path to the preopened directory. + #[cfg(target_os = "wasi")] + if filename.as_os_str().is_empty() { + return Err(io::Error::from_raw_os_error(libc::ENOENT).map_err_context( + || translate!("dd-error-failed-to-open", "path" => filename.quote()), + )); + } + opts.open(filename).map_err_context( || translate!("dd-error-failed-to-open", "path" => filename.quote()), )? @@ -848,6 +907,12 @@ impl<'a> Output<'a> { /// Instantiate this struct with the named file as a destination. fn new_file(filename: &Path, settings: &'a Settings) -> UResult { fn open_dst(path: &Path, cflags: &OConvFlags, oflags: &OFlags) -> Result { + // WASI resolves an empty path to the preopened directory. + #[cfg(target_os = "wasi")] + if path.as_os_str().is_empty() { + return Err(io::Error::from_raw_os_error(libc::ENOENT)); + } + let mut opts = OpenOptions::new(); opts.write(true) .create(!cflags.nocreat) @@ -1148,18 +1213,11 @@ fn dd_copy(mut i: Input, o: Output) -> io::Result<()> { // the input and output block sizes. let bsize = calc_bsize(i.settings.ibs, o.settings.obs); - // Start a thread that reports transfer progress. - // // The `dd` program reports its progress after every block is written, // at most every 1 second, and only if `status=progress` is given on - // the command-line or a SIGUSR1 signal is received. We - // perform this reporting in a new thread so as not to take - // any CPU time away from the actual reading and writing of - // data. We send a `ProgUpdate` from the transmitter `prog_tx` - // to the receives `rx`, and the receiver prints the transfer - // information. - let (prog_tx, rx) = mpsc::channel(); - let output_thread = thread::spawn(gen_prog_updater(rx, i.settings.status)); + // the command-line or a SIGUSR1 signal is received. A separate thread + // reports it so that it takes no CPU time away from reading and writing. + let reporter = ProgressReporter::spawn(i.settings.status); // Optimization: if no blocks are to be written, then don't // bother allocating any buffers. @@ -1170,14 +1228,7 @@ fn dd_copy(mut i: Input, o: Output) -> io::Result<()> { // need the contents of the input file in a system cache. // flush_caches_full_length(&i, &o); - return finalize( - BlockWriter::Unbuffered(o), - rstat, - wstat, - start, - &prog_tx, - output_thread, - ); + return finalize(BlockWriter::Unbuffered(o), rstat, wstat, start, reporter); } // Spawn a timer thread to provide a scheduled signal indicating when we @@ -1310,7 +1361,7 @@ fn dd_copy(mut i: Input, o: Output) -> io::Result<()> { }; rstat.records_truncated = conv_rstat.records_truncated; let prog_update = ProgUpdate::new(rstat, wstat, start.elapsed(), tp); - prog_tx.send(prog_update).unwrap_or(()); + reporter.send(prog_update); } wstat += conv_wstat; @@ -1326,14 +1377,12 @@ fn dd_copy(mut i: Input, o: Output) -> io::Result<()> { if let Some(e) = copy_error { // Flushing and syncing are pointless now, but the caller still wants the statistics. let prog_update = ProgUpdate::new(rstat, wstat, start.elapsed(), ProgUpdateType::Final); - prog_tx.send(prog_update).unwrap_or(()); - output_thread - .join() - .expect("Failed to join with the output thread."); + reporter.send(prog_update); + reporter.finish(); return Err(e); } - finalize(o, rstat, wstat, start, &prog_tx, output_thread) + finalize(o, rstat, wstat, start, reporter) } /// Write `data` through `converter`, which may count truncated records in `rstat`. @@ -1371,13 +1420,12 @@ fn write_to<'a>( } /// Flush output, print final stats, and join with the progress thread. -fn finalize( +fn finalize( mut output: BlockWriter, rstat: ReadStat, wstat: WriteStat, start: Instant, - prog_tx: &mpsc::Sender, - output_thread: thread::JoinHandle, + reporter: ProgressReporter, ) -> io::Result<()> { // Flush the output in case a partial write has been buffered but // not yet written. @@ -1392,11 +1440,8 @@ fn finalize( // Print the final read/write statistics. let wstat = wstat + wstat_update; let prog_update = ProgUpdate::new(rstat, wstat, start.elapsed(), ProgUpdateType::Final); - prog_tx.send(prog_update).unwrap_or(()); - // Wait for the output thread to finish - output_thread - .join() - .expect("Failed to join with the output thread."); + reporter.send(prog_update); + reporter.finish(); Ok(()) } diff --git a/src/uu/dd/src/progress.rs b/src/uu/dd/src/progress.rs index ebc11eb3010..a2ebc99cb23 100644 --- a/src/uu/dd/src/progress.rs +++ b/src/uu/dd/src/progress.rs @@ -15,12 +15,14 @@ use std::io::Write; #[cfg(target_os = "linux")] use std::sync::atomic::{AtomicBool, Ordering}; +#[cfg(not(target_os = "wasi"))] use std::sync::mpsc; use std::time::Duration; +#[cfg(not(target_os = "wasi"))] +use uucore::locale::setup_localization; use uucore::{ error::{UResult, set_exit_code}, format::num_format::{FloatVariant, Formatter}, - locale::setup_localization, translate, }; @@ -419,12 +421,69 @@ pub(crate) enum StatusLevel { None, } +/// Reports [`ProgUpdate`]s to stderr as `dd` runs. +/// +/// A reporting thread prints the updates. WASI has no threads, so the caller prints them inline. +#[cfg(not(target_os = "wasi"))] +pub(crate) struct ProgressReporter { + tx: mpsc::Sender, + thread: std::thread::JoinHandle<()>, +} + +#[cfg(not(target_os = "wasi"))] +impl ProgressReporter { + pub(crate) fn spawn(print_level: Option) -> Self { + let (tx, rx) = mpsc::channel(); + let thread = std::thread::spawn(gen_prog_updater(rx, print_level)); + Self { tx, thread } + } + + pub(crate) fn send(&self, update: ProgUpdate) { + self.tx.send(update).unwrap_or(()); + } + + pub(crate) fn finish(self) { + self.thread + .join() + .expect("Failed to join with the output thread."); + } +} + +#[cfg(target_os = "wasi")] +pub(crate) struct ProgressReporter { + print_level: Option, + progress_printed: std::cell::Cell, +} + +#[cfg(target_os = "wasi")] +impl ProgressReporter { + pub(crate) fn spawn(print_level: Option) -> Self { + Self { + print_level, + progress_printed: std::cell::Cell::new(false), + } + } + + pub(crate) fn send(&self, update: ProgUpdate) { + if update.update_type == ProgUpdateType::Final { + update.print_final_stats(self.print_level, self.progress_printed.get()); + return; + } + if Some(StatusLevel::Progress) == self.print_level { + update.reprint_prog_line(); + self.progress_printed.set(true); + } + } + + pub(crate) fn finish(self) {} +} + /// Return a closure that can be used in its own thread to print progress info. /// /// This function returns a closure that receives [`ProgUpdate`] /// instances sent through `rx`. When a [`ProgUpdate`] instance is /// received, the transfer statistics are re-printed to stderr. -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", target_os = "wasi")))] pub(crate) fn gen_prog_updater( rx: mpsc::Receiver, print_level: Option, diff --git a/src/uu/rm/src/rm.rs b/src/uu/rm/src/rm.rs index 2afa1093691..ae63fecf33c 100644 --- a/src/uu/rm/src/rm.rs +++ b/src/uu/rm/src/rm.rs @@ -105,8 +105,17 @@ fn report_verbose_write_error(result: UResult<()>) { /// Helper function to show error with context and return error status fn show_removal_error(error: io::Error, path: &Path) -> bool { - let e = error.map_err_context(|| translate!("rm-error-cannot-remove", "file" => path.quote())); - show_error!("{e}"); + let error = uucore::error::wasi_normalize_open_error(error); + if error.kind() == io::ErrorKind::IsADirectory { + show_error!( + "{}", + RmError::CannotRemoveIsDirectory(path.as_os_str().to_os_string()) + ); + } else { + let e = + error.map_err_context(|| translate!("rm-error-cannot-remove", "file" => path.quote())); + show_error!("{e}"); + } true } @@ -635,11 +644,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 } +/// Checks directory readability on WASI, which has no `PermissionsExt`. +#[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(); @@ -1072,9 +1087,26 @@ 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(); + + 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()) diff --git a/src/uu/seq/src/seq.rs b/src/uu/seq/src/seq.rs index b52f1504a4f..8e0eed14e7a 100644 --- a/src/uu/seq/src/seq.rs +++ b/src/uu/seq/src/seq.rs @@ -226,7 +226,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { match result { Ok(()) => Ok(()), - Err(err) if err.kind() == std::io::ErrorKind::BrokenPipe => { + Err(err) if uucore::error::wasi_is_broken_pipe(&err) => { // GNU seq prints the Broken pipe message but still exits with status 0 // unless SIGPIPE was explicitly ignored, in which case it should fail. let err = err.map_err_context(|| "write error".into()); diff --git a/src/uu/sort/src/sort.rs b/src/uu/sort/src/sort.rs index eb3a0959321..a0ab5bf9187 100644 --- a/src/uu/sort/src/sort.rs +++ b/src/uu/sort/src/sort.rs @@ -2169,7 +2169,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { for (line_num, line_res) in buf_reader.split(b'\0').enumerate() { let line = line_res.map_err(|error| SortError::ReadFailed { path: files0_from.clone(), - error, + error: uucore::error::wasi_normalize_read_error(error), })?; if line.as_slice() == STDIN_FILE.as_bytes() { return Err(SortError::MinusInStdIn.into()); @@ -3374,7 +3374,7 @@ fn open_with_open_failed_error(path: impl AsRef) -> UResult Ok(Box::new(f) as Box), Err(error) => Err(SortError::OpenFailed { path: path.to_owned(), - error, + error: uucore::error::wasi_normalize_open_error(error), } .into()), } diff --git a/src/uu/split/src/platform/wasi.rs b/src/uu/split/src/platform/wasi.rs index acfff8bed53..edf1f6352f8 100644 --- a/src/uu/split/src/platform/wasi.rs +++ b/src/uu/split/src/platform/wasi.rs @@ -29,16 +29,22 @@ pub fn instantiate_current_writer( translate!("split-error-would-overwrite-input", "file" => filename.quote()), )); } + let open_file_error = |e: std::io::Error| { + let e = uucore::error::strip_errno(&e); + std::io::Error::other(format!("{}: {e}", filename.quote())) + }; let file = if is_new { std::fs::OpenOptions::new() .write(true) .create(true) .truncate(true) - .open(std::path::Path::new(filename))? + .open(std::path::Path::new(filename)) + .map_err(open_file_error)? } else { std::fs::OpenOptions::new() .append(true) - .open(std::path::Path::new(filename))? + .open(std::path::Path::new(filename)) + .map_err(open_file_error)? }; Ok(Writer::File(file)) } diff --git a/src/uu/tee/src/tee.rs b/src/uu/tee/src/tee.rs index 8f1f6bf9d63..8b98cd9caea 100644 --- a/src/uu/tee/src/tee.rs +++ b/src/uu/tee/src/tee.rs @@ -232,6 +232,7 @@ impl MultiWriter { Ok(slice) => self.write_flush(slice)?, Err(e) if e.kind() == ErrorKind::Interrupted => {} Err(e) => { + let e = uucore::error::wasi_normalize_read_error(e); show_error!( "{}", translate!("tee-error-stdin", "error" => strip_errno(&e)) diff --git a/src/uu/tr/src/operation.rs b/src/uu/tr/src/operation.rs index 420dd69f935..9c8cef681ae 100644 --- a/src/uu/tr/src/operation.rs +++ b/src/uu/tr/src/operation.rs @@ -960,7 +960,10 @@ where Ok(0) => break, // EOF reached Ok(len) => len, Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, - Err(e) => return Err(e.map_err_context(|| translate!("tr-error-read-error"))), + Err(e) => { + let e = uucore::error::wasi_normalize_read_error(e); + return Err(e.map_err_context(|| translate!("tr-error-read-error"))); + } }; // Process the buffer and collect translated chars to output diff --git a/src/uu/tr/src/simd.rs b/src/uu/tr/src/simd.rs index 0f31e242008..33f0ee26ca2 100644 --- a/src/uu/tr/src/simd.rs +++ b/src/uu/tr/src/simd.rs @@ -129,7 +129,10 @@ where Ok(0) => break, Ok(len) => len, Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, - Err(e) => return Err(e.map_err_context(|| translate!("tr-error-read-error"))), + Err(e) => { + let e = uucore::error::wasi_normalize_read_error(e); + return Err(e.map_err_context(|| translate!("tr-error-read-error"))); + } }; output_buf.clear(); diff --git a/src/uu/tsort/Cargo.toml b/src/uu/tsort/Cargo.toml index 9b1f0ef82d2..dd769ad6879 100644 --- a/src/uu/tsort/Cargo.toml +++ b/src/uu/tsort/Cargo.toml @@ -32,6 +32,10 @@ windows-sys = { workspace = true, features = ["Win32_Storage_FileSystem"] } [target.'cfg(any(unix, target_os = "wasi"))'.dependencies] rustix = { workspace = true, features = ["fs"] } +[target.'cfg(target_os = "wasi")'.dependencies] +# wasip2 needs the rustix `std` feature for the `AsFd` bound on `fadvise`. +rustix = { workspace = true, features = ["std"] } + [[bin]] name = "tsort" path = "src/main.rs" diff --git a/src/uu/tsort/src/tsort.rs b/src/uu/tsort/src/tsort.rs index fd48eb0b3bd..1dfdc2c2dac 100644 --- a/src/uu/tsort/src/tsort.rs +++ b/src/uu/tsort/src/tsort.rs @@ -58,17 +58,15 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { process_input(io::stdin().lock(), &mut g)?; } else { let mut options: OpenOptions; - // some platforms cannot catch this as read error. Needs additional cost by stat + // Windows and WASI do not report a directory as a read error. + #[cfg(any(windows, target_os = "wasi"))] + if std::path::Path::new(input).is_dir() { + return Err(Error::Read(ReadError::IsDir(input.to_string_lossy().to_string())).into()); + } #[cfg(windows)] { use std::os::windows::fs::OpenOptionsExt; use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_SEQUENTIAL_SCAN; - let input = std::path::Path::new(input); - if input.is_dir() { - return Err( - Error::Read(ReadError::IsDir(input.to_string_lossy().to_string())).into(), - ); - } // advise the OS we will access the data sequentially if possible (windows) options = File::options() .custom_flags(FILE_FLAG_SEQUENTIAL_SCAN) diff --git a/src/uu/wc/src/wc.rs b/src/uu/wc/src/wc.rs index de824ceeff5..fbb9df446ae 100644 --- a/src/uu/wc/src/wc.rs +++ b/src/uu/wc/src/wc.rs @@ -700,7 +700,10 @@ fn word_count_from_input(input: &Input<'_>, settings: &Settings) -> CountResult }; match maybe_err { None => CountResult::Success(total), - Some(err) => CountResult::Interrupted(total, err), + Some(err) => { + let err = uucore::error::wasi_normalize_open_error(err); + CountResult::Interrupted(total, err) + } } } diff --git a/src/uucore/src/lib/mods/io.rs b/src/uucore/src/lib/mods/io.rs index b3b994d0426..6660a5bc2a3 100644 --- a/src/uucore/src/lib/mods/io.rs +++ b/src/uucore/src/lib/mods/io.rs @@ -82,13 +82,28 @@ impl OwnedFileDescriptorOrHandle { /// conversion from borrowed native type /// /// e.g. `std::io::stdout()`, `std::fs::File`, ... - #[cfg(not(windows))] + #[cfg(not(any(windows, target_os = "wasi")))] pub fn from(t: T) -> io::Result { Ok(Self { fx: t.as_fd().try_clone_to_owned()?, }) } + /// conversion from borrowed native type + /// + /// e.g. `std::io::stdout()`, `std::fs::File`, ... + /// + /// WASI does not support duplicating descriptors, so this takes the raw descriptor. + /// The input is forgotten to prevent a double close. + #[cfg(target_os = "wasi")] + #[allow(clippy::unnecessary_wraps)] + pub fn from(t: T) -> io::Result { + use std::os::fd::{AsRawFd, FromRawFd}; + let fx = unsafe { OwnedFd::from_raw_fd(t.as_fd().as_raw_fd()) }; + std::mem::forget(t); + Ok(Self { fx }) + } + /// instantiates a corresponding `File` pub fn into_file(self) -> File { File::from(self.fx) From 638533bcbf0a7134ed0040e17f3be1f9b6a3c29a Mon Sep 17 00:00:00 2001 From: Eduardo Rodrigues <16357187+eduardomourar@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:48:16 +0100 Subject: [PATCH 3/5] test: add wasi coverage and ci wiring Add integration tests for the WASI behavior. Ignore the tests that the WASI sandbox cannot run and give each a reason. Build relative symlink targets in the test harness on WASI. Add the `UUTESTS_WASM_RUNNER_ARGS` variable. Run the wasip2 integration suite in `wasi.yml` and extend the utility list. Document the ignore reasons in `docs/src/wasi-test-gaps.md`. --- .github/workflows/wasi.yml | 3 +- .../workspace.wordlist.txt | 6 + docs/src/wasi-test-gaps.md | 166 +++++++++++++++++- tests/by-util/test_cat.rs | 9 +- tests/by-util/test_comm.rs | 1 + tests/by-util/test_cp.rs | 10 ++ tests/by-util/test_csplit.rs | 13 ++ tests/by-util/test_cut.rs | 3 +- tests/by-util/test_dd.rs | 16 ++ tests/by-util/test_dir.rs | 1 + tests/by-util/test_factor.rs | 1 + tests/by-util/test_false.rs | 1 + tests/by-util/test_fold.rs | 3 +- tests/by-util/test_head.rs | 3 +- tests/by-util/test_ls.rs | 118 ++++++++++++- tests/by-util/test_mv.rs | 4 + tests/by-util/test_od.rs | 1 + tests/by-util/test_pathchk.rs | 4 + tests/by-util/test_printenv.rs | 8 + tests/by-util/test_ptx.rs | 1 + tests/by-util/test_pwd.rs | 8 + tests/by-util/test_rm.rs | 28 ++- tests/by-util/test_rmdir.rs | 3 - tests/by-util/test_seq.rs | 11 +- tests/by-util/test_split.rs | 48 +++++ tests/by-util/test_tail.rs | 14 ++ tests/by-util/test_touch.rs | 12 +- tests/by-util/test_tr.rs | 3 +- tests/by-util/test_true.rs | 1 + tests/by-util/test_tsort.rs | 1 + tests/by-util/test_tty.rs | 4 + tests/by-util/test_uname.rs | 8 +- tests/by-util/test_uniq.rs | 1 + tests/by-util/test_wc.rs | 43 ++--- tests/uutests/Cargo.toml | 1 + 35 files changed, 500 insertions(+), 58 deletions(-) diff --git a/.github/workflows/wasi.yml b/.github/workflows/wasi.yml index 164b685a221..b14df9b8d27 100644 --- a/.github/workflows/wasi.yml +++ b/.github/workflows/wasi.yml @@ -25,7 +25,7 @@ jobs: matrix: job: - { target: wasm32-wasip1, rust-flags: "--cfg wasi_runner" } - - { target: wasm32-wasip2, rust-flags: "--cfg wasi_runner --cfg wasip2_runner" } + - { target: wasm32-wasip2, rust-flags: "--cfg wasi_runner --cfg wasip2_runner" } steps: - uses: actions/checkout@v7.0.1 with: @@ -59,7 +59,6 @@ jobs: run: | cargo test --target ${{ matrix.job.target }} --no-default-features --features fs -p uucore fs::tests - name: Run integration tests via wasmtime - if: matrix.job.target == 'wasm32-wasip1' env: RUSTFLAGS: ${{ matrix.job.rust-flags }} run: | diff --git a/.vscode/cspell.dictionaries/workspace.wordlist.txt b/.vscode/cspell.dictionaries/workspace.wordlist.txt index 6762da55002..99aee1ba89b 100644 --- a/.vscode/cspell.dictionaries/workspace.wordlist.txt +++ b/.vscode/cspell.dictionaries/workspace.wordlist.txt @@ -122,11 +122,13 @@ ENODATA ENOENT ENOSPC ENOSYS +ENOTDIR ENOTEMPTY EOPNOTSUPP EPERM EPIPE EROFS +ETXTBSY # * vars/fcntl F_GETFL @@ -181,8 +183,10 @@ addrlen blocksize canonname chroot +cmdline dlsym execvp +fchmod fdatasync freeaddrinfo getaddrinfo @@ -202,6 +206,7 @@ inodes isatty lchown pathlen +reflink setgid setgroups settime @@ -216,6 +221,7 @@ strerror strlen syncfs umask +unflushed waitpid # * vars/nix diff --git a/docs/src/wasi-test-gaps.md b/docs/src/wasi-test-gaps.md index cb2c132bc1b..51ab9c8fcac 100644 --- a/docs/src/wasi-test-gaps.md +++ b/docs/src/wasi-test-gaps.md @@ -1,8 +1,8 @@ # 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 the integration tests run a WASI binary in wasmtime. This document lists the reasons. -To find all annotated tests: `grep -rn 'wasi_runner, ignore' tests/` +To find all annotated tests: `grep -rn 'wasi_runner, ignore\|wasip2_runner, ignore' tests/` ## Tools not yet covered by integration tests @@ -10,28 +10,176 @@ arch, b2sum, cat, cp, csplit, date, dir, dircolors, fmt, join, ls, md5sum, mkdir ## WASI sandbox: host paths not visible -The WASI guest only sees directories explicitly mapped with `--dir`. Host paths like `/proc`, `/sys`, and `/dev` are not accessible. Affected tests include those that read `/proc/version`, `/proc/modules`, `/proc/cpuinfo`, `/proc/self/mem`, `/sys/kernel/profiling`, `/dev/null`, `/dev/zero`, `/dev/full`, and tests that rely on anonymous pipes or Linux-specific I/O error paths. +The WASI guest only sees directories explicitly mapped with `--dir`. Host paths outside those mappings are not accessible, so any test that reads or writes an absolute host path fails. This is the single largest gap and covers many sub-cases: + +- Generic unmapped absolute paths ("WASI sandbox: host paths not visible", "WASI sandbox: absolute host paths not visible", "WASI sandbox: cross-scenario absolute host path not visible"). +- `/dev` special files: `/dev/null`, `/dev/zero`, `/dev/random`, `/dev/console`, and `/dev` generally. +- `/proc` and `/sys`: `/proc/version`, `/proc/modules`, `/proc/cpuinfo`, `/proc/self/mem`, `/proc/1/cmdline`, `/sys/kernel/profiling`, and `/proc`/`/sys` generally. +- Locale and timezone databases, which live under host paths the guest never sees ("WASI sandbox: locale database not visible", "WASI sandbox: timezone database not visible", "WASI sandbox: timezone/locale database not visible", including the `--time-style=locale` and "host locale check passes but the wasm guest can't use it" variants). +- Path identity/display mismatches that stem from the same root cause — the guest's view of the filesystem is a virtual root, not the host's real one: `pwd`/`getcwd` report the guest's virtual root instead of the host absolute path ("WASI sandbox: pwd reports the guest's virtual root..."), canonicalized paths resolve to the virtual root instead of the host path ("WASI sandbox: canonicalized path resolves to the guest's virtual root..."), `current_directory_resolved` used to build expected hyperlink URIs differs from the host path, `/` inside the guest is its own writable root rather than the real filesystem root, and UNC-style paths resolve differently inside the guest root than on the host. ## WASI: argv/filenames must be valid UTF-8 -The WASI specification requires that argv entries and filenames are valid UTF-8. Tests that pass non-UTF-8 bytes as arguments or create files with non-UTF-8 names cannot run under WASI. +The WASI specification requires that argv entries, environment values, and filenames are valid UTF-8. Tests that pass non-UTF-8 bytes as arguments, environment values, or create files with non-UTF-8 names cannot run under WASI ("WASI: argv/filenames must be valid UTF-8", "WASI: argv must be valid UTF-8", "WASI: env values must be valid UTF-8", "WASI: preopened directories reject non-UTF-8 filenames", "WASI: non-utf8 arguments cannot be passed through the spawned test harness", "WASI preview2: OsString requires valid UTF-8, unlike unix/wasip1"). ## WASI: no FIFO/mkfifo support -WASI does not support creating or opening FIFOs (named pipes). Tests that use `mkfifo` are skipped. +WASI does not support creating or opening FIFOs (named pipes). Tests that use `mkfifo`, classify files via `FileTypeExt::is_fifo()`, or read from a FIFO (which surfaces as `EINVAL`/"Invalid seek" instead of blocking) are skipped. ## WASI: no pipe/signal support -WASI does not support Unix signals or pipe creation. Tests that rely on `SIGPIPE`, broken pipe detection, or pipe-based I/O are skipped. +WASI does not support Unix signals or pipe creation. Tests that rely on `SIGPIPE`, `SIGINT`, broken pipe detection, or pipe-based I/O are skipped ("WASI: no pipe/signal support", "WASI: no signal support", "WASI: no signal support (SIGINT)"). ## WASI: no subprocess spawning -WASI does not support spawning child processes. Tests that shell out to other commands or invoke a second binary are skipped. +WASI does not support spawning child processes. Tests that shell out to other commands, invoke a second binary, or rely on `--filter`'s process-spawning support are skipped ("WASI: no subprocess spawning", "WASI: --filter has no process-spawning support"). + +## WASI: no stdout-to-file redirection + +Tests that redirect a subprocess's stdout directly to a file outside the test harness's own plumbing are skipped; the wasmtime runner does not support this redirection path. + +## WASI: sparse/reflink/ACL copy-on-write features not supported + +`cp`'s sparse-file detection, reflink/copy-on-write (`--reflink`), and ACL preservation rely on Linux-specific filesystem features that wasmtime's virtualized filesystem does not implement. + +## WASI: follow mode (-f) is not supported on this platform + +`tail -f` and related follow-mode behavior depend on OS-level file-change notification that is not available to a WASI guest. + +## WASI: st_mode has no real permission bits + +WASI's `stat` only reports file type, not real Unix permission bits, so `st_mode` is a placeholder. Tests that assert on permission bits, mode-dependent coloring, or mode-dependent output are skipped ("WASI: st_mode has no real permission bits", "...only file-type; ls -l shows placeholder rwx", "...color/mode-dependent output differs"). + +## WASI: chmod/umask have no real effect in the guest sandbox + +`chmod` has no ENOSYS-free syscall in the WASI guest, so tests that expect `chmod` to restore write permission or make a directory read-only are skipped. Similarly `umask()` only affects the wasmtime host process, not the guest sandbox, so tests asserting on umask-influenced output are skipped. + +## WASI: sort -m spawns real OS threads for multi-file merge + +`sort -m`'s multi-file merge path spawns real OS threads, which is unsupported under wasmtime's default configuration. This also affects `--compress-program`, since `ext_sort` falls back to a single-threaded in-memory path that bypasses the external compress program entirely. + +## WASI: File::try_clone() is unsupported + +`shuf --random-source` (and similar) rely on `File::try_clone()`, which is unsupported under the WASI runtime. + +## 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: read_link on absolute paths fails...", "WASI: read_link() not supported for symlinks"). ## WASI: stdin file position not preserved through wasmtime 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: inode/ctime/atime metadata gaps + +Several `stat`-adjacent metadata fields are unreliable or unavailable under WASI: inode display needs a `rustix::fs::stat`-based path and is currently gated to unix only; `ctime` is unavailable via `std::fs::Metadata` on stable; access/change time tracking granularity does not match the host filesystem's; and `rustix::fs::stat` doesn't return stable inode identity across path lookups under wasmtime ("WASI preview2: rustix::fs::stat doesn't return stable inode identity..."). + +## WASI: utimensat rejects negative (pre-1970) timestamps + +Setting a file's timestamp to before the Unix epoch fails with `EINVAL` under WASI's `utimensat`, unlike native Unix. + +## WASI: setting the system clock is not supported at all + +Unlike native Unix, where changing the system clock is merely permission-gated, WASI does not support setting the system clock at all. + +## WASI: direct file descriptor manipulation not supported + +Tests that manipulate file descriptors directly (e.g. reusing a descriptor across `dup`-like operations to alias stdin/stdout to the same file) rely on primitives not supported under WASI. + +## WASI: no /dev/fd/0 support + +Without `/dev/fd/0`, a redirected-directory stdin hits the generic pipe error path (like macOS) instead of the regular-file path with the GNU-matching error message. + +## WASI: killing the wasmtime process discards the unflushed output buffer + +When a test kills the wasmtime process to check partial output, any unflushed output buffer is discarded, so streamed bytes never reach stdout the way they would with a natively killed process. + +## WASI: resource limits not supported + +Tests that use `rlimit` to constrain resources (file descriptors, address space) don't observe the expected behavior under wasmtime, since it doesn't enforce host-style resource limits inside the guest. This includes address-space-limit regression tests, for which "the WASI runner target is not suitable." + +## WASI Preview2: exit with code requires an opt-in feature + +`std::process::exit` on `wasm32-wasip2` goes through the *stable* `wasi:cli/exit#exit` +function, which only carries a success/failure bit, so every nonzero exit code +collapses to `1`. + +The [wasi:cli/exit#exit-with-code](https://github.com/WebAssembly/WASI/blob/a1fc383d01eabaf3fac01de03c0ab1a01bfdd099/proposals/cli/wit/exit.wit#L16) +function propagates the real exit code, but it is marked `@unstable` in the +WIT definition. `uucore`'s `wasip2-exit-with-code` Cargo feature (off by +default) switches `uucore::error::process_exit` to call it via the `wasip2` +crate. Because the function is unstable, any WASI host must explicitly opt +in or the process traps instead of exiting (e.g. wasmtime requires +`-S cli-exit-with-code=y`). CI builds with this feature enabled and passes +that flag, so integration tests see the real exit code. + +## WASI Preview2: OS error message text/mapping differs from native Unix + +Some I/O error paths produce a different underlying OS error, or a +different-but-equivalent error text, under WASI Preview2 than on native +Unix, even though the exit code is the same. Examples: reading a directory +as a file surfaces as `Bad file descriptor` instead of `Is a directory`; +`stat` on a dangling symlink with a trailing slash returns `ENOTDIR` instead +of `ENOENT`; `stat` on a trailing-slash path over a regular file surfaces a +raw `ENOTDIR` from the runtime instead of going through the +`CannotStatNotADirectory` path. Tests that assert on the exact error text or +error code for these paths are skipped under `wasip2_runner`/`wasi_runner`. + +## WASI P2: /dev/full filesystem not available + +`/dev/full` (a device that always reports "No space left on device" on +write) is a Linux/FreeBSD/NetBSD-specific device node not present in the +WASI Preview2 sandbox. Tests that pipe output to `/dev/full` to exercise a +write-failure path are skipped. + +## Harness/environment mismatches (not WASI capability gaps) + +A handful of skipped tests aren't blocked by a WASI capability at all — they're blocked by how the test harness or CI environment invokes the binary: + +- Tests that invoke the binary directly via a shell script or `std::process::Command`, bypassing the wasmtime runner entirely, can't run against the WASI binary as-is. +- A test that sets `HOME` to a relative path breaks wasmtime's own cache-dir resolution. +- A test that asserts on macOS-specific error text can't pass when the wasm guest under test isn't macOS, even though the test binary itself runs on macOS. +- `ls`'s open-fd-leak regression test hits wasmtime's own `--dir` sandbox fd/depth limit before reaching the 30-level depth the test is trying to probe, unrelated to whether `ls` itself leaks descriptors. +- `sort`'s buffer-size test with `u64::MAX`-sized arguments overflows differently on WASI because the wasm guest is always a 32-bit target regardless of the host's pointer width. + +## WASI: the `same-file` crate has no WASI backend + +`cp --link` on a symlinked directory needs to detect whether source and +destination are the same file. That check goes through the `same-file` +crate, which has no `wasm32-wasip1`/`wasm32-wasip2` implementation and always +returns "same-file is not supported on this platform" on that target. + +## Needs investigation + +A number of `cp`, `ls`, and `mv` tests were originally bulk-tagged with the +placeholder `ignore = "WASI: needs investigation (chmod/interactive/device/mode gaps)"` +pending a closer look. Most have since been triaged: 59 of them actually pass +under wasmtime (their `ignore` attribute has been removed) once the earlier +placeholder was replaced with real testing. Search for `needs investigation` +in `tests/by-util/test_cp.rs`, `tests/by-util/test_ls.rs`, and +`tests/by-util/test_mv.rs` to find the ones still pending. + +The tests still carrying the placeholder fall into a few buckets, none fixable +with a small coreutils-side change: -`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. +- **chmod/permission-mode preservation**: `cp --preserve=mode`, `-p`, `-a`, + and friends hit `ENOSYS` ("Function not implemented") when they try to + `chmod` under WASI, since there is no working `fchmod`/`chmod` syscall in + the guest sandbox. +- **File type gaps**: char devices, sockets, and other special files can't be + `stat`ed or copied (`cp: cannot stat`, `cp: ... Not supported`) because the + WASI guest doesn't expose real device/socket types. +- **`filetime` has no WASI backend**: preserving timestamps through symlinks + (`set_symlink_file_times`) hits the crate's `wasm.rs` stub, which + unconditionally returns "Wasm not implemented". +- **Debug/reflink text mismatches**: `cp --debug`'s `copy offload`/`reflink`/ + `sparse detection` fields, and the `--reflink` error message, are written + assuming a `target_os = "linux"`/`"macos"` host and don't have a WASI-specific + branch, so the text differs from what the test expects. +- **Virtual-root-relative absolute paths**: a few tests build an absolute + path via `root_dir_resolved()` or copy `.` into a sibling directory + reached through `..`; both hit the same "WASI sandbox: pwd reports the + guest's virtual root, not the host's absolute path" limitation described + above, just via a different code path (`cp: cannot stat` on the + synthesized absolute path, or `cp: cannot copy a directory ... into + itself` on the `..`-relative destination). diff --git a/tests/by-util/test_cat.rs b/tests/by-util/test_cat.rs index 48f248da095..3a8f8d1513e 100644 --- a/tests/by-util/test_cat.rs +++ b/tests/by-util/test_cat.rs @@ -202,6 +202,7 @@ fn test_piped_to_dev_null() { #[test] #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "netbsd"))] +#[cfg_attr(wasip2_runner, ignore = "WASI P2: /dev/full filesystem not available")] fn test_piped_to_dev_full() { for append in [true, false] { for args in [&["-", "/dev/zero"][..], &["-n", "-", "/dev/zero"][..]] { @@ -653,10 +654,15 @@ fn test_domain_socket() { s.ucmd() .args(&[socket_path]) .fails() - .stderr_contains("No such device or address"); + .stderr_contains(if cfg!(wasi_runner) { + "No such file or directory" + } else { + "No such device or address" + }); } #[test] +#[cfg_attr(wasi_runner, ignore)] fn test_write_to_self_empty() { // it's ok if the input file is also the output file if it's empty let s = TestScenario::new(util_name!()); @@ -928,6 +934,7 @@ fn test_write_error_handling() { #[test] #[cfg(target_os = "linux")] +#[cfg_attr(wasip2_runner, ignore = "WASI P2: /dev/full filesystem not available")] fn test_version_help_dev_full() { use std::fs::OpenOptions; diff --git a/tests/by-util/test_comm.rs b/tests/by-util/test_comm.rs index e3d4164d84e..75ce5847478 100644 --- a/tests/by-util/test_comm.rs +++ b/tests/by-util/test_comm.rs @@ -807,6 +807,7 @@ fn test_c_locale_still_orders_by_bytes() { #[test] #[cfg(target_os = "linux")] +#[cfg_attr(wasip2_runner, ignore = "WASI P2: /dev/full filesystem not available")] fn test_comm_write_error_dev_full() { use std::fs::OpenOptions; let scene = TestScenario::new(util_name!()); diff --git a/tests/by-util/test_cp.rs b/tests/by-util/test_cp.rs index eff7534b4c6..22719b5a5cb 100644 --- a/tests/by-util/test_cp.rs +++ b/tests/by-util/test_cp.rs @@ -105,6 +105,7 @@ fn test_cp_stream_to_full() { #[test] #[cfg(target_os = "linux")] +#[cfg_attr(wasip2_runner, ignore = "WASI P2: /dev/full filesystem not available")] fn test_cp_verbose_write_error_is_reported() { let (at, mut ucmd) = at_and_ucmd!(); at.touch("source_file"); @@ -3330,6 +3331,10 @@ fn test_cp_conflicting_update() { #[test] #[cfg(any(target_os = "linux", target_os = "android"))] +#[cfg_attr( + wasi_runner, + ignore = "WASI: needs investigation (chmod/interactive/device/mode gaps)" +)] fn test_cp_reflink_insufficient_permission() { let (at, mut ucmd) = at_and_ucmd!(); @@ -3347,6 +3352,7 @@ fn test_cp_reflink_insufficient_permission() { #[cfg(target_os = "linux")] #[test] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] fn test_closes_file_descriptors() { use rlimit::Resource; @@ -4546,6 +4552,10 @@ fn test_cp_overriding_arguments() { } #[test] +#[cfg_attr( + wasip2_runner, + ignore = "WASI preview2: rustix::fs::stat doesn't return stable inode identity across path lookups (wasmtime filesystem limitation)" +)] fn test_copy_no_dereference_1() { let (at, mut ucmd) = at_and_ucmd!(); at.mkdir("a"); diff --git a/tests/by-util/test_csplit.rs b/tests/by-util/test_csplit.rs index c75c8d7f5ec..0ca754ae636 100644 --- a/tests/by-util/test_csplit.rs +++ b/tests/by-util/test_csplit.rs @@ -1789,6 +1789,10 @@ fn repeat_everything() { #[cfg(unix)] #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI: reading a FIFO returns EINVAL/'Invalid seek'" +)] fn test_named_pipe_input_file() { let (at, mut ucmd) = at_and_ucmd!(); @@ -1847,6 +1851,7 @@ fn test_stdin_no_trailing_newline() { #[test] #[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: argv/filenames must be valid UTF-8")] fn test_csplit_non_utf8_paths() { use std::os::unix::ffi::OsStringExt; let (at, mut ucmd) = at_and_ucmd!(); @@ -1860,6 +1865,10 @@ fn test_csplit_non_utf8_paths() { /// Test write error detection using /dev/full #[test] #[cfg(target_os = "linux")] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: host paths (/dev/full) not visible" +)] fn test_write_error_dev_full() { let (at, mut ucmd) = at_and_ucmd!(); at.symlink_file("/dev/full", "xx01"); @@ -1876,6 +1885,10 @@ fn test_write_error_dev_full() { /// Test write error with -k keeps files #[test] #[cfg(target_os = "linux")] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: host paths (/dev/full) not visible" +)] fn test_write_error_dev_full_keep_files() { let (at, mut ucmd) = at_and_ucmd!(); at.symlink_file("/dev/full", "xx01"); diff --git a/tests/by-util/test_cut.rs b/tests/by-util/test_cut.rs index 26ea8210787..3d95e24800a 100644 --- a/tests/by-util/test_cut.rs +++ b/tests/by-util/test_cut.rs @@ -1042,8 +1042,9 @@ fn test_emoji_delim() { .stdout_only("🌹\n"); } -#[cfg(target_os = "linux")] #[test] +#[cfg(target_os = "linux")] +#[cfg_attr(wasip2_runner, ignore = "WASI P2: /dev/full filesystem not available")] fn test_failed_write_is_reported() { new_ucmd!() .arg("-d=") diff --git a/tests/by-util/test_dd.rs b/tests/by-util/test_dd.rs index a5e4ab7b2b5..f6819ff912c 100644 --- a/tests/by-util/test_dd.rs +++ b/tests/by-util/test_dd.rs @@ -997,6 +997,7 @@ fn test_random_73k_test_obs_lt_not_a_multiple_ibs() { #[cfg(not(windows))] #[test] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: fifo reads surface as BrokenPipe")] fn test_random_73k_test_lazy_fullblock() { let (at, mut ucmd) = at_and_ucmd!(); at.mkfifo("fifo"); @@ -1149,6 +1150,7 @@ fn test_truncated_record() { /// Test that the output file can be `/dev/null`. #[cfg(unix)] #[test] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: no /dev/null device node")] fn test_outfile_dev_null() { new_ucmd!().arg("of=/dev/null").succeeds().no_stdout(); } @@ -1438,6 +1440,7 @@ fn test_bytes_suffix_recursive() { /// Test for "conv=sync" with a slow reader. #[cfg(not(windows))] #[test] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: fifo reads surface as BrokenPipe")] fn test_sync_delayed_reader() { let (at, mut ucmd) = at_and_ucmd!(); at.mkfifo("fifo"); @@ -1575,6 +1578,10 @@ fn test_failed_truncate_is_reported_and_copy_goes_on() { /// Test that a seek on an output FIFO results in a read. #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: hangs reading from a fifo opened for output" +)] fn test_seek_output_fifo() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -1619,6 +1626,10 @@ fn test_skip_input_fifo() { /// Test for reading part of stdin from each of two child processes. #[cfg(not(windows))] #[test] +#[cfg_attr( + wasi_runner, + ignore = "runs the coreutils binary directly via a raw shell pipeline, bypassing the WASI runner wrapping" +)] fn test_multiple_processes_reading_stdin() { // TODO Investigate if this is possible on Windows. let printf = "printf 'abcdef\n'".to_string(); @@ -2039,6 +2050,7 @@ fn test_wrong_number_err_msg() { #[test] #[cfg(unix)] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: no /dev/urandom device node")] fn test_no_dropped_writes() { const BLK_SIZE: usize = 0x4000; const COUNT: usize = 1000; @@ -2200,6 +2212,10 @@ fn test_nocache_eof_fadvise_zero_length() { #[test] #[cfg(not(target_os = "openbsd"))] +#[cfg_attr( + wasi_runner, + ignore = "WASI: rust-lang/libc lacks LC_* constants for the wasi target, so dd can't call setlocale there" +)] fn test_iso8859_1_case_conversion() { use std::process::Command; // Test ISO-8859-1 case conversion for accented characters diff --git a/tests/by-util/test_dir.rs b/tests/by-util/test_dir.rs index 08e519ea0e4..5f1a64aef9b 100644 --- a/tests/by-util/test_dir.rs +++ b/tests/by-util/test_dir.rs @@ -136,6 +136,7 @@ fn test_version() { #[test] #[cfg(target_os = "linux")] +#[cfg_attr(wasip2_runner, ignore = "WASI P2: /dev/full filesystem not available")] fn test_write_error() { let scene = TestScenario::new(util_name!()); scene.fixtures.touch("file"); diff --git a/tests/by-util/test_factor.rs b/tests/by-util/test_factor.rs index cbd1d312132..cd4eff0b662 100644 --- a/tests/by-util/test_factor.rs +++ b/tests/by-util/test_factor.rs @@ -388,6 +388,7 @@ fn fails_on_invalid_number() { #[test] #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "netbsd"))] +#[cfg_attr(wasip2_runner, ignore = "WASI P2: /dev/full filesystem not available")] fn short_circuit_write_error() { use std::fs::OpenOptions; diff --git a/tests/by-util/test_false.rs b/tests/by-util/test_false.rs index 8c672c8fa6f..cb3c3f19509 100644 --- a/tests/by-util/test_false.rs +++ b/tests/by-util/test_false.rs @@ -52,6 +52,7 @@ fn test_conflict() { #[test] #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "netbsd"))] +#[cfg_attr(wasip2_runner, ignore = "WASI P2: /dev/full filesystem not available")] fn test_full() { for option in ["--version", "--help"] { let dev_full = OpenOptions::new().write(true).open("/dev/full").unwrap(); diff --git a/tests/by-util/test_fold.rs b/tests/by-util/test_fold.rs index fc70e492172..71f394ee3a3 100644 --- a/tests/by-util/test_fold.rs +++ b/tests/by-util/test_fold.rs @@ -360,8 +360,9 @@ fn test_zero_width_data_line_counts() { ); } -#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "netbsd"))] #[test] +#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "netbsd"))] +#[cfg_attr(wasip2_runner, ignore = "WASI P2: /dev/full filesystem not available")] fn test_fold_reports_no_space_left_on_dev_full() { use std::fs::OpenOptions; use std::process::Stdio; diff --git a/tests/by-util/test_head.rs b/tests/by-util/test_head.rs index 4ab710793e0..a8496843f12 100644 --- a/tests/by-util/test_head.rs +++ b/tests/by-util/test_head.rs @@ -926,8 +926,9 @@ fn test_all_but_last_lines() { .stdout_is_fixture("lorem_ipsum_backwards_15_lines.expected"); } -#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "netbsd"))] #[test] +#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "netbsd"))] +#[cfg_attr(wasip2_runner, ignore = "WASI P2: /dev/full filesystem not available")] fn test_write_to_dev_full() { use std::fs::OpenOptions; diff --git a/tests/by-util/test_ls.rs b/tests/by-util/test_ls.rs index 6b02b890d36..e7b450b5c34 100644 --- a/tests/by-util/test_ls.rs +++ b/tests/by-util/test_ls.rs @@ -249,6 +249,7 @@ fn get_filesystem_type(scene: &TestScenario, path: &Path) -> String { #[cfg(all(feature = "truncate", feature = "dd"))] #[test] // FIXME: fix this test for FreeBSD and OpenBSD #[cfg(not(target_os = "openbsd"))] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] fn test_ls_allocation_size() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -490,6 +491,10 @@ fn test_ls_allocation_size() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: host paths (/dev/null) not visible" +)] fn test_ls_devices() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -1274,6 +1279,10 @@ fn test_ls_long_padding_of_size_column_with_multiple_files() { #[test] #[cfg(all(feature = "ln", feature = "mkdir", feature = "touch"))] #[allow(clippy::items_after_statements)] +#[cfg_attr( + wasi_runner, + ignore = "WASI: st_mode has no real permission bits; color/mode-dependent output differs" +)] fn test_ls_long_symlink_color() { // If you break this test after breaking mkdir, touch, or ln, do not be alarmed! // This test is made for ls, but it attempts to run those utils in the process. @@ -1535,7 +1544,7 @@ fn test_ls_long_dangling_symlink_color() { // Ensure dangling link name uses `or=` and target uses `mi=`. let name_regex = Regex::new(r"(?:\x1b\[[0-9;]*m)*\x1b\[([0-9;]*)mdir1/dangling_symlink\x1b\[0m").unwrap(); - let target_path = regex::escape(&at.plus_as_string("foo")); + let target_path = regex::escape(&format!("..{}foo", std::path::MAIN_SEPARATOR)); let target_pattern = format!(r"(?:\x1b\[[0-9;]*m)*\x1b\[([0-9;]*)m{target_path}\x1b\[0m"); let target_regex = Regex::new(&target_pattern).unwrap(); @@ -1768,6 +1777,10 @@ fn test_ls_directory_dangling_symlink_uses_ln_when_or_blank() { #[test] #[cfg(not(target_os = "openbsd"))] +#[cfg_attr( + wasi_runner, + ignore = "WASI: st_mode has no real permission bits; color/mode-dependent output differs" +)] fn test_ls_long_total_size() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -2245,6 +2258,10 @@ fn test_ls_order_size() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI: ctime is unavailable via std::fs::Metadata on stable" +)] fn test_ls_long_ctime() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -2288,6 +2305,10 @@ fn test_ls_order_birthtime() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: locale database not visible (time-style=locale)" +)] fn test_ls_time_styles() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -2654,6 +2675,10 @@ fn test_ls_order_time_breaks_ties_by_name() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI: access/change time tracking granularity does not match the host filesystem's" +)] fn test_ls_order_time() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -2955,6 +2980,10 @@ mod quoting { // and must not escape embedded apostrophes or double quotes; in the C // locale they fall back to ASCII single/double quotes. #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: locale database not visible (host locale check passes but the wasm guest can't use it)" + )] fn test_ls_quoting_locale_utf8() { if !is_locale_available("en_US.UTF-8") { return; @@ -3430,6 +3459,7 @@ mod quoting { /// gets escaped depending on the used locale. #[cfg(not(any(target_vendor = "apple", windows, target_os = "openbsd")))] #[test] + #[cfg_attr(wasi_runner, ignore = "WASI sandbox: locale database not visible")] fn test_locale_aware_quoting() { let cases: &[(&[u8], _, _, &[&str])] = &[ ( @@ -3492,6 +3522,7 @@ mod quoting { } #[test] + #[cfg_attr(wasi_runner, ignore = "WASI sandbox: locale database not visible")] fn test_c_dot_utf8_renders_utf8() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -3580,6 +3611,10 @@ fn test_ls_color() { #[test] #[cfg(not(feature = "selinux"))] // Disabled on the SELinux runner for now +#[cfg_attr( + wasi_runner, + ignore = "WASI: inode display is gated to unix; needs a rustix::fs::stat-based path" +)] fn test_ls_inode() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -3622,6 +3657,10 @@ fn test_ls_inode() { #[test] #[cfg(not(windows))] +#[cfg_attr( + wasi_runner, + ignore = "WASI: FileTypeExt::is_fifo() is unix-only, so FIFOs aren't classified" +)] fn test_ls_indicator_style() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -3737,7 +3776,7 @@ fn test_ls_indicator_style_symlink_target_long() { .succeeds() .stdout_contains("dir_link -> ") .stdout_does_not_contain("dir_link@ -> ") - .stdout_contains("/dir/"); + .stdout_contains(" dir/"); } #[test] @@ -5110,6 +5149,7 @@ fn test_ls_sort_extension() { } #[test] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: absolute host paths not visible")] fn test_ls_path() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -5152,6 +5192,10 @@ fn test_ls_path() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI: inode display is gated to unix; needs a rustix::fs::stat-based path" +)] fn test_ls_dangling_symlinks() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -5695,6 +5739,10 @@ fn test_tabsize_formatting() { #[cfg(all(unix, not(target_os = "android")))] #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: host paths (/dev/console) not visible" +)] fn test_device_number() { use std::fs::{metadata, read_dir}; use std::os::unix::fs::{FileTypeExt, MetadataExt}; @@ -5726,6 +5774,10 @@ fn test_device_number() { #[test] #[cfg(target_os = "linux")] +#[cfg_attr( + wasi_runner, + ignore = "WASI: preopened directories reject non-UTF-8 filenames" +)] fn test_invalid_utf8() { let (at, mut ucmd) = at_and_ucmd!(); @@ -5806,6 +5858,10 @@ fn test_ls_dired_quoting_style_name() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: current_directory_resolved differs from host path used in expected hyperlink URI" +)] fn test_ls_dired_hyperlink() { // we will have link but not the DIRED output // note that the order matters @@ -6468,6 +6524,10 @@ fn test_ls_cf_output_should_be_delimited_by_tab() { #[cfg(all(unix, feature = "dd"))] #[test] #[cfg(not(target_os = "openbsd"))] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: host paths (/dev/zero) not visible" +)] fn test_posixly_correct_and_block_size_env_vars() { let scene = TestScenario::new(util_name!()); @@ -6522,6 +6582,10 @@ fn test_posixly_correct_and_block_size_env_vars() { #[cfg(all(unix, feature = "dd"))] #[test] #[cfg(not(target_os = "openbsd"))] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: host paths (/dev/zero) not visible" +)] fn test_posixly_correct_and_block_size_env_vars_with_k() { let scene = TestScenario::new(util_name!()); @@ -6626,6 +6690,10 @@ fn test_ls_invalid_block_size() { #[cfg(all(unix, feature = "dd"))] #[test] #[cfg(not(target_os = "openbsd"))] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: host paths (/dev/zero) not visible" +)] fn test_ls_invalid_block_size_in_env_var() { let scene = TestScenario::new(util_name!()); @@ -6673,6 +6741,10 @@ fn test_ls_invalid_block_size_in_env_var() { #[cfg(all(unix, feature = "dd"))] #[test] #[cfg(not(target_os = "openbsd"))] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: host paths (/dev/zero) not visible" +)] fn test_ls_block_size_override() { let scene = TestScenario::new(util_name!()); @@ -6784,6 +6856,10 @@ fn test_ls_block_size_override_self() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: current_directory_resolved differs from host path used in expected hyperlink URI" +)] fn test_ls_hyperlink() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -6861,6 +6937,10 @@ fn test_ls_hyperlink_encode_link() { // spell-checker:enable #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: current_directory_resolved differs from host path used in expected hyperlink URI" +)] fn test_ls_hyperlink_dirs() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -6905,6 +6985,10 @@ fn test_ls_hyperlink_dirs() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: current_directory_resolved differs from host path used in expected hyperlink URI" +)] fn test_ls_hyperlink_recursive_dirs() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -7102,6 +7186,10 @@ fn test_term_colorterm() { #[cfg(all(unix, not(target_vendor = "apple")))] #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI: needs investigation (chmod/interactive/device/mode gaps)" +)] fn test_acl_display() { use std::process::Command; @@ -7186,6 +7274,10 @@ fn test_acl_display_macos() { // Each file with an ACL must not inflate the link-count column width. #[cfg(all(unix, not(target_vendor = "apple")))] #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI: needs investigation (chmod/interactive/device/mode gaps)" +)] fn test_acl_padding_not_inflated() { use std::process::Command; @@ -7240,6 +7332,10 @@ fn test_acl_padding_not_inflated() { #[test] #[cfg(not(feature = "selinux"))] // Disabled on the SELinux runner for now +#[cfg_attr( + wasi_runner, + ignore = "WASI: st_mode has no real permission bits; color/mode-dependent output differs" +)] fn test_ls_color_norm() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -7653,6 +7749,10 @@ fn test_unknown_format_specifier() { #[cfg(all(unix, not(target_vendor = "apple")))] #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI: needs investigation (chmod/interactive/device/mode gaps)" +)] fn test_acl_display_symlink() { use std::process::Command; @@ -7824,6 +7924,7 @@ fn test_ls_time_style_iso_recent_and_older() { } #[test] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: locale database not visible")] fn test_ls_time_style_posix_locale_override() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -8215,6 +8316,10 @@ fn test_f_flag_combined_behavior() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI: st_mode has no real permission bits, only file-type; ls -l shows placeholder rwx" +)] fn test_f_with_long_format() { // Test that -f works with long format (-l) let scene = TestScenario::new(util_name!()); @@ -8238,6 +8343,7 @@ fn test_f_with_long_format() { #[test] #[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] fn test_ls_proc_self_fd_no_errors() { // Regression test: ReadDir must stay alive until metadata() is called // to prevent "cannot access '/proc/self/fd/3'" errors. @@ -8253,6 +8359,10 @@ fn test_ls_proc_self_fd_no_errors() { #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "wasmtime's --dir sandbox hits its own fd/depth limit before 30 levels, unrelated to whether ls itself leaks fds" +)] fn test_ls_recursive_no_fd_leak() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -8274,6 +8384,10 @@ fn test_ls_recursive_no_fd_leak() { #[test] #[cfg(all(unix, not(target_vendor = "apple")))] +#[cfg_attr( + wasi_runner, + ignore = "WASI: preopened directories reject non-UTF-8 filenames" +)] fn test_ls_non_utf8_hidden() { use std::{ffi::OsStr, os::unix::ffi::OsStrExt}; let scene = TestScenario::new(util_name!()); diff --git a/tests/by-util/test_mv.rs b/tests/by-util/test_mv.rs index ef769ee57b6..fb883a77d2d 100644 --- a/tests/by-util/test_mv.rs +++ b/tests/by-util/test_mv.rs @@ -846,6 +846,10 @@ fn test_mv_backup_simple_guard_allows_unrelated_source() { /// backup rename cannot destroy it. GNU allows this. #[test] #[cfg(all(unix, not(target_os = "android")))] +#[cfg_attr( + wasip2_runner, + ignore = "WASI preview2: rustix::fs::stat doesn't return stable inode identity across path lookups (wasmtime filesystem limitation)" +)] fn test_mv_backup_simple_guard_allows_symlink_source() { let (at, mut ucmd) = at_and_ucmd!(); at.write("test_mv_sym_a", "DSTDATA"); diff --git a/tests/by-util/test_od.rs b/tests/by-util/test_od.rs index 66d89fa1a5f..e49bf6f60c2 100644 --- a/tests/by-util/test_od.rs +++ b/tests/by-util/test_od.rs @@ -1495,6 +1495,7 @@ fn test_od_eintr_handling() { // Regression test: od should handle write errors to /dev/full without aborting. #[test] #[cfg(target_os = "linux")] +#[cfg_attr(wasip2_runner, ignore = "WASI P2: /dev/full filesystem not available")] fn test_write_error_dev_full() { use std::fs::File; diff --git a/tests/by-util/test_pathchk.rs b/tests/by-util/test_pathchk.rs index 8370ad5bc51..473c0d85a24 100644 --- a/tests/by-util/test_pathchk.rs +++ b/tests/by-util/test_pathchk.rs @@ -25,6 +25,10 @@ fn test_invalid_arg() { #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI: symlink_metadata(\"\") succeeds instead of returning ENOENT" +)] fn test_default_mode() { // accept some reasonable default new_ucmd!().args(&["dir/file"]).succeeds().no_stdout(); diff --git a/tests/by-util/test_printenv.rs b/tests/by-util/test_printenv.rs index 89b9140fc21..4346895a38c 100644 --- a/tests/by-util/test_printenv.rs +++ b/tests/by-util/test_printenv.rs @@ -6,6 +6,10 @@ use uutests::new_ucmd; #[test] +#[cfg_attr( + wasi_runner, + ignore = "sets HOME to a relative path, which breaks wasmtime's own cache-dir resolution" +)] fn test_get_all() { new_ucmd!() .env("HOME", "FOO") @@ -69,6 +73,10 @@ fn test_invalid_option_exit_code() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "sets HOME to a relative path, which breaks wasmtime's own cache-dir resolution" +)] fn test_null_separator() { // printenv should use \x00 as separator if null option is provided for null_opt in ["-0", "--null"] { diff --git a/tests/by-util/test_ptx.rs b/tests/by-util/test_ptx.rs index 1ff0a325525..07d9ab862f1 100644 --- a/tests/by-util/test_ptx.rs +++ b/tests/by-util/test_ptx.rs @@ -238,6 +238,7 @@ fn test_format() { #[cfg(target_os = "linux")] #[test] +#[cfg_attr(wasip2_runner, ignore = "WASI P2: /dev/full filesystem not available")] fn test_failed_write_is_reported() { new_ucmd!() .arg("-G") diff --git a/tests/by-util/test_pwd.rs b/tests/by-util/test_pwd.rs index 9e8b0dfde8b..8cd0839258a 100644 --- a/tests/by-util/test_pwd.rs +++ b/tests/by-util/test_pwd.rs @@ -17,12 +17,20 @@ fn test_invalid_arg() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: pwd reports the guest's virtual root, not the host's absolute path" +)] fn test_default() { let (at, mut ucmd) = at_and_ucmd!(); ucmd.succeeds().stdout_is(at.root_dir_resolved() + "\n"); } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: pwd reports the guest's virtual root, not the host's absolute path" +)] fn test_ignores_non_option_arguments() { // GNU pwd ignores non-option operands, warning on stderr but exiting 0. let (at, mut ucmd) = at_and_ucmd!(); diff --git a/tests/by-util/test_rm.rs b/tests/by-util/test_rm.rs index 2a41460b1cb..4faa903fe67 100644 --- a/tests/by-util/test_rm.rs +++ b/tests/by-util/test_rm.rs @@ -355,6 +355,7 @@ fn test_verbose() { // write failure on stderr and exit 1 instead of panicking. #[test] #[cfg(target_os = "linux")] +#[cfg_attr(wasip2_runner, ignore = "WASI P2: /dev/full filesystem not available")] fn test_verbose_write_error_does_not_panic() { use std::fs::OpenOptions; @@ -379,6 +380,7 @@ fn test_verbose_write_error_does_not_panic() { // Directory variant of the #10551 regression test. #[test] #[cfg(target_os = "linux")] +#[cfg_attr(wasip2_runner, ignore = "WASI P2: /dev/full filesystem not available")] fn test_verbose_write_error_does_not_panic_dir() { use std::fs::OpenOptions; @@ -504,6 +506,7 @@ fn test_no_operand() { } #[test] +#[cfg_attr(wasi_runner, ignore)] fn test_verbose_slash() { let (at, mut ucmd) = at_and_ucmd!(); let dir = "test_rm_verbose_slash_directory"; @@ -941,6 +944,7 @@ fn test_current_or_parent_dir_rm4() { at.touch(file_1); at.touch(file_2); + #[cfg(not(wasi_runner))] let answers = [ "rm: refusing to remove '.' or '..' directory: skipping 'd/.'", "rm: refusing to remove '.' or '..' directory: skipping 'd/./'", @@ -952,6 +956,20 @@ fn test_current_or_parent_dir_rm4() { "rm: refusing to remove '.' or '..' directory: skipping '../'", "rm: refusing to remove '.' or '..' directory: skipping '..'", ]; + #[cfg(wasi_runner)] + let answers = [ + "rm: refusing to remove '.' or '..' directory: skipping 'd/.'", + "rm: refusing to remove '.' or '..' directory: skipping 'd/./'", + "rm: refusing to remove '.' or '..' directory: skipping 'd/./'", + "rm: refusing to remove '.' or '..' directory: skipping 'd/..'", + "rm: it is dangerous to operate recursively on 'd/../' (same as '/')", + "rm: use --no-preserve-root to override this failsafe", + "rm: refusing to remove '.' or '..' directory: skipping '.'", + "rm: it is dangerous to operate recursively on './' (same as '/')", + "rm: use --no-preserve-root to override this failsafe", + "rm: it is dangerous to operate recursively on '../' (same as '/')", + "rm: use --no-preserve-root to override this failsafe", + ]; let std_err_str = ts .ucmd() .arg("-rf") @@ -1183,6 +1201,7 @@ fn test_recursive_remove_unreadable_subdir() { at.set_mode("foo/bar", 0o0000); let result = ucmd.args(&["-r", "-f", "foo"]).fails(); + result.stderr_contains("Permission denied"); result.stderr_contains("foo/bar"); @@ -1240,9 +1259,12 @@ fn test_inaccessible_dir_recursive() { #[test] #[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore)] fn test_non_utf8_paths() { use std::ffi::OsStr; use std::os::unix::ffi::OsStrExt; + #[cfg(all(target_os = "wasi", target_env = "p1"))] + use std::os::wasi::ffi::OsStrExt; let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -1545,8 +1567,9 @@ fn test_symlink_to_readonly_no_prompt() { } /// Test that --preserve-root properly detects symlinks pointing to root. -#[cfg(unix)] #[test] +#[cfg(unix)] +#[cfg_attr(wasi_runner, ignore)] fn test_preserve_root_symlink_to_root() { let (at, mut ucmd) = at_and_ucmd!(); @@ -1583,8 +1606,9 @@ fn test_preserve_root_symlink_to_root_escapes_name() { } /// Test that --preserve-root properly detects nested symlinks pointing to root. -#[cfg(unix)] #[test] +#[cfg(unix)] +#[cfg_attr(wasi_runner, ignore)] fn test_preserve_root_nested_symlink_to_root() { let (at, mut ucmd) = at_and_ucmd!(); diff --git a/tests/by-util/test_rmdir.rs b/tests/by-util/test_rmdir.rs index 81db6e59b15..746094a2168 100644 --- a/tests/by-util/test_rmdir.rs +++ b/tests/by-util/test_rmdir.rs @@ -270,7 +270,6 @@ fn test_rmdir_remove_symlink_file() { } // This behavior is known to happen on Linux but not all Unixes -#[cfg(any(target_os = "linux", target_os = "android"))] #[test] #[cfg_attr(wasi_runner, ignore = "WASI: read_link() not supported for symlinks")] fn test_rmdir_remove_symlink_dir() { @@ -284,7 +283,6 @@ fn test_rmdir_remove_symlink_dir() { .stderr_is("rmdir: failed to remove 'dl/': Symbolic link not followed\n"); } -#[cfg(any(target_os = "linux", target_os = "android"))] #[test] #[cfg_attr(wasi_runner, ignore = "WASI: read_link() not supported for symlinks")] fn test_rmdir_remove_symlink_dangling() { @@ -297,7 +295,6 @@ fn test_rmdir_remove_symlink_dangling() { .stderr_is("rmdir: failed to remove 'dl/': Symbolic link not followed\n"); } -#[cfg(any(target_os = "linux", target_os = "android"))] #[test] #[cfg_attr(wasi_runner, ignore = "WASI: read_link() not supported for symlinks")] fn test_rmdir_remove_symlink_dir_with_trailing_slashes() { diff --git a/tests/by-util/test_seq.rs b/tests/by-util/test_seq.rs index f5c99f6f85e..d142d24027a 100644 --- a/tests/by-util/test_seq.rs +++ b/tests/by-util/test_seq.rs @@ -234,6 +234,10 @@ fn test_width_invalid_float() { #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "invokes the binary directly from a shell script, bypassing the wasmtime runner" +)] fn test_sigpipe_ignored_reports_write_error() { let scene = TestScenario::new(util_name!()); let seq_bin = scene.bin_path.clone().into_os_string(); @@ -298,6 +302,7 @@ fn test_separator_and_terminator() { #[test] #[cfg(unix)] +#[cfg_attr(wasi_runner, ignore = "WASI: argv/filenames must be valid UTF-8")] fn test_separator_non_utf8() { use std::{ffi::OsString, os::unix::ffi::OsStringExt}; @@ -321,6 +326,7 @@ fn test_separator_non_utf8() { #[test] #[cfg(unix)] +#[cfg_attr(wasi_runner, ignore = "WASI: argv/filenames must be valid UTF-8")] fn test_terminator_non_utf8() { use std::{ffi::OsString, os::unix::ffi::OsStringExt}; @@ -734,9 +740,10 @@ fn test_infinite_sequence(#[case] args: &[&str], #[case] expected_start: &[u8]) let result = new_ucmd!() .args(args) .run_stdout_starts_with(expected_start); - #[cfg(unix)] + // WASI and Windows have no signals, so only check that the process succeeded. + #[cfg(all(unix, not(wasi_runner)))] result.signal_name_is("PIPE"); - #[cfg(not(unix))] + #[cfg(any(not(unix), wasi_runner))] result.success(); } diff --git a/tests/by-util/test_split.rs b/tests/by-util/test_split.rs index 9840c786e84..1678215178f 100644 --- a/tests/by-util/test_split.rs +++ b/tests/by-util/test_split.rs @@ -133,6 +133,7 @@ fn test_invalid_arg() { #[test] #[cfg(all(unix, not(target_os = "android")))] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] fn test_split_to_non_seekable() { let (at, mut ucmd) = at_and_ucmd!(); at.symlink_file("/dev/stdout", "xaa"); @@ -310,6 +311,7 @@ fn test_split_additional_suffix_hyphen_value() { #[test] #[cfg(unix)] +#[cfg_attr(wasi_runner, ignore = "WASI: --filter has no process-spawning support")] fn test_filter() { // like `test_split_default()` but run a command before writing let (at, mut ucmd) = at_and_ucmd!(); @@ -353,6 +355,7 @@ fn test_filter_with_env_var_set() { #[test] #[cfg(unix)] +#[cfg_attr(wasi_runner, ignore = "WASI: --filter has no process-spawning support")] fn test_filter_command_fails() { let (at, mut ucmd) = at_and_ucmd!(); let name = "filter-will-fail"; @@ -977,6 +980,10 @@ creating file 'xaf' } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: host paths (/dev/null) not visible" +)] fn test_number_n() { let (at, mut ucmd) = at_and_ucmd!(); ucmd.args(&["-n", "5", "asciilowercase.txt"]).succeeds(); @@ -993,6 +1000,10 @@ fn test_number_n() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: host paths (/dev/null) not visible" +)] fn test_number_kth_of_n() { new_ucmd!() .args(&["--number=3/5", "asciilowercase.txt"]) @@ -1038,6 +1049,10 @@ fn test_number_kth_of_n() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "wasm guest is always 32-bit; u64::MAX-sized args overflow differently there" +)] fn test_number_kth_of_n_round_robin() { new_ucmd!() .args(&["--number", "r/2/3", "fivelines.txt"]) @@ -1250,6 +1265,10 @@ fn test_elide_empty_files_n_chunks() { #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: host paths (/dev/null) not visible" +)] fn test_elide_dev_null_n_chunks() { let (at, mut ucmd) = at_and_ucmd!(); ucmd.args(&["-e", "-n", "3", "/dev/null"]) @@ -1262,6 +1281,10 @@ fn test_elide_dev_null_n_chunks() { #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: host paths (/dev/zero) not visible" +)] fn test_dev_zero() { let (at, mut ucmd) = at_and_ucmd!(); ucmd.args(&["-n", "3", "/dev/zero"]) @@ -1289,6 +1312,10 @@ fn test_elide_empty_files_l_chunks() { #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: host paths (/dev/null) not visible" +)] fn test_elide_dev_null_l_chunks() { let (at, mut ucmd) = at_and_ucmd!(); ucmd.args(&["-e", "-n", "l/3", "/dev/null"]) @@ -1317,6 +1344,10 @@ fn test_elide_empty_files_preserves_line_chunks() { #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: host paths (/dev/zero) not visible" +)] fn test_number_by_bytes_dev_zero() { let (at, mut ucmd) = at_and_ucmd!(); ucmd.args(&["-n", "3", "/dev/zero"]) @@ -1376,6 +1407,10 @@ fn test_number_by_lines_kth_huge_number_of_chunks() { #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: host paths (/dev/null) not visible" +)] fn test_number_by_lines_kth_dev_null() { new_ucmd!() .args(&["-n", "l/3/10", "/dev/null"]) @@ -1766,6 +1801,10 @@ fn test_round_robin() { #[test] // TODO(#7542): Re-enable on Android once we figure out why rlimit is broken. #[cfg(target_os = "linux")] +#[cfg_attr( + wasi_runner, + ignore = "WASI: wasmtime itself needs more file descriptors than the guest's rlimit allows" +)] fn test_round_robin_limited_file_descriptors() { new_ucmd!() .args(&["-n", "r/40", "onehundredlines.txt"]) @@ -1810,6 +1849,7 @@ fn test_split_invalid_input() { /// clap is expected to fail/panic #[test] #[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: argv/filenames must be valid UTF-8")] fn test_split_non_utf8_argument_unix() { use std::ffi::OsStr; use std::os::unix::ffi::OsStrExt; @@ -2105,6 +2145,7 @@ fn test_long_lines() { #[test] #[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: argv/filenames must be valid UTF-8")] fn test_split_non_utf8_paths() { let (at, mut ucmd) = at_and_ucmd!(); @@ -2119,6 +2160,7 @@ fn test_split_non_utf8_paths() { #[test] #[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: argv/filenames must be valid UTF-8")] fn test_split_non_utf8_prefix_is_byte_preserving() { use std::ffi::OsStr; use std::os::unix::ffi::{OsStrExt, OsStringExt}; @@ -2158,6 +2200,7 @@ fn test_split_non_utf8_prefix_is_byte_preserving() { #[test] #[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: argv/filenames must be valid UTF-8")] fn test_split_non_utf8_additional_suffix_is_byte_preserving() { use std::ffi::OsStr; use std::os::unix::ffi::{OsStrExt, OsStringExt}; @@ -2259,6 +2302,7 @@ fn test_split_directory_already_exists() { #[test] #[cfg(all(target_os = "linux", target_env = "gnu"))] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] fn test_io_error() { // /proc/self/mem causes EIO new_ucmd!() @@ -2271,6 +2315,10 @@ fn test_io_error() { /// Writing a chunk to a full device must be reported and must stop the split. #[test] #[cfg(target_os = "linux")] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: host paths (/dev/full) not visible" +)] fn test_write_error_on_full_device() { if !Path::new("/dev/full").exists() { return; diff --git a/tests/by-util/test_tail.rs b/tests/by-util/test_tail.rs index 5ecc8b812c0..f02d4de0512 100644 --- a/tests/by-util/test_tail.rs +++ b/tests/by-util/test_tail.rs @@ -359,6 +359,10 @@ fn test_follow_redirect_stdin_name_retry() { not(target_os = "openbsd"), not(windows) ))] // FIXME: for currently not working platforms +#[cfg_attr( + wasi_runner, + ignore = "WASI: no /dev/fd/0, so redirected-directory stdin hits the generic pipe path (like macOS) instead of the regular-file path with the GNU-matching error message" +)] fn test_stdin_redirect_dir() { // $ mkdir dir // $ tail < dir, $ tail - < dir @@ -396,6 +400,10 @@ fn test_stdin_redirect_dir() { // `test_stdin_redirect_dir` #[test] #[cfg(target_vendor = "apple")] +#[cfg_attr( + wasi_runner, + ignore = "test binary runs on macOS but the wasm guest under test does not, so the expected macOS-specific error text never appears" +)] fn test_stdin_redirect_dir_when_target_os_is_macos() { // $ mkdir dir // $ tail < dir, $ tail - < dir @@ -4273,6 +4281,10 @@ fn test_when_follow_retry_then_initial_print_of_file_is_written_to_stdout() { // TODO: Add test for the warning `--pid=PID is not supported on this system` #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI: follow mode (-f) is not supported on this platform" +)] fn test_args_when_settings_check_warnings_then_shows_warnings() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -5354,6 +5366,7 @@ fn test_child_when_run_with_stderr_to_stdout() { #[cfg(target_os = "linux")] #[test] +#[cfg_attr(wasip2_runner, ignore = "WASI P2: /dev/full filesystem not available")] fn test_failed_write_is_reported() { new_ucmd!() .pipe_in("hello") @@ -5388,6 +5401,7 @@ fn test_failed_warning_write_is_reported() { #[cfg(target_os = "linux")] #[test] +#[cfg_attr(wasip2_runner, ignore = "WASI P2: /dev/full filesystem not available")] fn test_failed_write_is_reported_on_seekable_input() { let ts = TestScenario::new("tail"); let at = &ts.fixtures; diff --git a/tests/by-util/test_touch.rs b/tests/by-util/test_touch.rs index 5c831e4a1e3..8968d44b9f5 100644 --- a/tests/by-util/test_touch.rs +++ b/tests/by-util/test_touch.rs @@ -1110,6 +1110,10 @@ fn test_touch_leap_second() { #[test] #[cfg(not(windows))] // File::create doesn't support trailing separator in Windows +#[cfg_attr( + wasip2_runner, + ignore = "WASI preview2: stat on a dangling-symlink-with-trailing-slash returns ENOTDIR instead of ENOENT" +)] fn test_touch_trailing_slash_no_create() { let (at, mut ucmd) = at_and_ucmd!(); at.touch("file"); @@ -1335,11 +1339,9 @@ fn test_touch_device_files() { ignore = "WASI sandbox: absolute symlink targets cannot be followed" )] fn test_touch_does_not_truncate_symlink_target() { - use std::os::unix::fs::symlink; - let (at, mut ucmd) = at_and_ucmd!(); at.write("victim", "do not truncate me"); - symlink(at.plus("victim"), at.plus("link")).unwrap(); + at.symlink_file("victim", "link"); ucmd.arg("link").succeeds(); @@ -1354,10 +1356,8 @@ fn test_touch_does_not_truncate_symlink_target() { ignore = "WASI sandbox: absolute symlink targets cannot be followed" )] fn test_touch_through_dangling_symlink_creates_target() { - use std::os::unix::fs::symlink; - let (at, mut ucmd) = at_and_ucmd!(); - symlink(at.plus("missing"), at.plus("link")).unwrap(); + at.symlink_file("missing", "link"); ucmd.arg("link").succeeds(); diff --git a/tests/by-util/test_tr.rs b/tests/by-util/test_tr.rs index 29ab931722c..dc91aa44a63 100644 --- a/tests/by-util/test_tr.rs +++ b/tests/by-util/test_tr.rs @@ -1305,8 +1305,9 @@ fn test_octal_escape_ambiguous_followed_by_non_utf8() { .stderr_contains("warning: invalid utf8 sequence"); } -#[cfg(target_os = "linux")] #[test] +#[cfg(target_os = "linux")] +#[cfg_attr(wasip2_runner, ignore = "WASI P2: /dev/full filesystem not available")] fn test_failed_write_is_reported() { new_ucmd!() .pipe_in("hello") diff --git a/tests/by-util/test_true.rs b/tests/by-util/test_true.rs index 4e11449baee..8e7eb45504b 100644 --- a/tests/by-util/test_true.rs +++ b/tests/by-util/test_true.rs @@ -55,6 +55,7 @@ fn test_conflict() { #[test] #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "netbsd"))] +#[cfg_attr(wasip2_runner, ignore = "WASI P2: /dev/full filesystem not available")] fn test_full() { for option in ["--version", "--help"] { let dev_full = OpenOptions::new().write(true).open("/dev/full").unwrap(); diff --git a/tests/by-util/test_tsort.rs b/tests/by-util/test_tsort.rs index fbd1c0b8644..e263c1f6d5c 100644 --- a/tests/by-util/test_tsort.rs +++ b/tests/by-util/test_tsort.rs @@ -12,6 +12,7 @@ use uutests::new_ucmd; #[test] #[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: argv/filenames must be valid UTF-8")] fn test_tsort_non_utf8_paths() { use std::os::unix::ffi::OsStringExt; let (at, mut ucmd) = at_and_ucmd!(); diff --git a/tests/by-util/test_tty.rs b/tests/by-util/test_tty.rs index 5103a60efac..3a3981bf3c1 100644 --- a/tests/by-util/test_tty.rs +++ b/tests/by-util/test_tty.rs @@ -72,6 +72,10 @@ fn test_help() { #[test] // FixME: freebsd panic #[cfg(all(unix, not(target_os = "freebsd")))] +#[cfg_attr( + wasi_runner, + ignore = "runs the coreutils binary directly via a raw shell command, bypassing the WASI runner wrapping" +)] fn test_stdout_fail() { use std::process::{Command, Stdio}; use uutests::at_and_ts; diff --git a/tests/by-util/test_uname.rs b/tests/by-util/test_uname.rs index c5236334a0f..dc93df20dc1 100644 --- a/tests/by-util/test_uname.rs +++ b/tests/by-util/test_uname.rs @@ -106,11 +106,17 @@ fn test_uname_operating_system() { .arg("--operating-system") .succeeds() .stdout_is("Android\n"); - #[cfg(target_vendor = "apple")] + // Under the WASI runner, the binary under test reports "WASI", not the host OS. + #[cfg(all(target_vendor = "apple", not(wasi_runner)))] new_ucmd!() .arg("--operating-system") .succeeds() .stdout_is("Darwin\n"); + #[cfg(all(target_vendor = "apple", wasi_runner))] + new_ucmd!() + .arg("--operating-system") + .succeeds() + .stdout_is("WASI\n"); #[cfg(target_os = "freebsd")] new_ucmd!() .arg("--operating-system") diff --git a/tests/by-util/test_uniq.rs b/tests/by-util/test_uniq.rs index 98d72cda27d..84efb086c64 100644 --- a/tests/by-util/test_uniq.rs +++ b/tests/by-util/test_uniq.rs @@ -1214,6 +1214,7 @@ fn test_c_locale_counts_bytes() { #[cfg(target_os = "linux")] #[test] +#[cfg_attr(wasip2_runner, ignore = "WASI P2: /dev/full filesystem not available")] fn test_failed_write_is_reported() { new_ucmd!() .pipe_in("hello") diff --git a/tests/by-util/test_wc.rs b/tests/by-util/test_wc.rs index cdca007b7de..ec35b49eda9 100644 --- a/tests/by-util/test_wc.rs +++ b/tests/by-util/test_wc.rs @@ -261,17 +261,14 @@ fn test_read_error_order_with_stderr_to_stdout() { let (at, mut ucmd) = at_and_ucmd!(); at.mkdir("ioerrdir"); - let cmd = ucmd.arg("ioerrdir").stderr_to_stdout().fails(); - if std::env::var("UUTESTS_WASM_RUNNER").is_ok() { - // wasi-libc may report a different error string than the host libc - cmd.stdout_contains("wc: ioerrdir:"); - } else { - let expected = format!( - "{:>7} {:>7} {:>7} ioerrdir\nwc: ioerrdir: Is a directory\n", - 0, 0, 0 - ); - cmd.stdout_only(expected); - } + let expected = format!( + "{:>7} {:>7} {:>7} ioerrdir\nwc: ioerrdir: Is a directory\n", + 0, 0, 0 + ); + ucmd.arg("ioerrdir") + .stderr_to_stdout() + .fails() + .stdout_only(expected); } /// Test that getting counts from nonexistent file is an error. @@ -614,23 +611,17 @@ fn files0_from_dir() { const DOT_ERR: &str = dir_err!("."); // On Unix, `read(open("."))` fails. On Windows, `open(".")` fails. Thus, the errors happen in - // different contexts. On WASI, the error string may differ (e.g., "Bad file descriptor"). - let wasm = std::env::var("UUTESTS_WASM_RUNNER").is_ok(); - - let cmd = new_ucmd!().args(&["--files0-from=dir with spaces"]).fails(); - if wasm { - cmd.stderr_contains("wc: 'dir with spaces': read error:"); - } else { - cmd.stderr_only(dir_err!("'dir with spaces'")); - } + // different contexts. + new_ucmd!() + .args(&["--files0-from=dir with spaces"]) + .fails() + .stderr_only(dir_err!("'dir with spaces'")); // Those contexts have different rules about quoting in errors... - let cmd = new_ucmd!().args(&["--files0-from=."]).fails(); - if wasm { - cmd.stderr_contains("wc: .: read error:"); - } else { - cmd.stderr_only(DOT_ERR); - } + new_ucmd!() + .args(&["--files0-from=."]) + .fails() + .stderr_only(DOT_ERR); // That also means you cannot `< . wc --files0-from=-` on Windows. #[cfg(not(windows))] diff --git a/tests/uutests/Cargo.toml b/tests/uutests/Cargo.toml index b027db5cbaf..31e7d7f8816 100644 --- a/tests/uutests/Cargo.toml +++ b/tests/uutests/Cargo.toml @@ -31,6 +31,7 @@ uucore = { workspace = true, features = [ "process", "signals", "utmpx", + "fs", ] } [target.'cfg(all(unix, not(target_os = "fuchsia")))'.dependencies] From 1766b1204d5d8b60db03365e8ca5b56f8a07f6b6 Mon Sep 17 00:00:00 2001 From: Eduardo Rodrigues <16357187+eduardomourar@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:23:01 +0000 Subject: [PATCH 4/5] chore: use rust nightly Use nightly for wasm32-wasip2. Only nightly keeps the exit code in `std::process::exit`. --- .github/workflows/wasi.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/wasi.yml b/.github/workflows/wasi.yml index b14df9b8d27..8d3eb0f6783 100644 --- a/.github/workflows/wasi.yml +++ b/.github/workflows/wasi.yml @@ -24,13 +24,14 @@ jobs: fail-fast: false matrix: job: - - { target: wasm32-wasip1, rust-flags: "--cfg wasi_runner" } - - { target: wasm32-wasip2, rust-flags: "--cfg wasi_runner --cfg wasip2_runner" } + - { target: wasm32-wasip1, rust-flags: "--cfg wasi_runner", toolchain: stable } + # wasm32-wasip2 needs nightly, where `std::process::exit` keeps the exit code. + - { target: wasm32-wasip2, rust-flags: "--cfg wasi_runner --cfg wasip2_runner", toolchain: nightly } steps: - uses: actions/checkout@v7.0.1 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@${{ matrix.job.toolchain }} with: targets: ${{ matrix.job.target }} - uses: Swatinem/rust-cache@v2 From aa5fe0c4e84506aa51074af08561fd7d33bc44ea Mon Sep 17 00:00:00 2001 From: Eduardo Rodrigues <16357187+eduardomourar@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:35:42 +0000 Subject: [PATCH 5/5] fix(wasi): remove errno mappings that wasmtime 50 makes unnecessary Remove the directory error normalization from cat, dd and rm. Remove `wasi_is_broken_pipe`, because seq now gets `BrokenPipe` on preview 2. tee, tr and wc keep their mappings, because their tests still fail without them. Revert the WASI `OwnedFileDescriptorOrHandle::from` in uucore. dd now takes ownership of the stdout descriptor itself, because WASI cannot duplicate descriptors. Fix the `USimpleError` import for WASI in dd and two clippy warnings. --- src/uu/cat/src/cat.rs | 14 +++++--------- src/uu/dd/src/dd.rs | 26 +++++++++++++++++++------- src/uu/rm/src/rm.rs | 6 +++--- src/uu/seq/src/seq.rs | 2 +- src/uucore/src/lib/mods/error.rs | 18 ++---------------- src/uucore/src/lib/mods/io.rs | 17 +---------------- 6 files changed, 31 insertions(+), 52 deletions(-) diff --git a/src/uu/cat/src/cat.rs b/src/uu/cat/src/cat.rs index 5dece231868..8d330f8e009 100644 --- a/src/uu/cat/src/cat.rs +++ b/src/uu/cat/src/cat.rs @@ -393,14 +393,11 @@ fn cat_path(path: &OsString, options: &OutputOptions, state: &mut OutputState) - #[cfg(unix)] InputType::Socket => Err(CatError::NoSuchDeviceOrAddress), _ => { - let file = File::open(path).map_err(|e| { - let e = uucore::error::wasi_normalize_open_error(e); - match e.kind() { - ErrorKind::IsADirectory => CatError::IsDirectory, - #[cfg(target_os = "wasi")] - _ if e.raw_os_error() == Some(32) => CatError::TooManySymlinks, - _ => CatError::from(e), - } + let file = File::open(path).map_err(|e| match e.kind() { + ErrorKind::IsADirectory => CatError::IsDirectory, + #[cfg(target_os = "wasi")] + _ if e.raw_os_error() == Some(32) => CatError::TooManySymlinks, + _ => CatError::from(e), })?; if !is_safe_overwrite(&file, &io::stdout()) { return Err(CatError::OutputIsInput); @@ -464,7 +461,6 @@ fn get_input_type(path: &OsString) -> CatResult { let ft = match metadata(path) { Ok(md) => md.file_type(), Err(e) => { - let e = uucore::error::wasi_normalize_open_error(e); if e.kind() == ErrorKind::IsADirectory { return Err(CatError::IsDirectory); } diff --git a/src/uu/dd/src/dd.rs b/src/uu/dd/src/dd.rs index 5bb6644f702..9e9d651dd44 100644 --- a/src/uu/dd/src/dd.rs +++ b/src/uu/dd/src/dd.rs @@ -52,7 +52,7 @@ use std::time::{Duration, Instant}; use clap::{Arg, Command}; use num_integer::Integer; use uucore::display::Quotable; -#[cfg(unix)] +#[cfg(any(unix, target_os = "wasi"))] use uucore::error::USimpleError; use uucore::error::{FromIo, UResult, set_exit_code}; use uucore::show_if_err; @@ -392,10 +392,7 @@ impl Read for Source { match self { #[cfg(not(any(unix, target_os = "wasi")))] Self::Stdin(stdin) => stdin.read(buf), - // WASI defers the directory check from open to the first read. - Self::File(f) => f - .read(buf) - .map_err(uucore::error::wasi_normalize_open_error), + Self::File(f) => f.read(buf), #[cfg(any(unix, target_os = "wasi"))] Self::StdinFile(f) => f.read(buf), #[cfg(unix)] @@ -894,10 +891,25 @@ struct Output<'a> { settings: &'a Settings, } +/// Returns an owned handle to stdout. +/// +/// WASI cannot duplicate descriptors, so the WASI handle takes ownership of the stdout descriptor. +#[cfg_attr(target_os = "wasi", allow(clippy::unnecessary_wraps))] +fn stdout_handle() -> io::Result { + #[cfg(target_os = "wasi")] + { + // SAFETY: `io::Stdout` does not own descriptor 1, so only this handle closes it. + let fd = unsafe { std::os::fd::OwnedFd::from_raw_fd(io::stdout().as_raw_fd()) }; + Ok(OwnedFileDescriptorOrHandle::new(fd)) + } + #[cfg(not(target_os = "wasi"))] + OwnedFileDescriptorOrHandle::from(io::stdout()) +} + impl<'a> Output<'a> { /// Instantiate this struct with stdout as a destination. fn new_stdout(settings: &'a Settings) -> UResult { - let fx = OwnedFileDescriptorOrHandle::from(io::stdout())?; + let fx = stdout_handle()?; let mut dst = Dest::Stdout(fx.into_file()); dst.seek(settings.seek, settings.obs) .map_err_context(|| translate!("dd-error-write-error"))?; @@ -970,7 +982,7 @@ impl<'a> Output<'a> { /// already opened by the system (stdout) and has a state /// (current position) that shall be used. fn new_file_from_stdout(settings: &'a Settings) -> UResult { - let fx = OwnedFileDescriptorOrHandle::from(io::stdout())?; + let fx = stdout_handle()?; #[cfg(any(target_os = "linux", target_os = "android"))] if let Some(libc_flags) = make_linux_oflags(&settings.oflags) { rustix::fs::fcntl_setfl( diff --git a/src/uu/rm/src/rm.rs b/src/uu/rm/src/rm.rs index ae63fecf33c..d7617d76fd9 100644 --- a/src/uu/rm/src/rm.rs +++ b/src/uu/rm/src/rm.rs @@ -105,7 +105,6 @@ fn report_verbose_write_error(result: UResult<()>) { /// Helper function to show error with context and return error status fn show_removal_error(error: io::Error, path: &Path) -> bool { - let error = uucore::error::wasi_normalize_open_error(error); if error.kind() == io::ErrorKind::IsADirectory { show_error!( "{}", @@ -1094,8 +1093,9 @@ fn handle_writable_directory(path: &Path, options: &Options, _metadata: &Metadat 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, _, InteractiveMode::PromptProtected) | (false, false, InteractiveMode::Never) => { + true + } (_, false, _) => prompt_yes!( "attempt removal of inaccessible directory {}?", path.quote() diff --git a/src/uu/seq/src/seq.rs b/src/uu/seq/src/seq.rs index 8e0eed14e7a..b52f1504a4f 100644 --- a/src/uu/seq/src/seq.rs +++ b/src/uu/seq/src/seq.rs @@ -226,7 +226,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { match result { Ok(()) => Ok(()), - Err(err) if uucore::error::wasi_is_broken_pipe(&err) => { + Err(err) if err.kind() == std::io::ErrorKind::BrokenPipe => { // GNU seq prints the Broken pipe message but still exits with status 0 // unless SIGPIPE was explicitly ignored, in which case it should fail. let err = err.map_err_context(|| "write error".into()); diff --git a/src/uucore/src/lib/mods/error.rs b/src/uucore/src/lib/mods/error.rs index 15e0549f18d..152bd3c3dd5 100644 --- a/src/uucore/src/lib/mods/error.rs +++ b/src/uucore/src/lib/mods/error.rs @@ -527,30 +527,16 @@ pub fn wasi_normalize_open_error(err: std::io::Error) -> std::io::Error { /// This function does nothing on other targets. pub fn wasi_normalize_read_error(err: std::io::Error) -> std::io::Error { #[cfg(all(target_os = "wasi", target_env = "p1"))] - if matches!(err.raw_os_error(), Some(31) | Some(8)) { + if matches!(err.raw_os_error(), Some(31 | 8)) { return std::io::Error::new(std::io::ErrorKind::IsADirectory, "Is a directory"); } #[cfg(all(target_os = "wasi", target_env = "p2"))] - if matches!(err.raw_os_error(), Some(29) | Some(8)) { + if matches!(err.raw_os_error(), Some(29 | 8)) { return std::io::Error::new(std::io::ErrorKind::IsADirectory, "Is a directory"); } err } -/// Returns true if `err` is a broken pipe on the current platform. -/// -/// WASI preview 2 reports a broken pipe as raw `EIO`. -pub fn wasi_is_broken_pipe(err: &std::io::Error) -> bool { - if err.kind() == std::io::ErrorKind::BrokenPipe { - return true; - } - #[cfg(all(target_os = "wasi", target_env = "p2"))] - if err.raw_os_error() == Some(29) { - return true; - } - false -} - /// Enables the conversion from [`std::io::Error`] to [`UError`] and from [`std::io::Result`] to /// [`UResult`]. pub trait FromIo { diff --git a/src/uucore/src/lib/mods/io.rs b/src/uucore/src/lib/mods/io.rs index 6660a5bc2a3..b3b994d0426 100644 --- a/src/uucore/src/lib/mods/io.rs +++ b/src/uucore/src/lib/mods/io.rs @@ -82,28 +82,13 @@ impl OwnedFileDescriptorOrHandle { /// conversion from borrowed native type /// /// e.g. `std::io::stdout()`, `std::fs::File`, ... - #[cfg(not(any(windows, target_os = "wasi")))] + #[cfg(not(windows))] pub fn from(t: T) -> io::Result { Ok(Self { fx: t.as_fd().try_clone_to_owned()?, }) } - /// conversion from borrowed native type - /// - /// e.g. `std::io::stdout()`, `std::fs::File`, ... - /// - /// WASI does not support duplicating descriptors, so this takes the raw descriptor. - /// The input is forgotten to prevent a double close. - #[cfg(target_os = "wasi")] - #[allow(clippy::unnecessary_wraps)] - pub fn from(t: T) -> io::Result { - use std::os::fd::{AsRawFd, FromRawFd}; - let fx = unsafe { OwnedFd::from_raw_fd(t.as_fd().as_raw_fd()) }; - std::mem::forget(t); - Ok(Self { fx }) - } - /// instantiates a corresponding `File` pub fn into_file(self) -> File { File::from(self.fx)