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 candidate = expand_home_prefix(candidate, home_dir().as_deref());
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 home_dir() -> Option<PathBuf> {
std::env::var_os("HOME")
.filter(|h| !h.is_empty())
.map(PathBuf::from)
}
fn expand_home_prefix<'a>(path: &'a Path, home: Option<&Path>) -> std::borrow::Cow<'a, Path> {
use std::borrow::Cow;
let Some(raw) = path.to_str() else {
return Cow::Borrowed(path);
};
let Some(home) = home else {
return Cow::Borrowed(path);
};
if raw == "~" {
return Cow::Owned(home.to_path_buf());
}
match raw.strip_prefix("~/") {
Some(rest) => Cow::Owned(home.join(rest.trim_start_matches('/'))),
None => Cow::Borrowed(path),
}
}
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;
#[test]
fn expands_bare_tilde_and_tilde_slash() {
let home = Path::new("/home/u");
let cases = [
("~", "/home/u"),
("~/", "/home/u"),
("~/.locode/settings.json", "/home/u/.locode/settings.json"),
("~//nested", "/home/u/nested"),
];
for (raw, want) in cases {
let got = expand_home_prefix(Path::new(raw), Some(home));
assert_eq!(normalize_lexical(&got), Path::new(want), "{raw}");
}
}
#[test]
fn leaves_non_home_prefixes_alone() {
let home = Path::new("/home/u");
for raw in [
"~root/x",
"~x",
"a/~/b",
"./~",
"relative/path",
"/abs/path",
] {
let got = expand_home_prefix(Path::new(raw), Some(home));
assert_eq!(got.as_ref(), Path::new(raw), "{raw} must be untouched");
}
}
#[test]
fn without_home_the_path_is_unchanged() {
let got = expand_home_prefix(Path::new("~/x"), None);
assert_eq!(got.as_ref(), Path::new("~/x"));
}
#[tokio::test]
async fn tilde_resolves_to_the_real_home_not_a_cwd_subdir() {
let Some(home) = home_dir() else {
return; };
let dir = tempdir().unwrap();
let host = test_host(dir.path(), PathPolicy::Unrestricted, false);
let cwd = host.workspace_root().to_path_buf();
let resolved = host
.resolve_in_jail(&cwd, Path::new("~/.locode/settings.json"))
.await
.expect("unrestricted resolve");
assert_eq!(resolved, home.join(".locode/settings.json"));
assert!(!resolved.starts_with(&cwd), "must not land under cwd");
}
#[tokio::test]
async fn jailed_tilde_escape_names_the_expanded_path() {
let Some(home) = home_dir() else {
return;
};
let dir = tempdir().unwrap();
let host = test_host(dir.path(), PathPolicy::Jailed, false);
let cwd = host.workspace_root().to_path_buf();
let err = host
.resolve_in_jail(&cwd, Path::new("~/.locode/settings.json"))
.await
.expect_err("home is outside the workspace root");
match err {
PathError::Escape(shown) => {
assert!(shown.starts_with(&home.display().to_string()), "{shown}");
assert!(
!shown.contains('~'),
"the literal tilde must be gone: {shown}"
);
}
other => panic!("expected Escape, got {other:?}"),
}
}
#[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)]
#[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(_)));
}
#[cfg(unix)]
#[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"));
}
}