use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result};
#[derive(Debug, Clone)]
pub struct Workspace {
inner: Arc<Inner>,
}
#[derive(Debug)]
struct Inner {
root: PathBuf,
restricted: bool,
}
impl Workspace {
pub fn detect(override_root: Option<&Path>) -> Result<Self> {
let root = match override_root {
Some(p) => p.to_path_buf(),
None => find_git_root().unwrap_or(std::env::current_dir()?),
};
let root = std::fs::canonicalize(&root)
.with_context(|| format!("canonicalizing workspace root {}", root.display()))?;
if !root.is_dir() {
anyhow::bail!("workspace root is not a directory: {}", root.display());
}
Ok(Self {
inner: Arc::new(Inner {
root,
restricted: true,
}),
})
}
pub fn unrestricted() -> Self {
let root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
Self {
inner: Arc::new(Inner {
root,
restricted: false,
}),
}
}
pub fn root(&self) -> &Path {
&self.inner.root
}
pub fn is_restricted(&self) -> bool {
self.inner.restricted
}
pub fn resolve(&self, input: &str) -> Result<PathBuf> {
let joined = self.join(input);
let canonical = std::fs::canonicalize(&joined)
.with_context(|| format!("resolving path {}", joined.display()))?;
self.ensure_within(&canonical, input)?;
Ok(canonical)
}
pub fn resolve_for_create(&self, input: &str) -> Result<PathBuf> {
let joined = self.join(input);
let (existing, tail) = split_existing(&joined);
let canonical_existing = std::fs::canonicalize(&existing).with_context(|| {
format!(
"resolving nearest existing ancestor {} for {}",
existing.display(),
joined.display()
)
})?;
self.ensure_within(&canonical_existing, input)?;
let mut out = canonical_existing;
for c in tail.components() {
match c {
Component::Normal(s) => out.push(s),
other => anyhow::bail!("unexpected path component in tail: {:?}", other),
}
}
if self.inner.restricted && !out.starts_with(&self.inner.root) {
anyhow::bail!(
"path {} escapes workspace root {}",
out.display(),
self.inner.root.display()
);
}
Ok(out)
}
fn join(&self, input: &str) -> PathBuf {
let p = Path::new(input);
if p.is_absolute() {
p.to_path_buf()
} else {
self.inner.root.join(p)
}
}
fn ensure_within(&self, canonical: &Path, original: &str) -> Result<()> {
if !self.inner.restricted {
return Ok(());
}
if canonical.starts_with(&self.inner.root) {
return Ok(());
}
anyhow::bail!(
"path {} (resolved to {}) is outside workspace root {}",
original,
canonical.display(),
self.inner.root.display()
);
}
}
fn find_git_root() -> Option<PathBuf> {
let mut cur = std::env::current_dir().ok()?;
loop {
if cur.join(".git").exists() {
return Some(cur);
}
if !cur.pop() {
return None;
}
}
}
fn split_existing(p: &Path) -> (PathBuf, PathBuf) {
let mut existing = p.to_path_buf();
let mut tail_parts: Vec<PathBuf> = Vec::new();
while !existing.exists() {
match existing.file_name() {
Some(name) => tail_parts.push(PathBuf::from(name)),
None => break, }
if !existing.pop() {
break;
}
}
let mut tail = PathBuf::new();
for part in tail_parts.into_iter().rev() {
tail.push(part);
}
(existing, tail)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn ws(dir: &Path) -> Workspace {
Workspace::detect(Some(dir)).unwrap()
}
#[test]
fn resolve_existing_file_inside_root() {
let tmp = TempDir::new().unwrap();
let f = tmp.path().join("a.txt");
std::fs::write(&f, "x").unwrap();
let w = ws(tmp.path());
let r = w.resolve("a.txt").unwrap();
assert_eq!(r, std::fs::canonicalize(&f).unwrap());
}
#[test]
fn resolve_absolute_inside_root() {
let tmp = TempDir::new().unwrap();
let f = tmp.path().join("a.txt");
std::fs::write(&f, "x").unwrap();
let w = ws(tmp.path());
let r = w.resolve(f.to_str().unwrap()).unwrap();
assert_eq!(r, std::fs::canonicalize(&f).unwrap());
}
#[test]
fn resolve_rejects_parent_escape() {
let tmp = TempDir::new().unwrap();
let sub = tmp.path().join("sub");
std::fs::create_dir(&sub).unwrap();
let outside = tmp.path().join("outside.txt");
std::fs::write(&outside, "x").unwrap();
let w = ws(&sub);
let err = w.resolve("../outside.txt").unwrap_err().to_string();
assert!(err.contains("outside workspace root"), "got: {err}");
}
#[test]
fn resolve_rejects_absolute_outside() {
let tmp = TempDir::new().unwrap();
let w = ws(tmp.path());
let err = w.resolve("/etc/hostname").unwrap_err().to_string();
assert!(
err.contains("outside workspace root") || err.contains("resolving path"),
"got: {err}"
);
}
#[test]
#[cfg(unix)]
fn resolve_rejects_symlink_escape() {
let tmp = TempDir::new().unwrap();
let outside_dir = TempDir::new().unwrap();
let secret = outside_dir.path().join("secret.txt");
std::fs::write(&secret, "top-secret").unwrap();
let link = tmp.path().join("escape");
std::os::unix::fs::symlink(&secret, &link).unwrap();
let w = ws(tmp.path());
let err = w.resolve("escape").unwrap_err().to_string();
assert!(err.contains("outside workspace root"), "got: {err}");
}
#[test]
fn resolve_for_create_new_file_inside_root() {
let tmp = TempDir::new().unwrap();
let w = ws(tmp.path());
let r = w.resolve_for_create("newdir/new.txt").unwrap();
assert!(r.starts_with(std::fs::canonicalize(tmp.path()).unwrap()));
assert!(r.ends_with("newdir/new.txt"));
}
#[test]
fn resolve_for_create_rejects_outside() {
let tmp = TempDir::new().unwrap();
let w = ws(tmp.path());
let err = w.resolve_for_create("../evil.txt").unwrap_err().to_string();
assert!(
err.contains("escapes workspace root") || err.contains("outside workspace root"),
"got: {err}"
);
}
#[test]
fn unrestricted_accepts_anything() {
let w = Workspace::unrestricted();
assert!(w.resolve("/").is_ok() || w.resolve(".").is_ok());
assert!(!w.is_restricted());
}
}