use std::path::{Component, Path, PathBuf};
use crate::error::Error;
#[derive(Debug, Clone)]
pub struct Workspace {
root: PathBuf,
}
impl Workspace {
pub fn open(root: impl Into<PathBuf>) -> Result<Self, Error> {
let root = root.into();
if !root.exists() {
std::fs::create_dir_all(&root).map_err(|e| {
Error::Config(format!(
"failed to create workspace at {}: {e}",
root.display()
))
})?;
}
let root = root.canonicalize().map_err(|e| {
Error::Config(format!(
"failed to canonicalize workspace path {}: {e}",
root.display()
))
})?;
Ok(Self { root })
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn resolve(&self, path: &str) -> Result<PathBuf, Error> {
let p = Path::new(path);
if p.is_absolute() {
return Ok(p.to_path_buf());
}
let candidate = self.root.join(p);
let normalized = normalize_path(&candidate);
if !normalized.starts_with(&self.root) {
return Err(Error::Agent(format!(
"path '{}' escapes workspace root ({})",
path,
self.root.display()
)));
}
Ok(normalized)
}
}
pub fn normalize_path(path: &Path) -> PathBuf {
let mut components = Vec::new();
for component in path.components() {
match component {
Component::ParentDir => {
match components.last() {
Some(Component::Normal(_)) => {
components.pop();
}
_ => {
components.push(component);
}
}
}
Component::CurDir => {} _ => components.push(component),
}
}
components.iter().collect()
}
#[derive(Debug, Clone, Default)]
pub enum EnvPolicy {
#[default]
Inherit,
Allowlist(Vec<String>),
}
pub const DAEMON_ENV_ALLOWLIST: &[&str] = &[
"PATH", "HOME", "USER", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "TERM", "SHELL", "TMPDIR",
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn open_creates_directory() {
let dir = tempfile::tempdir().unwrap();
let ws_path = dir.path().join("new_workspace");
assert!(!ws_path.exists());
let ws = Workspace::open(&ws_path).unwrap();
assert!(ws_path.exists());
assert!(ws.root().is_absolute());
}
#[test]
fn open_existing_directory() {
let dir = tempfile::tempdir().unwrap();
let ws = Workspace::open(dir.path()).unwrap();
assert_eq!(ws.root(), dir.path().canonicalize().unwrap());
}
#[test]
fn resolve_relative_path() {
let dir = tempfile::tempdir().unwrap();
let ws = Workspace::open(dir.path()).unwrap();
let resolved = ws.resolve("notes.md").unwrap();
assert_eq!(resolved, ws.root().join("notes.md"));
}
#[test]
fn resolve_nested_relative_path() {
let dir = tempfile::tempdir().unwrap();
let ws = Workspace::open(dir.path()).unwrap();
let resolved = ws.resolve("sub/dir/file.txt").unwrap();
assert_eq!(resolved, ws.root().join("sub/dir/file.txt"));
}
#[test]
fn resolve_absolute_path_passthrough() {
let dir = tempfile::tempdir().unwrap();
let ws = Workspace::open(dir.path()).unwrap();
let resolved = ws.resolve("/etc/hosts").unwrap();
assert_eq!(resolved, PathBuf::from("/etc/hosts"));
}
#[test]
fn resolve_rejects_escape() {
let dir = tempfile::tempdir().unwrap();
let ws = Workspace::open(dir.path()).unwrap();
let result = ws.resolve("../../etc/passwd");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("escapes workspace root"), "got: {err}");
}
#[test]
fn resolve_allows_internal_dotdot() {
let dir = tempfile::tempdir().unwrap();
let ws = Workspace::open(dir.path()).unwrap();
let resolved = ws.resolve("sub/../file.txt").unwrap();
assert_eq!(resolved, ws.root().join("file.txt"));
}
#[test]
fn resolve_dot_path() {
let dir = tempfile::tempdir().unwrap();
let ws = Workspace::open(dir.path()).unwrap();
let resolved = ws.resolve(".").unwrap();
assert_eq!(resolved, ws.root().to_path_buf());
}
#[test]
fn normalize_path_basic() {
let path = Path::new("/a/b/../c/./d");
assert_eq!(normalize_path(path), PathBuf::from("/a/c/d"));
}
#[test]
fn normalize_path_no_escape_root() {
let path = Path::new("/a/../../b");
let normalized = normalize_path(path);
assert!(normalized.starts_with("/"));
}
#[test]
fn env_policy_default_is_inherit() {
assert!(matches!(EnvPolicy::default(), EnvPolicy::Inherit));
}
#[test]
fn daemon_env_allowlist_contains_path() {
assert!(DAEMON_ENV_ALLOWLIST.contains(&"PATH"));
}
}