Skip to content

Commit 79cbccf

Browse files
committed
Merge pull request uutils#8911 from naoNao89/fix-dirname-trailing-dot
Fix dirname handling of paths ending in `/.`
1 parent 97d8899 commit 79cbccf

File tree

2 files changed

+179
-13
lines changed

2 files changed

+179
-13
lines changed

src/uu/dirname/src/dirname.rs

Lines changed: 65 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,53 @@ mod options {
1818
pub const DIR: &str = "dir";
1919
}
2020

21+
/// Handle the special case where a path ends with "/."
22+
///
23+
/// This matches GNU/POSIX behavior where `dirname("/home/dos/.")` returns "/home/dos"
24+
/// rather than "/home" (which would be the result of `Path::parent()` due to normalization).
25+
/// Per POSIX.1-2017 dirname specification and GNU coreutils manual:
26+
/// - POSIX: <https://pubs.opengroup.org/onlinepubs/9699919799/utilities/dirname.html>
27+
/// - GNU: <https://www.gnu.org/software/coreutils/manual/html_node/dirname-invocation.html>
28+
///
29+
/// dirname should do simple string manipulation without path normalization.
30+
/// See issue #8910 and similar fix in basename (#8373, commit c5268a897).
31+
///
32+
/// Returns `Some(())` if the special case was handled (output already printed),
33+
/// or `None` if normal `Path::parent()` logic should be used.
34+
fn handle_trailing_dot(path_bytes: &[u8]) -> Option<()> {
35+
if !path_bytes.ends_with(b"/.") {
36+
return None;
37+
}
38+
39+
// Strip the "/." suffix and print the result
40+
if path_bytes.len() == 2 {
41+
// Special case: "/." -> "/"
42+
print!("/");
43+
Some(())
44+
} else {
45+
// General case: "/home/dos/." -> "/home/dos"
46+
let stripped = &path_bytes[..path_bytes.len() - 2];
47+
#[cfg(unix)]
48+
{
49+
use std::os::unix::ffi::OsStrExt;
50+
let result = std::ffi::OsStr::from_bytes(stripped);
51+
print_verbatim(result).unwrap();
52+
Some(())
53+
}
54+
#[cfg(not(unix))]
55+
{
56+
// On non-Unix, fall back to lossy conversion
57+
if let Ok(s) = std::str::from_utf8(stripped) {
58+
print!("{s}");
59+
Some(())
60+
} else {
61+
// Can't handle non-UTF-8 on non-Unix, fall through to normal logic
62+
None
63+
}
64+
}
65+
}
66+
}
67+
2168
#[uucore::main]
2269
pub fn uumain(args: impl uucore::Args) -> UResult<()> {
2370
let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?;
@@ -35,20 +82,25 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
3582
}
3683

3784
for path in &dirnames {
38-
let p = Path::new(path);
39-
match p.parent() {
40-
Some(d) => {
41-
if d.components().next().is_none() {
42-
print!(".");
43-
} else {
44-
print_verbatim(d).unwrap();
85+
let path_bytes = uucore::os_str_as_bytes(path.as_os_str()).unwrap_or(&[]);
86+
87+
if handle_trailing_dot(path_bytes).is_none() {
88+
// Normal path handling using Path::parent()
89+
let p = Path::new(path);
90+
match p.parent() {
91+
Some(d) => {
92+
if d.components().next().is_none() {
93+
print!(".");
94+
} else {
95+
print_verbatim(d).unwrap();
96+
}
4597
}
46-
}
47-
None => {
48-
if p.is_absolute() || path.as_os_str() == "/" {
49-
print!("/");
50-
} else {
51-
print!(".");
98+
None => {
99+
if p.is_absolute() || path.as_os_str() == "/" {
100+
print!("/");
101+
} else {
102+
print!(".");
103+
}
52104
}
53105
}
54106
}

tests/by-util/test_dirname.rs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,117 @@ fn test_emoji_handling() {
102102
.succeeds()
103103
.stdout_is("/🌟/emoji/path\u{0}");
104104
}
105+
106+
#[test]
107+
fn test_trailing_dot() {
108+
// Basic case: path ending with /. should return parent without stripping last component
109+
// This matches GNU coreutils behavior and fixes issue #8910
110+
new_ucmd!()
111+
.arg("/home/dos/.")
112+
.succeeds()
113+
.stdout_is("/home/dos\n");
114+
115+
// Root with dot
116+
new_ucmd!().arg("/.").succeeds().stdout_is("/\n");
117+
118+
// Relative path with /.
119+
new_ucmd!().arg("hello/.").succeeds().stdout_is("hello\n");
120+
121+
// Deeper path with /.
122+
new_ucmd!()
123+
.arg("/foo/bar/baz/.")
124+
.succeeds()
125+
.stdout_is("/foo/bar/baz\n");
126+
}
127+
128+
#[test]
129+
fn test_trailing_dot_with_zero_flag() {
130+
// Test that -z flag works correctly with /. paths
131+
new_ucmd!()
132+
.arg("-z")
133+
.arg("/home/dos/.")
134+
.succeeds()
135+
.stdout_is("/home/dos\u{0}");
136+
137+
new_ucmd!()
138+
.arg("--zero")
139+
.arg("/.")
140+
.succeeds()
141+
.stdout_is("/\u{0}");
142+
}
143+
144+
#[test]
145+
fn test_trailing_dot_multiple_paths() {
146+
// Test multiple paths, some with /. suffix
147+
new_ucmd!()
148+
.args(&["/home/dos/.", "/var/log", "/tmp/."])
149+
.succeeds()
150+
.stdout_is("/home/dos\n/var\n/tmp\n");
151+
}
152+
153+
#[test]
154+
fn test_trailing_dot_edge_cases() {
155+
// Double slash before dot (should still work)
156+
new_ucmd!()
157+
.arg("/home/dos//.")
158+
.succeeds()
159+
.stdout_is("/home/dos/\n");
160+
161+
// Path with . in middle (should use normal logic)
162+
new_ucmd!()
163+
.arg("/path/./to/file")
164+
.succeeds()
165+
.stdout_is("/path/./to\n");
166+
}
167+
168+
#[test]
169+
fn test_trailing_dot_emoji() {
170+
// Emoji paths with /. suffix
171+
new_ucmd!()
172+
.arg("/🌍/path/.")
173+
.succeeds()
174+
.stdout_is("/🌍/path\n");
175+
176+
new_ucmd!().arg("/🎉/🚀/.").succeeds().stdout_is("/🎉/🚀\n");
177+
}
178+
179+
#[test]
180+
#[cfg(unix)]
181+
fn test_trailing_dot_non_utf8() {
182+
use std::ffi::OsStr;
183+
use std::os::unix::ffi::OsStrExt;
184+
185+
// Create a path with non-UTF-8 bytes ending in /.
186+
let non_utf8_bytes = b"/test_\xFF\xFE/.";
187+
let non_utf8_path = OsStr::from_bytes(non_utf8_bytes);
188+
189+
// Test that dirname handles non-UTF-8 paths with /. suffix
190+
let result = new_ucmd!().arg(non_utf8_path).succeeds();
191+
192+
// The output should be the path without the /. suffix
193+
let output = result.stdout_str_lossy();
194+
assert!(!output.is_empty());
195+
assert!(output.contains("test_"));
196+
// Should not contain the . at the end
197+
assert!(!output.trim().ends_with('.'));
198+
}
199+
200+
#[test]
201+
fn test_existing_behavior_preserved() {
202+
// Ensure we didn't break existing test cases
203+
// These tests verify backward compatibility
204+
205+
// Normal paths without /. should work as before
206+
new_ucmd!().arg("/home/dos").succeeds().stdout_is("/home\n");
207+
208+
new_ucmd!()
209+
.arg("/home/dos/")
210+
.succeeds()
211+
.stdout_is("/home\n");
212+
213+
// Parent directory references
214+
new_ucmd!()
215+
.arg("/home/dos/..")
216+
.succeeds()
217+
.stdout_is("/home/dos\n");
218+
}

0 commit comments

Comments
 (0)