Skip to main content

haz_cache/
restore.rs

1//! [`CacheWriter::restore`] per `CACHE-019` and `CACHE-020`.
2//!
3//! Restoration materialises the recorded outputs of a cache
4//! entry at their workspace-absolute paths and returns the
5//! captured `stdout`/`stderr` byte streams for the caller to
6//! emit. The caller drives stream emission (subject to the
7//! task's `output.mode`); the cache layer is only responsible
8//! for producing the bytes and publishing the files.
9//!
10//! Two-phase publish per `CACHE-020`:
11//!
12//! 1. **Stage.** A per-restore directory under [`crate::layout::cache_root`]
13//!    named `.restore-<hex-key>-<random>/` collects every output
14//!    blob, byte-identical to what the entry directory records.
15//!    Each staged file is written with the recorded mode and
16//!    `fsync`-ed. The target's parent directory is created in
17//!    this phase too, so phase 2 can be pure renames.
18//! 2. **Publish.** Each staged file is renamed onto its target
19//!    workspace-absolute path. Renames are atomic on the host
20//!    filesystem (same FS, since both source and target sit
21//!    under `<workspace_root>`).
22//!
23//! The cache holds the workspace root so that
24//! `workspace_absolute_path` strings recorded in the manifest
25//! (rooted at `/`) can be mapped to real filesystem paths.
26//!
27//! Failure handling matches the spec's "all or nothing" intent
28//! best-effort: every failure inside `restore` returns an error,
29//! and the staging directory is wiped on the way out (success or
30//! failure) so transient publishing state is not leaked. If a
31//! failure occurs partway through phase 2, some targets are
32//! published and others are not; the caller is expected to treat
33//! the result as a miss and re-run the task fresh, per
34//! `CACHE-020` second paragraph.
35
36use std::path::{Path, PathBuf};
37
38use haz_domain::path::CanonicalPath;
39use haz_vfs::{FsError, WritableFilesystem};
40use snafu::{ResultExt, Snafu};
41
42use crate::layout;
43use crate::manifest::Manifest;
44use crate::writer::CacheWriter;
45
46/// Captured streams returned to the caller on a successful
47/// restore. The caller decides how to emit them per the task's
48/// configured `output.mode` (`CACHE-019` steps 2 and 3); the
49/// cache is opinion-free on emission.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct RestoredStreams {
52    /// Bytes that were captured on the run's `stdout`.
53    pub stdout: Vec<u8>,
54    /// Bytes that were captured on the run's `stderr`.
55    pub stderr: Vec<u8>,
56}
57
58/// Failure modes for [`CacheWriter::restore`].
59#[derive(Debug, Snafu)]
60pub enum RestoreError {
61    /// Underlying filesystem error during one of the restore
62    /// phases (reading a cached blob, staging the copy, creating
63    /// the target's parent directory, renaming onto the target).
64    /// The wrapped [`FsError`] carries the specific path.
65    #[snafu(display("filesystem error during cache restore: {source}"))]
66    Io {
67        /// The originating filesystem error.
68        source: FsError,
69    },
70}
71
72impl<Fs: WritableFilesystem> CacheWriter<Fs> {
73    /// Restore the cache entry described by `manifest` per
74    /// `CACHE-019`.
75    ///
76    /// Materialises every output declared in the manifest at its
77    /// workspace-absolute path with the recorded mode and
78    /// returns the captured `stdout`/`stderr` bytes. The caller
79    /// MUST have just obtained `manifest` from
80    /// `CacheReader::lookup`; the cache trusts the manifest content
81    /// (paths, content hashes, sizes, modes) as truth and does
82    /// not re-verify it.
83    ///
84    /// On error, the staging directory under the cache root is
85    /// removed regardless of which phase failed, so no transient
86    /// scratch space leaks. If the error occurs after one or
87    /// more targets have already been renamed onto, those
88    /// targets remain published; the caller MUST treat the error
89    /// as a miss and re-run the task fresh
90    /// (`CACHE-020` second paragraph).
91    ///
92    /// # Errors
93    ///
94    /// Returns [`RestoreError::Io`] wrapping the underlying
95    /// [`FsError`] if any filesystem operation along the phases
96    /// fails.
97    pub fn restore(&self, manifest: &Manifest) -> Result<RestoredStreams, RestoreError> {
98        let suffix = random_suffix_hex();
99        let stage_dir = layout::restore_staging_dir(self.cache_root(), &manifest.key, &suffix);
100        let result = self.restore_inner(manifest, &stage_dir);
101        // Best-effort cleanup of the staging directory. On
102        // success it is already empty after every rename; on
103        // failure it may hold staged files that never made it to
104        // their targets. Either way we drop it. We deliberately
105        // ignore errors here; the caller is informed of the
106        // primary failure via `result`.
107        let _ = self.fs().remove_dir_all(&stage_dir);
108        result
109    }
110
111    fn restore_inner(
112        &self,
113        manifest: &Manifest,
114        stage_dir: &Path,
115    ) -> Result<RestoredStreams, RestoreError> {
116        self.fs().create_dir_all(stage_dir).context(IoSnafu)?;
117
118        let stdout = self
119            .fs()
120            .read(&layout::stdout_path(self.cache_root(), &manifest.key))
121            .context(IoSnafu)?;
122        let stderr = self
123            .fs()
124            .read(&layout::stderr_path(self.cache_root(), &manifest.key))
125            .context(IoSnafu)?;
126
127        // Stage outputs into the sibling restore directory and
128        // prepare each target's parent.
129        let mut planned: Vec<(PathBuf, PathBuf)> = Vec::with_capacity(manifest.outputs.len());
130        for (i, blob) in manifest.outputs.iter().enumerate() {
131            let src =
132                layout::output_blob_path(self.cache_root(), &manifest.key, &blob.content_hash);
133            let bytes = self.fs().read(&src).context(IoSnafu)?;
134            let staged = stage_dir.join(format!("{i:08}"));
135            self.fs().write_file(&staged, &bytes).context(IoSnafu)?;
136            self.fs()
137                .set_permissions(&staged, blob.mode)
138                .context(IoSnafu)?;
139            self.fs().fsync_file(&staged).context(IoSnafu)?;
140
141            let target =
142                workspace_path_from_canonical(self.workspace_root(), &blob.workspace_absolute_path);
143            if let Some(parent) = target.parent() {
144                self.fs().create_dir_all(parent).context(IoSnafu)?;
145            }
146            planned.push((staged, target));
147        }
148
149        // Atomically publish each staged file into place.
150        for (staged, target) in &planned {
151            self.fs().rename(staged, target).context(IoSnafu)?;
152        }
153
154        Ok(RestoredStreams { stdout, stderr })
155    }
156}
157
158/// Map a workspace-anchored [`CanonicalPath`] onto a real
159/// filesystem path by joining each validated segment onto
160/// `workspace_root`.
161///
162/// Walks segments rather than concatenating the rendered string,
163/// so a host-OS separator inside a single segment cannot be
164/// reinterpreted as a separator. This is belt-and-braces: the
165/// [`CanonicalPath`] type's construction already rejects
166/// segments that contain `/` (`PATH-002`) or that resolve to `.`
167/// or `..`. The segment walk preserves that invariant across the
168/// boundary into [`std::path::PathBuf`].
169fn workspace_path_from_canonical(workspace_root: &Path, canonical: &CanonicalPath) -> PathBuf {
170    let mut p = workspace_root.to_path_buf();
171    for segment in canonical.segments() {
172        p.push(segment.as_str());
173    }
174    p
175}
176
177/// 16 lowercase hex characters of randomness, same shape as the
178/// store-time tmp suffix. Kept module-local rather than shared
179/// with `store.rs` to avoid a thin shared module for a four-line
180/// helper; the two call sites are independent and divergence is
181/// not a concern.
182fn random_suffix_hex() -> String {
183    let r: u64 = rand::random();
184    format!("{r:016x}")
185}
186
187#[cfg(test)]
188mod tests {
189    use std::path::{Path, PathBuf};
190
191    use haz_domain::settings::cache::HashAlgo;
192    use haz_vfs::{Filesystem, WritableFilesystem};
193    use haz_vfs_testing::MemFilesystem;
194
195    use crate::key::CacheKey;
196    use crate::store::{StoreInputs, StoredOutput};
197    use crate::writer::CacheWriter;
198
199    const WORKSPACE_ROOT: &str = "/ws";
200
201    fn sample_key() -> CacheKey {
202        let mut bytes = [0u8; 32];
203        bytes[0] = 0xAB;
204        bytes[1] = 0xCD;
205        CacheKey::from_bytes(bytes)
206    }
207
208    fn make_cache(fs: MemFilesystem, algo: HashAlgo) -> CacheWriter<MemFilesystem> {
209        CacheWriter::new(fs, Path::new(WORKSPACE_ROOT), algo)
210    }
211
212    /// Build a [`MemFilesystem`] preloaded with a workspace and
213    /// a single output file on disk.
214    fn fs_with_one_output(target: &Path, bytes: &[u8], mode: u32) -> MemFilesystem {
215        let mut fs = MemFilesystem::new();
216        fs.add_dir(target.parent().unwrap()).unwrap();
217        fs.add_file_with_mode(target, bytes.to_vec(), mode).unwrap();
218        fs
219    }
220
221    /// Drive store then restore as the executor would. Returns
222    /// the cache (so tests can inspect on-disk state after) and
223    /// the [`RestoredStreams`] returned by `restore`.
224    fn store_then_restore(
225        fs: MemFilesystem,
226        algo: HashAlgo,
227        outputs: &[StoredOutput<'_>],
228        stdout: &[u8],
229        stderr: &[u8],
230    ) -> (
231        CacheWriter<MemFilesystem>,
232        crate::restore::RestoredStreams,
233        crate::manifest::Manifest,
234    ) {
235        let cache = make_cache(fs, algo);
236        let key = sample_key();
237        let inputs = StoreInputs {
238            outputs,
239            stdout,
240            stderr,
241            created_at_unix: 1_715_700_000,
242        };
243        cache.store(&key, &inputs).unwrap();
244        let manifest = cache
245            .reader()
246            .lookup(&key)
247            .expect("store should produce a hit");
248        let restored = cache.restore(&manifest).expect("restore should succeed");
249        (cache, restored, manifest)
250    }
251
252    // ---- happy path: round-trip ----
253
254    #[test]
255    fn cache_019_restore_after_store_round_trips_outputs() {
256        let blob = b"hello-world";
257        let target = PathBuf::from("/ws/proj/out");
258        let fs = fs_with_one_output(&target, blob, 0o644);
259
260        let outs = [StoredOutput {
261            workspace_absolute_path: "/proj/out",
262            on_disk_path: &target,
263            mode: 0o644,
264        }];
265        let (cache, _restored, _manifest) = store_then_restore(
266            fs,
267            HashAlgo::Blake3,
268            &outs,
269            b"stdout-bytes",
270            b"stderr-bytes",
271        );
272
273        // Target on disk holds the restored bytes.
274        let got = cache.fs().read(&target).unwrap();
275        assert_eq!(got, blob);
276        let mode = cache.fs().mode_of(&target).unwrap();
277        assert_eq!(mode, 0o644);
278    }
279
280    #[test]
281    fn cache_019_restore_returns_captured_stdout_and_stderr_bytes() {
282        let blob = b"";
283        let target = PathBuf::from("/ws/proj/out");
284        let fs = fs_with_one_output(&target, blob, 0o644);
285        let outs = [StoredOutput {
286            workspace_absolute_path: "/proj/out",
287            on_disk_path: &target,
288            mode: 0o644,
289        }];
290        let (_cache, restored, _manifest) =
291            store_then_restore(fs, HashAlgo::Blake3, &outs, b"out-bytes\n", b"err-bytes\n");
292        assert_eq!(restored.stdout, b"out-bytes\n");
293        assert_eq!(restored.stderr, b"err-bytes\n");
294    }
295
296    // ---- degenerate input shapes ----
297
298    #[test]
299    fn cache_019_restore_with_no_outputs_returns_empty_streams_when_streams_are_empty() {
300        let mut fs = MemFilesystem::new();
301        fs.add_dir("/ws").unwrap();
302        let (_cache, restored, manifest) = store_then_restore(fs, HashAlgo::Blake3, &[], b"", b"");
303        assert!(restored.stdout.is_empty());
304        assert!(restored.stderr.is_empty());
305        assert_eq!(manifest.outputs.len(), 0);
306    }
307
308    #[test]
309    fn cache_019_restore_with_multiple_outputs_materialises_each_at_its_path() {
310        let mut fs = MemFilesystem::new();
311        fs.add_dir("/ws/proj").unwrap();
312        fs.add_file_with_mode("/ws/proj/a", b"alpha".to_vec(), 0o644)
313            .unwrap();
314        fs.add_file_with_mode("/ws/proj/b", b"beta-bytes".to_vec(), 0o755)
315            .unwrap();
316        let on_a = PathBuf::from("/ws/proj/a");
317        let on_b = PathBuf::from("/ws/proj/b");
318        let outs = [
319            StoredOutput {
320                workspace_absolute_path: "/proj/a",
321                on_disk_path: &on_a,
322                mode: 0o644,
323            },
324            StoredOutput {
325                workspace_absolute_path: "/proj/b",
326                on_disk_path: &on_b,
327                mode: 0o755,
328            },
329        ];
330        let (cache, _restored, _manifest) =
331            store_then_restore(fs, HashAlgo::Blake3, &outs, b"", b"");
332        assert_eq!(cache.fs().read(&on_a).unwrap(), b"alpha");
333        assert_eq!(cache.fs().read(&on_b).unwrap(), b"beta-bytes");
334        assert_eq!(cache.fs().mode_of(&on_a).unwrap(), 0o644);
335        assert_eq!(cache.fs().mode_of(&on_b).unwrap(), 0o755);
336    }
337
338    // ---- intermediate parent directories ----
339
340    #[test]
341    fn cache_019_restore_creates_missing_intermediate_directories_for_target() {
342        let blob = b"deep-output";
343        let target = PathBuf::from("/ws/proj/nested/deep/out");
344        // Build fs with the deep file present (so store can read
345        // it), then drop the nested chain BEFORE restore to model
346        // the target's parent vanishing between store and
347        // restore. We do this by issuing a fresh store on a
348        // brand-new filesystem.
349        let mut fs = MemFilesystem::new();
350        fs.add_dir("/ws/proj/nested/deep").unwrap();
351        fs.add_file_with_mode(&target, blob.to_vec(), 0o644)
352            .unwrap();
353
354        let cache = make_cache(fs, HashAlgo::Blake3);
355        let key = sample_key();
356        let outs = [StoredOutput {
357            workspace_absolute_path: "/proj/nested/deep/out",
358            on_disk_path: &target,
359            mode: 0o644,
360        }];
361        let inputs = StoreInputs {
362            outputs: &outs,
363            stdout: b"",
364            stderr: b"",
365            created_at_unix: 0,
366        };
367        cache.store(&key, &inputs).unwrap();
368
369        // Now wipe the workspace's proj/ tree to simulate
370        // "outputs are gone between store and restore".
371        cache.fs().remove_dir_all(Path::new("/ws/proj")).unwrap();
372
373        let manifest = cache.reader().lookup(&key).expect("entry still hits");
374        cache
375            .restore(&manifest)
376            .expect("restore must re-create the path");
377        assert_eq!(cache.fs().read(&target).unwrap(), blob);
378    }
379
380    // ---- overwrite of existing target ----
381
382    #[test]
383    fn cache_020_cache_019_restore_overwrites_an_existing_target_file() {
384        let target = PathBuf::from("/ws/proj/out");
385        let fs = fs_with_one_output(&target, b"original", 0o644);
386        let outs = [StoredOutput {
387            workspace_absolute_path: "/proj/out",
388            on_disk_path: &target,
389            mode: 0o644,
390        }];
391        let cache = make_cache(fs, HashAlgo::Blake3);
392        let key = sample_key();
393        cache
394            .store(
395                &key,
396                &StoreInputs {
397                    outputs: &outs,
398                    stdout: b"",
399                    stderr: b"",
400                    created_at_unix: 0,
401                },
402            )
403            .unwrap();
404
405        // Mutate the file in place to model a divergent run.
406        cache.fs().write_file(&target, b"divergent").unwrap();
407
408        let manifest = cache.reader().lookup(&key).unwrap();
409        cache.restore(&manifest).unwrap();
410        assert_eq!(cache.fs().read(&target).unwrap(), b"original");
411    }
412
413    // ---- I/O errors propagate ----
414
415    #[test]
416    fn cache_019_restore_propagates_missing_cached_blob_as_io_error() {
417        let target = PathBuf::from("/ws/proj/out");
418        let fs = fs_with_one_output(&target, b"x", 0o644);
419        let cache = make_cache(fs, HashAlgo::Blake3);
420        let key = sample_key();
421        let outs = [StoredOutput {
422            workspace_absolute_path: "/proj/out",
423            on_disk_path: &target,
424            mode: 0o644,
425        }];
426        cache
427            .store(
428                &key,
429                &StoreInputs {
430                    outputs: &outs,
431                    stdout: b"",
432                    stderr: b"",
433                    created_at_unix: 0,
434                },
435            )
436            .unwrap();
437
438        let manifest = cache.reader().lookup(&key).unwrap();
439
440        // Tamper: delete the cache entry directory after the
441        // lookup but before the restore. Lookup observed the
442        // entry; restore must surface the missing-blob failure.
443        let entry = crate::layout::entry_dir(cache.cache_root(), &key);
444        cache.fs().remove_dir_all(&entry).unwrap();
445
446        let err = cache.restore(&manifest).unwrap_err();
447        let msg = format!("{err}");
448        assert!(msg.contains("filesystem error"), "got: {msg}");
449    }
450
451    // ---- staging cleanup ----
452
453    #[test]
454    fn cache_019_restore_leaves_no_staging_directory_after_success() {
455        let target = PathBuf::from("/ws/proj/out");
456        let fs = fs_with_one_output(&target, b"x", 0o644);
457        let outs = [StoredOutput {
458            workspace_absolute_path: "/proj/out",
459            on_disk_path: &target,
460            mode: 0o644,
461        }];
462        let (cache, _restored, _manifest) =
463            store_then_restore(fs, HashAlgo::Blake3, &outs, b"", b"");
464
465        for entry in cache.fs().read_dir(cache.cache_root()).unwrap() {
466            let name = entry
467                .path
468                .file_name()
469                .unwrap()
470                .to_string_lossy()
471                .into_owned();
472            assert!(
473                !name.starts_with(".restore-"),
474                "staging directory must not persist after a successful restore, found: {name}"
475            );
476        }
477    }
478
479    #[test]
480    fn cache_019_restore_leaves_no_staging_directory_after_failure() {
481        let target = PathBuf::from("/ws/proj/out");
482        let fs = fs_with_one_output(&target, b"x", 0o644);
483        let cache = make_cache(fs, HashAlgo::Blake3);
484        let key = sample_key();
485        let outs = [StoredOutput {
486            workspace_absolute_path: "/proj/out",
487            on_disk_path: &target,
488            mode: 0o644,
489        }];
490        cache
491            .store(
492                &key,
493                &StoreInputs {
494                    outputs: &outs,
495                    stdout: b"",
496                    stderr: b"",
497                    created_at_unix: 0,
498                },
499            )
500            .unwrap();
501        let manifest = cache.reader().lookup(&key).unwrap();
502
503        // Force a phase-1 failure by deleting the cache entry
504        // (so reading the cached blob fails).
505        let entry = crate::layout::entry_dir(cache.cache_root(), &key);
506        cache.fs().remove_dir_all(&entry).unwrap();
507
508        let _ = cache.restore(&manifest).unwrap_err();
509        for entry in cache.fs().read_dir(cache.cache_root()).unwrap() {
510            let name = entry
511                .path
512                .file_name()
513                .unwrap()
514                .to_string_lossy()
515                .into_owned();
516            assert!(
517                !name.starts_with(".restore-"),
518                "staging directory must be cleaned up after a failed restore, found: {name}"
519            );
520        }
521    }
522
523    // ---- different hash algo ----
524
525    #[test]
526    fn cache_019_restore_works_under_sha256() {
527        let target = PathBuf::from("/ws/proj/out");
528        let fs = fs_with_one_output(&target, b"sha-bytes", 0o600);
529        let outs = [StoredOutput {
530            workspace_absolute_path: "/proj/out",
531            on_disk_path: &target,
532            mode: 0o600,
533        }];
534        let (cache, restored, _manifest) =
535            store_then_restore(fs, HashAlgo::Sha256, &outs, b"sha-stdout", b"sha-stderr");
536        assert_eq!(cache.fs().read(&target).unwrap(), b"sha-bytes");
537        assert_eq!(cache.fs().mode_of(&target).unwrap(), 0o600);
538        assert_eq!(restored.stdout, b"sha-stdout");
539        assert_eq!(restored.stderr, b"sha-stderr");
540    }
541}