use std::path::Path;
pub(crate) fn canonicalize_for_cache(working_dir: &str) -> String {
dunce::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 {
dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
#[cfg(windows)]
fn normalize_for_prefix(s: &str) -> String {
let stripped = s
.strip_prefix(r"\\?\UNC\")
.map(|rest| format!(r"\\{rest}"))
.unwrap_or_else(|| s.strip_prefix(r"\\?\").unwrap_or(s).to_string());
stripped.replace('\\', "/").to_lowercase()
}
#[cfg(not(windows))]
fn normalize_for_prefix(s: &str) -> String {
s.to_string()
}
pub(crate) fn path_is_within(child: &str, base: &str) -> bool {
let child = normalize_for_prefix(child);
let base = normalize_for_prefix(base);
let base = base.trim_end_matches('/');
if base.is_empty() || base == "/" || is_drive_root(base) {
return false;
}
child == base || child.starts_with(&format!("{base}/"))
}
pub(crate) fn path_starts_with(child: &Path, base: &Path) -> bool {
let child = normalize_for_prefix(&child.to_string_lossy());
let base = normalize_for_prefix(&base.to_string_lossy());
let base = base.trim_end_matches('/');
if base.is_empty() {
return child.starts_with('/');
}
child == base || child.starts_with(&format!("{base}/"))
}
pub(crate) fn paths_equivalent(a: &Path, b: &Path) -> bool {
normalize_for_prefix(&a.to_string_lossy()) == normalize_for_prefix(&b.to_string_lossy())
}
fn is_drive_root(s: &str) -> bool {
let b = s.as_bytes();
b.len() == 2 && b[0].is_ascii_alphabetic() && b[1] == b':'
}
fn is_drive_absolute(s: &str) -> bool {
let b = s.as_bytes();
b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && b[2] == b'\\'
}
fn verbatim_extend(path: String) -> String {
const MAX_PATH: usize = 260;
if path.len() <= MAX_PATH || path.starts_with(r"\\?\") {
return path;
}
if let Some(rest) = path.strip_prefix(r"\\") {
format!(r"\\?\UNC\{rest}")
} else if is_drive_absolute(&path) {
format!(r"\\?\{path}")
} else {
path
}
}
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 dunce::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) = dunce::canonicalize(ancestor) {
let mut out = canonical_ancestor;
for seg in tail.iter().rev() {
out.push(seg);
}
return verbatim_extend(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 path_starts_with_basic_and_boundary() {
assert!(path_starts_with(
Path::new("/proj/src/a.rs"),
Path::new("/proj")
));
assert!(path_starts_with(Path::new("/proj"), Path::new("/proj")));
assert!(!path_starts_with(
Path::new("/proj-other/a.rs"),
Path::new("/proj")
));
assert!(!path_starts_with(
Path::new("/elsewhere/a.rs"),
Path::new("/proj")
));
assert!(path_starts_with(Path::new("/a/b"), Path::new("/")));
}
#[test]
fn paths_equivalent_basic() {
assert!(paths_equivalent(
Path::new("/proj/src"),
Path::new("/proj/src")
));
assert!(!paths_equivalent(
Path::new("/proj/src"),
Path::new("/proj")
));
}
#[cfg(windows)]
#[test]
fn path_starts_with_windows_verbatim_vs_simplified() {
assert!(path_starts_with(
Path::new(r"\\?\C:\proj\aux\src\a.rs"),
Path::new(r"C:\proj"),
));
assert!(path_starts_with(
Path::new(r"C:\proj\src\a.rs"),
Path::new(r"\\?\C:\proj"),
));
assert!(path_starts_with(
Path::new(r"\\?\c:\proj/src/a.rs"),
Path::new(r"C:\proj"),
));
assert!(!path_starts_with(
Path::new(r"\\?\C:\proj-other\a.rs"),
Path::new(r"C:\proj"),
));
}
#[cfg(windows)]
#[test]
fn paths_equivalent_windows_verbatim_vs_simplified() {
assert!(paths_equivalent(
Path::new(r"\\?\C:\proj"),
Path::new(r"C:\proj")
));
assert!(paths_equivalent(
Path::new(r"\\?\c:\proj"),
Path::new(r"C:\proj")
));
assert!(!paths_equivalent(
Path::new(r"\\?\C:\proj2"),
Path::new(r"C:\proj")
));
}
#[test]
fn path_is_within_basic_and_boundary() {
assert!(path_is_within("/proj/src/a.rs", "/proj"));
assert!(path_is_within("/proj", "/proj"));
assert!(path_is_within("/proj/", "/proj"));
assert!(!path_is_within("/proj-other/a.rs", "/proj"));
assert!(!path_is_within("/elsewhere/a.rs", "/proj"));
assert!(!path_is_within("/a", ""));
assert!(!path_is_within("/a", "/"));
}
#[cfg(windows)]
#[test]
fn path_is_within_windows_verbatim_separators_and_case() {
assert!(path_is_within(r"\\?\C:\proj\src\a.dfy", r"C:\proj"));
assert!(path_is_within(r"\\?\C:\proj/src/a.dfy", r"C:\proj"));
assert!(path_is_within(r"\\?\c:\proj\src\a.dfy", r"C:\proj"));
assert!(path_is_within(r"C:\proj\a.dfy", r"\\?\C:\proj"));
assert!(!path_is_within(r"\\?\C:\proj-other\a.dfy", r"C:\proj"));
}
#[cfg(windows)]
#[test]
fn resolve_absolute_has_no_verbatim_prefix() {
let dir = std::env::temp_dir().join(format!("dirge-verbatim-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let existing = dir.join("here.dfy");
std::fs::write(&existing, "x").unwrap();
let r1 = resolve_absolute(existing.to_str().unwrap(), "/");
assert!(
!r1.starts_with(r"\\?\"),
"existing file leaked verbatim prefix: {r1}"
);
let missing = dir.join("newdir").join("not-yet.dfy");
let r2 = resolve_absolute(missing.to_str().unwrap(), dir.to_str().unwrap());
assert!(
!r2.starts_with(r"\\?\"),
"new-file path leaked verbatim prefix: {r2}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[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)]
if let Err(e) = std::os::windows::fs::symlink_file(&target, &link) {
eprintln!("skipping resolve_absolute_follows_symlinks: cannot create symlink: {e}");
let _ = std::fs::remove_dir_all(&dir);
return;
}
let resolved = resolve_absolute(link.to_str().unwrap(), "/");
let expected = dunce::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();
#[cfg(windows)]
if let Err(e) = std::os::windows::fs::symlink_dir(&real, &link) {
eprintln!(
"skipping resolve_absolute_walks_to_deepest_existing_ancestor_through_symlink: \
cannot create symlink: {e}"
);
let _ = std::fs::remove_dir_all(&base);
return;
}
let target = link.join("newdir/sub/file.rs");
let resolved = resolve_absolute(target.to_str().unwrap(), link.to_str().unwrap());
let expected = dunce::canonicalize(&real)
.unwrap()
.join("newdir")
.join("sub")
.join("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 = dunce::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 = dunce::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 = dunce::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());
}
#[test]
fn verbatim_extend_reapplies_prefix_only_for_overlong_windows_paths() {
let long = "a".repeat(300);
let drive = format!(r"C:\proj\{long}\file.rs");
assert_eq!(verbatim_extend(drive.clone()), format!(r"\\?\{drive}"));
let unc = format!(r"\\server\share\{long}\file.rs");
assert_eq!(
verbatim_extend(unc),
format!(r"\\?\UNC\server\share\{long}\file.rs")
);
let short = r"C:\proj\src\a.rs".to_string();
assert_eq!(verbatim_extend(short.clone()), short);
let verbatim = format!(r"\\?\C:\proj\{long}\file.rs");
assert_eq!(verbatim_extend(verbatim.clone()), verbatim);
let unix = format!("/home/user/{long}/file.rs");
assert_eq!(verbatim_extend(unix.clone()), unix);
}
}