Skip to content

Commit c78014a

Browse files
committed
fix(dirname): handle paths ending in /. correctly
Fixes #8910 Match POSIX/GNU behavior where dirname("/home/dos/.") returns "/home/dos" instead of "/home". This aligns with the basename fix from #8373 (c5268a8). The issue occurs because Rust's Path::parent() normalizes paths, treating "/home/dos/." the same as "/home/dos/", both returning "/home" as the parent. This breaks tools like homeshick that rely on correct dirname behavior for symlink generation. Solution: - Detect paths ending in "/." using byte-level checking - Strip the "/." suffix manually before path operations - Handle edge case: "/." -> "/" - Preserve non-UTF-8 path handling with platform-specific code - Add comprehensive tests covering edge cases Tests added: - Basic "/." handling (5 test cases) - Flag interaction with -z/--zero - Multiple paths with mixed "/." suffixes - Edge cases: double slashes, dots in middle - Unicode/emoji paths - Non-UTF-8 paths (Unix only) - Backward compatibility verification (7 test cases)
1 parent 15ba6aa commit c78014a

File tree

2 files changed

+181
-12
lines changed

2 files changed

+181
-12
lines changed

src/uu/dirname/src/dirname.rs

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -35,20 +35,57 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
3535
}
3636

3737
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();
38+
// Handle special case where path ends with /.
39+
// This matches GNU/POSIX behavior where dirname("/home/dos/.") returns "/home/dos"
40+
// rather than "/home" (which would be the result of Path::parent() due to normalization).
41+
// Per POSIX.1-2017 dirname specification and GNU coreutils manual:
42+
// - POSIX: https://pubs.opengroup.org/onlinepubs/9699919799/utilities/dirname.html
43+
// - GNU: https://www.gnu.org/software/coreutils/manual/html_node/dirname-invocation.html
44+
// dirname should do simple string manipulation without path normalization.
45+
// See issue #8910 and similar fix in basename (#8373, commit c5268a897).
46+
let path_bytes = uucore::os_str_as_bytes(path.as_os_str()).unwrap_or(&[]);
47+
if path_bytes.ends_with(b"/.") {
48+
// Strip the "/." suffix and print the result
49+
if path_bytes.len() == 2 {
50+
// Special case: "/." -> "/"
51+
print!("/");
52+
} else {
53+
// General case: "/home/dos/." -> "/home/dos"
54+
let stripped = &path_bytes[..path_bytes.len() - 2];
55+
#[cfg(unix)]
56+
{
57+
use std::os::unix::ffi::OsStrExt;
58+
let result = std::ffi::OsStr::from_bytes(stripped);
59+
print_verbatim(result).unwrap();
60+
}
61+
#[cfg(not(unix))]
62+
{
63+
// On non-Unix, fall back to lossy conversion
64+
if let Ok(s) = std::str::from_utf8(stripped) {
65+
print!("{}", s);
66+
} else {
67+
// Fallback for invalid UTF-8
68+
print_verbatim(Path::new(path)).unwrap();
69+
}
4570
}
4671
}
47-
None => {
48-
if p.is_absolute() || path.as_os_str() == "/" {
49-
print!("/");
50-
} else {
51-
print!(".");
72+
} else {
73+
// Normal path handling using Path::parent()
74+
let p = Path::new(path);
75+
match p.parent() {
76+
Some(d) => {
77+
if d.components().next().is_none() {
78+
print!(".");
79+
} else {
80+
print_verbatim(d).unwrap();
81+
}
82+
}
83+
None => {
84+
if p.is_absolute() || path.as_os_str() == "/" {
85+
print!("/");
86+
} else {
87+
print!(".");
88+
}
5289
}
5390
}
5491
}

tests/by-util/test_dirname.rs

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,135 @@ 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+
// Multiple components
128+
new_ucmd!()
129+
.arg("/usr/local/bin/.")
130+
.succeeds()
131+
.stdout_is("/usr/local/bin\n");
132+
}
133+
134+
#[test]
135+
fn test_trailing_dot_with_zero_flag() {
136+
// Test that -z flag works correctly with /. paths
137+
new_ucmd!()
138+
.arg("-z")
139+
.arg("/home/dos/.")
140+
.succeeds()
141+
.stdout_is("/home/dos\u{0}");
142+
143+
new_ucmd!()
144+
.arg("--zero")
145+
.arg("/.")
146+
.succeeds()
147+
.stdout_is("/\u{0}");
148+
}
149+
150+
#[test]
151+
fn test_trailing_dot_multiple_paths() {
152+
// Test multiple paths, some with /. suffix
153+
new_ucmd!()
154+
.args(&["/home/dos/.", "/var/log", "/tmp/."])
155+
.succeeds()
156+
.stdout_is("/home/dos\n/var\n/tmp\n");
157+
}
158+
159+
#[test]
160+
fn test_trailing_dot_edge_cases() {
161+
// Double slash before dot (should still work)
162+
new_ucmd!()
163+
.arg("/home/dos//.")
164+
.succeeds()
165+
.stdout_is("/home/dos/\n");
166+
167+
// Path with . in middle (should use normal logic)
168+
new_ucmd!()
169+
.arg("/path/./to/file")
170+
.succeeds()
171+
.stdout_is("/path/./to\n");
172+
}
173+
174+
#[test]
175+
fn test_trailing_dot_emoji() {
176+
// Emoji paths with /. suffix
177+
new_ucmd!()
178+
.arg("/🌍/path/.")
179+
.succeeds()
180+
.stdout_is("/🌍/path\n");
181+
182+
new_ucmd!().arg("/🎉/🚀/.").succeeds().stdout_is("/🎉/🚀\n");
183+
}
184+
185+
#[test]
186+
#[cfg(unix)]
187+
fn test_trailing_dot_non_utf8() {
188+
use std::ffi::OsStr;
189+
use std::os::unix::ffi::OsStrExt;
190+
191+
// Create a path with non-UTF-8 bytes ending in /.
192+
let non_utf8_bytes = b"/test_\xFF\xFE/.";
193+
let non_utf8_path = OsStr::from_bytes(non_utf8_bytes);
194+
195+
// Test that dirname handles non-UTF-8 paths with /. suffix
196+
let result = new_ucmd!().arg(non_utf8_path).succeeds();
197+
198+
// The output should be the path without the /. suffix
199+
let output = result.stdout_str_lossy();
200+
assert!(!output.is_empty());
201+
assert!(output.contains("test_"));
202+
// Should not contain the . at the end
203+
assert!(!output.trim().ends_with('.'));
204+
}
205+
206+
#[test]
207+
fn test_existing_behavior_preserved() {
208+
// Ensure we didn't break existing test cases
209+
// These tests verify backward compatibility
210+
211+
// Normal paths without /. should work as before
212+
new_ucmd!().arg("/home/dos").succeeds().stdout_is("/home\n");
213+
214+
new_ucmd!()
215+
.arg("/home/dos/")
216+
.succeeds()
217+
.stdout_is("/home\n");
218+
219+
// Parent directory references
220+
new_ucmd!()
221+
.arg("/home/dos/..")
222+
.succeeds()
223+
.stdout_is("/home/dos\n");
224+
225+
// Root
226+
new_ucmd!().arg("/").succeeds().stdout_is("/\n");
227+
228+
// Current directory
229+
new_ucmd!().arg(".").succeeds().stdout_is(".\n");
230+
231+
// Parent reference
232+
new_ucmd!().arg("..").succeeds().stdout_is(".\n");
233+
234+
// Empty string
235+
new_ucmd!().arg("").succeeds().stdout_is(".\n");
236+
}

0 commit comments

Comments
 (0)