use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};
pub fn try_canonicalize(path: &Path) -> PathBuf {
if let Ok(canonical) = path.canonicalize() {
return canonical;
}
try_canonicalize_ancestor_walk(path)
}
pub(crate) fn try_canonicalize_ancestor_walk(path: &Path) -> PathBuf {
let mut remaining = Vec::new();
let mut current = path.to_path_buf();
loop {
if let Ok(canonical) = current.canonicalize() {
let mut result = canonical;
for component in remaining.iter().rev() {
result = result.join(component);
}
return result;
}
match current.file_name() {
Some(name) => {
remaining.push(name.to_os_string());
if !current.pop() {
break;
}
}
None => break,
}
}
path.to_path_buf()
}
const MAX_SYMLINK_HOPS: usize = 40;
pub fn collect_symlink_hops(path: &Path) -> Vec<PathBuf> {
let mut hops = Vec::new();
let mut seen = HashSet::new();
let mut depth = 0usize;
resolve_hops(path, &mut hops, &mut seen, &mut depth);
hops
}
fn resolve_hops(
path: &Path,
hops: &mut Vec<PathBuf>,
seen: &mut HashSet<PathBuf>,
depth: &mut usize,
) -> PathBuf {
let mut result = PathBuf::new();
for component in path.components() {
match component {
Component::Prefix(_) | Component::RootDir => {
result.push(component.as_os_str());
}
Component::CurDir => {}
Component::ParentDir => {
result.pop();
}
Component::Normal(segment) => {
result.push(segment);
if *depth >= MAX_SYMLINK_HOPS {
continue;
}
let Ok(metadata) = std::fs::symlink_metadata(&result) else {
continue;
};
if !metadata.file_type().is_symlink() {
continue;
}
*depth += 1;
if !seen.insert(result.clone()) {
continue;
}
hops.push(result.clone());
let Ok(target) = std::fs::read_link(&result) else {
continue;
};
let target = if target.is_absolute() {
target
} else {
result
.parent()
.map(|parent| parent.join(&target))
.unwrap_or(target)
};
result = resolve_hops(&target, hops, seen, depth);
}
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn existing_path_canonicalizes() {
let dir = tempfile::tempdir().expect("tempdir");
let canonical = dir.path().canonicalize().expect("canonicalize");
assert_eq!(try_canonicalize(dir.path()), canonical);
}
#[test]
fn nonexistent_leaf_uses_ancestor() {
let dir = tempfile::tempdir().expect("tempdir");
let canonical_dir = dir.path().canonicalize().expect("canonicalize");
let nonexistent = dir.path().join("does_not_exist");
let result = try_canonicalize(&nonexistent);
assert_eq!(result, canonical_dir.join("does_not_exist"));
}
#[test]
fn nonexistent_nested_uses_deepest_ancestor() {
let dir = tempfile::tempdir().expect("tempdir");
let canonical_dir = dir.path().canonicalize().expect("canonicalize");
let nonexistent = dir.path().join("a").join("b").join("c");
let result = try_canonicalize(&nonexistent);
assert_eq!(result, canonical_dir.join("a").join("b").join("c"));
}
#[test]
fn existing_symlink_resolves_through() {
let dir = tempfile::tempdir().expect("tempdir");
let real_file = dir.path().join("real.txt");
fs::write(&real_file, "hello").expect("write file");
let link = dir.path().join("link.txt");
#[cfg(unix)]
std::os::unix::fs::symlink(&real_file, &link).expect("symlink");
#[cfg(unix)]
{
let result = try_canonicalize(&link);
assert_eq!(result, real_file.canonicalize().expect("canonicalize"));
}
}
#[cfg(unix)]
#[test]
fn collect_symlink_hops_empty_for_plain_path() {
let dir = tempfile::tempdir().expect("tempdir");
let canonical_dir = dir.path().canonicalize().expect("canonicalize");
let real_file = canonical_dir.join("real.txt");
fs::write(&real_file, "hello").expect("write file");
assert!(collect_symlink_hops(&real_file).is_empty());
}
#[cfg(unix)]
#[test]
fn collect_symlink_hops_single_hop_leaf_symlink() {
let dir = tempfile::tempdir().expect("tempdir");
let canonical_dir = dir.path().canonicalize().expect("canonicalize");
let real_file = canonical_dir.join("real.txt");
fs::write(&real_file, "hello").expect("write file");
let link = canonical_dir.join("link.txt");
std::os::unix::fs::symlink(&real_file, &link).expect("symlink");
let hops = collect_symlink_hops(&link);
assert_eq!(hops, vec![link]);
}
#[cfg(unix)]
#[test]
fn collect_symlink_hops_multi_hop_through_symlinked_directory() {
let dir = tempfile::tempdir().expect("tempdir");
let canonical_dir = dir.path().canonicalize().expect("canonicalize");
let hosts = canonical_dir.join("hosts");
let mymac = hosts.join("mymac");
fs::create_dir_all(&mymac).expect("mkdir mymac");
let real_gitconfig = mymac.join("gitconfig");
fs::write(&real_gitconfig, "[user]\n").expect("write gitconfig");
let current = hosts.join("current");
std::os::unix::fs::symlink(&mymac, ¤t).expect("symlink dir");
let gitconfig_link = canonical_dir.join(".gitconfig");
std::os::unix::fs::symlink(current.join("gitconfig"), &gitconfig_link)
.expect("symlink leaf");
let hops = collect_symlink_hops(&gitconfig_link);
assert_eq!(hops, vec![gitconfig_link.clone(), current]);
assert_eq!(
try_canonicalize(&gitconfig_link),
real_gitconfig.canonicalize().expect("canonicalize")
);
}
#[cfg(unix)]
#[test]
fn collect_symlink_hops_cycle_truncates_instead_of_looping() {
let dir = tempfile::tempdir().expect("tempdir");
let canonical_dir = dir.path().canonicalize().expect("canonicalize");
let a = canonical_dir.join("a");
let b = canonical_dir.join("b");
std::os::unix::fs::symlink(&b, &a).expect("symlink a->b");
std::os::unix::fs::symlink(&a, &b).expect("symlink b->a");
let hops = collect_symlink_hops(&a);
assert!(hops.len() <= MAX_SYMLINK_HOPS);
assert!(hops.contains(&a));
assert!(hops.contains(&b));
}
#[cfg(unix)]
#[test]
fn collect_symlink_hops_nonexistent_final_target_keeps_existing_hops() {
let dir = tempfile::tempdir().expect("tempdir");
let canonical_dir = dir.path().canonicalize().expect("canonicalize");
let hosts = canonical_dir.join("hosts");
let mymac = hosts.join("mymac");
fs::create_dir_all(&mymac).expect("mkdir mymac");
let current = hosts.join("current");
std::os::unix::fs::symlink(&mymac, ¤t).expect("symlink dir");
let gitconfig_link = canonical_dir.join(".gitconfig");
std::os::unix::fs::symlink(current.join("gitconfig"), &gitconfig_link)
.expect("symlink leaf");
let hops = collect_symlink_hops(&gitconfig_link);
assert_eq!(hops, vec![gitconfig_link, current]);
}
}