Skip to content

Commit 6c791e7

Browse files
authoredOct 22, 2024··
Merge pull request #349 from andrewliebenow/readlink-implement-dash-f
readlink: implement -f
2 parents a08d82d + eb2d992 commit 6c791e7

File tree

2 files changed

+198
-31
lines changed

2 files changed

+198
-31
lines changed
 

‎tree/readlink.rs

+193-29
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,12 @@
99

1010
use clap::Parser;
1111
use gettextrs::{bind_textdomain_codeset, setlocale, textdomain, LocaleCategory};
12+
use std::error::Error;
13+
use std::fs;
1214
use std::io::Write;
15+
use std::io::{stderr, stdout, ErrorKind};
1316
use std::path::PathBuf;
14-
use std::{fs, io};
17+
use std::path::{Component, Path};
1518

1619
/// readlink — display the contents of a symbolic link
1720
#[derive(Parser)]
@@ -21,33 +24,144 @@ struct Args {
2124
#[arg(short, long)]
2225
no_newline: bool,
2326

27+
// Not POSIX, but implemented by BusyBox, FreeBSD, GNU Core Utilities, toybox, and others
28+
/// Canonicalize the provided path, resolving symbolic links repeatedly if needed. The absolute path of the resolved file is printed.
29+
#[arg(short = 'f')]
30+
canonicalize: bool,
31+
32+
/// Print an error description to standard error when an error occurs and the specified file could not be resolved
33+
#[arg(short = 'v')]
34+
verbose: bool,
35+
2436
/// The pathname of an existing symbolic link
2537
pathname: PathBuf,
2638
}
2739

28-
fn do_readlink(args: &Args) -> Result<String, String> {
29-
let path = PathBuf::from(&args.pathname);
40+
// Behavior of "readlink -f /existent-directory/non-existent-file" varies
41+
// Most implementations: print hypothetical fully resolved absolute path, exit code 0
42+
// bsdutils/FreeBSD, toybox: print nothing, exit code 1
43+
//
44+
// Behavior of "readlink -f /non-existent-directory/non-existent-file" does not vary
45+
// All implementations: print nothing, exit code 1
46+
fn do_readlink(args: Args) -> Result<String, String> {
47+
let Args {
48+
no_newline,
49+
canonicalize,
50+
verbose,
51+
pathname,
52+
} = args;
53+
54+
let pathname_path = pathname.as_path();
55+
56+
let format_error = |description: &str, error: Option<&dyn Error>| {
57+
let pathname_path_display = pathname_path.display();
58+
59+
let st = if let Some(er) = error {
60+
format!("{pathname_path_display}: {description}: {er}")
61+
} else {
62+
format!("{pathname_path_display}: {description}")
63+
};
64+
65+
Result::<String, String>::Err(st)
66+
};
3067

31-
match fs::read_link(&path) {
32-
Ok(target) => {
33-
let output = target.display().to_string();
34-
if args.no_newline {
35-
Ok(output)
36-
} else {
37-
Ok(output + "\n")
68+
let format_returned_path = |path_to_return: &Path| {
69+
let path_to_return_display = path_to_return.display();
70+
71+
let st = if no_newline {
72+
format!("{path_to_return_display}")
73+
} else {
74+
format!("{path_to_return_display}\n")
75+
};
76+
77+
Result::<String, String>::Ok(st)
78+
};
79+
80+
let map_io_error = |error: &std::io::Error| {
81+
match error.kind() {
82+
ErrorKind::NotFound => {
83+
// All or almost all other implementations do not print an error here
84+
// (but they do exit with exit code 1)
85+
if verbose {
86+
format_error("No such file or directory", None)
87+
} else {
88+
Err(String::new())
89+
}
90+
}
91+
ErrorKind::PermissionDenied => {
92+
if verbose {
93+
format_error("Permission denied", None)
94+
} else {
95+
Err(String::new())
96+
}
3897
}
98+
_ => format_error("Unknown error", Some(&error)),
3999
}
40-
Err(e) => {
41-
let err_message = match e.kind() {
42-
io::ErrorKind::NotFound => {
43-
format!("readlink: {}: No such file or directory\n", path.display())
100+
};
101+
102+
if canonicalize {
103+
let recursively_resolved_path_buf = recursive_resolve(pathname_path.to_owned())?;
104+
105+
match fs::canonicalize(recursively_resolved_path_buf.as_path()) {
106+
Ok(pa) => format_returned_path(pa.as_path()),
107+
Err(er) => {
108+
let mut components = recursively_resolved_path_buf.components();
109+
110+
// Check if the last component of the path is a "normal" component
111+
// (e.g. "normal-component" in "/prefix/normal-component/suffix")
112+
//
113+
// If so, the fallback path (since the path itself could not be canonicalized)
114+
// is to canonicalize the parent directory path, and append the last path component
115+
if let Some(Component::Normal(last_component)) = components.next_back() {
116+
let parent_path = components.as_path();
117+
118+
match fs::canonicalize(parent_path) {
119+
Ok(parent_path_canonicalized) => {
120+
// Before printing the hypothetical resolved path:
121+
// ensure that the parent is actually a directory
122+
if !parent_path_canonicalized.is_dir() {
123+
return format_error("Not a directory", None);
124+
}
125+
126+
let parent_path_canonicalized_with_last_component = {
127+
let mut pa = parent_path_canonicalized;
128+
129+
pa.push(last_component);
130+
131+
pa
132+
};
133+
134+
format_returned_path(
135+
parent_path_canonicalized_with_last_component.as_path(),
136+
)
137+
}
138+
Err(err) => map_io_error(&err),
139+
}
140+
} else {
141+
map_io_error(&er)
142+
}
143+
}
144+
}
145+
} else {
146+
match fs::symlink_metadata(pathname_path) {
147+
Ok(me) => {
148+
if !me.is_symlink() {
149+
// POSIX says:
150+
// "If file does not name a symbolic link, readlink shall write a diagnostic message to standard error and exit with non-zero status."
151+
// However, this is violated by almost all implementations
152+
return if verbose {
153+
format_error("Not a symbolic link", None)
154+
} else {
155+
Err(String::new())
156+
};
44157
}
45-
io::ErrorKind::InvalidInput => {
46-
format!("readlink: {}: Not a symbolic link\n", path.display())
158+
159+
match fs::read_link(pathname_path) {
160+
Ok(pa) => format_returned_path(pa.as_path()),
161+
Err(er) => map_io_error(&er),
47162
}
48-
_ => format!("readlink: {}: {}\n", path.display(), e),
49-
};
50-
Err(err_message)
163+
}
164+
Err(er) => map_io_error(&er),
51165
}
52166
}
53167
}
@@ -59,19 +173,69 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
59173

60174
let args = Args::parse();
61175

62-
let mut exit_code = 0;
63-
64-
match do_readlink(&args) {
176+
let exit_code = match do_readlink(args) {
65177
Ok(output) => {
66-
print!("{}", output);
67-
io::stdout().flush().unwrap();
178+
let mut stdout_lock = stdout().lock();
179+
180+
write!(stdout_lock, "{output}").unwrap();
181+
182+
stdout_lock.flush().unwrap();
183+
184+
0_i32
68185
}
69-
Err(err) => {
70-
eprint!("{}", err);
71-
io::stderr().flush().unwrap();
72-
exit_code = 1;
186+
Err(error_description) => {
187+
if !error_description.is_empty() {
188+
let mut stderr_lock = stderr().lock();
189+
190+
writeln!(&mut stderr_lock, "readlink: {error_description}").unwrap();
191+
192+
stderr_lock.flush().unwrap();
193+
}
194+
195+
1_i32
73196
}
74-
}
197+
};
75198

76199
std::process::exit(exit_code);
77200
}
201+
202+
fn recursive_resolve(starting_path_buf: PathBuf) -> Result<PathBuf, String> {
203+
let mut current_path_buf = starting_path_buf;
204+
205+
let mut recursion_level = 0_usize;
206+
207+
#[allow(clippy::while_let_loop)]
208+
loop {
209+
match fs::read_link(current_path_buf.as_path()) {
210+
Ok(pa) => {
211+
recursion_level += 1_usize;
212+
213+
// https://unix.stackexchange.com/questions/53087/how-do-you-increase-maxsymlinks
214+
if recursion_level == 40_usize {
215+
return Err(format!(
216+
"Symbolic link chain is circular or just too long, gave up at \"{}\"",
217+
current_path_buf.to_string_lossy()
218+
));
219+
}
220+
221+
if pa.is_absolute() {
222+
current_path_buf = pa;
223+
} else {
224+
if !current_path_buf.pop() {
225+
return Err(format!(
226+
"Could not remove last path segment from path \"{}\"",
227+
current_path_buf.to_string_lossy()
228+
));
229+
}
230+
231+
current_path_buf.push(pa);
232+
}
233+
}
234+
Err(_) => {
235+
break;
236+
}
237+
}
238+
}
239+
240+
Ok(current_path_buf)
241+
}

‎tree/tests/readlink/mod.rs

+5-2
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,10 @@ fn test_readlink_non_existent_file() {
6363

6464
run_test(TestPlan {
6565
cmd: String::from("readlink"),
66-
args: vec![non_existent_path.to_str().unwrap().to_string()],
66+
args: vec![
67+
"-v".to_owned(),
68+
non_existent_path.to_str().unwrap().to_string(),
69+
],
6770
stdin_data: String::new(),
6871
expected_out: String::new(),
6972
expected_err: format!(
@@ -84,7 +87,7 @@ fn test_readlink_not_a_symlink() {
8487

8588
run_test(TestPlan {
8689
cmd: String::from("readlink"),
87-
args: vec![file_path.to_str().unwrap().to_string()],
90+
args: vec!["-v".to_owned(), file_path.to_str().unwrap().to_string()],
8891
stdin_data: String::new(),
8992
expected_out: String::new(),
9093
expected_err: format!(

0 commit comments

Comments
 (0)
Please sign in to comment.