use anyhow::{Context, Result, bail};
use std::fs;
use std::path::{Component, Path, PathBuf};
pub(crate) fn map_resolved_path_into_workspace(
repo_root: &Path,
workspace_repo_root: &Path,
resolved_path: &Path,
label: &str,
) -> Result<PathBuf> {
if contains_parent_dir(resolved_path) {
bail!(
"{} path contains '..' component: {}",
label,
resolved_path.display()
);
}
let relative = match resolved_path.strip_prefix(repo_root) {
Ok(relative) => relative.to_path_buf(),
Err(_) => {
let canonical_repo_root = canonicalize_allow_missing_tail(repo_root)
.with_context(|| format!("canonicalize repo root {}", repo_root.display()))?;
let canonical_resolved_path = canonicalize_allow_missing_tail(resolved_path)
.with_context(|| {
format!("canonicalize {} path {}", label, resolved_path.display())
})?;
canonical_resolved_path
.strip_prefix(&canonical_repo_root)
.with_context(|| {
format!(
"{} path {} is not under repo root {}",
label,
resolved_path.display(),
repo_root.display()
)
})?
.to_path_buf()
}
};
for component in relative.components() {
if component == Component::ParentDir {
bail!(
"{} path contains '..' component: {}",
label,
relative.display()
);
}
}
Ok(workspace_repo_root.join(&relative))
}
fn contains_parent_dir(path: &Path) -> bool {
path.components()
.any(|component| component == Component::ParentDir)
}
fn canonicalize_allow_missing_tail(path: &Path) -> Result<PathBuf> {
let mut missing_tail = Vec::new();
let mut cursor = path;
while !cursor.exists() {
let Some(name) = cursor.file_name() else {
break;
};
missing_tail.push(name.to_os_string());
let Some(parent) = cursor.parent() else {
break;
};
cursor = parent;
}
let mut canonical = fs::canonicalize(cursor)
.with_context(|| format!("canonicalize existing path {}", cursor.display()))?;
for component in missing_tail.iter().rev() {
canonical.push(component);
}
Ok(canonical)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
use std::os::unix::fs::symlink;
#[test]
fn map_resolved_path_into_workspace_rejects_traversal() {
let repo_root = PathBuf::from("/repo");
let workspace_root = PathBuf::from("/workspace");
let bad_path = PathBuf::from("/repo/../etc/passwd");
let result =
map_resolved_path_into_workspace(&repo_root, &workspace_root, &bad_path, "test");
assert!(result.is_err(), "Path with .. should be rejected");
let outside_path = PathBuf::from("/other/file.json");
let result =
map_resolved_path_into_workspace(&repo_root, &workspace_root, &outside_path, "test");
assert!(result.is_err(), "Path outside repo root should be rejected");
}
#[test]
fn map_resolved_path_into_workspace_accepts_valid_path() {
let repo_root = PathBuf::from("/repo");
let workspace_root = PathBuf::from("/workspace");
let resolved_path = PathBuf::from("/repo/.ralph/queue.json");
let result =
map_resolved_path_into_workspace(&repo_root, &workspace_root, &resolved_path, "queue");
assert!(result.is_ok());
assert_eq!(
result.unwrap(),
PathBuf::from("/workspace/.ralph/queue.json")
);
}
#[test]
fn map_resolved_path_into_workspace_accepts_nested_path() {
let repo_root = PathBuf::from("/repo");
let workspace_root = PathBuf::from("/workspace");
let resolved_path = PathBuf::from("/repo/queue/active.json");
let result =
map_resolved_path_into_workspace(&repo_root, &workspace_root, &resolved_path, "queue");
assert!(result.is_ok());
assert_eq!(
result.unwrap(),
PathBuf::from("/workspace/queue/active.json")
);
}
#[cfg(unix)]
#[test]
fn map_resolved_path_into_workspace_accepts_symlinked_repo_aliases() -> Result<()> {
let temp = tempfile::tempdir()?;
let repo_root = temp.path().join("repo-real");
let repo_alias = temp.path().join("repo-alias");
let workspace_root = temp.path().join("workspace");
std::fs::create_dir_all(repo_root.join(".ralph"))?;
symlink(&repo_root, &repo_alias)?;
let resolved_path = repo_alias.join(".ralph/queue.json");
std::fs::write(&resolved_path, "{}")?;
let mapped =
map_resolved_path_into_workspace(&repo_root, &workspace_root, &resolved_path, "queue")?;
assert_eq!(mapped, workspace_root.join(".ralph/queue.json"));
Ok(())
}
#[cfg(unix)]
#[test]
fn map_resolved_path_into_workspace_handles_missing_tail_with_repo_alias() -> Result<()> {
let temp = tempfile::tempdir()?;
let repo_root = temp.path().join("repo-real");
let repo_alias = temp.path().join("repo-alias");
let workspace_root = temp.path().join("workspace");
std::fs::create_dir_all(repo_root.join(".ralph"))?;
symlink(&repo_root, &repo_alias)?;
let resolved_path = repo_alias.join(".ralph/cache/missing-done.json");
let mapped =
map_resolved_path_into_workspace(&repo_root, &workspace_root, &resolved_path, "done")?;
assert_eq!(
mapped,
workspace_root.join(".ralph/cache/missing-done.json")
);
Ok(())
}
}