use std::path::Path;
pub(crate) fn canonicalize_for_cache(working_dir: &str) -> String {
std::fs::canonicalize(working_dir)
.ok()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| working_dir.to_string())
}
pub fn canonical_or_self(path: &Path) -> std::path::PathBuf {
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
pub(crate) fn resolve_absolute(path: &str, working_dir: &str) -> String {
let p = Path::new(path);
let joined = if p.is_absolute() {
p.to_path_buf()
} else {
Path::new(working_dir).join(p)
};
match std::fs::canonicalize(&joined) {
Ok(canonical) => canonical.to_string_lossy().to_string(),
Err(_) => {
let normalized = lexical_normalize(&joined);
let mut ancestor = normalized.as_path();
let mut tail: Vec<std::ffi::OsString> = Vec::new();
loop {
if let Ok(canonical_ancestor) = std::fs::canonicalize(ancestor) {
let mut out = canonical_ancestor;
for seg in tail.iter().rev() {
out.push(seg);
}
return out.to_string_lossy().to_string();
}
match (ancestor.parent(), ancestor.file_name()) {
(Some(parent), Some(name)) => {
tail.push(name.to_os_string());
ancestor = parent;
}
_ => return normalized.to_string_lossy().to_string(),
}
}
}
}
}
fn lexical_normalize(p: &Path) -> std::path::PathBuf {
use std::path::{Component, PathBuf};
let mut out: Vec<Component> = Vec::new();
for c in p.components() {
match c {
Component::CurDir => {}
Component::ParentDir => {
if matches!(out.last(), Some(Component::Normal(_))) {
out.pop();
} else {
out.push(c);
}
}
other => out.push(other),
}
}
let mut buf = PathBuf::new();
for c in &out {
buf.push(c.as_os_str());
}
buf
}
#[allow(dead_code)] pub fn validate_path(path: &str) -> Result<(), String> {
let p = Path::new(path);
if p.is_absolute() {
return Ok(());
}
if path.contains('/') || path.contains('\\') {
return Ok(());
}
if path.contains('.') {
return Ok(());
}
if path.chars().all(|c| c.is_ascii_digit()) {
return Err(format!(
"Refusing to use numeric path {:?}. Use an absolute path with a real file name.",
path,
));
}
if path.chars().count() <= 2 {
return Err(format!(
"Refusing to use trivial path {:?}. Use an absolute path with a real file name.",
path,
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_absolute_follows_symlinks() {
let dir =
std::env::temp_dir().join(format!("dirge-f7-symlink-test-{}", std::process::id(),));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let target = dir.join("real-secret.txt");
std::fs::write(&target, "hunter2").unwrap();
let link = dir.join("benign-name.txt");
#[cfg(unix)]
std::os::unix::fs::symlink(&target, &link).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_file(&target, &link).unwrap();
let resolved = resolve_absolute(link.to_str().unwrap(), "/");
let expected = std::fs::canonicalize(&target)
.unwrap()
.to_string_lossy()
.into_owned();
assert_eq!(resolved, expected, "symlink should resolve to its target",);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn resolve_absolute_walks_to_deepest_existing_ancestor_through_symlink() {
let base = std::env::temp_dir().join(format!("dirge-deepancestor-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let real = base.join("real_proj");
std::fs::create_dir_all(&real).unwrap();
let link = base.join("link_proj");
#[cfg(unix)]
std::os::unix::fs::symlink(&real, &link).unwrap();
let target = link.join("newdir/sub/file.rs");
let resolved = resolve_absolute(target.to_str().unwrap(), link.to_str().unwrap());
let expected = std::fs::canonicalize(&real)
.unwrap()
.join("newdir/sub/file.rs")
.to_string_lossy()
.into_owned();
assert_eq!(
resolved, expected,
"deep new path under symlinked cwd must resolve to realpath form",
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn resolve_absolute_dotdot_escape_through_symlinked_cwd_still_escapes() {
let base = std::env::temp_dir().join(format!("dirge-escape-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let real = base.join("real_proj");
std::fs::create_dir_all(&real).unwrap();
let link = base.join("link_proj");
#[cfg(unix)]
std::os::unix::fs::symlink(&real, &link).unwrap();
let cwd = link.to_string_lossy().into_owned();
let traversal = "newdir/../../../../../../etc/passwd";
let resolved = resolve_absolute(traversal, &cwd);
let cwd_canonical = std::fs::canonicalize(&real).unwrap();
let resolved_path = std::path::PathBuf::from(&resolved);
assert!(
!resolved_path.starts_with(&cwd_canonical),
"escape via .. must resolve outside the cwd subtree; got {resolved:?}",
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn resolve_absolute_handles_nonexistent_via_parent_canonicalize() {
let dir =
std::env::temp_dir().join(format!("dirge-f7-newfile-test-{}", std::process::id(),));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let new_file = dir.join("does-not-exist-yet.txt");
let resolved = resolve_absolute(new_file.to_str().unwrap(), "/");
let expected_parent = std::fs::canonicalize(&dir).unwrap();
let expected = expected_parent
.join("does-not-exist-yet.txt")
.to_string_lossy()
.into_owned();
assert_eq!(resolved, expected);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn resolve_absolute_normalizes_dotdot_in_full_lexical_fallback() {
let dir = std::env::temp_dir().join(format!("dirge-c3-traversal-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let cwd = dir.to_string_lossy().into_owned();
let traversal = "no_such_dir/no_such_subdir/../../../etc/passwd";
let resolved = resolve_absolute(traversal, &cwd);
let cwd_canonical = std::fs::canonicalize(&cwd).unwrap();
let resolved_path = std::path::PathBuf::from(&resolved);
assert!(
!resolved_path.starts_with(&cwd_canonical) && !resolved_path.starts_with(&cwd),
"lexical-fallback path-traversal should escape cwd subtree; got {:?}, cwd {:?}",
resolved_path,
cwd_canonical,
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn validate_accepts_absolute_paths() {
assert!(validate_path("/etc/hosts").is_ok());
assert!(validate_path("/Users/bob/src/main.rs").is_ok());
}
#[test]
fn validate_accepts_relative_paths_with_separator() {
assert!(validate_path("src/main.rs").is_ok());
assert!(validate_path("lib/core.js").is_ok());
assert!(validate_path("..\\windows\\path").is_ok());
}
#[test]
fn validate_accepts_relative_names_with_extension() {
assert!(validate_path("Cargo.toml").is_ok());
assert!(validate_path("README.md").is_ok());
assert!(validate_path("build.sh").is_ok());
}
#[test]
fn validate_accepts_extensionless_names_that_are_not_trivial() {
assert!(validate_path("Makefile").is_ok());
assert!(validate_path("Dockerfile").is_ok());
assert!(validate_path("README").is_ok());
assert!(validate_path("LICENSE").is_ok());
assert!(validate_path("abc").is_ok());
}
#[test]
fn validate_rejects_numeric_paths() {
assert!(validate_path("1").is_err());
assert!(validate_path("42").is_err());
assert!(validate_path("007").is_err());
}
#[test]
fn validate_rejects_short_nonsense_paths() {
assert!(validate_path("a").is_err());
assert!(validate_path("xy").is_err());
}
}