use std::path::{Path, PathBuf};
use anyhow::{anyhow, Result};
#[derive(Clone, Debug)]
pub struct Workspace {
roots: Vec<PathBuf>,
}
impl Workspace {
pub fn new<I, P>(roots: I) -> Result<Self>
where
I: IntoIterator<Item = P>,
P: AsRef<Path>,
{
let resolved: Vec<PathBuf> = roots
.into_iter()
.filter_map(|r| std::fs::canonicalize(r.as_ref()).ok())
.collect();
if resolved.is_empty() {
return Err(anyhow!(
"no readable workspace root; refusing to start with nothing to confine to"
));
}
Ok(Workspace { roots: resolved })
}
pub fn cwd() -> Result<Self> {
Workspace::new([std::env::current_dir()?])
}
pub fn roots(&self) -> &[PathBuf] {
&self.roots
}
pub fn root_labels(&self) -> Vec<String> {
self.roots.iter().map(|p| strip_verbatim(p)).collect()
}
pub fn resolve(&self, locator: &str) -> Result<PathBuf> {
if locator.trim().is_empty() {
return Err(anyhow!("empty path"));
}
let candidate = Path::new(locator);
let joined = if candidate.is_absolute() {
candidate.to_path_buf()
} else {
self.roots[0].join(candidate)
};
let resolved = std::fs::canonicalize(&joined)
.map_err(|e| anyhow!("cannot resolve `{locator}`: {e}"))?;
if self.roots.iter().any(|r| resolved.starts_with(r)) {
Ok(resolved)
} else {
Err(anyhow!(
"`{locator}` resolves to `{}`, which is outside this workspace ({})",
strip_verbatim(&resolved),
self.root_labels().join(", ")
))
}
}
}
fn strip_verbatim(p: &Path) -> String {
let s = p.to_string_lossy().to_string();
match s.strip_prefix(r"\\?\") {
Some(rest) => rest.to_string(),
None => s,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn scratch() -> PathBuf {
let p = std::env::temp_dir().join(format!(
"scema-omni-ws-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&p).unwrap();
fs::canonicalize(&p).unwrap()
}
#[test]
fn a_path_inside_a_root_resolves() {
let root = scratch();
fs::create_dir_all(root.join("sub")).unwrap();
let ws = Workspace::new([&root]).unwrap();
assert!(ws.resolve("sub").is_ok());
assert!(ws.resolve(root.join("sub").to_str().unwrap()).is_ok());
fs::remove_dir_all(&root).ok();
}
#[test]
fn dot_dot_cannot_climb_out() {
let root = scratch();
fs::create_dir_all(root.join("sub")).unwrap();
let ws = Workspace::new([root.join("sub")]).unwrap();
let err = ws.resolve("..").unwrap_err().to_string();
assert!(err.contains("outside this workspace"), "got {err}");
fs::remove_dir_all(&root).ok();
}
#[test]
fn an_absolute_path_elsewhere_is_refused_and_the_error_names_the_roots() {
let root = scratch();
let ws = Workspace::new([&root]).unwrap();
let outside = std::env::temp_dir();
let err = ws.resolve(outside.to_str().unwrap()).unwrap_err().to_string();
assert!(err.contains("outside this workspace"));
assert!(err.contains(&strip_verbatim(&root)), "got {err}");
fs::remove_dir_all(&root).ok();
}
#[test]
#[cfg(unix)]
fn a_symlink_pointing_out_is_refused() {
let root = scratch();
let inside = root.join("inside");
fs::create_dir_all(&inside).unwrap();
let target = scratch();
std::os::unix::fs::symlink(&target, inside.join("escape")).unwrap();
let ws = Workspace::new([&inside]).unwrap();
assert!(ws.resolve("escape").is_err(), "a symlink out is still out");
fs::remove_dir_all(&root).ok();
fs::remove_dir_all(&target).ok();
}
#[test]
fn a_relative_path_resolves_against_the_root_not_the_process_cwd() {
let root = scratch();
fs::create_dir_all(root.join("marker")).unwrap();
let ws = Workspace::new([&root]).unwrap();
let got = ws.resolve("marker").unwrap();
assert!(got.starts_with(&root));
fs::remove_dir_all(&root).ok();
}
#[test]
fn a_workspace_with_no_readable_root_refuses_to_exist() {
assert!(Workspace::new(["definitely-not-here-4f2a"]).is_err());
}
#[test]
fn a_missing_path_inside_a_root_is_an_error_not_a_silent_pass() {
let root = scratch();
let ws = Workspace::new([&root]).unwrap();
let err = ws.resolve("no-such-dir").unwrap_err().to_string();
assert!(err.contains("cannot resolve"), "got {err}");
fs::remove_dir_all(&root).ok();
}
}