use std::io::ErrorKind;
use std::path::{Component, Path, PathBuf};
use crate::{Host, PathPolicy};
#[derive(Debug, thiserror::Error)]
pub enum PathError {
#[error("path escapes the workspace root: {0}")]
Escape(String),
#[error("workspace root is invalid: {0}")]
InvalidRoot(String),
#[error("io error resolving {path}: {source}")]
Io {
path: String,
source: std::io::Error,
},
}
impl Host {
pub async fn resolve_in_jail(
&self,
cwd: &Path,
candidate: &Path,
) -> Result<PathBuf, PathError> {
let absolute = if candidate.is_absolute() {
candidate.to_path_buf()
} else {
cwd.join(candidate)
};
let normalized = normalize_lexical(&absolute);
if self.path_policy == PathPolicy::Unrestricted {
return Ok(normalized);
}
if !normalized.starts_with(&self.workspace_root) {
return Err(PathError::Escape(normalized.display().to_string()));
}
let canonical_ancestor =
canonicalize_existing_ancestor(&normalized)
.await
.map_err(|source| PathError::Io {
path: normalized.display().to_string(),
source,
})?;
if !canonical_ancestor.starts_with(&self.workspace_root) {
return Err(PathError::Escape(normalized.display().to_string()));
}
Ok(normalized)
}
}
fn normalize_lexical(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
other => out.push(other.as_os_str()),
}
}
out
}
async fn canonicalize_existing_ancestor(path: &Path) -> std::io::Result<PathBuf> {
let mut current = path;
loop {
match tokio::fs::canonicalize(current).await {
Ok(canonical) => return Ok(canonical),
Err(e) if e.kind() == ErrorKind::NotFound => match current.parent() {
Some(parent) => current = parent,
None => return Err(e),
},
Err(e) => return Err(e),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{PathPolicy, test_host};
use tempfile::tempdir;
#[tokio::test]
async fn rejects_parent_and_absolute_escapes() {
let dir = tempdir().unwrap();
let host = test_host(dir.path(), PathPolicy::Jailed, false);
let root = host.workspace_root().to_path_buf();
for bad in ["../etc/passwd", "/etc/passwd", "a/../../b"] {
let err = host
.resolve_in_jail(&root, Path::new(bad))
.await
.expect_err(bad);
assert!(matches!(err, PathError::Escape(_)), "{bad} should escape");
}
}
#[tokio::test]
async fn allows_paths_in_jail_including_nonexistent_leaf() {
let dir = tempdir().unwrap();
let host = test_host(dir.path(), PathPolicy::Jailed, false);
let root = host.workspace_root().to_path_buf();
let resolved = host
.resolve_in_jail(&root, Path::new("newdir/new.txt"))
.await
.expect("nonexistent leaf under root is allowed");
assert!(resolved.starts_with(&root));
let sub = root.join("sub");
let resolved = host
.resolve_in_jail(&sub, Path::new("f.txt"))
.await
.expect("relative under cwd");
assert_eq!(resolved, sub.join("f.txt"));
}
#[cfg(unix)]
#[tokio::test]
async fn rejects_symlink_escape() {
let dir = tempdir().unwrap();
let outside = tempdir().unwrap();
let host = test_host(dir.path(), PathPolicy::Jailed, false);
let root = host.workspace_root().to_path_buf();
std::os::unix::fs::symlink(outside.path(), root.join("link")).unwrap();
let err = host
.resolve_in_jail(&root, Path::new("link/secret.txt"))
.await
.expect_err("symlink escape");
assert!(matches!(err, PathError::Escape(_)));
}
#[tokio::test]
async fn unrestricted_allows_escapes() {
let dir = tempdir().unwrap();
let host = test_host(dir.path(), PathPolicy::Unrestricted, false);
let root = host.workspace_root().to_path_buf();
let resolved = host
.resolve_in_jail(&root, Path::new("/etc/hostname"))
.await
.expect("unrestricted allows absolute out-of-root");
assert_eq!(resolved, Path::new("/etc/hostname"));
}
}