Skip to content
Merged
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
78 changes: 65 additions & 13 deletions src/uu/dirname/src/dirname.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,53 @@ mod options {
pub const DIR: &str = "dir";
}

/// Handle the special case where a path ends with "/."
///
/// This matches GNU/POSIX behavior where `dirname("/home/dos/.")` returns "/home/dos"
/// rather than "/home" (which would be the result of `Path::parent()` due to normalization).
/// Per POSIX.1-2017 dirname specification and GNU coreutils manual:
/// - POSIX: <https://pubs.opengroup.org/onlinepubs/9699919799/utilities/dirname.html>
/// - GNU: <https://www.gnu.org/software/coreutils/manual/html_node/dirname-invocation.html>
///
/// dirname should do simple string manipulation without path normalization.
/// See issue #8910 and similar fix in basename (#8373, commit c5268a897).
///
/// Returns `Some(())` if the special case was handled (output already printed),
/// or `None` if normal `Path::parent()` logic should be used.
fn handle_trailing_dot(path_bytes: &[u8]) -> Option<()> {
if !path_bytes.ends_with(b"/.") {
return None;
}

// Strip the "/." suffix and print the result
if path_bytes.len() == 2 {
// Special case: "/." -> "/"
print!("/");
Some(())
} else {
// General case: "/home/dos/." -> "/home/dos"
let stripped = &path_bytes[..path_bytes.len() - 2];
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
let result = std::ffi::OsStr::from_bytes(stripped);
print_verbatim(result).unwrap();
Some(())
}
#[cfg(not(unix))]
{
// On non-Unix, fall back to lossy conversion
if let Ok(s) = std::str::from_utf8(stripped) {
print!("{s}");
Some(())
} else {
// Can't handle non-UTF-8 on non-Unix, fall through to normal logic
None
}
}
}
}

#[uucore::main]
pub fn uumain(args: impl uucore::Args) -> UResult<()> {
let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?;
Expand All @@ -35,20 +82,25 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
}

for path in &dirnames {
let p = Path::new(path);
match p.parent() {
Some(d) => {
if d.components().next().is_none() {
print!(".");
} else {
print_verbatim(d).unwrap();
let path_bytes = uucore::os_str_as_bytes(path.as_os_str()).unwrap_or(&[]);

if handle_trailing_dot(path_bytes).is_none() {
// Normal path handling using Path::parent()
let p = Path::new(path);
match p.parent() {
Some(d) => {
if d.components().next().is_none() {
print!(".");
} else {
print_verbatim(d).unwrap();
}
}
}
None => {
if p.is_absolute() || path.as_os_str() == "/" {
print!("/");
} else {
print!(".");
None => {
if p.is_absolute() || path.as_os_str() == "/" {
print!("/");
} else {
print!(".");
}
}
}
}
Expand Down
114 changes: 114 additions & 0 deletions tests/by-util/test_dirname.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,117 @@ fn test_emoji_handling() {
.succeeds()
.stdout_is("/🌟/emoji/path\u{0}");
}

#[test]
fn test_trailing_dot() {
// Basic case: path ending with /. should return parent without stripping last component
// This matches GNU coreutils behavior and fixes issue #8910
new_ucmd!()
.arg("/home/dos/.")
.succeeds()
.stdout_is("/home/dos\n");

// Root with dot
new_ucmd!().arg("/.").succeeds().stdout_is("/\n");

// Relative path with /.
new_ucmd!().arg("hello/.").succeeds().stdout_is("hello\n");

// Deeper path with /.
new_ucmd!()
.arg("/foo/bar/baz/.")
.succeeds()
.stdout_is("/foo/bar/baz\n");
}

#[test]
fn test_trailing_dot_with_zero_flag() {
// Test that -z flag works correctly with /. paths
new_ucmd!()
.arg("-z")
.arg("/home/dos/.")
.succeeds()
.stdout_is("/home/dos\u{0}");

new_ucmd!()
.arg("--zero")
.arg("/.")
.succeeds()
.stdout_is("/\u{0}");
}

#[test]
fn test_trailing_dot_multiple_paths() {
// Test multiple paths, some with /. suffix
new_ucmd!()
.args(&["/home/dos/.", "/var/log", "/tmp/."])
.succeeds()
.stdout_is("/home/dos\n/var\n/tmp\n");
}

#[test]
fn test_trailing_dot_edge_cases() {
// Double slash before dot (should still work)
new_ucmd!()
.arg("/home/dos//.")
.succeeds()
.stdout_is("/home/dos/\n");

// Path with . in middle (should use normal logic)
new_ucmd!()
.arg("/path/./to/file")
.succeeds()
.stdout_is("/path/./to\n");
}

#[test]
fn test_trailing_dot_emoji() {
// Emoji paths with /. suffix
new_ucmd!()
.arg("/🌍/path/.")
.succeeds()
.stdout_is("/🌍/path\n");

new_ucmd!().arg("/🎉/🚀/.").succeeds().stdout_is("/🎉/🚀\n");
}

#[test]
#[cfg(unix)]
fn test_trailing_dot_non_utf8() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;

// Create a path with non-UTF-8 bytes ending in /.
let non_utf8_bytes = b"/test_\xFF\xFE/.";
let non_utf8_path = OsStr::from_bytes(non_utf8_bytes);

// Test that dirname handles non-UTF-8 paths with /. suffix
let result = new_ucmd!().arg(non_utf8_path).succeeds();

// The output should be the path without the /. suffix
let output = result.stdout_str_lossy();
assert!(!output.is_empty());
assert!(output.contains("test_"));
// Should not contain the . at the end
assert!(!output.trim().ends_with('.'));
}

#[test]
fn test_existing_behavior_preserved() {
// Ensure we didn't break existing test cases
// These tests verify backward compatibility

// Normal paths without /. should work as before
new_ucmd!().arg("/home/dos").succeeds().stdout_is("/home\n");

new_ucmd!()
.arg("/home/dos/")
.succeeds()
.stdout_is("/home\n");

// Parent directory references
new_ucmd!()
.arg("/home/dos/..")
.succeeds()
.stdout_is("/home/dos\n");
}
Loading