Skip to content

Commit a6187b5

Browse files
committed
Add integration tests for cat/sort/tail/touch + underlying fixes
1 parent aa91c0d commit a6187b5

14 files changed

Lines changed: 276 additions & 90 deletions

File tree

.github/workflows/wasi.yml

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,18 +50,17 @@ jobs:
5050
# Tests incompatible with WASI are annotated with
5151
# #[cfg_attr(wasi_runner, ignore)] in the test source files.
5252
# TODO: add integration tests for these tools as WASI support is extended:
53-
# arch b2sum cat cksum cp csplit date dir dircolors fmt join ln
53+
# arch b2sum cksum cp csplit date dir dircolors fmt join ln
5454
# ls md5sum mkdir mv nproc pathchk pr printenv ptx pwd readlink
5555
# realpath rm rmdir seq sha1sum sha224sum sha256sum sha384sum
56-
# sha512sum shred sleep sort split tail touch tsort uname uniq
57-
# vdir yes
56+
# sha512sum shred sleep split tsort uname uniq vdir yes
5857
UUTESTS_BINARY_PATH="$(pwd)/target/wasm32-wasip1/debug/coreutils.wasm" \
5958
UUTESTS_WASM_RUNNER=wasmtime \
6059
cargo test --test tests -- \
6160
test_base32:: test_base64:: test_basenc:: test_basename:: \
62-
test_comm:: test_cut:: test_dirname:: test_echo:: \
61+
test_cat:: test_comm:: test_cut:: test_dirname:: test_echo:: \
6362
test_expand:: test_factor:: test_false:: test_fold:: \
6463
test_head:: test_link:: test_nl:: test_numfmt:: \
65-
test_od:: test_paste:: test_printf:: test_shuf:: test_sum:: \
66-
test_tee:: test_tr:: test_true:: test_truncate:: \
67-
test_unexpand:: test_unlink:: test_wc::
64+
test_od:: test_paste:: test_printf:: test_shuf:: test_sort:: \
65+
test_sum:: test_tail:: test_tee:: test_touch:: test_tr:: \
66+
test_true:: test_truncate:: test_unexpand:: test_unlink:: test_wc::

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/src/wasi-test-gaps.md

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ To find all annotated tests: `grep -rn 'wasi_runner, ignore' tests/`
66

77
## Tools not yet covered by integration tests
88

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

1111
## WASI sandbox: host paths not visible
1212

@@ -31,3 +31,51 @@ WASI does not support spawning child processes. Tests that shell out to other co
3131
## WASI: stdin file position not preserved through wasmtime
3232

3333
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.
34+
35+
## WASI: read_link fails under wasmtime via spawned test harness
36+
37+
When the WASI binary is spawned via `std::process::Command` from the cargo-test harness, `fs::read_link` (and operations that follow symlinks, such as opening a symlink to a FIFO or traversing a symlink loop) can return `EPERM` on absolute paths — paths that work when wasmtime is invoked directly. Individual symptom tests skipped under this umbrella are annotated with narrower reasons describing the observed errno mismatch.
38+
39+
## WASI: no Unix domain socket support
40+
41+
WASI does not support Unix domain sockets. Tests that create or read from `AF_UNIX` sockets are skipped.
42+
43+
## WASI: no locale data
44+
45+
The WASI sandbox does not ship locale data, so `setlocale`/`LC_ALL` have no effect and sorting falls back to byte comparison. Tests that depend on locale-aware collation or month-name translation are skipped.
46+
47+
## WASI: tail follow mode disabled
48+
49+
`tail -f` / `tail -F` (follow mode) requires change-notification mechanisms (`inotify`, `kqueue`) and signal handling that WASI does not provide, so follow is disabled on WASI and a warning is emitted. Tests that exercise follow behaviour are skipped.
50+
51+
## WASI: cannot detect unsafe overwrite
52+
53+
`is_unsafe_overwrite` (used by `cat` to detect input-is-output situations) is stubbed to return `false` on WASI because the required `stat` / device-and-inode comparison is not available. Tests that assert this error path are skipped.
54+
55+
## WASI: pre-epoch timestamps not representable
56+
57+
WASI Preview 1 `Timestamp` is a `u64` nanosecond count since the Unix epoch, so `path_filestat_set_times` (and therefore `touch -t` with a two-digit year ≥ 69) cannot express dates before 1970. Tests that set pre-epoch timestamps are skipped.
58+
59+
## WASI: no timezone database
60+
61+
wasi-libc does not ship tzdata, so `TZ` is not honoured and timezone-dependent validation (e.g. `touch -t` rejecting a nonexistent local time during a DST transition) does not happen. Tests that rely on this are skipped.
62+
63+
## WASI: guest root is a writable preopen
64+
65+
The test harness maps the per-test working directory as the guest's `/`. That makes `/` writable inside the guest, so GNU-style protections against operating on the system root (e.g. `touch /` failing) cannot be exercised. Tests that assert these protections are skipped.
66+
67+
## WASI: `touch -` (stdout) unsupported
68+
69+
On WASI, `touch -` returns `UnsupportedPlatformFeature` because the guest cannot reliably locate the host file backing stdout. Tests that exercise `touch -` are skipped.
70+
71+
## WASI: errno/error-message mismatches
72+
73+
Several error paths surface different errno values (and therefore different error messages) through wasmtime than on POSIX. Observed cases:
74+
75+
- Opening a directory as a file returns `EBADF` rather than `EISDIR`.
76+
- Redirecting a directory into stdin returns `ENOENT` rather than `EISDIR`.
77+
- Filesystem permission errors surface as `ENOENT` rather than `EACCES`.
78+
- Symlink-loop traversal does not reliably surface `ELOOP` ("Too many levels of symbolic links").
79+
- Opening a symlink-to-directory does not reliably surface `EISDIR`.
80+
81+
Tests that assert specific error text for these paths are skipped.

src/uu/cat/src/platform/mod.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ pub use self::unix::is_unsafe_overwrite;
99
#[cfg(windows)]
1010
pub use self::windows::is_unsafe_overwrite;
1111

12-
// WASI: no fstat-based device/inode checks available; assume safe.
12+
// WASI: when stdout is inherited from a host file descriptor, wasmtime
13+
// reports its fstat as all-zero (st_dev == st_ino == 0), so the dev/inode
14+
// comparison against any input file descriptor can never match. There is
15+
// no reliable way to detect unsafe overwrite here; assume safe rather than
16+
// risk a spurious error.
1317
#[cfg(target_os = "wasi")]
1418
pub fn is_unsafe_overwrite<I, O>(_input: &I, _output: &O) -> bool {
1519
false

src/uu/cp/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ fluent = { workspace = true }
4343
exacl = { workspace = true, optional = true }
4444
nix = { workspace = true, features = ["fs"] }
4545

46+
[target.'cfg(target_os = "wasi")'.dependencies]
47+
rustix = { workspace = true, features = ["fs"] }
48+
4649
[[bin]]
4750
name = "cp"
4851
path = "src/main.rs"

src/uu/cp/src/cp.rs

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -897,12 +897,7 @@ impl Attributes {
897897
#[cfg(unix)]
898898
ownership: Preserve::Yes { required: true },
899899
mode: Preserve::Yes { required: true },
900-
// WASI: filetime panics in from_last_{access,modification}_time,
901-
// so timestamps cannot be preserved. Mark as optional so -a works.
902-
#[cfg(not(target_os = "wasi"))]
903900
timestamps: Preserve::Yes { required: true },
904-
#[cfg(target_os = "wasi")]
905-
timestamps: Preserve::Yes { required: false },
906901
context: {
907902
#[cfg(feature = "feat_selinux")]
908903
{
@@ -1843,11 +1838,37 @@ pub(crate) fn copy_attributes(
18431838
})?;
18441839

18451840
handle_preserve(attributes.timestamps, || -> CopyResult<()> {
1846-
// filetime's WASI backend panics in from_last_{access,modification}_time,
1847-
// so return ENOTSUP. handle_preserve silently suppresses ENOTSUP for
1848-
// optional preservation (-a) and reports it for required (--preserve=timestamps).
18491841
#[cfg(target_os = "wasi")]
1850-
return Err(io::Error::from_raw_os_error(libc::EOPNOTSUPP).into());
1842+
{
1843+
// `filetime`'s WASI backend panics in
1844+
// `from_last_{access,modification}_time`. Reach `utimensat` directly
1845+
// through `rustix`, converting `SystemTime` → `Timespec` via
1846+
// `UNIX_EPOCH` (which matches the `path_filestat_set_times` contract).
1847+
use std::time::UNIX_EPOCH;
1848+
let to_timespec = |t: std::time::SystemTime| -> io::Result<rustix::fs::Timespec> {
1849+
// Pre-epoch source times can't be represented by WASI's
1850+
// `path_filestat_set_times` (unsigned nanosecond count).
1851+
let d = t
1852+
.duration_since(UNIX_EPOCH)
1853+
.map_err(|e| io::Error::new(io::ErrorKind::Unsupported, e))?;
1854+
Ok(rustix::fs::Timespec {
1855+
tv_sec: d.as_secs() as _,
1856+
tv_nsec: d.subsec_nanos() as _,
1857+
})
1858+
};
1859+
let timestamps = rustix::fs::Timestamps {
1860+
last_access: to_timespec(source_metadata.accessed()?)?,
1861+
last_modification: to_timespec(source_metadata.modified()?)?,
1862+
};
1863+
let flags = if dest.is_symlink() {
1864+
rustix::fs::AtFlags::SYMLINK_NOFOLLOW
1865+
} else {
1866+
rustix::fs::AtFlags::empty()
1867+
};
1868+
rustix::fs::utimensat(rustix::fs::CWD, dest, &timestamps, flags)
1869+
.map_err(io::Error::from)?;
1870+
Ok(())
1871+
}
18511872

18521873
#[cfg(not(target_os = "wasi"))]
18531874
{
@@ -1937,20 +1958,14 @@ fn symlink_file(
19371958
}
19381959
#[cfg(target_os = "wasi")]
19391960
{
1940-
use std::ffi::CString;
1941-
use std::os::wasi::ffi::OsStrExt;
1942-
let src_c = CString::new(source.as_os_str().as_bytes())
1943-
.map_err(|e| CpError::Error(e.to_string()))?;
1944-
let dst_c =
1945-
CString::new(dest.as_os_str().as_bytes()).map_err(|e| CpError::Error(e.to_string()))?;
1946-
if unsafe { libc::symlink(src_c.as_ptr(), dst_c.as_ptr()) } != 0 {
1947-
return Err(CpError::IoErrContext(
1948-
io::Error::last_os_error(),
1961+
rustix::fs::symlink(source, dest).map_err(|e| {
1962+
CpError::IoErrContext(
1963+
io::Error::from(e),
19491964
translate!("cp-error-cannot-create-symlink",
19501965
"dest" => get_filename(dest).unwrap_or("?").quote(),
19511966
"source" => get_filename(source).unwrap_or("?").quote()),
1952-
));
1953-
}
1967+
)
1968+
})?;
19541969
}
19551970
if let Ok(file_info) = FileInformation::from_path(dest, false) {
19561971
symlinked_files.insert(file_info);

src/uu/sort/src/sort.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2154,11 +2154,14 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
21542154
.map(PathBuf::from)
21552155
.or_else(|| env::var_os("TMPDIR").map(PathBuf::from))
21562156
.unwrap_or_else(|| {
2157-
// env::temp_dir() panics on WASI; default to /tmp
21582157
#[cfg(target_os = "wasi")]
2159-
return PathBuf::from("/tmp");
2158+
{
2159+
uucore::fs::wasi_default_tmp_dir()
2160+
}
21602161
#[cfg(not(target_os = "wasi"))]
2161-
env::temp_dir()
2162+
{
2163+
env::temp_dir()
2164+
}
21622165
}),
21632166
);
21642167

src/uu/touch/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ fluent = { workspace = true }
3030

3131
[target.'cfg(unix)'.dependencies]
3232
libc = { workspace = true }
33+
34+
[target.'cfg(any(unix, target_os = "wasi"))'.dependencies]
3335
rustix = { workspace = true, features = ["fs"] }
3436

3537
[dev-dependencies]

src/uu/touch/src/touch.rs

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ pub mod error;
1010

1111
use clap::builder::{PossibleValue, ValueParser};
1212
use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command};
13-
use filetime::{FileTime, set_file_times, set_symlink_file_times};
13+
use filetime::FileTime;
14+
#[cfg(not(target_os = "wasi"))]
15+
use filetime::{set_file_times, set_symlink_file_times};
1416
use jiff::civil::Time;
1517
use jiff::fmt::strtime;
1618
use jiff::tz::TimeZone;
@@ -631,6 +633,47 @@ fn try_futimens_via_write_fd(path: &Path, atime: FileTime, mtime: FileTime) -> s
631633
futimens(&file, &timestamps).map_err(|e| Error::from_raw_os_error(e.raw_os_error()))
632634
}
633635

636+
/// WASI replacement for `filetime::set_file_times`.
637+
///
638+
/// The `filetime` crate has an unimplemented stub on `wasm32-wasi`. WASI
639+
/// supports setting both atime and mtime via `utimensat`, which we reach
640+
/// through `rustix`.
641+
#[cfg(target_os = "wasi")]
642+
fn set_file_times(path: &Path, atime: FileTime, mtime: FileTime) -> std::io::Result<()> {
643+
wasi_utimensat(path, atime, mtime, false)
644+
}
645+
646+
/// WASI replacement for `filetime::set_symlink_file_times`.
647+
#[cfg(target_os = "wasi")]
648+
fn set_symlink_file_times(path: &Path, atime: FileTime, mtime: FileTime) -> std::io::Result<()> {
649+
wasi_utimensat(path, atime, mtime, true)
650+
}
651+
652+
#[cfg(target_os = "wasi")]
653+
fn wasi_utimensat(
654+
path: &Path,
655+
atime: FileTime,
656+
mtime: FileTime,
657+
no_follow: bool,
658+
) -> std::io::Result<()> {
659+
let timestamps = rustix::fs::Timestamps {
660+
last_access: rustix::fs::Timespec {
661+
tv_sec: atime.unix_seconds(),
662+
tv_nsec: atime.nanoseconds() as _,
663+
},
664+
last_modification: rustix::fs::Timespec {
665+
tv_sec: mtime.unix_seconds(),
666+
tv_nsec: mtime.nanoseconds() as _,
667+
},
668+
};
669+
let flags = if no_follow {
670+
rustix::fs::AtFlags::SYMLINK_NOFOLLOW
671+
} else {
672+
rustix::fs::AtFlags::empty()
673+
};
674+
rustix::fs::utimensat(rustix::fs::CWD, path, &timestamps, flags).map_err(Error::from)
675+
}
676+
634677
/// Get metadata of the provided path
635678
/// If `follow` is `true`, the function will try to follow symlinks. Errors if the symlink is dangling, otherwise defaults to symlink metadata.
636679
/// If `follow` is `false`, the function will return metadata of the symlink itself
@@ -648,6 +691,19 @@ fn stat(path: &Path, follow: bool) -> std::io::Result<(FileTime, FileTime)> {
648691
fs::symlink_metadata(path)?
649692
};
650693

694+
// `FileTime::from_last_{access,modification}_time` is unimplemented on
695+
// `wasm32-wasi`, so go through `Metadata::{accessed, modified}` (which
696+
// return `SystemTime`) and convert via `FileTime::from_system_time`.
697+
#[cfg(target_os = "wasi")]
698+
{
699+
let atime = metadata.accessed()?;
700+
let mtime = metadata.modified()?;
701+
Ok((
702+
FileTime::from_system_time(atime),
703+
FileTime::from_system_time(mtime),
704+
))
705+
}
706+
#[cfg(not(target_os = "wasi"))]
651707
Ok((
652708
FileTime::from_last_access_time(&metadata),
653709
FileTime::from_last_modification_time(&metadata),

0 commit comments

Comments
 (0)