use anyhow::{Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use super::{CacheDescriptor, ExplainAction, ObjectStore, RebuildReason};
impl ObjectStore {
pub fn restore_from_descriptor(
&self,
ctx: &crate::build_context::BuildContext,
cache_key: &str,
output_paths: &[PathBuf],
) -> Result<bool> {
let Some(descriptor) = self.get_descriptor_pulling(ctx, cache_key) else { return Ok(false) };
match descriptor {
CacheDescriptor::Marker => Ok(true),
CacheDescriptor::Blob { checksum, mode } => {
let Some(output_path) = output_paths.first() else { return Ok(true) };
if output_path.exists() {
if let Some(existing) = Self::verify_checksum(ctx, output_path)
&& existing == checksum {
return Ok(true);
}
fs::remove_file(output_path)
.with_context(|| format!("Failed to remove stale cached file: {}", output_path.display()))?;
}
if !self.ensure_object(ctx, &checksum) {
return Ok(false);
}
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create output directory: {}", parent.display()))?;
}
self.restore_file(&checksum, output_path, mode)
.with_context(|| format!("Failed to restore blob to: {}", output_path.display()))?;
Ok(true)
}
CacheDescriptor::Tree { entries } => {
for entry in &entries {
let file_path = super::safe_entry_path(&entry.path)?;
if file_path.exists() {
if let Some(existing) = Self::verify_checksum(ctx, file_path)
&& existing == entry.checksum {
continue;
}
fs::remove_file(file_path)
.with_context(|| format!("Failed to remove stale cached file: {}", file_path.display()))?;
}
if !self.ensure_object(ctx, &entry.checksum) {
return Ok(false);
}
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory for tree restore: {}", parent.display()))?;
}
self.restore_file(&entry.checksum, file_path, entry.mode)
.with_context(|| format!("Failed to restore tree entry: {}", file_path.display()))?;
}
Ok(true)
}
}
}
fn verify_checksum(ctx: &crate::build_context::BuildContext, path: &Path) -> Option<String> {
crate::checksum::checksum_output(ctx, path).ok().map(|(c, _)| c)
}
pub fn needs_rebuild_descriptor(
&self,
ctx: &crate::build_context::BuildContext,
cache_key: &str,
output_paths: &[PathBuf],
) -> bool {
let Some(descriptor) = self.get_descriptor_pulling(ctx, cache_key) else { return true };
match descriptor {
CacheDescriptor::Marker => false,
CacheDescriptor::Blob { checksum, .. } => {
let content_ok = output_paths.first().is_none_or(|p|
Self::verify_checksum(ctx, p).as_ref() == Some(&checksum));
!content_ok || output_paths.iter().skip(1).any(|p| !p.exists())
}
CacheDescriptor::Tree { entries } => {
output_paths.iter().any(|p| !p.exists())
|| entries.iter().any(|e| {
let p = Path::new(&e.path);
!p.exists() || Self::verify_checksum(ctx, p).as_ref() != Some(&e.checksum)
})
}
}
}
pub fn can_restore_descriptor(&self, ctx: &crate::build_context::BuildContext, cache_key: &str) -> bool {
let Some(descriptor) = self.get_descriptor_pulling(ctx, cache_key) else { return false };
match descriptor {
CacheDescriptor::Marker => true,
CacheDescriptor::Blob { checksum, .. } => self.ensure_object(ctx, &checksum),
CacheDescriptor::Tree { entries } => {
#[allow(clippy::unnecessary_fold, reason = "must not short-circuit: every object has to be pulled")]
entries.iter().fold(true, |ok, e| self.ensure_object(ctx, &e.checksum) && ok)
}
}
}
pub fn explain_descriptor(
&self,
ctx: &crate::build_context::BuildContext,
descriptor_key: &str,
output_paths: &[PathBuf],
force: bool,
) -> ExplainAction {
if force {
return ExplainAction::Rebuild(RebuildReason::Force);
}
let Some(descriptor) = self.get_descriptor_pulling(ctx, descriptor_key) else {
return ExplainAction::Rebuild(RebuildReason::NoCacheEntry);
};
match descriptor {
CacheDescriptor::Marker => ExplainAction::Skip,
CacheDescriptor::Blob { checksum, .. } => {
for (i, p) in output_paths.iter().enumerate() {
let needs_restore = if i == 0 {
!p.exists() || Self::verify_checksum(ctx, p).as_ref() != Some(&checksum)
} else {
!p.exists()
};
if needs_restore {
let display = p.display().to_string();
if self.object_available(ctx, &checksum) {
return ExplainAction::Restore(RebuildReason::OutputMissing(display));
}
return ExplainAction::Rebuild(RebuildReason::OutputMissing(display));
}
}
ExplainAction::Skip
}
CacheDescriptor::Tree { entries } => {
for entry in &entries {
let p = Path::new(&entry.path);
let needs_restore = !p.exists()
|| Self::verify_checksum(ctx, p).as_ref() != Some(&entry.checksum);
if needs_restore {
if self.object_available(ctx, &entry.checksum) {
return ExplainAction::Restore(RebuildReason::OutputMissing(entry.path.clone()));
}
return ExplainAction::Rebuild(RebuildReason::OutputMissing(entry.path.clone()));
}
}
ExplainAction::Skip
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::build_context::BuildContext;
#[test]
fn marker_never_rebuilds_missing_descriptor_always_does() {
let tmp = tempfile::TempDir::new().unwrap();
let store = ObjectStore::new_in(tmp.path());
let ctx = BuildContext::new();
ctx.set_mtime_check(false);
assert!(store.needs_rebuild_descriptor(&ctx, "absent99", &[]));
assert!(!store.can_restore_descriptor(&ctx, "absent99"));
store.store_marker(&ctx, "cafe1234").unwrap();
assert!(!store.needs_rebuild_descriptor(&ctx, "cafe1234", &[]));
assert!(store.can_restore_descriptor(&ctx, "cafe1234"));
}
#[test]
fn blob_rebuild_verification_is_tiered() {
let tmp = tempfile::TempDir::new().unwrap();
let store = ObjectStore::new_in(tmp.path());
let ctx = BuildContext::new();
ctx.set_mtime_check(false);
let key = "beef5678";
let first = tmp.path().join("first.out");
let second = tmp.path().join("second.out");
fs::write(&first, b"primary").unwrap();
store.store_blob_descriptor(&ctx, key, &first).unwrap();
let outputs = vec![first.clone(), second.clone()];
assert!(store.needs_rebuild_descriptor(&ctx, key, &outputs),
"second output missing → rebuild");
fs::write(&second, b"anything at all").unwrap();
assert!(!store.needs_rebuild_descriptor(&ctx, key, &outputs),
"extra outputs are existence-checked only");
fs::write(&first, b"tampered").unwrap();
assert!(store.needs_rebuild_descriptor(&ctx, key, &outputs),
"first output is content-verified");
}
#[test]
fn restore_replaces_corrupted_output_and_reports_missing_object() {
let tmp = tempfile::TempDir::new().unwrap();
let store = ObjectStore::new_in(tmp.path());
let ctx = BuildContext::new();
ctx.set_mtime_check(false);
let key = "feed9abc";
let out = tmp.path().join("out.txt");
fs::write(&out, b"cached bytes").unwrap();
store.store_blob_descriptor(&ctx, key, &out).unwrap();
fs::write(&out, b"corrupted").unwrap();
assert!(store.restore_from_descriptor(&ctx, key, std::slice::from_ref(&out)).unwrap());
assert_eq!(fs::read(&out).unwrap(), b"cached bytes",
"restore must replace corrupted content with the cached bytes");
let checksum = ObjectStore::calculate_checksum_bytes(&fs::read(&out).unwrap());
fs::remove_file(store.object_path(&checksum)).unwrap();
fs::remove_file(&out).unwrap();
assert!(!store.restore_from_descriptor(&ctx, key, std::slice::from_ref(&out)).unwrap(),
"no object → cannot restore, caller must build");
}
#[test]
fn tree_verifies_and_restores_every_entry() {
let tmp = tempfile::TempDir::new().unwrap();
let store = ObjectStore::new_in(tmp.path());
let ctx = BuildContext::new();
ctx.set_mtime_check(false);
let key = "dead4321";
let outdir = tmp.path().join("outdir");
fs::create_dir_all(&outdir).unwrap();
fs::write(outdir.join("a.txt"), b"alpha").unwrap();
fs::write(outdir.join("b.txt"), b"beta").unwrap();
let dirs = [std::sync::Arc::new(outdir.clone())];
store.store_tree_descriptor(&ctx, key, &dirs, &[], &|_| false).unwrap();
assert!(!store.needs_rebuild_descriptor(&ctx, key, &[]));
fs::write(outdir.join("b.txt"), b"tampered").unwrap();
assert!(store.needs_rebuild_descriptor(&ctx, key, &[]),
"any corrupted tree entry must flag a rebuild");
assert!(store.restore_from_descriptor(&ctx, key, &[]).unwrap());
assert_eq!(fs::read(outdir.join("b.txt")).unwrap(), b"beta");
assert!(!store.needs_rebuild_descriptor(&ctx, key, &[]));
}
}