Skip to main content

qk_cache/
lib.rs

1//! Local content-addressed cache shared by linked Git worktrees.
2
3mod evict;
4mod glob;
5mod hash;
6mod paths;
7mod remote;
8mod store;
9pub mod warm;
10
11use std::collections::BTreeMap;
12use std::path::{Path, PathBuf};
13use std::sync::OnceLock;
14use std::sync::atomic::{AtomicBool, Ordering};
15
16use anyhow::{Context, Result};
17use qk_config::Workspace;
18use qk_executor::{Capture, Outcome, PreparedTask, execute, execute_captured};
19use qk_taskgraph::{Task, TaskGraph};
20use serde_json::{Value, json};
21
22pub use evict::{Pruned, max_size, parse_size, prune};
23pub use glob::Pattern;
24pub use hash::{Resolved, SourceIgnore, source_files, without_resolution};
25pub use paths::{
26    Outputs, cache_directory, cache_location, clear_worktree_state, resolved_outputs,
27    worktree_state,
28};
29
30/// The cache for one run; its workspace snapshot is taken on first use.
31pub struct Cache {
32    pub root: PathBuf,
33    snapshot: OnceLock<std::result::Result<hash::Snapshot, String>>,
34    remote: Option<std::sync::Arc<remote::Remote>>,
35    /// Whether results and warm state may be restored or published.
36    reuse: bool,
37    /// The tasks whose outputs this run left exactly as they were.
38    kept: std::sync::Mutex<std::collections::BTreeSet<String>>,
39    /// Warm state being saved in the background.
40    saves: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>>,
41}
42
43/// A task's output fingerprint for its dependents' keys, or why it has none:
44/// `"<task id>: <reason>"` for the task where fingerprinting first failed.
45pub type Fingerprint = std::result::Result<String, String>;
46
47/// Where a task's result came from.
48#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
49#[serde(rename_all = "kebab-case")]
50pub enum CacheStatus {
51    LocalHit,
52    RemoteHit,
53    Miss,
54    /// Ran without consulting the cache: not cacheable, bypassed or disabled.
55    Uncached,
56}
57
58/// One actual keyed execution, independent of reusable cache entries.
59pub struct Execution {
60    pub log: qk_executor::RecordedLog,
61    pub inputs_unchanged: bool,
62}
63
64pub struct TaskResult {
65    pub outcome: Outcome,
66    pub fingerprint: Fingerprint,
67    pub cache: CacheStatus,
68    /// The task's key and what it was computed from, when it has one.
69    pub key: Option<(String, Value)>,
70    /// What warm state did, for targets that keep it.
71    pub warm: Option<warm::WarmReport>,
72    pub execution: Option<Execution>,
73}
74
75impl TaskResult {
76    pub fn uncached(outcome: Outcome, reason: String) -> Self {
77        Self {
78            outcome,
79            fingerprint: Err(reason),
80            cache: CacheStatus::Uncached,
81            key: None,
82            warm: None,
83            execution: None,
84        }
85    }
86}
87
88/// Each task's resolved inputs, for affected selection. `extra` paths are
89/// candidates beside the workspace's files, so a deleted file still matches
90/// the inputs that named it. Env and runtime inputs are not evaluated.
91pub fn resolve_tasks(
92    workspace: &Workspace,
93    graph: &TaskGraph,
94    extra: &[String],
95) -> Result<BTreeMap<String, Resolved>> {
96    let cache = paths::cache_location(workspace);
97    let snapshot = hash::Snapshot::new(workspace, graph, &cache)?.with_candidates(extra);
98    let cancelled = AtomicBool::new(false);
99    graph
100        .tasks
101        .iter()
102        .map(|(id, task)| {
103            let resolved = hash::resolve(&snapshot, workspace, task, None, &cancelled)
104                .with_context(|| format!("cannot resolve the inputs of {id}"))?;
105            Ok((id.clone(), resolved))
106        })
107        .collect()
108}
109
110/// Every task's resolved inputs, or why they could not be resolved, with the
111/// workspace files they were chosen from.
112pub struct Resolution {
113    pub candidates: std::collections::BTreeSet<String>,
114    pub tasks: BTreeMap<String, std::result::Result<Resolved, String>>,
115}
116
117pub fn resolve_each(workspace: &Workspace, graph: &TaskGraph) -> Result<Resolution> {
118    let cache = paths::cache_location(workspace);
119    let snapshot = hash::Snapshot::new(workspace, graph, &cache)?;
120    let cancelled = AtomicBool::new(false);
121    let tasks = graph
122        .tasks
123        .iter()
124        .map(|(id, task)| {
125            let resolved = hash::resolve(&snapshot, workspace, task, None, &cancelled)
126                .map_err(|error| format!("{error:#}"));
127            (id.clone(), resolved)
128        })
129        .collect();
130    Ok(Resolution {
131        candidates: snapshot.files.clone(),
132        tasks,
133    })
134}
135
136impl Cache {
137    pub fn new(root: PathBuf) -> Self {
138        Self {
139            root,
140            snapshot: OnceLock::new(),
141            remote: None,
142            reuse: true,
143            kept: Default::default(),
144            saves: Default::default(),
145        }
146    }
147
148    /// Records verified executions without configuring or using result stores.
149    pub fn for_observation(workspace: &Workspace) -> Self {
150        Self {
151            reuse: false,
152            ..Self::new(paths::cache_location(workspace))
153        }
154    }
155
156    /// The workspace's cache, with the remote store its nx.json `s3` key
157    /// configures, if any. Credentials and modes are read from `environment`,
158    /// which includes the workspace's dotenv files. A remote store that cannot
159    /// be used is reported and left out; the local cache still works.
160    pub fn for_workspace(
161        workspace: &Workspace,
162        environment: &BTreeMap<std::ffi::OsString, std::ffi::OsString>,
163    ) -> Self {
164        let root = paths::cache_location(workspace);
165        let remote = match remote::configure(workspace.config.extra.get("s3"), environment) {
166            Ok(remote) => remote.map(std::sync::Arc::new),
167            Err(error) => {
168                qk_executor::status!("qk: remote cache disabled: {error:#}");
169                None
170            }
171        };
172        if let Some(remote) = &remote {
173            remote.read_index(&root);
174        }
175        Self {
176            remote,
177            ..Self::new(root)
178        }
179    }
180
181    /// Saves what later runs reuse and waits for background uploads to the
182    /// remote store, reporting failures.
183    pub fn finish(&self, workspace: &Workspace) {
184        if !self.reuse {
185            return;
186        }
187        if let Some(Ok(snapshot)) = self.snapshot.get() {
188            snapshot.save_digests(&workspace.root);
189        }
190        // Before the uploads, which a save may add to.
191        for save in std::mem::take(&mut *self.saves.lock().unwrap()) {
192            let _ = save.join();
193        }
194        if let Some(remote) = &self.remote {
195            let failures = remote.finish();
196            for failure in &failures {
197                qk_executor::status!("qk: remote cache upload failed for {failure}");
198            }
199        }
200    }
201
202    fn snapshot(
203        &self,
204        workspace: &Workspace,
205        graph: &TaskGraph,
206    ) -> std::result::Result<&hash::Snapshot, String> {
207        self.snapshot
208            .get_or_init(|| {
209                hash::Snapshot::new(workspace, graph, &self.root)
210                    .map_err(|error| format!("cannot snapshot the workspace: {error:#}"))
211            })
212            .as_ref()
213            .map_err(Clone::clone)
214    }
215
216    pub fn run(
217        &self,
218        workspace: &Workspace,
219        graph: &TaskGraph,
220        task: &Task,
221        prepared: &PreparedTask,
222        dependencies: &BTreeMap<String, Fingerprint>,
223        cancelled: &AtomicBool,
224    ) -> Result<TaskResult> {
225        let result = self.run_task(workspace, graph, task, prepared, dependencies, cancelled);
226        if let Some(Ok(snapshot)) = self.snapshot.get() {
227            snapshot.outputs_written(workspace, task);
228        }
229        result
230    }
231
232    fn run_task(
233        &self,
234        workspace: &Workspace,
235        graph: &TaskGraph,
236        task: &Task,
237        prepared: &PreparedTask,
238        dependencies: &BTreeMap<String, Fingerprint>,
239        cancelled: &AtomicBool,
240    ) -> Result<TaskResult> {
241        let cacheable = task.definition.cache == Some(true);
242        // Held until the task is done, whether it ran or came from the cache.
243        let _kept = self
244            .reuse
245            .then(|| warm::keep_across(workspace, graph, task));
246        let warm = if self.reuse {
247            match warm::config(workspace, task) {
248                Ok(warm) => warm,
249                Err(error) => {
250                    qk_executor::status!("qk: {}: warm state ignored ({error:#})", task.id);
251                    None
252                }
253            }
254        } else {
255            None
256        };
257        // The warm variables point tools at their state; they are not inputs,
258        // so the key is computed from the task without them.
259        let mut running = prepared.clone();
260        if let Some(warm) = &warm {
261            for (name, value) in &warm.env {
262                running.execution.insert(name.into(), value.into());
263            }
264        }
265        // What was restored, and which groups were on disk already.
266        let before = |running: &PreparedTask| -> (Option<warm::Restored>, Vec<String>) {
267            let Some(warm) = warm.as_ref() else {
268                return (None, Vec::new());
269            };
270            match self
271                .initialize()
272                .and_then(|()| self.restore_warm(workspace, task, warm, running))
273            {
274                Ok((restored, present)) => {
275                    ((!restored.groups.is_empty()).then_some(restored), present)
276                }
277                Err(error) => {
278                    qk_executor::status!("qk: {}: warm state not restored ({error:#})", task.id);
279                    (None, Vec::new())
280                }
281            }
282        };
283        let after = |outcome: Outcome,
284                     (restored, present): (Option<warm::Restored>, Vec<String>),
285                     running: &PreparedTask|
286         -> Option<warm::WarmReport> {
287            let warm = warm.as_ref()?;
288            let started = std::time::Instant::now();
289            let mut background = false;
290            let save_ms = if outcome != Outcome::Success {
291                None
292            } else if warm.background {
293                match self.save_warm_in_background(workspace, task, warm, running) {
294                    Ok(()) => background = true,
295                    Err(error) => {
296                        qk_executor::status!("qk: {}: warm state not saved ({error:#})", task.id);
297                    }
298                }
299                None
300            } else {
301                match self.save_warm(workspace, task, warm, running) {
302                    Ok(()) => Some(started.elapsed().as_millis() as u64),
303                    Err(error) => {
304                        qk_executor::status!("qk: {}: warm state not saved ({error:#})", task.id);
305                        None
306                    }
307                }
308            };
309            Some(warm::WarmReport {
310                restored,
311                present,
312                save_ms,
313                background,
314            })
315        };
316        let fallback = |reason: String| {
317            execute(prepared, cancelled).map(|outcome| TaskResult::uncached(outcome, reason))
318        };
319        // Reports why this task runs uncached, attributing the reason to this task.
320        let bypass = |reason: String| {
321            if cacheable {
322                qk_executor::status!("qk: {}: cache bypassed ({reason})", task.id);
323            }
324            fallback(format!("{}: {reason}", task.id))
325        };
326        let dependencies = match dependencies
327            .iter()
328            .map(|(id, key)| key.clone().map(|key| (id.clone(), key)))
329            .collect::<std::result::Result<BTreeMap<_, _>, _>>()
330        {
331            Ok(dependencies) => dependencies,
332            Err(root) => {
333                // Keep the original task and reason so chains stay readable.
334                if cacheable {
335                    qk_executor::status!("qk: {}: cache bypassed (depends on {root})", task.id);
336                }
337                return fallback(root);
338            }
339        };
340        let snapshot = match self.snapshot(workspace, graph) {
341            Ok(snapshot) => snapshot,
342            Err(reason) => return bypass(reason),
343        };
344        let dependencies =
345            match hash::dependency_keys(snapshot, workspace, graph, task, &dependencies, cancelled)
346            {
347                Ok(dependencies) => dependencies,
348                Err(error) => return bypass(format!("{error:#}")),
349            };
350        let (key, inputs) = match hash::inputs(
351            snapshot,
352            workspace,
353            task,
354            prepared,
355            &dependencies,
356            cancelled,
357        )
358        .and_then(|inputs| Ok((hash::key(&inputs)?, inputs)))
359        {
360            Ok(key) => key,
361            Err(error) => return bypass(format!("{error:#}")),
362        };
363        let keyed = Some((key.clone(), inputs));
364        // As in Nx, the task sees its hash.
365        running
366            .execution
367            .insert("NX_TASK_HASH".into(), key.clone().into());
368        let running = &running;
369        let outputs = match paths::Outputs::new(workspace, task) {
370            Ok(outputs) => outputs,
371            Err(error) => return bypass(format!("{error:#}")),
372        };
373        if !cacheable {
374            let restored = before(running);
375            let outcome = execute(running, cancelled)?;
376            let warm_report = after(outcome, restored, running);
377            let fingerprint = if self.unchanged(
378                snapshot,
379                workspace,
380                graph,
381                task,
382                prepared,
383                &dependencies,
384                &key,
385                outcome,
386                cancelled,
387            ) {
388                outputs_fingerprint(task, &workspace.root, &outputs, &key)
389            } else {
390                Err(format!("{}: did not complete unchanged", task.id))
391            };
392            return Ok(TaskResult {
393                outcome,
394                fingerprint,
395                cache: CacheStatus::Uncached,
396                key: keyed,
397                warm: warm_report,
398                execution: None,
399            });
400        }
401        if !self.reuse {
402            let capture = Capture::new(None, prepared.display.clone()).retain_log();
403            let outcome = execute_captured(running, cancelled, Some(&capture))?;
404            let inputs_unchanged = self.inputs_match(
405                snapshot,
406                workspace,
407                graph,
408                task,
409                prepared,
410                &dependencies,
411                &key,
412                cancelled,
413            );
414            let fingerprint = if outcome == Outcome::Success && inputs_unchanged {
415                outputs_fingerprint(task, &workspace.root, &outputs, &key)
416            } else {
417                Err(format!("{}: did not complete unchanged", task.id))
418            };
419            return Ok(TaskResult {
420                outcome,
421                fingerprint,
422                cache: CacheStatus::Uncached,
423                key: keyed,
424                warm: None,
425                execution: capture.take_log().map(|log| Execution {
426                    log,
427                    inputs_unchanged,
428                }),
429            });
430        }
431        if let Err(error) = self.initialize() {
432            return bypass(format!("cache unavailable: {error}"));
433        }
434        let (_lock, waited) = match self.lock(&key, cancelled) {
435            Ok(Some(lock)) => lock,
436            Ok(None) => {
437                return Ok(TaskResult::uncached(
438                    Outcome::Cancelled,
439                    format!("{}: cancelled", task.id),
440                ));
441            }
442            Err(error) => return bypass(format!("cache lock unavailable: {error}")),
443        };
444        // Inputs may have changed while another worktree held this key's lock.
445        if waited
446            && hash::recheck_dependency_keys(
447                snapshot,
448                workspace,
449                graph,
450                task,
451                &dependencies,
452                cancelled,
453            )
454            .and_then(|dependencies| {
455                hash::fingerprint(
456                    snapshot,
457                    workspace,
458                    task,
459                    prepared,
460                    &dependencies,
461                    cancelled,
462                )
463            })
464            .ok()
465            .as_ref()
466                != Some(&key)
467        {
468            return bypass("inputs changed while waiting for another run".into());
469        }
470        match self.restore(
471            &workspace.root,
472            &task.id,
473            &task.dependencies,
474            &key,
475            &outputs,
476            &prepared.display,
477            qk_executor::Shown::LocalCache,
478        ) {
479            Ok(Some(fingerprint)) => {
480                if let Some(remote) = &self.remote {
481                    remote.held(&key);
482                }
483                qk_executor::report::event(qk_executor::report::Event::Cache {
484                    id: task.id.clone(),
485                    cached: qk_executor::report::Cached::Hit,
486                });
487                return Ok(TaskResult {
488                    outcome: Outcome::Success,
489                    fingerprint: Ok(fingerprint),
490                    cache: CacheStatus::LocalHit,
491                    key: keyed,
492                    warm: None,
493                    execution: None,
494                });
495            }
496            Ok(None) => {}
497            Err(error) => {
498                qk_executor::status!("qk: {}: ignoring unusable cache entry ({error})", task.id)
499            }
500        }
501        if let Some(remote) = &self.remote {
502            let fetched = remote.fetch(&self.root, &key).and_then(|found| {
503                if !found {
504                    return Ok(None);
505                }
506                self.restore(
507                    &workspace.root,
508                    &task.id,
509                    &task.dependencies,
510                    &key,
511                    &outputs,
512                    &prepared.display,
513                    qk_executor::Shown::RemoteCache,
514                )
515            });
516            match fetched {
517                Ok(Some(fingerprint)) => {
518                    qk_executor::report::event(qk_executor::report::Event::Cache {
519                        id: task.id.clone(),
520                        cached: qk_executor::report::Cached::RemoteHit,
521                    });
522                    return Ok(TaskResult {
523                        outcome: Outcome::Success,
524                        fingerprint: Ok(fingerprint),
525                        cache: CacheStatus::RemoteHit,
526                        key: keyed,
527                        warm: None,
528                        execution: None,
529                    });
530                }
531                Ok(None) => {}
532                Err(error) => {
533                    qk_executor::status!("qk: {}: remote cache unavailable ({error:#})", task.id)
534                }
535            }
536        }
537        qk_executor::report::event(qk_executor::report::Event::Cache {
538            id: task.id.clone(),
539            cached: qk_executor::report::Cached::Miss,
540        });
541        let log = match tempfile::NamedTempFile::new_in(self.root.join("tmp")) {
542            Ok(log) => log,
543            Err(error) => return bypass(format!("cache log unavailable: {error}")),
544        };
545        let capture =
546            Capture::new(Some(log.as_file().try_clone()?), prepared.display.clone()).retain_log();
547        let restored = before(running);
548        let outcome = execute_captured(running, cancelled, Some(&capture))?;
549        let warm_report = after(outcome, restored, running);
550        let inputs_unchanged = self.inputs_match(
551            snapshot,
552            workspace,
553            graph,
554            task,
555            prepared,
556            &dependencies,
557            &key,
558            cancelled,
559        );
560        let execution = capture.take_log().map(|log| Execution {
561            log,
562            inputs_unchanged,
563        });
564        let fingerprint = if outcome != Outcome::Success || !inputs_unchanged {
565            Err(format!("{}: did not complete unchanged", task.id))
566        } else if !capture.healthy() {
567            outputs_fingerprint(task, &workspace.root, &outputs, &key)
568        } else {
569            // The saved manifest already hashes every output; reuse it.
570            match self.publish(&workspace.root, &key, &outputs, log.path()) {
571                Ok(fingerprint) => {
572                    store::record_outputs(&workspace.root, &task.id, &key, &outputs);
573                    if let Some(remote) = &self.remote {
574                        remote.upload(&self.root, &key);
575                    }
576                    Ok(fingerprint)
577                }
578                Err(error) => {
579                    qk_executor::status!("qk: {}: could not save cache entry ({error})", task.id);
580                    outputs_fingerprint(task, &workspace.root, &outputs, &key)
581                }
582            }
583        };
584        Ok(TaskResult {
585            outcome,
586            fingerprint,
587            cache: CacheStatus::Miss,
588            key: keyed,
589            warm: warm_report,
590            execution,
591        })
592    }
593
594    /// Whether a successful task's inputs still match the key it ran under.
595    #[allow(clippy::too_many_arguments)]
596    fn unchanged(
597        &self,
598        snapshot: &hash::Snapshot,
599        workspace: &Workspace,
600        graph: &TaskGraph,
601        task: &Task,
602        prepared: &PreparedTask,
603        dependencies: &BTreeMap<String, String>,
604        before: &str,
605        outcome: Outcome,
606        cancelled: &AtomicBool,
607    ) -> bool {
608        if outcome != Outcome::Success || cancelled.load(Ordering::SeqCst) {
609            return false;
610        }
611        self.inputs_match(
612            snapshot,
613            workspace,
614            graph,
615            task,
616            prepared,
617            dependencies,
618            before,
619            cancelled,
620        )
621    }
622
623    /// Rechecks declared inputs for successful and failed executions alike.
624    #[allow(clippy::too_many_arguments)]
625    fn inputs_match(
626        &self,
627        snapshot: &hash::Snapshot,
628        workspace: &Workspace,
629        graph: &TaskGraph,
630        task: &Task,
631        prepared: &PreparedTask,
632        dependencies: &BTreeMap<String, String>,
633        before: &str,
634        cancelled: &AtomicBool,
635    ) -> bool {
636        if cancelled.load(Ordering::SeqCst) {
637            return false;
638        }
639        let after = hash::recheck_dependency_keys(
640            snapshot,
641            workspace,
642            graph,
643            task,
644            dependencies,
645            cancelled,
646        )
647        .and_then(|dependencies| {
648            hash::fingerprint(
649                snapshot,
650                workspace,
651                task,
652                prepared,
653                &dependencies,
654                cancelled,
655            )
656        });
657        if after.as_deref().ok() != Some(before) {
658            if task.definition.cache == Some(true) {
659                if self.reuse {
660                    qk_executor::status!(
661                        "qk: {}: not caching because inputs changed during execution",
662                        task.id
663                    );
664                } else {
665                    qk_executor::status!(
666                        "qk: {}: excluding execution from flaky results because inputs changed during execution",
667                        task.id
668                    );
669                }
670            }
671            return false;
672        }
673        true
674    }
675}
676
677/// Per-output values for dependents' keys. Disk and manifest must agree exactly,
678/// so a dependent's key is the same whether this task was restored or executed.
679pub(crate) fn file_output(content: &str, mode: u32) -> Value {
680    json!({"content": content, "mode": mode})
681}
682
683pub(crate) fn link_output(target: &str) -> Value {
684    json!({"link": target})
685}
686
687pub(crate) fn directory_output() -> Value {
688    json!("directory")
689}
690
691/// What a task's dependents are keyed on. A task that declares outputs is
692/// known to them only through those outputs, so a run that reproduces them
693/// leaves its dependents cached however its own inputs changed. A task
694/// without declared outputs may affect its dependents in ways qk cannot see,
695/// so they are keyed on its inputs.
696pub(crate) fn combine_outputs(
697    input: &str,
698    files: &BTreeMap<String, Value>,
699    declared: bool,
700) -> Result<String> {
701    let identity = if declared { "declared outputs" } else { input };
702    Ok(blake3::hash(&serde_json::to_vec(&(identity, files))?)
703        .to_hex()
704        .to_string())
705}
706
707fn outputs_fingerprint(
708    task: &Task,
709    root: &Path,
710    outputs: &paths::Outputs,
711    input: &str,
712) -> Fingerprint {
713    output_fingerprint(root, outputs, input)
714        .map_err(|error| format!("{}: cannot fingerprint outputs: {error:#}", task.id))
715}
716
717fn output_fingerprint(root: &Path, outputs: &paths::Outputs, input: &str) -> Result<String> {
718    let mut files = BTreeMap::new();
719    for entry in outputs.entries(root)? {
720        let (path, metadata) = entry?;
721        paths::safe_parents(root, &path)?;
722        let absolute = root.join(&path);
723        let value = if metadata.file_type().is_symlink() {
724            link_output(
725                std::fs::read_link(absolute)?
726                    .to_str()
727                    .context("symlink target must be UTF-8")?,
728            )
729        } else if metadata.is_file() {
730            file_output(&hash::digest_file(&absolute)?, store::mode(&metadata))
731        } else {
732            directory_output()
733        };
734        files.insert(path, value);
735    }
736    combine_outputs(input, &files, outputs.declared())
737}
738
739/// Runs the private detached-upload protocol used by the qk executable.
740#[doc(hidden)]
741pub fn run_upload_worker() -> anyhow::Result<()> {
742    remote::background::run()
743}
744
745#[cfg(test)]
746mod tests {
747    use super::*;
748
749    #[test]
750    fn concurrent_blob_writes_keep_results_ordered_and_repair_corruption() {
751        let workspace = tempfile::tempdir().unwrap();
752        let root = workspace.path();
753        let cache = Cache::new(root.join("cache"));
754        cache.initialize().unwrap();
755        let mut sources = Vec::new();
756        for index in 0..130 {
757            let source = root.join(format!("source-{index}"));
758            std::fs::write(&source, format!("content-{}", index % 65)).unwrap();
759            sources.push(source);
760        }
761        let digest = cache.put_blob(&sources[0]).unwrap();
762        std::fs::write(cache.root.join("blobs").join(&digest), "corrupt").unwrap();
763        std::fs::remove_file(&sources[31]).unwrap();
764        let results = cache.put_blobs(&sources);
765        assert_eq!(results.len(), sources.len());
766        for (index, result) in results.into_iter().enumerate() {
767            if index == 31 {
768                assert_eq!(
769                    result
770                        .unwrap_err()
771                        .downcast_ref::<std::io::Error>()
772                        .unwrap()
773                        .kind(),
774                    std::io::ErrorKind::NotFound
775                );
776                continue;
777            }
778            let digest = result.unwrap();
779            let held = cache.root.join("blobs").join(&digest);
780            assert_eq!(
781                std::fs::read(&held).unwrap(),
782                format!("content-{}", index % 65).as_bytes()
783            );
784            assert_eq!(hash::digest_file(&held).unwrap(), digest);
785        }
786    }
787
788    /// Content the store holds already is reused, not copied over again.
789    #[cfg(unix)]
790    #[test]
791    fn a_blob_the_store_holds_is_not_written_again() {
792        use std::os::unix::fs::MetadataExt;
793        let workspace = tempfile::tempdir().unwrap();
794        let root = workspace.path();
795        let cache = Cache::new(root.join(".qk/cache"));
796        cache.initialize().unwrap();
797        std::fs::write(root.join("first"), "same").unwrap();
798        std::fs::write(root.join("second"), "same").unwrap();
799        std::fs::write(root.join("other"), "different").unwrap();
800        let first = cache.put_blob(&root.join("first")).unwrap();
801        let blob = cache.root.join("blobs").join(&first);
802        let stored = std::fs::metadata(&blob).unwrap().ino();
803        assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
804        assert_eq!(std::fs::metadata(&blob).unwrap().ino(), stored);
805        // A stored blob whose content no longer matches is stored anew.
806        std::fs::write(&blob, "corrupt").unwrap();
807        assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
808        assert_eq!(std::fs::read(&blob).unwrap(), b"same");
809        // So is one that cannot be read.
810        {
811            use std::os::unix::fs::PermissionsExt;
812            std::fs::set_permissions(&blob, std::fs::Permissions::from_mode(0o000)).unwrap();
813        }
814        assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
815        assert_eq!(std::fs::read(&blob).unwrap(), b"same");
816        let other = cache.put_blob(&root.join("other")).unwrap();
817        assert_ne!(other, first);
818        assert_eq!(
819            std::fs::read(cache.root.join("blobs").join(other)).unwrap(),
820            b"different"
821        );
822    }
823
824    /// Complete-directory restore preserves contents, links, modes, timestamps
825    /// and fingerprints, and the subsequent hit keeps restored files in place.
826    #[test]
827    fn manifest_and_disk_output_fingerprints_agree() {
828        let workspace = tempfile::tempdir().unwrap();
829        let root = workspace.path();
830        std::fs::create_dir_all(root.join("dist/nested/empty")).unwrap();
831        std::fs::write(root.join("dist/nested/a.txt"), "a").unwrap();
832        std::fs::write(root.join("dist/b.bin"), [0, 1, 2]).unwrap();
833        std::fs::create_dir_all(root.join("bundle/empty")).unwrap();
834        std::fs::write(root.join("bundle/readonly"), "readonly").unwrap();
835        #[cfg(unix)]
836        {
837            use std::os::unix::fs::PermissionsExt;
838            let script = root.join("dist/run.sh");
839            std::fs::write(&script, "#!/bin/sh\n").unwrap();
840            std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
841            std::os::unix::fs::symlink("nested/a.txt", root.join("dist/link")).unwrap();
842            std::fs::set_permissions(
843                root.join("dist/nested"),
844                std::fs::Permissions::from_mode(0o500),
845            )
846            .unwrap();
847            std::fs::set_permissions(
848                root.join("bundle/readonly"),
849                std::fs::Permissions::from_mode(0o444),
850            )
851            .unwrap();
852        }
853        let log = root.join("log");
854        std::fs::write(&log, "").unwrap();
855
856        let cache = Cache::new(root.join(".qk/cache"));
857        cache.initialize().unwrap();
858        let outputs = paths::Outputs::from_patterns(&["dist", "bundle"]);
859        let key = "0".repeat(64);
860        let saved = cache.publish(root, &key, &outputs, &log).unwrap();
861        assert_eq!(saved, output_fingerprint(root, &outputs, &key).unwrap());
862
863        #[cfg(unix)]
864        {
865            use std::os::unix::fs::PermissionsExt;
866            // The manifest retains the restrictive mode; cleanup needs write
867            // permission on the old directory to remove its files.
868            std::fs::set_permissions(
869                root.join("dist/nested"),
870                std::fs::Permissions::from_mode(0o755),
871            )
872            .unwrap();
873        }
874        std::fs::remove_dir_all(root.join("dist")).unwrap();
875        std::fs::remove_dir_all(root.join("bundle")).unwrap();
876        let before = std::time::SystemTime::now();
877        let restored = cache
878            .restore(
879                root,
880                "app:build",
881                &Default::default(),
882                &key,
883                &outputs,
884                &qk_executor::Display::Stream,
885                qk_executor::Shown::LocalCache,
886            )
887            .unwrap();
888        assert_eq!(restored, Some(saved.clone()));
889        assert_eq!(saved, output_fingerprint(root, &outputs, &key).unwrap());
890        assert!(root.join("dist/nested/empty").is_dir());
891        assert!(root.join("bundle/empty").is_dir());
892        assert_eq!(
893            std::fs::read_to_string(root.join("bundle/readonly")).unwrap(),
894            "readonly"
895        );
896        #[cfg(unix)]
897        {
898            assert_eq!(
899                std::fs::read_link(root.join("dist/link")).unwrap(),
900                Path::new("nested/a.txt")
901            );
902            assert_eq!(
903                store::mode(&std::fs::metadata(root.join("dist/run.sh")).unwrap()),
904                0o755
905            );
906            assert_eq!(
907                store::mode(&std::fs::metadata(root.join("dist/nested")).unwrap()),
908                0o500
909            );
910            assert_eq!(
911                store::mode(&std::fs::metadata(root.join("bundle/readonly")).unwrap()),
912                0o444
913            );
914        }
915        let path = root.join("dist/nested/a.txt");
916        let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
917        assert!(modified >= before);
918        let kept = cache
919            .restore(
920                root,
921                "app:build",
922                &Default::default(),
923                &key,
924                &outputs,
925                &qk_executor::Display::Hidden,
926                qk_executor::Shown::LocalCache,
927            )
928            .unwrap();
929        assert_eq!(kept, Some(saved));
930        assert_eq!(
931            std::fs::metadata(&path).unwrap().modified().unwrap(),
932            modified
933        );
934        #[cfg(unix)]
935        {
936            use std::os::unix::fs::PermissionsExt;
937            std::fs::set_permissions(
938                root.join("dist/nested"),
939                std::fs::Permissions::from_mode(0o755),
940            )
941            .unwrap();
942        }
943    }
944}