use std::path::{Path, PathBuf};
use haz_domain::path::CanonicalPath;
use haz_vfs::{FsError, WritableFilesystem};
use snafu::{ResultExt, Snafu};
use crate::layout;
use crate::manifest::Manifest;
use crate::writer::CacheWriter;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RestoredStreams {
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
}
#[derive(Debug, Snafu)]
pub enum RestoreError {
#[snafu(display("filesystem error during cache restore: {source}"))]
Io {
source: FsError,
},
}
impl<Fs: WritableFilesystem> CacheWriter<Fs> {
pub fn restore(&self, manifest: &Manifest) -> Result<RestoredStreams, RestoreError> {
let suffix = random_suffix_hex();
let stage_dir = layout::restore_staging_dir(self.cache_root(), &manifest.key, &suffix);
let result = self.restore_inner(manifest, &stage_dir);
let _ = self.fs().remove_dir_all(&stage_dir);
result
}
fn restore_inner(
&self,
manifest: &Manifest,
stage_dir: &Path,
) -> Result<RestoredStreams, RestoreError> {
self.fs().create_dir_all(stage_dir).context(IoSnafu)?;
let stdout = self
.fs()
.read(&layout::stdout_path(self.cache_root(), &manifest.key))
.context(IoSnafu)?;
let stderr = self
.fs()
.read(&layout::stderr_path(self.cache_root(), &manifest.key))
.context(IoSnafu)?;
let mut planned: Vec<(PathBuf, PathBuf)> = Vec::with_capacity(manifest.outputs.len());
for (i, blob) in manifest.outputs.iter().enumerate() {
let src =
layout::output_blob_path(self.cache_root(), &manifest.key, &blob.content_hash);
let bytes = self.fs().read(&src).context(IoSnafu)?;
let staged = stage_dir.join(format!("{i:08}"));
self.fs().write_file(&staged, &bytes).context(IoSnafu)?;
self.fs()
.set_permissions(&staged, blob.mode)
.context(IoSnafu)?;
self.fs().fsync_file(&staged).context(IoSnafu)?;
let target =
workspace_path_from_canonical(self.workspace_root(), &blob.workspace_absolute_path);
if let Some(parent) = target.parent() {
self.fs().create_dir_all(parent).context(IoSnafu)?;
}
planned.push((staged, target));
}
for (staged, target) in &planned {
self.fs().rename(staged, target).context(IoSnafu)?;
}
Ok(RestoredStreams { stdout, stderr })
}
}
fn workspace_path_from_canonical(workspace_root: &Path, canonical: &CanonicalPath) -> PathBuf {
let mut p = workspace_root.to_path_buf();
for segment in canonical.segments() {
p.push(segment.as_str());
}
p
}
fn random_suffix_hex() -> String {
let r: u64 = rand::random();
format!("{r:016x}")
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use haz_domain::settings::cache::HashAlgo;
use haz_vfs::{Filesystem, WritableFilesystem};
use haz_vfs_testing::MemFilesystem;
use crate::key::CacheKey;
use crate::store::{StoreInputs, StoredOutput};
use crate::writer::CacheWriter;
const WORKSPACE_ROOT: &str = "/ws";
fn sample_key() -> CacheKey {
let mut bytes = [0u8; 32];
bytes[0] = 0xAB;
bytes[1] = 0xCD;
CacheKey::from_bytes(bytes)
}
fn make_cache(fs: MemFilesystem, algo: HashAlgo) -> CacheWriter<MemFilesystem> {
CacheWriter::new(fs, Path::new(WORKSPACE_ROOT), algo)
}
fn fs_with_one_output(target: &Path, bytes: &[u8], mode: u32) -> MemFilesystem {
let mut fs = MemFilesystem::new();
fs.add_dir(target.parent().unwrap()).unwrap();
fs.add_file_with_mode(target, bytes.to_vec(), mode).unwrap();
fs
}
fn store_then_restore(
fs: MemFilesystem,
algo: HashAlgo,
outputs: &[StoredOutput<'_>],
stdout: &[u8],
stderr: &[u8],
) -> (
CacheWriter<MemFilesystem>,
crate::restore::RestoredStreams,
crate::manifest::Manifest,
) {
let cache = make_cache(fs, algo);
let key = sample_key();
let inputs = StoreInputs {
outputs,
stdout,
stderr,
created_at_unix: 1_715_700_000,
};
cache.store(&key, &inputs).unwrap();
let manifest = cache
.reader()
.lookup(&key)
.expect("store should produce a hit");
let restored = cache.restore(&manifest).expect("restore should succeed");
(cache, restored, manifest)
}
#[test]
fn cache_019_restore_after_store_round_trips_outputs() {
let blob = b"hello-world";
let target = PathBuf::from("/ws/proj/out");
let fs = fs_with_one_output(&target, blob, 0o644);
let outs = [StoredOutput {
workspace_absolute_path: "/proj/out",
on_disk_path: &target,
mode: 0o644,
}];
let (cache, _restored, _manifest) = store_then_restore(
fs,
HashAlgo::Blake3,
&outs,
b"stdout-bytes",
b"stderr-bytes",
);
let got = cache.fs().read(&target).unwrap();
assert_eq!(got, blob);
let mode = cache.fs().mode_of(&target).unwrap();
assert_eq!(mode, 0o644);
}
#[test]
fn cache_019_restore_returns_captured_stdout_and_stderr_bytes() {
let blob = b"";
let target = PathBuf::from("/ws/proj/out");
let fs = fs_with_one_output(&target, blob, 0o644);
let outs = [StoredOutput {
workspace_absolute_path: "/proj/out",
on_disk_path: &target,
mode: 0o644,
}];
let (_cache, restored, _manifest) =
store_then_restore(fs, HashAlgo::Blake3, &outs, b"out-bytes\n", b"err-bytes\n");
assert_eq!(restored.stdout, b"out-bytes\n");
assert_eq!(restored.stderr, b"err-bytes\n");
}
#[test]
fn cache_019_restore_with_no_outputs_returns_empty_streams_when_streams_are_empty() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
let (_cache, restored, manifest) = store_then_restore(fs, HashAlgo::Blake3, &[], b"", b"");
assert!(restored.stdout.is_empty());
assert!(restored.stderr.is_empty());
assert_eq!(manifest.outputs.len(), 0);
}
#[test]
fn cache_019_restore_with_multiple_outputs_materialises_each_at_its_path() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws/proj").unwrap();
fs.add_file_with_mode("/ws/proj/a", b"alpha".to_vec(), 0o644)
.unwrap();
fs.add_file_with_mode("/ws/proj/b", b"beta-bytes".to_vec(), 0o755)
.unwrap();
let on_a = PathBuf::from("/ws/proj/a");
let on_b = PathBuf::from("/ws/proj/b");
let outs = [
StoredOutput {
workspace_absolute_path: "/proj/a",
on_disk_path: &on_a,
mode: 0o644,
},
StoredOutput {
workspace_absolute_path: "/proj/b",
on_disk_path: &on_b,
mode: 0o755,
},
];
let (cache, _restored, _manifest) =
store_then_restore(fs, HashAlgo::Blake3, &outs, b"", b"");
assert_eq!(cache.fs().read(&on_a).unwrap(), b"alpha");
assert_eq!(cache.fs().read(&on_b).unwrap(), b"beta-bytes");
assert_eq!(cache.fs().mode_of(&on_a).unwrap(), 0o644);
assert_eq!(cache.fs().mode_of(&on_b).unwrap(), 0o755);
}
#[test]
fn cache_019_restore_creates_missing_intermediate_directories_for_target() {
let blob = b"deep-output";
let target = PathBuf::from("/ws/proj/nested/deep/out");
let mut fs = MemFilesystem::new();
fs.add_dir("/ws/proj/nested/deep").unwrap();
fs.add_file_with_mode(&target, blob.to_vec(), 0o644)
.unwrap();
let cache = make_cache(fs, HashAlgo::Blake3);
let key = sample_key();
let outs = [StoredOutput {
workspace_absolute_path: "/proj/nested/deep/out",
on_disk_path: &target,
mode: 0o644,
}];
let inputs = StoreInputs {
outputs: &outs,
stdout: b"",
stderr: b"",
created_at_unix: 0,
};
cache.store(&key, &inputs).unwrap();
cache.fs().remove_dir_all(Path::new("/ws/proj")).unwrap();
let manifest = cache.reader().lookup(&key).expect("entry still hits");
cache
.restore(&manifest)
.expect("restore must re-create the path");
assert_eq!(cache.fs().read(&target).unwrap(), blob);
}
#[test]
fn cache_020_cache_019_restore_overwrites_an_existing_target_file() {
let target = PathBuf::from("/ws/proj/out");
let fs = fs_with_one_output(&target, b"original", 0o644);
let outs = [StoredOutput {
workspace_absolute_path: "/proj/out",
on_disk_path: &target,
mode: 0o644,
}];
let cache = make_cache(fs, HashAlgo::Blake3);
let key = sample_key();
cache
.store(
&key,
&StoreInputs {
outputs: &outs,
stdout: b"",
stderr: b"",
created_at_unix: 0,
},
)
.unwrap();
cache.fs().write_file(&target, b"divergent").unwrap();
let manifest = cache.reader().lookup(&key).unwrap();
cache.restore(&manifest).unwrap();
assert_eq!(cache.fs().read(&target).unwrap(), b"original");
}
#[test]
fn cache_019_restore_propagates_missing_cached_blob_as_io_error() {
let target = PathBuf::from("/ws/proj/out");
let fs = fs_with_one_output(&target, b"x", 0o644);
let cache = make_cache(fs, HashAlgo::Blake3);
let key = sample_key();
let outs = [StoredOutput {
workspace_absolute_path: "/proj/out",
on_disk_path: &target,
mode: 0o644,
}];
cache
.store(
&key,
&StoreInputs {
outputs: &outs,
stdout: b"",
stderr: b"",
created_at_unix: 0,
},
)
.unwrap();
let manifest = cache.reader().lookup(&key).unwrap();
let entry = crate::layout::entry_dir(cache.cache_root(), &key);
cache.fs().remove_dir_all(&entry).unwrap();
let err = cache.restore(&manifest).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("filesystem error"), "got: {msg}");
}
#[test]
fn cache_019_restore_leaves_no_staging_directory_after_success() {
let target = PathBuf::from("/ws/proj/out");
let fs = fs_with_one_output(&target, b"x", 0o644);
let outs = [StoredOutput {
workspace_absolute_path: "/proj/out",
on_disk_path: &target,
mode: 0o644,
}];
let (cache, _restored, _manifest) =
store_then_restore(fs, HashAlgo::Blake3, &outs, b"", b"");
for entry in cache.fs().read_dir(cache.cache_root()).unwrap() {
let name = entry
.path
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
assert!(
!name.starts_with(".restore-"),
"staging directory must not persist after a successful restore, found: {name}"
);
}
}
#[test]
fn cache_019_restore_leaves_no_staging_directory_after_failure() {
let target = PathBuf::from("/ws/proj/out");
let fs = fs_with_one_output(&target, b"x", 0o644);
let cache = make_cache(fs, HashAlgo::Blake3);
let key = sample_key();
let outs = [StoredOutput {
workspace_absolute_path: "/proj/out",
on_disk_path: &target,
mode: 0o644,
}];
cache
.store(
&key,
&StoreInputs {
outputs: &outs,
stdout: b"",
stderr: b"",
created_at_unix: 0,
},
)
.unwrap();
let manifest = cache.reader().lookup(&key).unwrap();
let entry = crate::layout::entry_dir(cache.cache_root(), &key);
cache.fs().remove_dir_all(&entry).unwrap();
let _ = cache.restore(&manifest).unwrap_err();
for entry in cache.fs().read_dir(cache.cache_root()).unwrap() {
let name = entry
.path
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
assert!(
!name.starts_with(".restore-"),
"staging directory must be cleaned up after a failed restore, found: {name}"
);
}
}
#[test]
fn cache_019_restore_works_under_sha256() {
let target = PathBuf::from("/ws/proj/out");
let fs = fs_with_one_output(&target, b"sha-bytes", 0o600);
let outs = [StoredOutput {
workspace_absolute_path: "/proj/out",
on_disk_path: &target,
mode: 0o600,
}];
let (cache, restored, _manifest) =
store_then_restore(fs, HashAlgo::Sha256, &outs, b"sha-stdout", b"sha-stderr");
assert_eq!(cache.fs().read(&target).unwrap(), b"sha-bytes");
assert_eq!(cache.fs().mode_of(&target).unwrap(), 0o600);
assert_eq!(restored.stdout, b"sha-stdout");
assert_eq!(restored.stderr, b"sha-stderr");
}
}