use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};
const MAX_SYMLINK_DEPTH: usize = 40;
const MIN_SAFE_SYMLINK_DEPTH: usize = 8;
pub fn find_symlink_in_path(path: &Path) -> std::io::Result<Option<PathBuf>> {
let mut current = PathBuf::new();
for component in path.components() {
match component {
Component::RootDir | Component::Prefix(_) => {
current.push(component.as_os_str());
continue; }
Component::CurDir | Component::ParentDir => {
current.push(component.as_os_str());
continue; }
Component::Normal(part) => {
current.push(part);
}
}
match std::fs::symlink_metadata(¤t) {
Ok(meta) if meta.file_type().is_symlink() => {
return Ok(Some(current));
}
Ok(_) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
break; }
Err(e) => return Err(e),
}
}
Ok(None)
}
pub fn resolve_deepest_existing_ancestor(path: &Path) -> std::io::Result<Option<PathBuf>> {
let mut tail: Vec<std::ffi::OsString> = Vec::new();
let mut dir = path.to_path_buf();
while let Some(parent_ref) = dir.parent() {
let parent = parent_ref.to_path_buf();
match std::fs::symlink_metadata(&dir) {
Ok(meta) if meta.file_type().is_symlink() => {
let resolved = match std::fs::canonicalize(&dir) {
Ok(r) => r,
Err(_) => {
let target = std::fs::read_link(&dir)?;
if target.is_absolute() {
target
} else {
dir.parent().map(|p| p.join(&target)).unwrap_or(target)
}
}
};
let final_path = tail.iter().rev().fold(resolved, |acc, seg| acc.join(seg));
return Ok(Some(final_path));
}
Ok(_) => {
match std::fs::canonicalize(&dir) {
Ok(canon) if canon != dir => {
let final_path = tail.iter().rev().fold(canon, |acc, seg| acc.join(seg));
return Ok(Some(final_path));
}
Ok(_) => return Ok(None), Err(_) => return Ok(None), }
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if let Some(name) = dir.file_name() {
tail.push(name.to_os_string());
}
dir = parent;
continue;
}
Err(e) => return Err(e),
}
}
Ok(None)
}
fn effective_chain_depth(policy_depth: u8) -> usize {
(policy_depth as usize).clamp(MIN_SAFE_SYMLINK_DEPTH, MAX_SYMLINK_DEPTH)
}
pub fn paths_for_write_check(path: &Path, deny_search_depth: u8) -> Vec<PathBuf> {
let mut result: HashSet<PathBuf> = HashSet::new();
result.insert(path.to_path_buf());
match std::fs::symlink_metadata(path) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if let Ok(Some(resolved)) = resolve_deepest_existing_ancestor(path) {
result.insert(resolved);
}
return result.into_iter().collect();
}
_ => {}
}
let mut current = path.to_path_buf();
let mut visited: HashSet<PathBuf> = HashSet::new();
for _ in 0..effective_chain_depth(deny_search_depth) {
if visited.contains(¤t) {
break; }
visited.insert(current.clone());
let meta = match std::fs::symlink_metadata(¤t) {
Ok(m) => m,
Err(_) => break,
};
#[cfg(unix)]
{
use std::os::unix::fs::FileTypeExt as _;
let ft = meta.file_type();
if ft.is_fifo() || ft.is_socket() || ft.is_block_device() || ft.is_char_device() {
break;
}
}
if !meta.file_type().is_symlink() {
if let Ok(canon) = std::fs::canonicalize(¤t) {
result.insert(canon);
}
break;
}
let target = match std::fs::read_link(¤t) {
Ok(t) => t,
Err(_) => break,
};
let next = if target.is_absolute() {
target
} else {
match current.parent() {
Some(parent) => parent.join(&target),
None => break,
}
};
result.insert(next.clone());
current = next;
}
result.into_iter().collect()
}
#[inline]
pub fn is_path_inside(path: &Path, root: &Path) -> bool {
path.starts_with(root)
}
pub fn is_dangerous_system_path(path: &Path) -> bool {
{
let s = path.to_string_lossy();
if s == "*" || s.ends_with("/*") || s.ends_with("/\\*") {
return true;
}
}
if path == Path::new("/") {
return true;
}
if let Ok(home) = std::env::var("HOME")
&& path == Path::new(&home)
{
return true;
}
#[cfg(windows)]
{
let s = path.to_string_lossy().to_lowercase();
if s.len() <= 3 && s.chars().nth(1) == Some(':') {
return true;
}
if let Some(parent) = path.parent() {
let parent_s = parent.to_string_lossy().to_lowercase();
if parent_s.len() <= 3 && parent_s.chars().nth(1) == Some(':') {
return true;
}
}
}
if let Some(parent) = path.parent()
&& parent == Path::new("/")
{
return true;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn same_path_is_inside() {
assert!(is_path_inside(Path::new("/a/b"), Path::new("/a/b")));
}
#[test]
fn child_is_inside() {
assert!(is_path_inside(Path::new("/a/b/c"), Path::new("/a/b")));
}
#[test]
fn sibling_is_not_inside() {
assert!(!is_path_inside(Path::new("/a/bc"), Path::new("/a/b")));
}
#[test]
fn parent_is_not_inside_child() {
assert!(!is_path_inside(Path::new("/a"), Path::new("/a/b")));
}
#[test]
fn prefix_match_respects_component_boundary() {
assert!(!is_path_inside(
Path::new("/etc/password"),
Path::new("/etc/pass")
));
}
#[test]
fn root_is_dangerous() {
assert!(is_dangerous_system_path(Path::new("/")));
}
#[test]
fn direct_children_of_root_are_dangerous() {
for p in [
"/usr",
"/etc",
"/bin",
"/sbin",
"/dev",
"/proc",
"/sys",
"/var",
"/tmp",
"/boot",
"/lib",
"/lib64",
"/System",
"/Library",
"/Applications",
] {
assert!(
is_dangerous_system_path(Path::new(p)),
"{p} should be dangerous"
);
}
}
#[test]
fn subdirs_of_system_dirs_are_safe() {
for p in [
"/usr/local",
"/usr/local/share",
"/var/tmp/build",
"/tmp/sandbox",
"/etc/apache2",
] {
assert!(
!is_dangerous_system_path(Path::new(p)),
"{p} should be safe"
);
}
}
#[test]
fn user_project_dirs_are_safe() {
for p in ["/home/user/project", "/Users/alice/work"] {
assert!(
!is_dangerous_system_path(Path::new(p)),
"{p} should be safe"
);
}
}
#[test]
fn glob_wildcards_are_dangerous() {
assert!(is_dangerous_system_path(Path::new("*")));
assert!(is_dangerous_system_path(Path::new("/home/user/*")));
}
#[test]
fn no_symlink_returns_none() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("real.txt");
fs::write(&file, b"hi").unwrap();
let result = find_symlink_in_path(&file).unwrap();
let _ = result;
}
#[cfg(unix)]
#[test]
fn symlink_component_detected() {
let tmp = tempfile::tempdir().unwrap();
let real_dir = tmp.path().join("real");
let link_dir = tmp.path().join("link");
let target_file = real_dir.join("secret.txt");
fs::create_dir_all(&real_dir).unwrap();
fs::write(&target_file, b"secret").unwrap();
std::os::unix::fs::symlink(&real_dir, &link_dir).unwrap();
let path_through_link = link_dir.join("secret.txt");
let found = find_symlink_in_path(&path_through_link).unwrap();
assert!(found.is_some(), "should detect symlink component");
}
#[test]
fn real_file_returns_at_least_original() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("plain.txt");
fs::write(&file, b"x").unwrap();
let paths = paths_for_write_check(&file, 0);
assert!(paths.contains(&file) || paths.iter().any(|p| p.ends_with("plain.txt")));
}
#[cfg(unix)]
#[test]
fn symlink_expands_to_full_chain() {
let tmp = tempfile::tempdir().unwrap();
let real = tmp.path().join("real.txt");
let link = tmp.path().join("link.txt");
fs::write(&real, b"secret").unwrap();
std::os::unix::fs::symlink(&real, &link).unwrap();
let paths = paths_for_write_check(&link, 0);
assert!(paths.len() >= 2, "expected ≥2 paths, got {paths:?}");
let has_real = paths.iter().any(|p| {
p.ends_with("real.txt")
|| std::fs::canonicalize(p)
.ok()
.map(|c| c.ends_with("real.txt"))
.unwrap_or(false)
});
assert!(has_real, "resolved target should be in chain: {paths:?}");
}
#[cfg(unix)]
#[test]
fn dangling_symlink_ancestor_resolved() {
let tmp = tempfile::tempdir().unwrap();
let outside = tmp.path().join("outside");
let link = tmp.path().join("link");
std::os::unix::fs::symlink(&outside, &link).unwrap();
let check_target = link.join("new_file.txt");
let paths = paths_for_write_check(&check_target, 0);
let has_outside = paths.iter().any(|p| p.ends_with("outside/new_file.txt"));
assert!(
has_outside,
"dangling symlink should resolve to outside dir: {paths:?}"
);
}
#[test]
fn effective_chain_depth_respects_min_floor_for_plan_mode() {
assert_eq!(effective_chain_depth(3), MIN_SAFE_SYMLINK_DEPTH);
assert_eq!(effective_chain_depth(0), MIN_SAFE_SYMLINK_DEPTH);
for d in 0..=MIN_SAFE_SYMLINK_DEPTH as u8 {
assert_eq!(
effective_chain_depth(d),
MIN_SAFE_SYMLINK_DEPTH,
"depth {d} must clamp UP to floor {MIN_SAFE_SYMLINK_DEPTH}"
);
}
}
#[test]
fn effective_chain_depth_passes_through_in_band_values() {
assert_eq!(effective_chain_depth(5), MIN_SAFE_SYMLINK_DEPTH);
assert_eq!(effective_chain_depth(10), 10);
assert_eq!(effective_chain_depth(20), 20);
}
#[test]
fn effective_chain_depth_caps_at_kernel_max() {
assert_eq!(effective_chain_depth(40), MAX_SYMLINK_DEPTH);
assert_eq!(effective_chain_depth(100), MAX_SYMLINK_DEPTH);
assert_eq!(effective_chain_depth(u8::MAX), MAX_SYMLINK_DEPTH);
}
#[test]
fn effective_chain_depth_is_monotone_non_decreasing() {
let mut prev = effective_chain_depth(0);
for d in 1..=u8::MAX {
let cur = effective_chain_depth(d);
assert!(cur >= prev, "depth {d}: {cur} regressed from prev {prev}");
prev = cur;
}
}
#[test]
fn ancestor_no_symlink_returns_none() {
let tmp = tempfile::tempdir().unwrap();
let result = resolve_deepest_existing_ancestor(tmp.path()).unwrap();
let _ = result;
}
#[cfg(unix)]
#[test]
fn live_symlink_resolved() {
let tmp = tempfile::tempdir().unwrap();
let real_dir = tmp.path().join("real");
let link_dir = tmp.path().join("link");
fs::create_dir_all(&real_dir).unwrap();
std::os::unix::fs::symlink(&real_dir, &link_dir).unwrap();
let path = link_dir.join("new_file.txt");
let resolved = resolve_deepest_existing_ancestor(&path).unwrap();
let resolved = resolved.expect("should find a symlink");
assert!(resolved.ends_with("real/new_file.txt"), "got: {resolved:?}");
}
}