Skip to main content

qk_cache/
scratch.rs

1//! `qk:sandbox`: `false` runs a task unsandboxed under `--sandbox`. An object
2//! sandboxes it, and its `scratch` names directories in the workspace the task
3//! keeps nothing in between runs, such as a tool's cache it cannot be told to
4//! keep elsewhere.
5//!
6//! qk empties each scratch directory before the task runs, with or without
7//! `--sandbox`, so nothing an earlier run left there can change a result,
8//! whether or not the tool checks what it finds. The cache neither keeps nor
9//! restores them, a recording leaves them out, and the sandbox lets the task
10//! read and write them. A tool whose cache is safe to keep, as one checking
11//! every entry against what it read, is better kept as warm state.
12
13use 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
20/// The fields a `qk:sandbox` object can set. Others are ignored, as Nx
21/// ignores keys it does not know; `qk doctor` reports them.
22pub const FIELDS: &[&str] = &["scratch"];
23
24/// What a target's `qk:sandbox` says.
25#[derive(Clone, Debug, Default, PartialEq, Eq)]
26pub struct Settings {
27    /// Whether the task runs sandboxed under `--sandbox`.
28    pub sandboxed: bool,
29    /// Its scratch directories, as written.
30    pub scratch: Vec<String>,
31}
32
33impl Settings {
34    /// The settings of a target's `qk:sandbox` value: sandboxed without it.
35    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
58/// A task's scratch directories, absolute: each must lie inside the
59/// workspace, outside `.git`, `.qk` and `node_modules` in any case, hold no
60/// project, nor the cache or qk's state, and neither be nor lie under a
61/// symbolic link, which emptying it could follow out of the workspace.
62pub 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
70/// [`directories`] for a `qk:sandbox` value of one of `project`'s targets.
71pub 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    // Where qk keeps its own, which the workspace holds outside Git, as the
86    // file system resolves it.
87    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
113/// Two tasks in a run cannot share a scratch directory, nor have one inside
114/// another's: emptying it before one runs would take what the other is
115/// using.
116pub 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
139/// `path` as the file system resolves it: the part of it that exists with
140/// its links and `..` followed, then the rest with `..` taken away.
141fn 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
167/// Whether one of two paths is, or lies under, the other, comparing names
168/// without regard to case, as a file system may hold them.
169fn 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
179/// Why `relative`, below `root`, could lead out of it: a part of it that
180/// exists as a symbolic link.
181fn 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            // Nothing below a part that does not exist can be a link yet.
191            Err(_) => return Ok(()),
192        }
193    }
194    Ok(())
195}
196
197/// `written`, a workspace path, as its parts, or why it cannot be emptied
198/// before each run. Names are compared without regard to case, as a
199/// file system may hold them.
200fn 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        // A project the workspace holds at its root holds every path.
216        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
229/// The parts of `path`, a workspace path, in lower case, or why it is none.
230fn 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}