1use std::path::{Component, Path, PathBuf};
14
15use anyhow::{Context, Result, bail};
16use qk_config::Workspace;
17use qk_taskgraph::{Task, TaskGraph};
18use serde_json::Value;
19
20pub const FIELDS: &[&str] = &["scratch"];
23
24#[derive(Clone, Debug, Default, PartialEq, Eq)]
26pub struct Settings {
27 pub sandboxed: bool,
29 pub scratch: Vec<String>,
31}
32
33impl Settings {
34 pub fn of(value: Option<&Value>) -> Result<Self> {
36 let scratch = match value {
37 None | Some(Value::Bool(true)) => Vec::new(),
38 Some(Value::Bool(false)) => {
39 return Ok(Self {
40 sandboxed: false,
41 scratch: Vec::new(),
42 });
43 }
44 Some(Value::Object(object)) => match object.get("scratch") {
45 None => Vec::new(),
46 Some(value) => serde_json::from_value(value.clone())
47 .context("qk:sandbox.scratch must be an array of paths")?,
48 },
49 Some(_) => bail!("qk:sandbox must be true, false or an object"),
50 };
51 Ok(Self {
52 sandboxed: true,
53 scratch,
54 })
55 }
56}
57
58pub fn directories(workspace: &Workspace, task: &Task) -> Result<Vec<PathBuf>> {
63 directories_of(
64 workspace,
65 &task.project,
66 task.definition.extra.get("qk:sandbox"),
67 )
68}
69
70pub fn directories_of(
72 workspace: &Workspace,
73 project: &str,
74 value: Option<&Value>,
75) -> Result<Vec<PathBuf>> {
76 let settings = Settings::of(value)?;
77 if settings.scratch.is_empty() {
78 return Ok(Vec::new());
79 }
80 let projects: Vec<&str> = workspace
81 .projects
82 .values()
83 .map(|project| project.root.as_str())
84 .collect();
85 let kept = [
88 resolved(&crate::paths::worktree_state(&workspace.root)),
89 resolved(&crate::paths::cache_location(workspace)),
90 ];
91 settings
92 .scratch
93 .iter()
94 .map(|written| {
95 let expanded = crate::paths::expand(workspace, project, written)?;
96 let directory = check(&expanded, &projects)
97 .and_then(|relative| {
98 unlinked(&workspace.root, &relative)?;
99 let directory = workspace.root.join(&relative);
100 if kept.iter().any(|kept| nested(kept, &resolved(&directory))) {
101 bail!("it would empty qk's cache or state");
102 }
103 Ok(directory)
104 })
105 .with_context(|| {
106 format!("qk:sandbox.scratch {written:?} cannot be a scratch directory")
107 })?;
108 Ok(directory)
109 })
110 .collect()
111}
112
113pub fn check_overlaps(workspace: &Workspace, graph: &TaskGraph) -> Result<()> {
117 let mut claimed: Vec<(PathBuf, &str)> = Vec::new();
118 for (id, task) in &graph.tasks {
119 let named = directories(workspace, task).with_context(|| format!("cannot execute {id}"))?;
120 for directory in named {
121 if let Some((_, other)) = claimed
122 .iter()
123 .find(|(known, other)| *other != id && nested(known, &directory))
124 {
125 let shown = directory
126 .strip_prefix(&workspace.root)
127 .unwrap_or(&directory);
128 bail!(
129 "{id} and {other} both use {} as scratch, which each empties before it runs",
130 shown.display()
131 );
132 }
133 claimed.push((directory, id));
134 }
135 }
136 Ok(())
137}
138
139fn resolved(path: &Path) -> PathBuf {
142 let mut existing = path;
143 let mut rest = Vec::new();
144 loop {
145 if let Ok(mut real) = existing.canonicalize() {
146 for part in rest.into_iter().rev() {
147 match part {
148 Component::ParentDir => {
149 real.pop();
150 }
151 Component::CurDir => {}
152 part => real.push(part),
153 }
154 }
155 return real;
156 }
157 match (existing.parent(), existing.components().next_back()) {
158 (Some(parent), Some(last)) => {
159 rest.push(last);
160 existing = parent;
161 }
162 _ => return path.to_owned(),
163 }
164 }
165}
166
167fn nested(a: &Path, b: &Path) -> bool {
170 let names = |path: &Path| -> Vec<String> {
171 path.components()
172 .map(|part| part.as_os_str().to_string_lossy().to_lowercase())
173 .collect()
174 };
175 let (a, b) = (names(a), names(b));
176 a.starts_with(&b) || b.starts_with(&a)
177}
178
179fn unlinked(root: &Path, relative: &Path) -> Result<()> {
182 let mut path = root.to_owned();
183 for part in Path::new(relative).components() {
184 path.push(part);
185 match std::fs::symlink_metadata(&path) {
186 Ok(metadata) if metadata.file_type().is_symlink() => {
187 bail!("it is or lies under a symbolic link, which emptying it could follow")
188 }
189 Ok(_) => {}
190 Err(_) => return Ok(()),
192 }
193 }
194 Ok(())
195}
196
197fn check(written: &str, projects: &[&str]) -> Result<PathBuf> {
201 if written.contains(['*', '?', '[', '{']) {
202 bail!("it is a glob, where a directory is named");
203 }
204 let named = parts(written)?;
205 if named.is_empty() {
206 bail!("it would empty the workspace");
207 }
208 if named
209 .iter()
210 .any(|part| matches!(part.as_str(), ".git" | ".qk" | "node_modules"))
211 {
212 bail!("it lies in .git, .qk or node_modules");
213 }
214 for project in projects {
215 if let Ok(root) = parts(project)
217 && !root.is_empty()
218 && root.starts_with(&named)
219 {
220 bail!("it would empty the project in {project}");
221 }
222 }
223 Ok(written
224 .split('/')
225 .filter(|part| !part.is_empty() && *part != ".")
226 .collect())
227}
228
229fn parts(path: &str) -> Result<Vec<String>> {
231 Path::new(path)
232 .components()
233 .filter_map(|component| match component {
234 Component::Normal(part) => Some(Ok(part.to_string_lossy().to_lowercase())),
235 Component::CurDir => None,
236 _ => Some(Err(anyhow::anyhow!(
237 "it is not a path inside the workspace"
238 ))),
239 })
240 .collect()
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 #[test]
248 fn reads_false_true_and_an_object() {
249 assert!(!Settings::of(Some(&Value::Bool(false))).unwrap().sandboxed);
250 assert!(Settings::of(None).unwrap().sandboxed);
251 let settings = Settings::of(Some(
252 &serde_json::json!({"scratch": ["{projectRoot}/.cache"]}),
253 ))
254 .unwrap();
255 assert!(settings.sandboxed);
256 assert_eq!(settings.scratch, ["{projectRoot}/.cache"]);
257 assert!(Settings::of(Some(&serde_json::json!("no"))).is_err());
258 assert!(Settings::of(Some(&serde_json::json!({"scratch": "x"}))).is_err());
259 }
260
261 #[cfg(unix)]
262 #[test]
263 fn refuses_a_path_through_a_symbolic_link() {
264 let root = tempfile::tempdir().unwrap();
265 let outside = tempfile::tempdir().unwrap();
266 std::os::unix::fs::symlink(outside.path(), root.path().join("link")).unwrap();
267 std::fs::create_dir(root.path().join("real")).unwrap();
268 assert!(unlinked(root.path(), Path::new("link/cache")).is_err());
269 assert!(unlinked(root.path(), Path::new("link")).is_err());
270 assert!(unlinked(root.path(), Path::new("real/cache")).is_ok());
271 assert!(unlinked(root.path(), Path::new("missing/cache")).is_ok());
272 }
273
274 #[test]
275 fn resolves_what_exists_and_takes_away_the_rest() {
276 let root = tempfile::tempdir().unwrap();
277 let real = root.path().canonicalize().unwrap();
278 std::fs::create_dir(root.path().join("foo")).unwrap();
279 assert_eq!(
280 resolved(&root.path().join("foo/../scratch")),
281 real.join("scratch")
282 );
283 assert_eq!(
284 resolved(&root.path().join("missing/../scratch/cache")),
285 real.join("scratch/cache")
286 );
287 }
288
289 #[test]
290 fn nests_paths_whatever_their_case() {
291 assert!(nested(Path::new("/w/Cache"), Path::new("/w/cache/lint")));
292 assert!(nested(
293 Path::new("/w/build/cache"),
294 Path::new("/w/Build/Cache")
295 ));
296 assert!(!nested(Path::new("/w/cache"), Path::new("/w/cached")));
297 }
298
299 #[test]
300 fn refuses_what_cannot_be_emptied() {
301 let projects = ["./apps/app/", "libs/lib", "."];
302 assert_eq!(
303 check("./apps/app/.fallow/", &projects).unwrap(),
304 Path::new("apps/app/.fallow")
305 );
306 for refused in [
307 "apps/app",
308 "apps/app/",
309 "./apps/app",
310 "Apps/App",
311 "apps",
312 "libs",
313 "",
314 ".",
315 "../outside",
316 "apps/../../outside",
317 "apps/app/node_modules/.cache",
318 "apps/app/NODE_MODULES/.cache",
319 ".git/qk",
320 ".GIT/qk",
321 ".qk/cache",
322 "apps/app/**/cache",
323 ] {
324 assert!(check(refused, &projects).is_err(), "{refused}");
325 }
326 }
327}