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