1use 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#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct RestoredStreams {
52 pub stdout: Vec<u8>,
54 pub stderr: Vec<u8>,
56}
57
58#[derive(Debug, Snafu)]
60pub enum RestoreError {
61 #[snafu(display("filesystem error during cache restore: {source}"))]
66 Io {
67 source: FsError,
69 },
70}
71
72impl<Fs: WritableFilesystem> CacheWriter<Fs> {
73 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 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 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 for (staged, target) in &planned {
151 self.fs().rename(staged, target).context(IoSnafu)?;
152 }
153
154 Ok(RestoredStreams { stdout, stderr })
155 }
156}
157
158fn 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
177fn 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 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 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 #[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 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 #[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 #[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 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 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 #[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 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 #[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 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 #[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 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 #[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}