1use haz_vfs::{EntryKind, FsError, FsMetadata, WritableFilesystem};
30use snafu::Snafu;
31
32use crate::cache::Cache;
33use crate::key::CacheKey;
34use crate::layout;
35use crate::manifest::{HashFunctionLabel, Manifest};
36
37#[derive(Debug, Snafu)]
43pub enum CacheLookupError {
44 #[snafu(display("filesystem error during cache lookup: {source}"))]
47 Io {
48 source: FsError,
50 },
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum CacheLookupStatus {
57 Hit(Manifest),
62 MissNoEntry,
64 MissSchemaMismatch,
68 MissCorruptEntry,
74}
75
76impl<Fs: WritableFilesystem> Cache<Fs> {
77 #[must_use]
97 pub fn lookup(&self, key: &CacheKey) -> Option<Manifest> {
98 let manifest_path = layout::manifest_path(self.cache_root(), key);
99 let bytes = self.fs().read(&manifest_path).ok()?;
100 let manifest = Manifest::from_json(&bytes).ok()?;
101
102 if !manifest.current_chapter_revision_matches() {
103 return None;
104 }
105 if HashFunctionLabel::from(self.hash_algo()) != manifest.hash_function {
106 return None;
107 }
108
109 for blob in &manifest.outputs {
110 let blob_path = layout::output_blob_path(self.cache_root(), key, &blob.content_hash);
111 let m = self.fs().metadata(&blob_path).ok()?;
112 if m.kind != EntryKind::File || m.size != blob.size {
113 return None;
114 }
115 }
116
117 let stdout_m = self
118 .fs()
119 .metadata(&layout::stdout_path(self.cache_root(), key))
120 .ok()?;
121 if stdout_m.kind != EntryKind::File || stdout_m.size != manifest.stdout_len {
122 return None;
123 }
124
125 let stderr_m = self
126 .fs()
127 .metadata(&layout::stderr_path(self.cache_root(), key))
128 .ok()?;
129 if stderr_m.kind != EntryKind::File || stderr_m.size != manifest.stderr_len {
130 return None;
131 }
132
133 Some(manifest)
134 }
135
136 pub fn lookup_status(&self, key: &CacheKey) -> Result<CacheLookupStatus, CacheLookupError> {
153 let manifest_path = layout::manifest_path(self.cache_root(), key);
154 let bytes = match self.fs().read(&manifest_path) {
155 Ok(b) => b,
156 Err(FsError::NotFound { .. } | FsError::NotAFile { .. }) => {
157 return Ok(CacheLookupStatus::MissNoEntry);
158 }
159 Err(source) => return Err(CacheLookupError::Io { source }),
160 };
161 let Ok(manifest) = Manifest::from_json(&bytes) else {
162 return Ok(CacheLookupStatus::MissCorruptEntry);
163 };
164 if !manifest.current_chapter_revision_matches()
165 || HashFunctionLabel::from(self.hash_algo()) != manifest.hash_function
166 {
167 return Ok(CacheLookupStatus::MissSchemaMismatch);
168 }
169 for blob in &manifest.outputs {
170 let blob_path = layout::output_blob_path(self.cache_root(), key, &blob.content_hash);
171 let Some(m) = self.metadata_for_lookup(&blob_path)? else {
172 return Ok(CacheLookupStatus::MissCorruptEntry);
173 };
174 if m.kind != EntryKind::File || m.size != blob.size {
175 return Ok(CacheLookupStatus::MissCorruptEntry);
176 }
177 }
178 let stdout_path = layout::stdout_path(self.cache_root(), key);
179 let Some(stdout_m) = self.metadata_for_lookup(&stdout_path)? else {
180 return Ok(CacheLookupStatus::MissCorruptEntry);
181 };
182 if stdout_m.kind != EntryKind::File || stdout_m.size != manifest.stdout_len {
183 return Ok(CacheLookupStatus::MissCorruptEntry);
184 }
185 let stderr_path = layout::stderr_path(self.cache_root(), key);
186 let Some(stderr_m) = self.metadata_for_lookup(&stderr_path)? else {
187 return Ok(CacheLookupStatus::MissCorruptEntry);
188 };
189 if stderr_m.kind != EntryKind::File || stderr_m.size != manifest.stderr_len {
190 return Ok(CacheLookupStatus::MissCorruptEntry);
191 }
192 Ok(CacheLookupStatus::Hit(manifest))
193 }
194
195 fn metadata_for_lookup(
198 &self,
199 path: &std::path::Path,
200 ) -> Result<Option<FsMetadata>, CacheLookupError> {
201 match self.fs().metadata(path) {
202 Ok(m) => Ok(Some(m)),
203 Err(FsError::NotFound { .. } | FsError::NotAFile { .. }) => Ok(None),
204 Err(source) => Err(CacheLookupError::Io { source }),
205 }
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use std::path::Path;
212
213 use haz_domain::path::CanonicalPath;
214 use haz_domain::settings::cache::HashAlgo;
215 use haz_vfs::{MemFilesystem, WritableFilesystem};
216
217 use crate::cache::Cache;
218 use crate::hasher::Hasher;
219 use crate::key::CacheKey;
220 use crate::key::prefix::CHAPTER_REVISION;
221 use crate::layout;
222 use crate::manifest::{HashFunctionLabel, Manifest, OutputBlob};
223
224 fn cp(s: &str) -> CanonicalPath {
225 CanonicalPath::parse_workspace_absolute(s)
226 .expect("test helper expects a valid workspace-absolute path")
227 }
228
229 const WORKSPACE_ROOT: &str = "/ws";
230
231 fn sample_key() -> CacheKey {
232 let mut bytes = [0u8; 32];
233 bytes[0] = 0xAB;
234 bytes[1] = 0xCD;
235 CacheKey::from_bytes(bytes)
236 }
237
238 fn hash_bytes(algo: HashAlgo, data: &[u8]) -> [u8; 32] {
239 let mut h = Hasher::new(algo);
240 h.update(data);
241 h.finalize()
242 }
243
244 fn write_valid_entry(
249 fs: &MemFilesystem,
250 cache_root: &Path,
251 key: &CacheKey,
252 algo: HashAlgo,
253 ) -> Manifest {
254 let stdout_bytes = b"stdout-body".to_vec();
255 let stderr_bytes = b"stderr-body".to_vec();
256 let blob_bytes = b"blob-body".to_vec();
257
258 let content_hash = hash_bytes(algo, &blob_bytes);
259
260 let manifest = Manifest {
261 chapter_revision: CHAPTER_REVISION,
262 hash_function: HashFunctionLabel::from(algo),
263 key: *key,
264 outputs: vec![OutputBlob {
265 workspace_absolute_path: cp("/proj/out"),
266 content_hash,
267 #[allow(clippy::cast_possible_truncation)]
268 size: blob_bytes.len() as u64,
269 mode: 0o644,
270 }],
271 #[allow(clippy::cast_possible_truncation)]
272 stdout_len: stdout_bytes.len() as u64,
273 #[allow(clippy::cast_possible_truncation)]
274 stderr_len: stderr_bytes.len() as u64,
275 stdout_hash: hash_bytes(algo, &stdout_bytes),
276 stderr_hash: hash_bytes(algo, &stderr_bytes),
277 exit_status: 0,
278 created_at_unix: 1_715_700_000,
279 };
280
281 fs.create_dir_all(&layout::outputs_dir(cache_root, key))
282 .unwrap();
283 fs.write_file(
284 &layout::manifest_path(cache_root, key),
285 &manifest.to_json_bytes(),
286 )
287 .unwrap();
288 fs.write_file(&layout::stdout_path(cache_root, key), &stdout_bytes)
289 .unwrap();
290 fs.write_file(&layout::stderr_path(cache_root, key), &stderr_bytes)
291 .unwrap();
292 fs.write_file(
293 &layout::output_blob_path(cache_root, key, &content_hash),
294 &blob_bytes,
295 )
296 .unwrap();
297
298 manifest
299 }
300
301 fn fresh_cache(algo: HashAlgo) -> (Cache<MemFilesystem>, CacheKey) {
302 let fs = MemFilesystem::new();
303 let cache = Cache::new(fs, Path::new(WORKSPACE_ROOT), algo);
304 (cache, sample_key())
305 }
306
307 #[test]
310 fn cache_015_hit_returns_manifest() {
311 let (cache, key) = fresh_cache(HashAlgo::Blake3);
312 let expected = write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
313
314 let got = cache.lookup(&key).expect("expected a hit");
315 assert_eq!(got, expected);
316 }
317
318 #[test]
321 fn cache_016_miss_when_manifest_absent() {
322 let (cache, key) = fresh_cache(HashAlgo::Blake3);
323 assert!(cache.lookup(&key).is_none());
325 }
326
327 #[test]
330 fn cache_016_miss_when_manifest_unparseable() {
331 let (cache, key) = fresh_cache(HashAlgo::Blake3);
332 cache
333 .fs()
334 .create_dir_all(&layout::entry_dir(cache.cache_root(), &key))
335 .unwrap();
336 cache
337 .fs()
338 .write_file(
339 &layout::manifest_path(cache.cache_root(), &key),
340 b"not json at all",
341 )
342 .unwrap();
343 assert!(cache.lookup(&key).is_none());
344 }
345
346 #[test]
349 fn cache_016_miss_when_hash_function_mismatches() {
350 let (cache, key) = fresh_cache(HashAlgo::Blake3);
352 write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Sha256);
353 assert!(cache.lookup(&key).is_none());
354 }
355
356 #[test]
359 fn cache_016_miss_when_chapter_revision_mismatches() {
360 let (cache, key) = fresh_cache(HashAlgo::Blake3);
361 let mut manifest =
362 write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
363 manifest.chapter_revision = CHAPTER_REVISION.saturating_add(1);
364 cache
365 .fs()
366 .write_file(
367 &layout::manifest_path(cache.cache_root(), &key),
368 &manifest.to_json_bytes(),
369 )
370 .unwrap();
371 assert!(cache.lookup(&key).is_none());
372 }
373
374 #[test]
377 fn cache_016_miss_when_output_blob_missing() {
378 let (cache, key) = fresh_cache(HashAlgo::Blake3);
379 let manifest = write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
380 let mut tampered = manifest;
383 tampered.outputs[0].content_hash = [0x99u8; 32];
384 cache
385 .fs()
386 .write_file(
387 &layout::manifest_path(cache.cache_root(), &key),
388 &tampered.to_json_bytes(),
389 )
390 .unwrap();
391 assert!(cache.lookup(&key).is_none());
392 }
393
394 #[test]
397 fn cache_016_miss_when_output_blob_size_mismatch() {
398 let (cache, key) = fresh_cache(HashAlgo::Blake3);
399 let manifest = write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
400 let blob_path =
403 layout::output_blob_path(cache.cache_root(), &key, &manifest.outputs[0].content_hash);
404 cache
405 .fs()
406 .write_file(&blob_path, b"a-much-longer-payload")
407 .unwrap();
408 assert!(cache.lookup(&key).is_none());
409 }
410
411 #[test]
414 fn cache_016_miss_when_stdout_missing() {
415 let (cache, key) = fresh_cache(HashAlgo::Blake3);
416 write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
417 cache
424 .fs()
425 .remove_dir_all(&layout::entry_dir(cache.cache_root(), &key))
426 .unwrap();
427 let stderr_bytes = b"stderr-body".to_vec();
429 let blob_bytes = b"blob-body".to_vec();
430 let content_hash = hash_bytes(HashAlgo::Blake3, &blob_bytes);
431 let manifest = Manifest {
432 chapter_revision: CHAPTER_REVISION,
433 hash_function: HashFunctionLabel::Blake3,
434 key,
435 outputs: vec![OutputBlob {
436 workspace_absolute_path: cp("/proj/out"),
437 content_hash,
438 #[allow(clippy::cast_possible_truncation)]
439 size: blob_bytes.len() as u64,
440 mode: 0o644,
441 }],
442 stdout_len: 11,
443 #[allow(clippy::cast_possible_truncation)]
444 stderr_len: stderr_bytes.len() as u64,
445 stdout_hash: [0u8; 32],
446 stderr_hash: hash_bytes(HashAlgo::Blake3, &stderr_bytes),
447 exit_status: 0,
448 created_at_unix: 0,
449 };
450 cache
451 .fs()
452 .create_dir_all(&layout::outputs_dir(cache.cache_root(), &key))
453 .unwrap();
454 cache
455 .fs()
456 .write_file(
457 &layout::manifest_path(cache.cache_root(), &key),
458 &manifest.to_json_bytes(),
459 )
460 .unwrap();
461 cache
462 .fs()
463 .write_file(
464 &layout::stderr_path(cache.cache_root(), &key),
465 &stderr_bytes,
466 )
467 .unwrap();
468 cache
469 .fs()
470 .write_file(
471 &layout::output_blob_path(cache.cache_root(), &key, &content_hash),
472 &blob_bytes,
473 )
474 .unwrap();
475 assert!(cache.lookup(&key).is_none());
476 }
477
478 #[test]
479 fn cache_016_miss_when_stdout_size_mismatch() {
480 let (cache, key) = fresh_cache(HashAlgo::Blake3);
481 write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
482 cache
485 .fs()
486 .write_file(
487 &layout::stdout_path(cache.cache_root(), &key),
488 b"different-length-stdout-payload",
489 )
490 .unwrap();
491 assert!(cache.lookup(&key).is_none());
492 }
493
494 #[test]
497 fn cache_016_miss_when_stderr_missing() {
498 let (cache, key) = fresh_cache(HashAlgo::Blake3);
499 write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
500 cache
501 .fs()
502 .remove_dir_all(&layout::entry_dir(cache.cache_root(), &key))
503 .unwrap();
504 let stdout_bytes = b"stdout-body".to_vec();
505 let blob_bytes = b"blob-body".to_vec();
506 let content_hash = hash_bytes(HashAlgo::Blake3, &blob_bytes);
507 let manifest = Manifest {
508 chapter_revision: CHAPTER_REVISION,
509 hash_function: HashFunctionLabel::Blake3,
510 key,
511 outputs: vec![OutputBlob {
512 workspace_absolute_path: cp("/proj/out"),
513 content_hash,
514 #[allow(clippy::cast_possible_truncation)]
515 size: blob_bytes.len() as u64,
516 mode: 0o644,
517 }],
518 #[allow(clippy::cast_possible_truncation)]
519 stdout_len: stdout_bytes.len() as u64,
520 stderr_len: 11,
521 stdout_hash: hash_bytes(HashAlgo::Blake3, &stdout_bytes),
522 stderr_hash: [0u8; 32],
523 exit_status: 0,
524 created_at_unix: 0,
525 };
526 cache
527 .fs()
528 .create_dir_all(&layout::outputs_dir(cache.cache_root(), &key))
529 .unwrap();
530 cache
531 .fs()
532 .write_file(
533 &layout::manifest_path(cache.cache_root(), &key),
534 &manifest.to_json_bytes(),
535 )
536 .unwrap();
537 cache
538 .fs()
539 .write_file(
540 &layout::stdout_path(cache.cache_root(), &key),
541 &stdout_bytes,
542 )
543 .unwrap();
544 cache
545 .fs()
546 .write_file(
547 &layout::output_blob_path(cache.cache_root(), &key, &content_hash),
548 &blob_bytes,
549 )
550 .unwrap();
551 assert!(cache.lookup(&key).is_none());
552 }
553
554 #[test]
555 fn cache_016_miss_when_stderr_size_mismatch() {
556 let (cache, key) = fresh_cache(HashAlgo::Blake3);
557 write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
558 cache
559 .fs()
560 .write_file(
561 &layout::stderr_path(cache.cache_root(), &key),
562 b"different-length-stderr-payload",
563 )
564 .unwrap();
565 assert!(cache.lookup(&key).is_none());
566 }
567
568 use crate::lookup::CacheLookupStatus;
573
574 #[test]
575 fn lookup_status_returns_hit_for_valid_entry() {
576 let (cache, key) = fresh_cache(HashAlgo::Blake3);
577 let expected = write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
578 match cache.lookup_status(&key).unwrap() {
579 CacheLookupStatus::Hit(m) => assert_eq!(m, expected),
580 other => panic!("expected Hit, got {other:?}"),
581 }
582 }
583
584 #[test]
585 fn lookup_status_returns_miss_no_entry_for_absent_manifest() {
586 let (cache, key) = fresh_cache(HashAlgo::Blake3);
587 assert_eq!(
588 cache.lookup_status(&key).unwrap(),
589 CacheLookupStatus::MissNoEntry,
590 );
591 }
592
593 #[test]
594 fn lookup_status_returns_miss_corrupt_entry_for_unparseable_manifest() {
595 let (cache, key) = fresh_cache(HashAlgo::Blake3);
596 cache
597 .fs()
598 .create_dir_all(&layout::entry_dir(cache.cache_root(), &key))
599 .unwrap();
600 cache
601 .fs()
602 .write_file(
603 &layout::manifest_path(cache.cache_root(), &key),
604 b"not json",
605 )
606 .unwrap();
607 assert_eq!(
608 cache.lookup_status(&key).unwrap(),
609 CacheLookupStatus::MissCorruptEntry,
610 );
611 }
612
613 #[test]
614 fn lookup_status_returns_miss_schema_mismatch_for_chapter_revision() {
615 let (cache, key) = fresh_cache(HashAlgo::Blake3);
616 let mut manifest =
617 write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
618 manifest.chapter_revision = CHAPTER_REVISION.saturating_add(1);
619 cache
620 .fs()
621 .write_file(
622 &layout::manifest_path(cache.cache_root(), &key),
623 &manifest.to_json_bytes(),
624 )
625 .unwrap();
626 assert_eq!(
627 cache.lookup_status(&key).unwrap(),
628 CacheLookupStatus::MissSchemaMismatch,
629 );
630 }
631
632 #[test]
633 fn lookup_status_returns_miss_schema_mismatch_for_hash_function() {
634 let (cache, key) = fresh_cache(HashAlgo::Blake3);
636 write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Sha256);
637 assert_eq!(
638 cache.lookup_status(&key).unwrap(),
639 CacheLookupStatus::MissSchemaMismatch,
640 );
641 }
642
643 #[test]
644 fn lookup_status_returns_miss_corrupt_entry_for_missing_blob() {
645 let (cache, key) = fresh_cache(HashAlgo::Blake3);
646 let manifest = write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
647 let mut tampered = manifest;
650 tampered.outputs[0].content_hash = [0x99u8; 32];
651 cache
652 .fs()
653 .write_file(
654 &layout::manifest_path(cache.cache_root(), &key),
655 &tampered.to_json_bytes(),
656 )
657 .unwrap();
658 assert_eq!(
659 cache.lookup_status(&key).unwrap(),
660 CacheLookupStatus::MissCorruptEntry,
661 );
662 }
663
664 #[test]
665 fn lookup_status_returns_miss_corrupt_entry_for_blob_size_mismatch() {
666 let (cache, key) = fresh_cache(HashAlgo::Blake3);
667 let manifest = write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
668 let blob_path =
669 layout::output_blob_path(cache.cache_root(), &key, &manifest.outputs[0].content_hash);
670 cache
671 .fs()
672 .write_file(&blob_path, b"different-payload-length")
673 .unwrap();
674 assert_eq!(
675 cache.lookup_status(&key).unwrap(),
676 CacheLookupStatus::MissCorruptEntry,
677 );
678 }
679
680 #[test]
681 fn lookup_status_returns_miss_corrupt_entry_for_stdout_size_mismatch() {
682 let (cache, key) = fresh_cache(HashAlgo::Blake3);
683 write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
684 cache
685 .fs()
686 .write_file(
687 &layout::stdout_path(cache.cache_root(), &key),
688 b"different-length-stdout-payload",
689 )
690 .unwrap();
691 assert_eq!(
692 cache.lookup_status(&key).unwrap(),
693 CacheLookupStatus::MissCorruptEntry,
694 );
695 }
696
697 #[test]
698 fn lookup_status_returns_miss_corrupt_entry_for_stderr_size_mismatch() {
699 let (cache, key) = fresh_cache(HashAlgo::Blake3);
700 write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
701 cache
702 .fs()
703 .write_file(
704 &layout::stderr_path(cache.cache_root(), &key),
705 b"different-length-stderr-payload",
706 )
707 .unwrap();
708 assert_eq!(
709 cache.lookup_status(&key).unwrap(),
710 CacheLookupStatus::MissCorruptEntry,
711 );
712 }
713
714 #[test]
717 fn partial_tmp_dir_does_not_affect_other_keys() {
718 let (cache, key_a) = fresh_cache(HashAlgo::Blake3);
719 let mut b_bytes = [0u8; 32];
722 b_bytes[0] = 0xAB;
723 b_bytes[31] = 0xFF;
724 let key_b = CacheKey::from_bytes(b_bytes);
725 assert_eq!(layout::shard(&key_a), layout::shard(&key_b));
726
727 write_valid_entry(cache.fs(), cache.cache_root(), &key_a, HashAlgo::Blake3);
729
730 let tmp = layout::tmp_entry_dir(cache.cache_root(), &key_b, "rnd123");
733 cache.fs().create_dir_all(&tmp).unwrap();
734 cache
735 .fs()
736 .write_file(&tmp.join("manifest.json"), b"partial junk")
737 .unwrap();
738
739 assert!(cache.lookup(&key_a).is_some());
740 assert!(cache.lookup(&key_b).is_none());
742 }
743}