Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/wasi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@
EXCLUDE="df|du|env|more"
UTILS=$(./util/show-utils.sh | tr ' ' '\n' | grep -vE "^($EXCLUDE)$" | sed 's/^/-p uu_/' | tr '\n' ' ')
cargo test --target ${{ matrix.job.target }} --no-default-features $UTILS
- name: Run uucore fs unit tests
env:
# The fs tests touch the filesystem, so wasmtime must preopen a

Check warning on line 52 in .github/workflows/wasi.yml

View workflow job for this annotation

GitHub Actions / Style/spelling (ubuntu-latest, feat_os_unix)

WARNING: `cspell`: Unknown word 'preopen' (file:'.github/workflows/wasi.yml', line:52)
# directory; map the host cwd to the guest root so the tests' relative
# scratch directories resolve.
CARGO_TARGET_WASM32_WASIP1_RUNNER: wasmtime --dir=.::/ --
CARGO_TARGET_WASM32_WASIP2_RUNNER: wasmtime --dir=.::/ --
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:
Expand Down
215 changes: 189 additions & 26 deletions src/uucore/src/lib/features/fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -777,8 +777,8 @@ pub fn is_symlink_loop(path: &Path) -> bool {
true
}

#[cfg(not(unix))]
// Hard link comparison is not supported on non-Unix platforms
#[cfg(not(any(unix, target_os = "wasi")))]
// Hard link comparison is not supported on non-Unix, non-WASI platforms
pub fn are_hardlinks_to_same_file(_source: &Path, _target: &Path) -> bool {
false
}
Expand All @@ -793,21 +793,23 @@ pub fn are_hardlinks_to_same_file(_source: &Path, _target: &Path) -> bool {
/// # Returns
///
/// * `bool` - Returns `true` if the paths are hard links to the same file, and `false` otherwise.
#[cfg(unix)]
// Uses `rustix` because it exposes st_ino/st_dev on both Unix and WASI on
// stable, unlike the platform-specific std MetadataExt traits.
#[cfg(any(unix, target_os = "wasi"))]
pub fn are_hardlinks_to_same_file(source: &Path, target: &Path) -> bool {
// The target is usually the one that does not exist, so look it up first
// and return early instead of also querying the source for nothing.
let Ok(target_metadata) = fs::symlink_metadata(target) else {
let Ok(target_stat) = rustix::fs::lstat(target) else {
return false;
};
let Ok(source_metadata) = fs::symlink_metadata(source) else {
let Ok(source_stat) = rustix::fs::lstat(source) else {
return false;
};

source_metadata.ino() == target_metadata.ino() && source_metadata.dev() == target_metadata.dev()
source_stat.st_ino == target_stat.st_ino && source_stat.st_dev == target_stat.st_dev
}

#[cfg(not(unix))]
#[cfg(not(any(unix, target_os = "wasi")))]
pub fn are_hardlinks_or_one_way_symlink_to_same_file(_source: &Path, _target: &Path) -> bool {
false
}
Expand All @@ -822,18 +824,19 @@ pub fn are_hardlinks_or_one_way_symlink_to_same_file(_source: &Path, _target: &P
/// # Returns
///
/// * `bool` - Returns `true` if either of above conditions are true, and `false` otherwise.
#[cfg(unix)]
#[cfg(any(unix, target_os = "wasi"))]
pub fn are_hardlinks_or_one_way_symlink_to_same_file(source: &Path, target: &Path) -> bool {
// As above, look up the target first: if it does not exist, there is
// nothing to compare the source with.
let Ok(target_metadata) = fs::symlink_metadata(target) else {
let Ok(target_stat) = rustix::fs::lstat(target) else {
return false;
};
let Ok(source_metadata) = fs::metadata(source) else {
// Follow symlinks on the source so a source symlink pointing at target matches.
let Ok(source_stat) = rustix::fs::stat(source) else {
return false;
};

source_metadata.ino() == target_metadata.ino() && source_metadata.dev() == target_metadata.dev()
source_stat.st_ino == target_stat.st_ino && source_stat.st_dev == target_stat.st_dev
}

/// Returns true if the passed `path` ends with a path terminator.
Expand Down Expand Up @@ -1227,6 +1230,33 @@ mod tests {
#[cfg(unix)]
use tempfile::{NamedTempFile, tempdir};

// `tempfile` is unusable on WASI (`std::env::temp_dir` aborts there), so
// tests create a uniquely named directory under the current (preopened,
// on WASI) directory and clean it up on drop.
struct ScratchDir(PathBuf);

impl ScratchDir {
fn new(tag: &str) -> Self {
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = PathBuf::from(format!("uucore_fs_test_{tag}_{n}"));
let _ = fs::remove_dir_all(&path);
fs::create_dir(&path).unwrap();
Self(path)
}

fn join(&self, name: &str) -> PathBuf {
self.0.join(name)
}
}

impl Drop for ScratchDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}

struct NormalizePathTestCase<'a> {
path: &'a str,
test: &'a str,
Expand Down Expand Up @@ -1565,30 +1595,39 @@ mod tests {

#[test]
fn test_are_files_identical() {
use std::io::Write;
use tempfile::NamedTempFile;
let dir = ScratchDir::new("identical");
let file1 = dir.join("file1");
let file2 = dir.join("file2");
let file3 = dir.join("file3");

let mut file1 = NamedTempFile::new().unwrap();
let mut file2 = NamedTempFile::new().unwrap();
let mut file3 = NamedTempFile::new().unwrap();

file1.write_all(b"hello world").unwrap();
file2.write_all(b"hello world").unwrap();
file3.write_all(b"hello rust!").unwrap();
fs::write(&file1, b"hello world").unwrap();
fs::write(&file2, b"hello world").unwrap();
fs::write(&file3, b"hello rust!").unwrap();

// Identical contents
assert!(are_files_identical(file1.path(), file2.path()).unwrap());
assert!(are_files_identical(&file1, &file2).unwrap());

// Same size, different contents
assert!(!are_files_identical(file1.path(), file3.path()).unwrap());
assert!(!are_files_identical(&file1, &file3).unwrap());

// Different size
let mut file4 = NamedTempFile::new().unwrap();
file4.write_all(b"hello").unwrap();
assert!(!are_files_identical(file1.path(), file4.path()).unwrap());
let file4 = dir.join("file4");
fs::write(&file4, b"hello").unwrap();
assert!(!are_files_identical(&file1, &file4).unwrap());

// Non-existent file
assert!(are_files_identical(file1.path(), "non_existent_file_path").is_err());
assert!(are_files_identical(&file1, dir.join("non_existent_file_path")).is_err());
}

#[cfg(any(unix, target_os = "wasi"))]
#[test]
fn test_file_information_equality_same_file() {
Comment thread
eduardomourar marked this conversation as resolved.
let dir = ScratchDir::new("eq_same");
let file = dir.join("file");
fs::write(&file, "content").unwrap();
let info1 = FileInformation::from_path(&file, true).unwrap();
let info2 = FileInformation::from_path(&file, true).unwrap();
assert!(info1 == info2);
}

#[cfg(unix)]
Expand Down Expand Up @@ -1617,4 +1656,128 @@ mod tests {
assert!(path_is_root_dir(&link, true));
assert!(!path_is_root_dir(&link, false));
}

#[cfg(any(unix, target_os = "wasi"))]
#[test]
fn test_file_information_equality_hard_link() {
let dir = ScratchDir::new("eq_hl");
let file = dir.join("file");
let link = dir.join("link");
fs::write(&file, "content").unwrap();
fs::hard_link(&file, &link).unwrap();

let info1 = FileInformation::from_path(&file, true).unwrap();
let info2 = FileInformation::from_path(&link, true).unwrap();
assert!(info1 == info2);
}

#[cfg(any(unix, target_os = "wasi"))]
#[test]
fn test_file_information_inequality_different_files() {
let dir = ScratchDir::new("neq");
let a = dir.join("a");
let b = dir.join("b");
fs::write(&a, "a").unwrap();
fs::write(&b, "b").unwrap();

let info1 = FileInformation::from_path(&a, true).unwrap();
let info2 = FileInformation::from_path(&b, true).unwrap();
assert!(info1 != info2);
}

#[cfg(any(unix, target_os = "wasi"))]
#[test]
fn test_file_information_hash_consistent_with_eq() {
use std::collections::hash_map::DefaultHasher;
use std::hash::Hasher;

let dir = ScratchDir::new("hash");
let file = dir.join("file");
let link = dir.join("link");
fs::write(&file, "content").unwrap();
fs::hard_link(&file, &link).unwrap();

let info1 = FileInformation::from_path(&file, true).unwrap();
let info2 = FileInformation::from_path(&link, true).unwrap();

let mut h1 = DefaultHasher::new();
let mut h2 = DefaultHasher::new();
info1.hash(&mut h1);
info2.hash(&mut h2);
assert_eq!(h1.finish(), h2.finish());
}

#[cfg(any(unix, target_os = "wasi"))]
#[test]
fn test_are_hardlinks_to_same_file_missing_path() {
let dir = ScratchDir::new("missing");
let file = dir.join("file");
fs::write(&file, "content").unwrap();
let missing = dir.join("does_not_exist");

assert!(!are_hardlinks_to_same_file(&file, &missing));
assert!(!are_hardlinks_to_same_file(&missing, &file));
}

#[cfg(any(unix, target_os = "wasi"))]
#[test]
fn test_are_hardlinks_or_one_way_symlink_different_files() {
let dir = ScratchDir::new("sym_neq");
let a = dir.join("a");
let b = dir.join("b");
fs::write(&a, "a").unwrap();
fs::write(&b, "b").unwrap();

assert!(!are_hardlinks_or_one_way_symlink_to_same_file(&a, &b));
}

#[cfg(any(unix, target_os = "wasi"))]
#[test]
fn test_are_hardlinks_or_one_way_symlink_missing_path() {
let dir = ScratchDir::new("sym_missing");
let file = dir.join("file");
fs::write(&file, "content").unwrap();
let missing = dir.join("does_not_exist");

assert!(!are_hardlinks_or_one_way_symlink_to_same_file(
&file, &missing
));
}

// wasip2's symlink creation aborts (rather than returning an error) when the
// runtime lacks support, which the harness cannot recover from, so the
// symlink case is limited to Unix and wasip1.
#[cfg(any(unix, all(target_os = "wasi", target_env = "p1")))]
#[test]
fn test_are_hardlinks_or_one_way_symlink_same_file() {
let dir = ScratchDir::new("symlink");
let file = dir.join("file");
fs::write(&file, "content").unwrap();
let link = dir.join("link");

// The link contents resolve relative to the link's own directory, so
// point it at the sibling file by name.
#[cfg(unix)]
unix::fs::symlink("file", &link).unwrap();
#[cfg(target_os = "wasi")]
{
#[allow(deprecated)]
fs::soft_link("file", &link).unwrap();
}

// Source symlink resolves (stat) to the target, matched against the
// target's own lstat.
assert!(are_hardlinks_or_one_way_symlink_to_same_file(&link, &file));
}

#[cfg(unix)]
#[test]
fn test_file_information_inode() {
use std::os::unix::fs::MetadataExt;

let temp_file = NamedTempFile::new().unwrap();
let info = FileInformation::from_path(temp_file.path(), true).unwrap();
let expected_ino = fs::metadata(temp_file.path()).unwrap().ino();
assert_eq!(info.inode(), expected_ino);
}
}
2 changes: 1 addition & 1 deletion src/uucore/src/lib/features/fsext/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@

#[cfg(not(unix))]
fn metadata_get_change_time(_md: &Metadata) -> Option<SystemTime> {
// Not available.
// ctime isn't exposed by std Metadata outside unix
None
}

Expand Down Expand Up @@ -221,7 +221,7 @@
};

// Prefer the device number from mountinfo: stat()ing the mount point
// would trigger automounts, which can hang.

Check warning on line 224 in src/uucore/src/lib/features/fsext/mod.rs

View workflow job for this annotation

GitHub Actions / Style/spelling (ubuntu-latest, feat_os_unix)

WARNING: `cspell`: Unknown word 'automounts' (file:'src/uucore/src/lib/features/fsext/mod.rs', line:224)
let dev_id = (file_name == LINUX_MOUNTINFO)
.then(|| parse_dev_id(raw[2]))
.flatten()
Expand Down
Loading