From 939bec5bb06e4fa4ccb528caecbb61f9b95ce002 Mon Sep 17 00:00:00 2001 From: Evan Alvarez Date: Thu, 13 Aug 2026 14:57:12 -0500 Subject: [PATCH 1/2] ls: don't show . and .. on orphaned directories --- src/uu/ls/src/ls.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/uu/ls/src/ls.rs b/src/uu/ls/src/ls.rs index c5ccb505c7..af6862a34d 100644 --- a/src/uu/ls/src/ls.rs +++ b/src/uu/ls/src/ls.rs @@ -1274,6 +1274,23 @@ fn dotdot_path(parent: &Path) -> PathBuf { dotdot } +fn is_still_linked(path_data: &PathData) -> bool { + let Some(self_metadata) = path_data.metadata() else { + return false; + }; + + let parent = dotdot_path(path_data.path()); + let Ok(parent_entries) = fs::read_dir(&parent) else { + return false; + }; + + parent_entries.flatten().any(|entry| { + entry + .metadata() + .is_ok_and(|md| md.ino() == self_metadata.ino() && md.dev() == self_metadata.dev()) + }) +} + fn collect_directory_entries( entries: &mut Vec, path_data: &PathData, @@ -1283,7 +1300,7 @@ fn collect_directory_entries( ) -> UResult<()> { entries.clear(); - if config.files == Files::All { + if config.files == Files::All && is_still_linked(path_data) { entries.push(PathData::new( path_data.path().to_path_buf().into(), None, From c2e3bc7469aca49c78cdb4b86d1733808956d667 Mon Sep 17 00:00:00 2001 From: Evan Alvarez Date: Thu, 13 Aug 2026 15:54:42 -0500 Subject: [PATCH 2/2] ls: add regress test for orphaned directory dot handling --- src/uu/ls/src/ls.rs | 2 ++ tests/by-util/test_ls.rs | 48 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/uu/ls/src/ls.rs b/src/uu/ls/src/ls.rs index af6862a34d..d2b6ff0b63 100644 --- a/src/uu/ls/src/ls.rs +++ b/src/uu/ls/src/ls.rs @@ -1275,6 +1275,8 @@ fn dotdot_path(parent: &Path) -> PathBuf { } fn is_still_linked(path_data: &PathData) -> bool { + use std::os::unix::fs::MetadataExt; + let Some(self_metadata) = path_data.metadata() else { return false; }; diff --git a/tests/by-util/test_ls.rs b/tests/by-util/test_ls.rs index 4bb56eef76..9267a5788a 100644 --- a/tests/by-util/test_ls.rs +++ b/tests/by-util/test_ls.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. // spell-checker:ignore (words) READMECAREFULLY birthtime doesntexist oneline somebackup lrwx somefile somegroup somehiddenbackup somehiddenfile tabsize aaaaaaaa bbbb cccc dddddddd ncccc neee naaaaa nbcdef nfffff dired subdired tmpfs mdir COLORTERM mexe bcdef mfoo timefile -// spell-checker:ignore (words) fakeroot setcap drwxr bcdlps mdangling mentry awith acolons NOFILE NOTCAPABLE +// spell-checker:ignore (words) fakeroot setcap drwxr bcdlps mdangling mentry awith acolons NOFILE NOTCAPABLE newfstatat fchdir #![allow( clippy::similar_names, clippy::too_many_lines, @@ -7626,6 +7626,52 @@ fn test_ls_recursive_no_fd_leak() { .stderr_is(""); } +#[test] +#[cfg(unix)] +fn test_ls_dot_orphaned_directory() { + use std::os::fd::AsRawFd; + use std::os::unix::process::CommandExt; + use std::process::Command; + use uucore::libc; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.mkdir("z"); + + // Hold fd on `z` while its a directory + let dir_fd = at.open("z"); + let raw_fd = dir_fd.as_raw_fd(); + + // Delete and replace it with a file of the same name + at.rmdir("z"); + at.touch("z"); + + // Run `ls` with cwd set on the fd to operate on the old orphaned dir in fs tree + let mut cmd = Command::new(&scene.bin_path); + cmd.arg(&scene.util_name).arg("-la"); + unsafe { + cmd.pre_exec(move || { + if libc::fchdir(raw_fd) != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let output = cmd.output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + + let has_dot_entries = stdout.lines().any(|line| { + let trimmed = line.trim_end(); + trimmed.ends_with(" .") || trimmed.ends_with(" ..") + }); + + assert!( + !has_dot_entries, + "expected no '.'/'..' entries. got:\n{stdout}" + ); +} + #[test] #[cfg(all(unix, not(target_os = "macos")))] fn test_ls_non_utf8_hidden() {