From 4a35f8bccb0a681734d0d82332fc315406f6fffc Mon Sep 17 00:00:00 2001 From: Anthony DePasquale Date: Mon, 30 Mar 2026 16:56:05 +0200 Subject: [PATCH 1/6] ln: add WASI support via symlink_path On wasm32-wasip1, std::os::unix::fs::symlink is not available, but WASI preview 1 provides path_symlink which Rust exposes as std::os::wasi::fs::symlink_path. Import it under the symlink alias so the existing call site works without changes. Follows the same pattern as cp.rs for enabling wasi_ext. --- src/uu/ln/src/ln.rs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/uu/ln/src/ln.rs b/src/uu/ln/src/ln.rs index 5fb75a86730..7556fb2b6e0 100644 --- a/src/uu/ln/src/ln.rs +++ b/src/uu/ln/src/ln.rs @@ -3,6 +3,8 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +#![cfg_attr(target_os = "wasi", feature(wasi_ext))] + // spell-checker:ignore (ToDO) srcpath targetpath EEXIST use clap::{Arg, ArgAction, Command}; @@ -21,6 +23,8 @@ use thiserror::Error; #[cfg(any(unix, target_os = "redox"))] use std::os::unix::fs::symlink; +#[cfg(target_os = "wasi")] +use std::os::wasi::fs::symlink_path as symlink; #[cfg(windows)] use std::os::windows::fs::{symlink_dir, symlink_file}; use std::path::{Path, PathBuf}; @@ -488,11 +492,3 @@ pub fn symlink, P2: AsRef>(src: P1, dst: P2) -> std::io::R symlink_file(src, dst) } } - -#[cfg(target_os = "wasi")] -fn symlink, P2: AsRef>(_src: P1, _dst: P2) -> std::io::Result<()> { - Err(std::io::Error::new( - std::io::ErrorKind::Unsupported, - "symlinks not supported on this platform", - )) -} From cd91996faa14ff1af28df9a72c29f257af00979a Mon Sep 17 00:00:00 2001 From: Anthony DePasquale Date: Wed, 8 Apr 2026 13:56:48 +0200 Subject: [PATCH 2/6] ln: use wasi-libc symlink instead of unstable symlink_path --- Cargo.lock | 1 + src/uu/ln/Cargo.toml | 1 + src/uu/ln/src/ln.rs | 24 ++++++++++++++++++++---- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d181f4d914e..f740d3534bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3728,6 +3728,7 @@ version = "0.8.0" dependencies = [ "clap", "fluent", + "libc", "thiserror 2.0.18", "uucore", ] diff --git a/src/uu/ln/Cargo.toml b/src/uu/ln/Cargo.toml index 6527ee89a2b..fbe17621fef 100644 --- a/src/uu/ln/Cargo.toml +++ b/src/uu/ln/Cargo.toml @@ -20,6 +20,7 @@ path = "src/ln.rs" [dependencies] clap = { workspace = true } +libc = { workspace = true } uucore = { workspace = true, features = ["backup-control", "fs"] } thiserror = { workspace = true } fluent = { workspace = true } diff --git a/src/uu/ln/src/ln.rs b/src/uu/ln/src/ln.rs index 7556fb2b6e0..35b9d2f67f9 100644 --- a/src/uu/ln/src/ln.rs +++ b/src/uu/ln/src/ln.rs @@ -3,8 +3,6 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -#![cfg_attr(target_os = "wasi", feature(wasi_ext))] - // spell-checker:ignore (ToDO) srcpath targetpath EEXIST use clap::{Arg, ArgAction, Command}; @@ -21,10 +19,12 @@ use std::ffi::OsString; use std::fs; use thiserror::Error; +#[cfg(target_os = "wasi")] +use std::ffi::CString; +#[cfg(target_os = "wasi")] +use std::io; #[cfg(any(unix, target_os = "redox"))] use std::os::unix::fs::symlink; -#[cfg(target_os = "wasi")] -use std::os::wasi::fs::symlink_path as symlink; #[cfg(windows)] use std::os::windows::fs::{symlink_dir, symlink_file}; use std::path::{Path, PathBuf}; @@ -492,3 +492,19 @@ pub fn symlink, P2: AsRef>(src: P1, dst: P2) -> std::io::R symlink_file(src, dst) } } + +#[cfg(target_os = "wasi")] +pub fn symlink, P2: AsRef>(src: P1, dst: P2) -> io::Result<()> { + use std::os::wasi::ffi::OsStrExt; + + let src_c = CString::new(src.as_ref().as_os_str().as_bytes()) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; + let dst_c = CString::new(dst.as_ref().as_os_str().as_bytes()) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; + + if unsafe { libc::symlink(src_c.as_ptr(), dst_c.as_ptr()) } == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} From 49bff40e5fd01255c8c8a25a416fbdc96f145349 Mon Sep 17 00:00:00 2001 From: Anthony DePasquale Date: Wed, 8 Apr 2026 16:36:13 +0200 Subject: [PATCH 3/6] ln: use rustix::fs::symlink instead of libc + CString on WASI --- Cargo.lock | 2 +- src/uu/ln/Cargo.toml | 2 +- src/uu/ln/src/ln.rs | 15 +-------------- 3 files changed, 3 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f740d3534bf..d1cc9546e76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3728,7 +3728,7 @@ version = "0.8.0" dependencies = [ "clap", "fluent", - "libc", + "rustix", "thiserror 2.0.18", "uucore", ] diff --git a/src/uu/ln/Cargo.toml b/src/uu/ln/Cargo.toml index fbe17621fef..6b4401e3a9d 100644 --- a/src/uu/ln/Cargo.toml +++ b/src/uu/ln/Cargo.toml @@ -20,7 +20,7 @@ path = "src/ln.rs" [dependencies] clap = { workspace = true } -libc = { workspace = true } +rustix = { workspace = true, features = ["fs"] } uucore = { workspace = true, features = ["backup-control", "fs"] } thiserror = { workspace = true } fluent = { workspace = true } diff --git a/src/uu/ln/src/ln.rs b/src/uu/ln/src/ln.rs index 35b9d2f67f9..e67bd6f42f3 100644 --- a/src/uu/ln/src/ln.rs +++ b/src/uu/ln/src/ln.rs @@ -19,8 +19,6 @@ use std::ffi::OsString; use std::fs; use thiserror::Error; -#[cfg(target_os = "wasi")] -use std::ffi::CString; #[cfg(target_os = "wasi")] use std::io; #[cfg(any(unix, target_os = "redox"))] @@ -495,16 +493,5 @@ pub fn symlink, P2: AsRef>(src: P1, dst: P2) -> std::io::R #[cfg(target_os = "wasi")] pub fn symlink, P2: AsRef>(src: P1, dst: P2) -> io::Result<()> { - use std::os::wasi::ffi::OsStrExt; - - let src_c = CString::new(src.as_ref().as_os_str().as_bytes()) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; - let dst_c = CString::new(dst.as_ref().as_os_str().as_bytes()) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; - - if unsafe { libc::symlink(src_c.as_ptr(), dst_c.as_ptr()) } == 0 { - Ok(()) - } else { - Err(io::Error::last_os_error()) - } + rustix::fs::symlink(src.as_ref(), dst.as_ref()).map_err(io::Error::from) } From ab44c397214ba7aff26d92aea8f90975901ea9d5 Mon Sep 17 00:00:00 2001 From: Anthony DePasquale Date: Sun, 12 Apr 2026 17:40:22 +0200 Subject: [PATCH 4/6] uucore: use symlink_metadata for backup lookup Avoids Path::exists() returning false for a live symlink whose target stat fails (observed under wasmtime), and also keeps a dangling .~N~ symlink from being overwritten by a --backup=numbered rename. --- src/uucore/src/lib/features/backup_control.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/uucore/src/lib/features/backup_control.rs b/src/uucore/src/lib/features/backup_control.rs index f56d131c7d6..34f73521061 100644 --- a/src/uucore/src/lib/features/backup_control.rs +++ b/src/uucore/src/lib/features/backup_control.rs @@ -91,6 +91,7 @@ use std::{ error::Error, ffi::{OsStr, OsString}, fmt::{Debug, Display}, + fs, path::{Path, PathBuf}, }; @@ -442,7 +443,11 @@ fn numbered_backup_path(path: &Path) -> PathBuf { let mut i: u64 = 1; loop { let new_path = simple_backup_path(path, OsString::from(format!(".~{i}~"))); - if !new_path.exists() { + // Use `symlink_metadata` rather than `exists()` so that a dangling + // symlink still counts as an existing backup (avoiding a silent + // overwrite), and so we do not report a live symlink as missing when + // the target cannot be stat'd. + if fs::symlink_metadata(&new_path).is_err() { return new_path; } i += 1; @@ -451,7 +456,7 @@ fn numbered_backup_path(path: &Path) -> PathBuf { fn existing_backup_path>(path: &Path, suffix: S) -> PathBuf { let test_path = simple_backup_path(path, OsString::from(".~1~")); - if test_path.exists() { + if fs::symlink_metadata(&test_path).is_ok() { return numbered_backup_path(path); } simple_backup_path(path, suffix.as_ref()) From 5fb92fb5d5178ed6233a8e83ea9af7abb9a75688 Mon Sep 17 00:00:00 2001 From: Anthony DePasquale Date: Sun, 12 Apr 2026 17:40:59 +0200 Subject: [PATCH 5/6] tests/ln: skip WASI-incompatible cases - test_symlink_to_dir_2args uses an absolute host tmpdir path that isn't visible inside the WASI sandbox. - test_ln_non_utf8_paths requires non-UTF-8 filenames, which WASI forbids. - test_relative_src_already_symlink hits a read_link-on-absolute-path failure specific to wasmtime launched through cargo test's spawn. --- tests/by-util/test_ln.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/by-util/test_ln.rs b/tests/by-util/test_ln.rs index 560a7364f2f..9d8bfdf995b 100644 --- a/tests/by-util/test_ln.rs +++ b/tests/by-util/test_ln.rs @@ -501,6 +501,7 @@ fn test_symlink_implicit_target_dir() { } #[test] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] fn test_symlink_to_dir_2args() { let (at, mut ucmd) = at_and_ucmd!(); let filename = "test_symlink_to_dir_2args_file"; @@ -754,6 +755,10 @@ fn test_relative_dst_already_symlink() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI: read_link on absolute paths fails under wasmtime via spawned test harness" +)] fn test_relative_src_already_symlink() { let (at, mut ucmd) = at_and_ucmd!(); at.touch("file1"); @@ -967,6 +972,7 @@ fn test_ln_seen_file() { #[test] #[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: argv/filenames must be valid UTF-8")] fn test_ln_non_utf8_paths() { use std::ffi::OsStr; use std::os::unix::ffi::OsStrExt; From 0bdca7503883c313a37c3e3a4e3cda40a4741207 Mon Sep 17 00:00:00 2001 From: Anthony DePasquale Date: Sun, 12 Apr 2026 17:41:16 +0200 Subject: [PATCH 6/6] ci(wasi): run ln integration tests Adds test_ln:: to the wasmtime integration test list and drops ln from the pending-tools TODO. Documents the read_link-on-absolute-paths gap in wasi-test-gaps.md. --- .github/workflows/wasi.yml | 4 ++-- docs/src/wasi-test-gaps.md | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/wasi.yml b/.github/workflows/wasi.yml index 8c3721d6ac5..00bb9f65988 100644 --- a/.github/workflows/wasi.yml +++ b/.github/workflows/wasi.yml @@ -50,7 +50,7 @@ jobs: # Tests incompatible with WASI are annotated with # #[cfg_attr(wasi_runner, ignore)] in the test source files. # TODO: add integration tests for these tools as WASI support is extended: - # arch b2sum cat cksum cp csplit date dir dircolors fmt join ln + # arch b2sum cat cksum cp csplit date dir dircolors fmt join # ls md5sum mkdir mv nproc pathchk pr printenv ptx pwd readlink # realpath rm rmdir seq sha1sum sha224sum sha256sum sha384sum # sha512sum shred sleep sort split tail touch tsort uname uniq @@ -61,7 +61,7 @@ jobs: test_base32:: test_base64:: test_basenc:: test_basename:: \ test_comm:: test_cut:: test_dirname:: test_echo:: \ test_expand:: test_factor:: test_false:: test_fold:: \ - test_head:: test_link:: test_nl:: test_numfmt:: \ + test_head:: test_link:: test_ln:: test_nl:: test_numfmt:: \ test_od:: test_paste:: test_printf:: test_shuf:: test_sum:: \ test_tee:: test_tr:: test_true:: test_truncate:: \ test_unexpand:: test_unlink:: test_wc:: diff --git a/docs/src/wasi-test-gaps.md b/docs/src/wasi-test-gaps.md index 18406243c9b..4789ef03dc9 100644 --- a/docs/src/wasi-test-gaps.md +++ b/docs/src/wasi-test-gaps.md @@ -6,7 +6,7 @@ To find all annotated tests: `grep -rn 'wasi_runner, ignore' tests/` ## Tools not yet covered by integration tests -arch, b2sum, cat, cksum, cp, csplit, date, dir, dircolors, fmt, join, 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 +arch, b2sum, cat, cksum, cp, csplit, date, dir, dircolors, fmt, join, ls, md5sum, mkdir, mv, nproc, pathchk, pr, printenv, ptx, pwd, readlink, realpath, rm, rmdir, seq, sha1sum, sha224sum, sha256sum, sha384sum, sha512sum, shred, sleep, sort, split, tail, touch, tsort, uname, uniq, vdir, yes ## WASI sandbox: host paths not visible @@ -31,3 +31,7 @@ WASI does not support spawning child processes. Tests that shell out to other co ## 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 + +`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.