From 1550b742a1f67aaf6a836d450df183308af933f8 Mon Sep 17 00:00:00 2001 From: binhvq Date: Wed, 11 Nov 2026 14:44:54 +0700 Subject: [PATCH 1/3] diff: keep an exact-name dangling symlink with --ignore-file-name-case For 'diff FILE DIR' the entry of DIR was looked up with Path::exists(), which follows symlinks. A dangling symlink named exactly like FILE counted as missing, and a differently cased entry was compared instead when the directory listed it first. --- src/params.rs | 3 ++- tests/by-util/test_diff.rs | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/params.rs b/src/params.rs index 9e3e5a35..dd53194b 100644 --- a/src/params.rs +++ b/src/params.rs @@ -416,7 +416,8 @@ pub fn parse_params>( /// `name`: `name` itself, or with --ignore-file-name-case, an entry whose name /// differs only by case when `name` does not exist. fn directory_entry(dir: &Path, name: &OsStr, params: &Params) -> OsString { - if params.ignore_file_name_case && !dir.join(name).exists() { + // symlink_metadata(), as exists() says no for a dangling symlink. + if params.ignore_file_name_case && dir.join(name).symlink_metadata().is_err() { let wanted = fold_file_name_case(name); let entries = std::fs::read_dir(dir).into_iter().flatten().flatten(); if let Some(entry) = entries diff --git a/tests/by-util/test_diff.rs b/tests/by-util/test_diff.rs index 10fc0a10..d61ef0c8 100644 --- a/tests/by-util/test_diff.rs +++ b/tests/by-util/test_diff.rs @@ -818,6 +818,42 @@ fn directory_name_matching() -> Result<(), Box> { Ok(()) } +// An entry with the exact name is used even if it is a dangling symlink; a +// case variant only stands in for a name that is not there at all. +#[cfg(unix)] +#[test] +fn ignore_file_name_case_keeps_exact_dangling_link() -> Result<(), Box> { + let tmp_dir = tempdir()?; + let file = tmp_dir.path().join("foo"); + std::fs::write(&file, "foo\n")?; + + // Which entry a directory lists first is up to the file system (oldest + // first, newest first, hashed), so create the link both before and after + // several variants: at least one layout lists a variant first. + for link_first in [true, false] { + let dir = tmp_dir.path().join(format!("link_first_{link_first}")); + std::fs::create_dir(&dir)?; + if link_first { + std::os::unix::fs::symlink("gone", dir.join("foo"))?; + } + for variant in ["FOO", "Foo", "fOO", "foO", "FoO"] { + std::fs::write(dir.join(variant), "zz\n")?; + } + if !link_first { + std::os::unix::fs::symlink("gone", dir.join("foo"))?; + } + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff").arg("--ignore-file-name-case"); + cmd.arg(&file).arg(&dir); + cmd.assert() + .code(predicate::eq(2)) + .stdout("") + .stderr(predicate::str::contains("No such file or directory")); + } + Ok(()) +} + #[cfg(unix)] #[test] fn no_dereference() -> Result<(), Box> { From ea1a87b4b5316b4f58724fe743ba7e6a0e66192b Mon Sep 17 00:00:00 2001 From: binhvq Date: Wed, 11 Nov 2026 17:03:14 +0700 Subject: [PATCH 2/3] test: skip dangling link case on case-insensitive file systems --- tests/by-util/test_diff.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/by-util/test_diff.rs b/tests/by-util/test_diff.rs index d61ef0c8..f125bb2b 100644 --- a/tests/by-util/test_diff.rs +++ b/tests/by-util/test_diff.rs @@ -826,6 +826,11 @@ fn ignore_file_name_case_keeps_exact_dangling_link() -> Result<(), Box Date: Thu, 12 Nov 2026 04:05:58 +0700 Subject: [PATCH 3/3] tests: port the dangling link case test to uutests --- tests/by-util/test_diff.rs | 34 +++++++++++++++------------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/tests/by-util/test_diff.rs b/tests/by-util/test_diff.rs index 5e130f44..54cdfe24 100644 --- a/tests/by-util/test_diff.rs +++ b/tests/by-util/test_diff.rs @@ -634,41 +634,37 @@ fn directory_name_matching() { // case variant only stands in for a name that is not there at all. #[cfg(unix)] #[test] -fn ignore_file_name_case_keeps_exact_dangling_link() -> Result<(), Box> { - let tmp_dir = tempdir()?; - let file = tmp_dir.path().join("foo"); - std::fs::write(&file, "foo\n")?; - if tmp_dir.path().join("FOO").exists() { +fn ignore_file_name_case_keeps_exact_dangling_link() { + let (at, ts) = at_and_ts!(); + at.write("foo", "foo\n"); + if at.file_exists("FOO") { // The file system is not case-sensitive: the variants below would // all be the link itself. - return Ok(()); + return; } // Which entry a directory lists first is up to the file system (oldest // first, newest first, hashed), so create the link both before and after // several variants: at least one layout lists a variant first. for link_first in [true, false] { - let dir = tmp_dir.path().join(format!("link_first_{link_first}")); - std::fs::create_dir(&dir)?; + let dir = format!("link_first_{link_first}"); + at.mkdir(&dir); if link_first { - std::os::unix::fs::symlink("gone", dir.join("foo"))?; + at.relative_symlink_file("gone", &format!("{dir}/foo")); } for variant in ["FOO", "Foo", "fOO", "foO", "FoO"] { - std::fs::write(dir.join(variant), "zz\n")?; + at.write(&format!("{dir}/{variant}"), "zz\n"); } if !link_first { - std::os::unix::fs::symlink("gone", dir.join("foo"))?; + at.relative_symlink_file("gone", &format!("{dir}/foo")); } - let mut cmd = cargo_bin_cmd!("diffutils"); - cmd.arg("diff").arg("--ignore-file-name-case"); - cmd.arg(&file).arg(&dir); - cmd.assert() - .code(predicate::eq(2)) - .stdout("") - .stderr(predicate::str::contains("No such file or directory")); + ts.ucmd() + .args(&["--ignore-file-name-case", "foo", &dir]) + .fails_with_code(2) + .no_stdout() + .stderr_contains("No such file or directory"); } - Ok(()) } // Among names differing only by case, the one that is the same on both sides