use std::path::Path;
pub struct SymlinkClassification {
pub description: Vec<u8>,
pub unreadable: bool,
}
fn path_bytes(path: &Path) -> Vec<u8> {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
path.as_os_str().as_bytes().to_vec()
}
#[cfg(not(unix))]
{
path.to_string_lossy().into_owned().into_bytes()
}
}
pub fn render_symlink_target(target: &Path, escape_control_bytes: bool) -> Vec<u8> {
use std::fmt::Write;
let raw = path_bytes(target);
if !escape_control_bytes {
return raw;
}
let decoded = String::from_utf8_lossy(&raw);
let mut escaped = String::with_capacity(decoded.len());
for character in decoded.chars() {
let code = character as u32;
if is_terminal_control(code) {
#[allow(clippy::let_underscore_must_use)]
let _ = if code <= 0xFF {
write!(escaped, "\\x{code:02x}")
} else {
write!(escaped, "\\u{{{code:04x}}}")
};
} else {
escaped.push(character);
}
}
escaped.into_bytes()
}
fn is_terminal_control(code: u32) -> bool {
const BIDI_OVERRIDES: [u32; 9] = [
0x200E, 0x200F, 0x061C, 0x202A, 0x202B, 0x202C, 0x202D, 0x202E, 0x2066, ];
code < 0x20
|| code == 0x7F
|| (0x80..=0x9F).contains(&code)
|| (0x2066..=0x2069).contains(&code)
|| BIDI_OVERRIDES.contains(&code)
}
fn prefixed(prefix: &[u8], target: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(prefix.len() + target.len());
out.extend_from_slice(prefix);
out.extend_from_slice(target);
out
}
pub fn classify_symlink(
path: &Path,
follows_symlinks: bool,
escape_control_bytes: bool,
) -> Option<SymlinkClassification> {
if !std::fs::symlink_metadata(path)
.ok()?
.file_type()
.is_symlink()
{
return None;
}
let target = std::fs::read_link(path).ok()?;
if target.as_os_str().is_empty() {
return Some(SymlinkClassification {
description: format!(
"unreadable symlink `{}' (No such file or directory)",
path.display()
)
.into_bytes(),
unreadable: true,
});
}
if std::fs::metadata(path).is_err() {
return Some(SymlinkClassification {
description: prefixed(
b"broken symbolic link to ",
&render_symlink_target(&target, escape_control_bytes),
),
unreadable: true,
});
}
if follows_symlinks {
return None;
}
Some(SymlinkClassification {
description: prefixed(
b"symbolic link to ",
&render_symlink_target(&target, escape_control_bytes),
),
unreadable: false,
})
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn test_render_symlink_target_passes_control_bytes_through_when_not_a_terminal() {
let cases: &[(&str, &str)] = &[
("plain.txt", "plain.txt"),
("../../up/two.txt", "../../up/two.txt"),
("/absolute/target", "/absolute/target"),
("esc\u{1b}[2Jclear", "esc\u{1b}[2Jclear"),
("bell\u{7}", "bell\u{7}"),
("del\u{7f}", "del\u{7f}"),
];
for (input, expected) in cases {
let rendered = render_symlink_target(Path::new(input), false);
assert_eq!(
rendered,
expected.as_bytes(),
"captured output must pass bytes through verbatim for {input:?} \
-- this is the branch that preserves GNU `file` parity"
);
}
}
#[test]
fn test_render_symlink_target_escapes_control_bytes_on_a_terminal() {
let cases: &[(&str, &str)] = &[
("plain.txt", "plain.txt"),
("../../up/two.txt", "../../up/two.txt"),
("esc\u{1b}[2Jclear", "esc\\x1b[2Jclear"),
("bell\u{7}", "bell\\x07"),
("del\u{7f}", "del\\x7f"),
("tab\there", "tab\\x09here"),
("nl\nhere", "nl\\x0ahere"),
("caf\u{e9}", "caf\u{e9}"),
];
for (input, expected) in cases {
let rendered = render_symlink_target(Path::new(input), true);
assert_eq!(
rendered,
expected.as_bytes(),
"interactive output must escape control bytes for {input:?}"
);
}
}
#[test]
fn test_render_symlink_target_escapes_c1_controls_and_bidi_overrides_on_a_terminal() {
let cases: &[(&str, &str)] = &[
("osc\u{9d}0;title", "osc\\x9d0;title"),
("csi\u{9b}2J", "csi\\x9b2J"),
("low\u{80}", "low\\x80"),
("high\u{9f}", "high\\x9f"),
("rtl\u{202e}txt.exe", "rtl\\u{202e}txt.exe"),
("iso\u{2066}x", "iso\\u{2066}x"),
("lrm\u{200e}x", "lrm\\u{200e}x"),
("ok\u{a0}x", "ok\u{a0}x"),
("caf\u{e9}", "caf\u{e9}"),
];
for (input, expected) in cases {
let rendered = render_symlink_target(Path::new(input), true);
assert_eq!(
rendered,
expected.as_bytes(),
"interactive output must neutralize {input:?}"
);
}
}
#[cfg(unix)]
#[test]
fn test_render_symlink_target_preserves_non_utf8_bytes_when_not_a_terminal() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
let raw = b"bad\xff\xfename.txt";
let target = Path::new(OsStr::from_bytes(raw));
assert_eq!(
render_symlink_target(target, false),
raw,
"the captured-output branch must pass invalid UTF-8 through unchanged"
);
let escaped = render_symlink_target(target, true);
assert!(escaped.starts_with(b"bad"), "valid prefix must survive");
assert!(escaped.ends_with(b"name.txt"), "valid suffix must survive");
}
#[cfg(unix)]
#[test]
fn test_classify_symlink_distinguishes_followed_from_not_a_symlink() {
let temp_dir = tempfile::TempDir::new().unwrap();
let target = temp_dir.path().join("real.txt");
std::fs::write(&target, b"content").unwrap();
let link = temp_dir.path().join("valid.link");
std::os::unix::fs::symlink("real.txt", &link).unwrap();
assert!(
classify_symlink(&link, true, false).is_none(),
"a reachable symlink under follow must fall through to the target"
);
let classified = classify_symlink(&link, false, false)
.expect("a reachable symlink under no-follow must be classified");
assert_eq!(classified.description, b"symbolic link to real.txt");
assert!(
!classified.unreadable,
"a readable target rmagic declined to read is not an I/O failure"
);
}
#[test]
fn test_classify_symlink_returns_none_for_a_regular_file() {
let temp_dir = tempfile::TempDir::new().unwrap();
let path = temp_dir.path().join("regular.txt");
std::fs::write(&path, b"content").unwrap();
assert!(
classify_symlink(&path, true, false).is_none(),
"a regular file must fall through to ordinary classification"
);
}
#[test]
fn test_classify_symlink_returns_none_for_a_missing_path() {
let temp_dir = tempfile::TempDir::new().unwrap();
let path = temp_dir.path().join("does-not-exist");
assert!(
classify_symlink(&path, true, false).is_none(),
"a nonexistent non-symlink path must keep its existing error path"
);
}
}