use anyhow::{Context, Result};
use super::ObjectStore;
impl ObjectStore {
#[allow(clippy::unnecessary_wraps)] pub(super) fn try_push_object_to_remote(&self, ctx: &crate::build_context::BuildContext, checksum: &str) -> Result<()> {
let Some(remote) = &self.remote else { return Ok(()) };
if !self.has_object(checksum) {
return Ok(());
}
let (prefix, rest) = checksum.split_at(super::CHECKSUM_PREFIX_LEN.min(checksum.len()));
let remote_key = format!("objects/{prefix}/{rest}");
if remote.exists(ctx, &remote_key).unwrap_or(false) {
return Ok(());
}
match self.read_object(checksum) {
Ok(content) => {
if let Err(e) = remote.upload_bytes(ctx, &remote_key, &content) {
crate::output::warn(&format!("failed to push to remote cache: {e}"));
}
}
Err(e) => crate::output::warn(&format!("failed to read object for remote push: {e}")),
}
Ok(())
}
pub(super) fn ensure_object(&self, ctx: &crate::build_context::BuildContext, checksum: &str) -> bool {
if self.has_object(checksum) {
return true;
}
if !self.remote_pull {
return false;
}
match self.try_fetch_object_from_remote(ctx, checksum) {
Ok(fetched) => fetched,
Err(e) => {
crate::output::warn(&format!("failed to fetch from remote cache: {e}"));
false
}
}
}
pub(super) fn object_available(&self, ctx: &crate::build_context::BuildContext, checksum: &str) -> bool {
if self.has_object(checksum) {
return true;
}
if !self.remote_pull {
return false;
}
let Some(remote) = &self.remote else { return false };
let (prefix, rest) = checksum.split_at(super::CHECKSUM_PREFIX_LEN.min(checksum.len()));
remote.exists(ctx, &format!("objects/{prefix}/{rest}")).unwrap_or(false)
}
pub(super) fn try_fetch_object_from_remote(&self, ctx: &crate::build_context::BuildContext, checksum: &str) -> Result<bool> {
let Some(remote) = &self.remote else { return Ok(false) };
if self.has_object(checksum) {
return Ok(true);
}
let (prefix, rest) = checksum.split_at(super::CHECKSUM_PREFIX_LEN.min(checksum.len()));
let remote_key = format!("objects/{prefix}/{rest}");
let Some(bytes) = remote.download_bytes(ctx, &remote_key)? else {
return Ok(false);
};
let actual = Self::calculate_checksum_bytes(&bytes);
if actual != checksum {
anyhow::bail!(
"Remote cache object {remote_key} is corrupt: content hashes to {actual}, expected {checksum}"
);
}
self.store_object(&bytes)?;
Ok(true)
}
#[allow(clippy::unnecessary_wraps)] pub(super) fn try_push_descriptor_to_remote(&self, ctx: &crate::build_context::BuildContext, descriptor_key: &str, data: &[u8]) -> Result<()> {
let Some(remote) = &self.remote else { return Ok(()) };
let remote_key = format!("descriptors/{descriptor_key}");
if let Err(e) = remote.upload_bytes(ctx, &remote_key, data) {
crate::output::warn(&format!("failed to push descriptor to remote cache: {e}"));
}
Ok(())
}
pub(super) fn try_fetch_descriptor_from_remote(&self, ctx: &crate::build_context::BuildContext, descriptor_key: &str) -> Result<Option<super::CacheDescriptor>> {
let Some(remote) = &self.remote else { return Ok(None) };
let remote_key = format!("descriptors/{descriptor_key}");
let Some(data) = remote.download_bytes(ctx, &remote_key)? else {
return Ok(None);
};
let descriptor: super::CacheDescriptor = serde_json::from_slice(&data)
.with_context(|| format!("Remote cache descriptor {remote_key} is malformed"))?;
if let super::CacheDescriptor::Tree { entries } = &descriptor {
for entry in entries {
let path = super::safe_entry_path(&entry.path)
.with_context(|| format!("Remote cache descriptor {remote_key} rejected"))?;
if path.is_absolute() {
anyhow::bail!(
"Remote cache descriptor {remote_key} contains absolute entry path '{}'",
entry.path);
}
}
}
self.store_descriptor(descriptor_key, &descriptor)?;
Ok(Some(descriptor))
}
}
#[cfg(test)]
mod tests {
use super::super::ObjectStore;
use crate::build_context::BuildContext;
use std::fs;
#[test]
fn a_populated_remote_satisfies_a_cold_restore() {
let tmp = tempfile::TempDir::new().unwrap();
let remote_dir = tmp.path().join("remote");
let ctx = BuildContext::new();
ctx.set_mtime_check(false);
let key = "0bcd1234";
let out = tmp.path().join("out.txt");
fs::write(&out, b"produced bytes").unwrap();
{
let producer = ObjectStore::new_with_remote(
&tmp.path().join("a"), "a.redb", &remote_dir,
);
producer.store_blob_descriptor(&ctx, key, &out).unwrap();
}
fs::remove_file(&out).unwrap();
let consumer = ObjectStore::new_with_remote(
&tmp.path().join("b"), "b.redb", &remote_dir,
);
assert!(consumer.get_descriptor(key).is_none(),
"consumer must start with a cold local cache");
assert!(consumer.can_restore_descriptor(&ctx, key),
"a populated remote must be able to satisfy the restore");
assert!(consumer.restore_from_descriptor(&ctx, key, std::slice::from_ref(&out)).unwrap());
assert_eq!(fs::read(&out).unwrap(), b"produced bytes");
}
#[test]
fn pull_disabled_ignores_a_populated_remote() {
let tmp = tempfile::TempDir::new().unwrap();
let remote_dir = tmp.path().join("remote");
let ctx = BuildContext::new();
ctx.set_mtime_check(false);
let key = "0bcd5678";
let out = tmp.path().join("out.txt");
fs::write(&out, b"produced bytes").unwrap();
{
let producer = ObjectStore::new_with_remote(
&tmp.path().join("a"), "a.redb", &remote_dir,
);
producer.store_blob_descriptor(&ctx, key, &out).unwrap();
}
fs::remove_file(&out).unwrap();
let mut consumer = ObjectStore::new_with_remote(
&tmp.path().join("b"), "b.redb", &remote_dir,
);
consumer.remote_pull = false;
assert!(!consumer.can_restore_descriptor(&ctx, key));
assert!(!consumer.restore_from_descriptor(&ctx, key, std::slice::from_ref(&out)).unwrap());
}
#[test]
fn remote_pull_restores_every_tree_entry() {
let ctx = BuildContext::new();
ctx.set_mtime_check(false);
let key = "0bcd9abc";
let base = std::path::PathBuf::from(
format!("target/test-tmp/remote-pull-{}", std::process::id()));
let remote_dir = base.join("remote");
let outdir = base.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())];
{
let producer = ObjectStore::new_with_remote(
&base.join("a"), "a.redb", &remote_dir,
);
producer.store_tree_descriptor(&ctx, key, &dirs, &[], &|_| false).unwrap();
}
fs::remove_dir_all(&outdir).unwrap();
let consumer = ObjectStore::new_with_remote(
&base.join("b"), "b.redb", &remote_dir,
);
assert!(consumer.restore_from_descriptor(&ctx, key, &[]).unwrap());
assert_eq!(fs::read(outdir.join("a.txt")).unwrap(), b"alpha");
assert_eq!(fs::read(outdir.join("b.txt")).unwrap(), b"beta");
let _ = fs::remove_dir_all(&base);
}
#[test]
fn corrupt_remote_object_is_rejected() {
let tmp = tempfile::TempDir::new().unwrap();
let remote_dir = tmp.path().join("remote");
let ctx = BuildContext::new();
ctx.set_mtime_check(false);
let key = "0bcddead";
let out = tmp.path().join("out.txt");
fs::write(&out, b"produced bytes").unwrap();
let checksum = {
let producer = ObjectStore::new_with_remote(
&tmp.path().join("a"), "a.redb", &remote_dir,
);
producer.store_blob_descriptor(&ctx, key, &out).unwrap();
ObjectStore::calculate_checksum_bytes(b"produced bytes")
};
let (prefix, rest) = checksum.split_at(super::super::CHECKSUM_PREFIX_LEN);
let remote_object = remote_dir.join("objects").join(prefix).join(rest);
fs::remove_file(&remote_object).unwrap();
fs::write(&remote_object, b"tampered bytes").unwrap();
fs::remove_file(&out).unwrap();
let consumer = ObjectStore::new_with_remote(
&tmp.path().join("b"), "b.redb", &remote_dir,
);
assert!(!consumer.restore_from_descriptor(&ctx, key, std::slice::from_ref(&out)).unwrap(),
"a corrupt remote object must not be admitted; caller falls back to building");
assert!(!consumer.has_object(&checksum),
"the corrupt bytes must not land in the local store");
}
}