1use std::path::{Path, PathBuf};
25
26use anyhow::{anyhow, Result};
27
28#[derive(Clone, Debug)]
30pub struct Workspace {
31 roots: Vec<PathBuf>,
32}
33
34impl Workspace {
35 pub fn new<I, P>(roots: I) -> Result<Self>
41 where
42 I: IntoIterator<Item = P>,
43 P: AsRef<Path>,
44 {
45 let resolved: Vec<PathBuf> = roots
46 .into_iter()
47 .filter_map(|r| std::fs::canonicalize(r.as_ref()).ok())
48 .collect();
49 if resolved.is_empty() {
50 return Err(anyhow!(
51 "no readable workspace root; refusing to start with nothing to confine to"
52 ));
53 }
54 Ok(Workspace { roots: resolved })
55 }
56
57 pub fn cwd() -> Result<Self> {
59 Workspace::new([std::env::current_dir()?])
60 }
61
62 pub fn roots(&self) -> &[PathBuf] {
63 &self.roots
64 }
65
66 pub fn root_labels(&self) -> Vec<String> {
68 self.roots.iter().map(|p| strip_verbatim(p)).collect()
69 }
70
71 pub fn resolve(&self, locator: &str) -> Result<PathBuf> {
76 if locator.trim().is_empty() {
77 return Err(anyhow!("empty path"));
78 }
79 let candidate = Path::new(locator);
80 let joined = if candidate.is_absolute() {
84 candidate.to_path_buf()
85 } else {
86 self.roots[0].join(candidate)
87 };
88 let resolved = std::fs::canonicalize(&joined)
89 .map_err(|e| anyhow!("cannot resolve `{locator}`: {e}"))?;
90
91 if self.roots.iter().any(|r| resolved.starts_with(r)) {
92 Ok(resolved)
93 } else {
94 Err(anyhow!(
95 "`{locator}` resolves to `{}`, which is outside this workspace ({})",
96 strip_verbatim(&resolved),
97 self.root_labels().join(", ")
98 ))
99 }
100 }
101}
102
103fn strip_verbatim(p: &Path) -> String {
105 let s = p.to_string_lossy().to_string();
106 match s.strip_prefix(r"\\?\") {
107 Some(rest) => rest.to_string(),
108 None => s,
109 }
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115 use std::fs;
116
117 fn scratch() -> PathBuf {
118 let p = std::env::temp_dir().join(format!(
119 "scema-omni-ws-{}-{}",
120 std::process::id(),
121 std::time::SystemTime::now()
122 .duration_since(std::time::UNIX_EPOCH)
123 .unwrap()
124 .as_nanos()
125 ));
126 fs::create_dir_all(&p).unwrap();
127 fs::canonicalize(&p).unwrap()
128 }
129
130 #[test]
131 fn a_path_inside_a_root_resolves() {
132 let root = scratch();
133 fs::create_dir_all(root.join("sub")).unwrap();
134 let ws = Workspace::new([&root]).unwrap();
135 assert!(ws.resolve("sub").is_ok());
136 assert!(ws.resolve(root.join("sub").to_str().unwrap()).is_ok());
137 fs::remove_dir_all(&root).ok();
138 }
139
140 #[test]
141 fn dot_dot_cannot_climb_out() {
142 let root = scratch();
143 fs::create_dir_all(root.join("sub")).unwrap();
144 let ws = Workspace::new([root.join("sub")]).unwrap();
145 let err = ws.resolve("..").unwrap_err().to_string();
146 assert!(err.contains("outside this workspace"), "got {err}");
147 fs::remove_dir_all(&root).ok();
148 }
149
150 #[test]
151 fn an_absolute_path_elsewhere_is_refused_and_the_error_names_the_roots() {
152 let root = scratch();
154 let ws = Workspace::new([&root]).unwrap();
155 let outside = std::env::temp_dir();
156 let err = ws.resolve(outside.to_str().unwrap()).unwrap_err().to_string();
157 assert!(err.contains("outside this workspace"));
158 assert!(err.contains(&strip_verbatim(&root)), "got {err}");
159 fs::remove_dir_all(&root).ok();
160 }
161
162 #[test]
163 #[cfg(unix)]
164 fn a_symlink_pointing_out_is_refused() {
165 let root = scratch();
168 let inside = root.join("inside");
169 fs::create_dir_all(&inside).unwrap();
170 let target = scratch();
171 std::os::unix::fs::symlink(&target, inside.join("escape")).unwrap();
172 let ws = Workspace::new([&inside]).unwrap();
173 assert!(ws.resolve("escape").is_err(), "a symlink out is still out");
174 fs::remove_dir_all(&root).ok();
175 fs::remove_dir_all(&target).ok();
176 }
177
178 #[test]
179 fn a_relative_path_resolves_against_the_root_not_the_process_cwd() {
180 let root = scratch();
183 fs::create_dir_all(root.join("marker")).unwrap();
184 let ws = Workspace::new([&root]).unwrap();
185 let got = ws.resolve("marker").unwrap();
186 assert!(got.starts_with(&root));
187 fs::remove_dir_all(&root).ok();
188 }
189
190 #[test]
191 fn a_workspace_with_no_readable_root_refuses_to_exist() {
192 assert!(Workspace::new(["definitely-not-here-4f2a"]).is_err());
193 }
194
195 #[test]
196 fn a_missing_path_inside_a_root_is_an_error_not_a_silent_pass() {
197 let root = scratch();
198 let ws = Workspace::new([&root]).unwrap();
199 let err = ws.resolve("no-such-dir").unwrap_err().to_string();
200 assert!(err.contains("cannot resolve"), "got {err}");
201 fs::remove_dir_all(&root).ok();
202 }
203}