use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use a3s_box_core::dirs_home;
use serde::{Deserialize, Serialize};
use super::layer::{sha256_bytes, sha256_file, LayerInfo};
mod export;
mod import;
pub(super) use export::{inspect_build_cache_artifact, BuildCacheExportIdentity, BuildCacheTrace};
pub use export::{
BuildCacheReceipt, RecordedBuildCache, BUILD_CACHE_ARTIFACT_MEDIA_TYPE,
BUILD_CACHE_CONFIG_MEDIA_TYPE,
};
pub use import::hydrate_recorded_build_cache;
static STORE_SEQ: AtomicU64 = AtomicU64::new(0);
const DEFAULT_MAX_BYTES: u64 = 2 * 1024 * 1024 * 1024;
fn configured_max_bytes() -> u64 {
std::env::var("A3S_BOX_BUILDCACHE_MAX_BYTES")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(DEFAULT_MAX_BYTES)
}
#[derive(Debug, Serialize, Deserialize)]
struct KeyRecord {
digest: String,
diff_id: String,
size: u64,
}
#[derive(Debug, Clone)]
pub(crate) struct CachedLayer {
pub(crate) blob_path: PathBuf,
pub(crate) digest: String,
pub(crate) diff_id: String,
pub(crate) size: u64,
}
pub(crate) struct BuildCache {
dir: PathBuf,
}
impl BuildCache {
pub(crate) fn open() -> Option<Self> {
Self::open_in(dirs_home().join("buildcache"))
}
fn open_in(dir: PathBuf) -> Option<Self> {
std::fs::create_dir_all(dir.join("blobs")).ok()?;
std::fs::create_dir_all(dir.join("keys")).ok()?;
Some(Self { dir })
}
fn lock(&self) -> std::io::Result<crate::file_lock::FileLock> {
crate::file_lock::FileLock::acquire(&self.dir.join("cache"))
}
pub(crate) fn chain(
prev_key: &str,
instruction_repr: &str,
input_hash: Option<&str>,
) -> String {
let mut buf = String::with_capacity(prev_key.len() + instruction_repr.len() + 1);
buf.push_str(prev_key);
buf.push('\n');
buf.push_str(instruction_repr);
if let Some(h) = input_hash {
buf.push('\n');
buf.push_str(h);
}
sha256_bytes(buf.as_bytes())
}
pub(crate) fn lookup(&self, key: &str) -> Option<CachedLayer> {
let _lock = self.lock().ok()?;
self.lookup_unlocked(key)
}
fn lookup_unlocked(&self, key: &str) -> Option<CachedLayer> {
let key_path = self.dir.join("keys").join(key);
let bytes = std::fs::read(&key_path).ok()?;
let record: KeyRecord = serde_json::from_slice(&bytes).ok()?;
let blob_path = self.dir.join("blobs").join(&record.digest);
if !cached_blob_is_valid(&blob_path, &record.digest, record.size) {
return None;
}
Some(CachedLayer {
blob_path,
digest: record.digest,
diff_id: record.diff_id,
size: record.size,
})
}
pub(crate) fn store(&self, key: &str, layer: &LayerInfo, diff_id: &str) {
let Ok(_lock) = self.lock() else {
return;
};
self.store_unlocked(key, layer, diff_id);
}
fn store_unlocked(&self, key: &str, layer: &LayerInfo, diff_id: &str) {
if self.publish_entry_unlocked(key, layer, diff_id) {
self.prune_to_unlocked(configured_max_bytes());
}
}
fn publish_entry_unlocked(&self, key: &str, layer: &LayerInfo, diff_id: &str) -> bool {
if !cached_blob_is_valid(&layer.path, &layer.digest, layer.size) {
return false;
}
let blob_path = self.dir.join("blobs").join(&layer.digest);
if !cached_blob_is_valid(&blob_path, &layer.digest, layer.size) {
let seq = STORE_SEQ.fetch_add(1, Ordering::Relaxed);
let staging = self.dir.join("blobs").join(format!(
".staging-{}-{}-{}",
layer.digest,
std::process::id(),
seq
));
if std::fs::copy(&layer.path, &staging).is_err() {
let _ = std::fs::remove_file(&staging);
return false;
}
if !cached_blob_is_valid(&staging, &layer.digest, layer.size) {
let _ = std::fs::remove_file(&staging);
return false;
}
if blob_path.exists() && std::fs::remove_file(&blob_path).is_err() {
let _ = std::fs::remove_file(&staging);
return false;
}
if std::fs::rename(&staging, &blob_path).is_err() {
let _ = std::fs::remove_file(&staging);
return false;
}
}
let record = KeyRecord {
digest: layer.digest.clone(),
diff_id: diff_id.to_string(),
size: layer.size,
};
if let Ok(bytes) = serde_json::to_vec(&record) {
let target = self.dir.join("keys").join(key);
let temporary = target.with_extension("tmp");
if a3s_box_core::fs_atomic::write_durable(&temporary, &target, &bytes).is_ok() {
return true;
}
}
false
}
#[cfg(test)]
fn prune_to(&self, cap: u64) {
let Ok(_lock) = self.lock() else {
return;
};
self.prune_to_unlocked(cap);
}
fn prune_to_unlocked(&self, cap: u64) {
let _ = self.prune_to_unlocked_preserving(cap, &BTreeSet::new());
}
fn prune_to_unlocked_preserving(&self, cap: u64, preserved: &BTreeSet<String>) -> bool {
let blobs_dir = self.dir.join("blobs");
let Ok(read_dir) = std::fs::read_dir(&blobs_dir) else {
return false;
};
let mut blobs: Vec<(std::time::SystemTime, u64, PathBuf, bool)> = Vec::new();
let mut total: u64 = 0;
for entry in read_dir.flatten() {
let Ok(meta) = entry.metadata() else { continue };
if !meta.is_file() {
continue;
}
let len = meta.len();
total = total.saturating_add(len);
let mtime = meta.modified().unwrap_or(std::time::UNIX_EPOCH);
let path = entry.path();
let keep = path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| preserved.contains(name));
blobs.push((mtime, len, path, keep));
}
if total <= cap {
return true;
}
blobs.sort_by_key(|(mtime, _, _, _)| *mtime); for (_, len, path, keep) in blobs {
if total <= cap {
break;
}
if keep {
continue;
}
if std::fs::remove_file(&path).is_ok() {
total = total.saturating_sub(len);
}
}
self.prune_orphan_keys();
total <= cap
}
fn prune_orphan_keys(&self) {
let Ok(read_dir) = std::fs::read_dir(self.dir.join("keys")) else {
return;
};
for entry in read_dir.flatten() {
let path = entry.path();
let keep = std::fs::read(&path)
.ok()
.and_then(|bytes| serde_json::from_slice::<KeyRecord>(&bytes).ok())
.is_some_and(|record| self.dir.join("blobs").join(&record.digest).exists());
if !keep {
let _ = std::fs::remove_file(&path);
}
}
}
}
fn cached_blob_is_valid(path: &Path, digest: &str, size: u64) -> bool {
if digest.len() != 64
|| !digest
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return false;
}
let Ok(metadata) = std::fs::symlink_metadata(path) else {
return false;
};
metadata.is_file()
&& !metadata.file_type().is_symlink()
&& metadata.len() == size
&& sha256_file(path).is_ok_and(|actual| actual == digest)
}
pub(crate) fn hash_context_sources(context_dir: &Path, src_patterns: &[String]) -> Option<String> {
use sha2::{Digest, Sha256};
let mut files: Vec<(PathBuf, PathBuf)> = Vec::new();
for src in src_patterns {
let src_path = context_dir.join(src.trim_start_matches('/'));
if !src_path.exists() {
return None;
}
if src_path.is_dir() {
collect_files(&src_path, &src_path, &mut files)?;
} else {
let rel = PathBuf::from(src);
files.push((rel, src_path));
}
}
files.sort_by(|a, b| a.0.cmp(&b.0));
let mut hasher = Sha256::new();
for (rel, full) in &files {
let bytes = std::fs::read(full).ok()?;
hasher.update(rel.to_string_lossy().as_bytes());
hasher.update(b"\0");
hasher.update((bytes.len() as u64).to_le_bytes());
hasher.update(&bytes);
}
Some(hex::encode(hasher.finalize()))
}
fn collect_files(root: &Path, current: &Path, out: &mut Vec<(PathBuf, PathBuf)>) -> Option<()> {
for entry in std::fs::read_dir(current).ok()? {
let entry = entry.ok()?;
let path = entry.path();
if path.is_dir() {
collect_files(root, &path, out)?;
} else {
let rel = path.strip_prefix(root).ok()?.to_path_buf();
out.push((rel, path));
}
}
Some(())
}
#[cfg(test)]
mod tests;