Files
clawstor/claw-store/src/cluster/build_cache.rs
T
Omar Sobh cb07bfc574 capture: stream to a Writer instead of buffering the whole tar in RAM
Field finding 2026-07-12 (clawverse measurement): the buffered
`capture_target -> Vec<u8>` path peaked at 2.8 GB RAM to capture a
6.1 GB target/debug into a 995 MiB compressed tar. Every byte
crossed RAM before touching the network.

* `capture_target_to_writer(target_dir, writer) -> u64` — new
  streaming variant. Walks the tree + writes tar+zstd straight into
  the caller's Writer via a small ByteCounter wrapper. Peak memory
  stays at ~zstd sliding window size (few MB).
* `capture_target -> Vec<u8>` kept as a thin wrapper for the tests
  + smaller callers that don't care.
* `cmd_build`: capture into a tempfile under `target/`, then open
  it with `tokio::fs::File` (AsyncRead + Unpin) and hand that to
  `call_blob_put_stream`. Same-filesystem tempfile means no cross-
  mount concerns; auto-unlinks on drop.

+1 test: `capture_streaming_matches_buffered_and_restores_correctly`
proves the streamed bytes match the buffered variant, the reported
byte count agrees with the written length, and roundtrip restore
from the streamed file works.

Combined with PR #22 (QUIC idle timeout), this closes the two RAM/
timeout blockers surfaced by the clawverse pilot. Expected memory
ceiling on a runner drops from GBs to MBs, unlocking small-runner
deployments (the actual pitch use case).
2026-07-12 06:02:55 -07:00

863 lines
34 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Fingerprint-keyed build-artifact cache (Phase 5).
//!
//! The killer feature. Given a cargo workspace, compute a
//! deterministic 32-byte BLAKE3 fingerprint over the inputs that
//! determine what artifacts should be produced ([`FingerprintInputs`])
//! and use it as the primary key for cached target dirs.
//!
//! # Flow
//!
//! On a CI runner or dev machine:
//!
//! ```text
//! 1. compute_fingerprint(workspace, profile, features)
//! 2. cluster::rpc::call_blob_stat(conn, fingerprint_as_blob_id)
//! → hit: BlobGetStream → restore_target(bytes, target/) → cargo builds
//! the workspace's OWN crates in seconds
//! → miss: cargo build --release ... (full build)
//! then capture_target(target/) → BlobPutStream + PutManifest
//! ```
//!
//! # What's captured, what's not
//!
//! Portable subset only:
//! * `target/<profile>/deps/` — the 95% of size, compiled dep rlibs
//! * `target/<profile>/.fingerprint/` — cargo's own per-crate mtime
//! state; without it cargo doesn't trust the deps and rebuilds them
//! * `target/<profile>/build/` — build.rs outputs (OUT_DIR files)
//! * `target/<profile>/examples/` — sometimes referenced by test bins
//! * Small top-level files: `.cargo-lock`, `.rustc_info.json`, `CACHEDIR.TAG`
//!
//! Explicitly NOT captured:
//! * `incremental/` — rustc's per-machine incremental cache. It's tied
//! to absolute paths + rustc's own machine state; restoring it on
//! another host silently corrupts the build. Cargo re-populates it
//! locally on the first rebuild (~1 minute penalty).
//! * `*.d` dependency-info files with absolute paths — cargo regenerates
//! these from `.fingerprint/`.
use anyhow::{bail, Context, Result};
use blake3::Hasher;
use serde::{Deserialize, Serialize};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
/// Top-level directories captured from `target/<profile>/`.
const CAPTURED_SUBDIRS: &[&str] = &["deps", ".fingerprint", "build", "examples"];
/// Small top-level files worth preserving from `target/<profile>/`.
const CAPTURED_TOP_FILES: &[&str] = &[".cargo-lock", ".rustc_info.json", "CACHEDIR.TAG"];
/// zstd compression level for the tarball. Level 3 is the crate's
/// default: near-instant compress, ~510× ratio on cargo `.rlib`.
const ZSTD_LEVEL: i32 = 3;
/// Compressed-tarball payload cap. Same as the RPC bounded cap so
/// small blobs go over `BlobPut`; larger ones must use `BlobPutStream`.
/// Used only in tests to guard against runaway captures.
pub const CAPTURE_INLINE_CEILING: usize = 16 * 1024 * 1024;
/// The set of build inputs whose combined value determines whether two
/// cached artifact sets are interchangeable. All fields must be
/// captured deterministically — random ordering or wall-clock
/// dependence would blow the cache.
///
/// Missing / absent files (e.g. no `rust-toolchain.toml`) are
/// represented as empty strings, so a project that adds an empty
/// `.cargo/config.toml` gets the same fingerprint it had before
/// creating the empty file (the file's absence == the file's emptiness).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FingerprintInputs {
/// Full text of `Cargo.lock`. Determines the resolved dep graph.
pub cargo_lock: String,
/// Output of `rustc --version --verbose`. Includes the rustc
/// commit hash, LLVM version, and host triple — anything that
/// changes compiler output.
pub rustc_version_verbose: String,
/// Full text of the workspace's `.cargo/config.toml`, or `""` if
/// absent. Captures build flags and registry overrides.
pub cargo_config: String,
/// Full text of the workspace's `rust-toolchain.toml`, or `""` if
/// absent. Rustup uses this to pin a toolchain version.
pub rust_toolchain: String,
/// Cargo profile: typically `"dev"` or `"release"`.
pub profile: String,
/// Enabled features. Sorted + deduped at collection time.
pub features: Vec<String>,
/// `RUSTFLAGS` environment variable at build time, or `""` if unset.
/// Affects rustc codegen (e.g. `-C target-cpu=native`).
pub rustflags: String,
/// Extracted host triple from `rustc_version_verbose`. Kept
/// separately so callers can inspect it without re-parsing.
pub target_triple: String,
}
impl FingerprintInputs {
/// Collect from a workspace on disk. Reads Cargo.lock, shells
/// out to `rustc --version --verbose`, reads optional config
/// files. Errors when Cargo.lock is missing (the workspace isn't
/// a cargo project) or rustc isn't in PATH.
pub fn collect(workspace: &Path, profile: &str, features: &[String]) -> Result<Self> {
let cargo_lock = std::fs::read_to_string(workspace.join("Cargo.lock"))
.with_context(|| format!("reading Cargo.lock under {}", workspace.display()))?;
let out = Command::new("rustc")
.args(["--version", "--verbose"])
.output()
.context("running `rustc --version --verbose`")?;
if !out.status.success() {
bail!(
"rustc --version --verbose failed with exit {}",
out.status
);
}
let rustc_verbose = String::from_utf8(out.stdout)
.context("rustc --version --verbose produced non-UTF-8 output")?;
let cargo_config = read_optional(&workspace.join(".cargo/config.toml"))?;
let rust_toolchain = read_optional(&workspace.join("rust-toolchain.toml"))?;
let rustflags = std::env::var("RUSTFLAGS").unwrap_or_default();
let target_triple = rustc_verbose
.lines()
.find_map(|l| l.strip_prefix("host: "))
.unwrap_or("unknown-triple")
.to_string();
let mut features = features.to_vec();
features.sort();
features.dedup();
Ok(Self {
cargo_lock,
rustc_version_verbose: rustc_verbose,
cargo_config,
rust_toolchain,
profile: profile.to_string(),
features,
rustflags,
target_triple,
})
}
/// Compute the fingerprint hash. Deterministic given identical
/// inputs, order-independent for features (already sorted), and
/// null-byte-separated so `["ab","c"]` hashes differently from
/// `["a","bc"]`.
pub fn compute(&self) -> Fingerprint {
let mut h = Hasher::new();
// Domain-separated by field with a fixed sentinel — different
// versions of this struct produce different hashes without a
// manual version tag.
h.update(b"clawstor.fingerprint.v1\0");
update_field(&mut h, b"cargo_lock", self.cargo_lock.as_bytes());
update_field(
&mut h,
b"rustc_version_verbose",
self.rustc_version_verbose.as_bytes(),
);
update_field(&mut h, b"cargo_config", self.cargo_config.as_bytes());
update_field(&mut h, b"rust_toolchain", self.rust_toolchain.as_bytes());
update_field(&mut h, b"profile", self.profile.as_bytes());
h.update(b"features\0");
for f in &self.features {
h.update(f.as_bytes());
h.update(b"\0");
}
h.update(b"\0");
update_field(&mut h, b"rustflags", self.rustflags.as_bytes());
update_field(&mut h, b"target_triple", self.target_triple.as_bytes());
Fingerprint(h.finalize().into())
}
}
/// Read a file; return `""` when it doesn't exist. Errors on any other
/// filesystem failure so a permission-denied doesn't silently produce
/// a "no file" hash.
fn read_optional(path: &Path) -> Result<String> {
match std::fs::read_to_string(path) {
Ok(s) => Ok(s),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
Err(e) => Err(anyhow::Error::from(e))
.with_context(|| format!("reading optional {}", path.display())),
}
}
/// Feed one labelled field into the fingerprint hasher.
fn update_field(h: &mut Hasher, name: &[u8], value: &[u8]) {
h.update(name);
h.update(b"\0");
h.update(value);
h.update(b"\0");
}
/// 32-byte BLAKE3 fingerprint over build inputs. Shape parallels
/// [`crate::cluster::blob::BlobId`] — same content-addressing spirit,
/// but keyed to inputs rather than outputs. A fingerprint identifies
/// a cached target dir; a BlobId identifies its bytes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Fingerprint([u8; 32]);
impl Fingerprint {
pub fn from_bytes(hash: [u8; 32]) -> Self {
Self(hash)
}
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
/// Lowercase hex string, 64 chars. Suitable for filenames + logs.
pub fn to_hex(&self) -> String {
let mut out = String::with_capacity(64);
for b in self.0 {
out.push_str(&format!("{b:02x}"));
}
out
}
}
impl std::fmt::Display for Fingerprint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.to_hex())
}
}
/// Bundle the portable subset of `target/<profile>/` into a
/// zstd-compressed tarball. Returns the bytes ready to hand to
/// [`crate::cluster::blob::BlobStore::put_bytes`] (bounded inputs)
/// or `put_stream` (larger workspaces).
///
/// `target_dir` should be `<workspace>/target/<profile>` — the
/// caller resolves the profile so this function doesn't have to
/// know about cargo's directory layout beyond "the deps live here".
/// Field finding 2026-07-12: walk `src` recursively in sorted order
/// and append every regular file + directory to `tar` under
/// `<archive_prefix>/<rel>`. Two calls on byte-identical trees produce
/// byte-identical tar output (given `HeaderMode::Deterministic`), even
/// across nodes whose `read_dir` returns entries in different orders.
///
/// Symlinks are appended as symlinks (the tar crate handles the header
/// bookkeeping); anything else — sockets, fifos — is skipped.
fn append_dir_sorted<W: std::io::Write>(
tar: &mut tar::Builder<W>,
archive_prefix: &str,
src: &Path,
) -> Result<()> {
let mut stack: Vec<(PathBuf, String)> =
vec![(src.to_path_buf(), archive_prefix.to_string())];
while let Some((dir, archive_dir)) = stack.pop() {
let mut entries: Vec<_> = std::fs::read_dir(&dir)
.with_context(|| format!("reading {}", dir.display()))?
.filter_map(|e| e.ok())
.collect();
// Sort by filename bytes — stable across filesystems.
entries.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
for entry in entries {
let ft = entry.file_type()?;
let name = entry.file_name();
let name_str = match name.to_str() {
Some(s) => s,
None => continue,
};
let archive_path = format!("{}/{}", archive_dir, name_str);
let full = entry.path();
if ft.is_dir() {
// Push for later processing; also emit the directory
// header so an empty dir survives the roundtrip.
let mut header = tar::Header::new_gnu();
header.set_entry_type(tar::EntryType::Directory);
header.set_size(0);
header.set_mode(0o755);
header.set_mtime(0);
header.set_cksum();
tar.append_data(
&mut header,
format!("{}/", archive_path),
std::io::empty(),
)?;
stack.push((full, archive_path));
} else if ft.is_file() {
let mut f = std::fs::File::open(&full)
.with_context(|| format!("opening {}", full.display()))?;
tar.append_file(&archive_path, &mut f)
.with_context(|| format!("appending {}", full.display()))?;
} else if ft.is_symlink() {
let link_target = std::fs::read_link(&full)
.with_context(|| format!("reading symlink {}", full.display()))?;
let mut header = tar::Header::new_gnu();
header.set_entry_type(tar::EntryType::Symlink);
header.set_size(0);
header.set_mode(0o777);
header.set_mtime(0);
header
.set_link_name(&link_target)
.context("setting symlink header link_name")?;
header.set_cksum();
tar.append_data(&mut header, &archive_path, std::io::empty())?;
}
// Other types (sockets, fifos) are skipped.
}
}
Ok(())
}
/// Field finding 2026-07-12 (clawverse measurement): capture the tar
/// into `out` via a streaming writer, so the whole ~1 GB blob never
/// sits in RAM at once. Returns the byte count actually written.
///
/// Callers stream the result into `BlobPutStream` by opening `out`
/// with `tokio::fs::File::open` — that's `AsyncRead + Unpin`, which
/// is what `call_blob_put_stream` accepts. Peak RAM stays at ~zstd
/// sliding window size (few MB) regardless of source size.
pub fn capture_target_to_writer<W: std::io::Write>(
target_dir: &Path,
out: W,
) -> Result<u64> {
if !target_dir.is_dir() {
bail!(
"target dir {} does not exist or is not a directory",
target_dir.display()
);
}
let counter = ByteCounter::new(out);
let encoder = zstd::stream::write::Encoder::new(counter, ZSTD_LEVEL)
.context("initialising zstd encoder")?;
let mut tar = tar::Builder::new(encoder);
tar.mode(tar::HeaderMode::Deterministic);
tar.follow_symlinks(false);
for sub in CAPTURED_SUBDIRS {
let path = target_dir.join(sub);
if path.is_dir() {
append_dir_sorted(&mut tar, sub, &path)
.with_context(|| format!("archiving {}", path.display()))?;
}
}
for file in CAPTURED_TOP_FILES {
let path = target_dir.join(file);
if path.is_file() {
let mut f = std::fs::File::open(&path)
.with_context(|| format!("opening {}", path.display()))?;
tar.append_file(file, &mut f)
.with_context(|| format!("appending {}", path.display()))?;
}
}
let encoder = tar.into_inner().context("closing tar builder")?;
let counter = encoder.finish().context("finalising zstd stream")?;
Ok(counter.into_bytes_written())
}
/// A small wrapper that counts bytes written to an underlying writer.
/// Used by [`capture_target_to_writer`] so the streaming path returns
/// a byte count without buffering the output.
struct ByteCounter<W> {
inner: W,
written: u64,
}
impl<W> ByteCounter<W> {
fn new(inner: W) -> Self {
Self { inner, written: 0 }
}
fn into_bytes_written(self) -> u64 {
self.written
}
}
impl<W: std::io::Write> std::io::Write for ByteCounter<W> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let n = self.inner.write(buf)?;
self.written = self.written.saturating_add(n as u64);
Ok(n)
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
/// Legacy in-memory capture. Kept as a thin wrapper over the streaming
/// variant so existing tests + callers keep working; new code should
/// prefer `capture_target_to_writer` for bounded memory.
pub fn capture_target(target_dir: &Path) -> Result<Vec<u8>> {
let mut buf = Vec::new();
capture_target_to_writer(target_dir, &mut buf)?;
Ok(buf)
}
/// Reverse of [`capture_target`]. Decompresses + unpacks the captured
/// bytes into `target_dir`, which is created if missing. Existing files
/// with the same relative path are overwritten; files outside the
/// captured subdirs are untouched.
pub fn restore_target(bytes: &[u8], target_dir: &Path) -> Result<()> {
std::fs::create_dir_all(target_dir)
.with_context(|| format!("creating {}", target_dir.display()))?;
let decoder =
zstd::stream::read::Decoder::new(bytes).context("initialising zstd decoder")?;
let mut archive = tar::Archive::new(decoder);
archive.set_preserve_permissions(true);
archive.set_preserve_mtime(true);
archive
.unpack(target_dir)
.with_context(|| format!("unpacking into {}", target_dir.display()))?;
Ok(())
}
/// Bare-metal convenience: capture from a workspace + profile,
/// keeping the pipeline explicit for callers who don't want the
/// full config-driven flow yet.
pub fn capture_workspace(workspace: &Path, profile: &str) -> Result<Vec<u8>> {
let target_dir = workspace.join("target").join(profile);
capture_target(&target_dir)
}
/// Compute a fingerprint from a workspace + profile + features.
/// Returns `(inputs, fingerprint)` so the caller can log what went
/// into the hash.
pub fn compute_workspace_fingerprint(
workspace: &Path,
profile: &str,
features: &[String],
) -> Result<(FingerprintInputs, Fingerprint)> {
let inputs = FingerprintInputs::collect(workspace, profile, features)?;
let fp = inputs.compute();
Ok((inputs, fp))
}
/// Return true if two byte slices are equal when interpreted as tar
/// archives (i.e. same set of entries, same content per entry). Used
/// by tests to compare captures across two workspaces that differ
/// only in fingerprint-irrelevant ways.
#[cfg(test)]
fn zstd_tar_contents_equal(a: &[u8], b: &[u8]) -> Result<bool> {
fn extract(bytes: &[u8]) -> Result<Vec<(PathBuf, Vec<u8>)>> {
let decoder = zstd::stream::read::Decoder::new(bytes)?;
let mut archive = tar::Archive::new(decoder);
let mut out: Vec<(PathBuf, Vec<u8>)> = Vec::new();
for entry in archive.entries()? {
let mut entry = entry?;
let path = entry.path()?.into_owned();
let mut contents = Vec::new();
entry.read_to_end(&mut contents)?;
out.push((path, contents));
}
out.sort_by(|x, y| x.0.cmp(&y.0));
Ok(out)
}
Ok(extract(a)? == extract(b)?)
}
#[cfg(test)]
mod tests {
use super::*;
fn write_file(root: &Path, relative: &str, bytes: &[u8]) {
let path = root.join(relative);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(bytes).unwrap();
}
fn baseline_inputs() -> FingerprintInputs {
FingerprintInputs {
cargo_lock: "[[package]]\nname = \"foo\"\nversion = \"0.1.0\"\n".into(),
rustc_version_verbose:
"rustc 1.75.0\nhost: aarch64-apple-darwin\nrelease: 1.75.0\n".into(),
cargo_config: String::new(),
rust_toolchain: String::new(),
profile: "dev".into(),
features: vec!["a".into(), "b".into()],
rustflags: String::new(),
target_triple: "aarch64-apple-darwin".into(),
}
}
#[test]
fn fingerprint_is_deterministic() {
let a = baseline_inputs().compute();
let b = baseline_inputs().compute();
assert_eq!(a, b);
}
#[test]
fn fingerprint_changes_when_cargo_lock_changes() {
let base = baseline_inputs().compute();
let mut mutated = baseline_inputs();
mutated.cargo_lock.push_str("# extra line\n");
assert_ne!(base, mutated.compute());
}
#[test]
fn fingerprint_changes_when_profile_changes() {
let base = baseline_inputs().compute();
let mut mutated = baseline_inputs();
mutated.profile = "release".into();
assert_ne!(base, mutated.compute());
}
#[test]
fn fingerprint_changes_when_features_change() {
let base = baseline_inputs().compute();
let mut mutated = baseline_inputs();
mutated.features.push("c".into());
assert_ne!(base, mutated.compute());
}
#[test]
fn fingerprint_is_feature_order_independent() {
// Collect() sorts features. Test that two orderings produce
// the same fingerprint after collection semantics.
let mut a = baseline_inputs();
a.features = vec!["b".into(), "a".into()];
a.features.sort();
let mut b = baseline_inputs();
b.features = vec!["a".into(), "b".into()];
b.features.sort();
assert_eq!(a.compute(), b.compute());
}
#[test]
fn fingerprint_domain_separation_prevents_field_collision() {
// If two fields' contents happened to concat identically, a
// naive hasher would produce the same hash. Ours prefixes each
// field with a label + null separator, so this doesn't happen.
let mut a = baseline_inputs();
a.cargo_lock = "shared".into();
a.rustflags = "".into();
let mut b = baseline_inputs();
b.cargo_lock = "".into();
b.rustflags = "shared".into();
assert_ne!(a.compute(), b.compute());
}
#[test]
fn fingerprint_hex_length_and_stability() {
let fp = baseline_inputs().compute();
assert_eq!(fp.to_hex().len(), 64);
// Round-trip the raw bytes.
let raw = *fp.as_bytes();
let recomputed = Fingerprint::from_bytes(raw);
assert_eq!(recomputed, fp);
assert_eq!(recomputed.to_hex(), fp.to_hex());
}
#[test]
fn read_optional_returns_empty_for_missing() {
let tmp = tempfile::TempDir::new().unwrap();
let out = read_optional(&tmp.path().join("does-not-exist")).unwrap();
assert!(out.is_empty());
}
#[test]
fn read_optional_returns_content_for_existing() {
let tmp = tempfile::TempDir::new().unwrap();
let p = tmp.path().join("f");
std::fs::write(&p, b"hello").unwrap();
let out = read_optional(&p).unwrap();
assert_eq!(out, "hello");
}
#[test]
fn collect_errors_when_cargo_lock_absent() {
let tmp = tempfile::TempDir::new().unwrap();
let err = FingerprintInputs::collect(tmp.path(), "dev", &[])
.unwrap_err()
.to_string();
assert!(err.contains("Cargo.lock"), "unexpected error: {err}");
}
#[test]
fn collect_reads_cargo_lock_and_computes() {
let tmp = tempfile::TempDir::new().unwrap();
write_file(tmp.path(), "Cargo.lock", b"[[package]]\nname = \"x\"\n");
// rustc must be on PATH — this is the same env our test suite
// runs under so it's a safe assumption.
let inputs = FingerprintInputs::collect(tmp.path(), "dev", &["feat".into()]).unwrap();
assert!(inputs.cargo_lock.contains("package"));
assert!(!inputs.rustc_version_verbose.is_empty());
assert!(inputs.target_triple != "unknown-triple");
assert_eq!(inputs.profile, "dev");
assert_eq!(inputs.features, vec!["feat".to_string()]);
// And computing works.
let _fp = inputs.compute();
}
#[test]
fn capture_target_errors_when_dir_missing() {
let tmp = tempfile::TempDir::new().unwrap();
let err = capture_target(&tmp.path().join("nope"))
.unwrap_err()
.to_string();
assert!(err.contains("does not exist"), "unexpected: {err}");
}
#[test]
fn capture_and_restore_round_trip_preserves_files() {
let tmp = tempfile::TempDir::new().unwrap();
let target = tmp.path().join("target");
write_file(&target, "deps/libfoo.rlib", b"fake rlib content");
write_file(&target, "deps/libbar.rlib", b"another rlib");
write_file(&target, ".fingerprint/foo/lib-foo", b"fingerprint blob");
write_file(&target, "build/foo-abc/output", b"build script output");
write_file(&target, ".rustc_info.json", b"{\"stub\":\"info\"}");
// Not captured — should be absent post-restore.
write_file(&target, "incremental/foo/session", b"incremental data");
write_file(&target, "examples/demo", b"demo binary");
let bytes = capture_target(&target).unwrap();
assert!(!bytes.is_empty());
assert!(
bytes.len() < CAPTURE_INLINE_CEILING,
"test capture stayed inline"
);
let dst = tmp.path().join("restored");
restore_target(&bytes, &dst).unwrap();
// Captured files are present + equal.
assert_eq!(
std::fs::read(dst.join("deps/libfoo.rlib")).unwrap(),
b"fake rlib content"
);
assert_eq!(
std::fs::read(dst.join("deps/libbar.rlib")).unwrap(),
b"another rlib"
);
assert_eq!(
std::fs::read(dst.join(".fingerprint/foo/lib-foo")).unwrap(),
b"fingerprint blob"
);
assert_eq!(
std::fs::read(dst.join("build/foo-abc/output")).unwrap(),
b"build script output"
);
assert_eq!(
std::fs::read(dst.join(".rustc_info.json")).unwrap(),
b"{\"stub\":\"info\"}"
);
assert_eq!(std::fs::read(dst.join("examples/demo")).unwrap(), b"demo binary");
// `incremental/` is intentionally NOT captured.
assert!(
!dst.join("incremental").exists(),
"incremental/ must be excluded — it's not portable"
);
}
#[test]
fn capture_is_order_independent_of_filesystem_readdir() {
// Field finding 2026-07-12: `append_dir_all` used
// `read_dir`'s native order, which differs across filesystems.
// Two byte-identical trees produced different tars whose only
// difference was entry order. Guard: create two trees whose
// files are the same but written in DIFFERENT orders (which
// biases readdir on many FS layouts), and require the
// captures to match. `append_dir_sorted` — the new walker —
// sorts by filename so the order at capture time is fixed.
let tmp = tempfile::TempDir::new().unwrap();
let a = tmp.path().join("A");
let b = tmp.path().join("B");
// Tree A: write a, b, c
write_file(&a, "deps/aaa.rlib", b"aaa content");
write_file(&a, "deps/bbb.rlib", b"bbb content");
write_file(&a, "deps/ccc.rlib", b"ccc content");
write_file(&a, ".fingerprint/aaa/xxx", b"fp-aaa");
write_file(&a, ".fingerprint/bbb/xxx", b"fp-bbb");
// Tree B: same files, reverse creation order
write_file(&b, "deps/ccc.rlib", b"ccc content");
write_file(&b, "deps/bbb.rlib", b"bbb content");
write_file(&b, "deps/aaa.rlib", b"aaa content");
write_file(&b, ".fingerprint/bbb/xxx", b"fp-bbb");
write_file(&b, ".fingerprint/aaa/xxx", b"fp-aaa");
let cap_a = capture_target(&a).unwrap();
let cap_b = capture_target(&b).unwrap();
assert_eq!(
cap_a, cap_b,
"captures must be byte-identical after sorted walk"
);
}
#[test]
fn capture_yields_identical_bytes_for_identical_input() {
// With HeaderMode::Deterministic on the tar builder, two
// captures of identical trees produce byte-identical tarballs.
// That's what lets the same target dir hash to the same BlobId
// across builds.
let tmp = tempfile::TempDir::new().unwrap();
let t1 = tmp.path().join("t1");
let t2 = tmp.path().join("t2");
write_file(&t1, "deps/a.rlib", b"content");
write_file(&t1, ".fingerprint/x", b"fp");
write_file(&t2, "deps/a.rlib", b"content");
write_file(&t2, ".fingerprint/x", b"fp");
let a = capture_target(&t1).unwrap();
let b = capture_target(&t2).unwrap();
assert!(zstd_tar_contents_equal(&a, &b).unwrap());
}
#[test]
fn capture_streaming_matches_buffered_and_restores_correctly() {
// Field finding 2026-07-12: the buffered `capture_target` used
// 2.8 GB peak RAM on clawverse. `capture_target_to_writer`
// streams into a caller-provided Writer. This guards two
// properties: (1) the streamed bytes match the buffered
// variant exactly, (2) the reported byte count agrees, and
// (3) restore roundtrip works from a file-backed writer.
let src = tempfile::TempDir::new().unwrap();
let target = src.path().join("target");
write_file(&target, "deps/a.rlib", b"aaaaaaaaaaa");
write_file(&target, "deps/b.rlib", b"bbbbbbbbbbb");
write_file(&target, ".fingerprint/aa/xxx", b"aa-fp");
write_file(&target, ".fingerprint/bb/xxx", b"bb-fp");
write_file(&target, "build/cc/cc.rlib", b"cc");
let buffered = capture_target(&target).unwrap();
let out_dir = tempfile::TempDir::new().unwrap();
let out_path = out_dir.path().join("capture.tar.zst");
let file = std::fs::File::create(&out_path).unwrap();
let mut writer = std::io::BufWriter::new(file);
let reported = capture_target_to_writer(&target, &mut writer).unwrap();
use std::io::Write;
writer.flush().unwrap();
let streamed = std::fs::read(&out_path).unwrap();
assert_eq!(
streamed, buffered,
"streaming capture must match buffered capture byte-for-byte"
);
assert_eq!(
reported as usize,
buffered.len(),
"reported byte count must equal actual bytes written"
);
// Roundtrip: restore from the streamed file, verify contents.
let restored = tempfile::TempDir::new().unwrap();
restore_target(&streamed, restored.path()).unwrap();
assert_eq!(
std::fs::read(restored.path().join("deps/a.rlib")).unwrap(),
b"aaaaaaaaaaa"
);
assert_eq!(
std::fs::read(restored.path().join(".fingerprint/bb/xxx")).unwrap(),
b"bb-fp"
);
}
#[test]
fn capture_skips_top_level_files_not_in_allowlist() {
let tmp = tempfile::TempDir::new().unwrap();
let target = tmp.path().join("target");
write_file(&target, "deps/keep.rlib", b"keep");
// random top-level file NOT in CAPTURED_TOP_FILES
write_file(&target, "random.txt", b"drop me");
let bytes = capture_target(&target).unwrap();
let dst = tmp.path().join("restored");
restore_target(&bytes, &dst).unwrap();
assert!(dst.join("deps/keep.rlib").exists());
assert!(
!dst.join("random.txt").exists(),
"random top-level file should not be captured"
);
}
#[test]
fn capture_workspace_resolves_profile_dir() {
let tmp = tempfile::TempDir::new().unwrap();
let workspace = tmp.path();
let target_release = workspace.join("target").join("release");
write_file(&target_release, "deps/x.rlib", b"released");
let bytes = capture_workspace(workspace, "release").unwrap();
let dst = tmp.path().join("restored");
restore_target(&bytes, &dst).unwrap();
assert_eq!(std::fs::read(dst.join("deps/x.rlib")).unwrap(), b"released");
}
#[test]
fn compute_workspace_fingerprint_returns_inputs_and_hash() {
let tmp = tempfile::TempDir::new().unwrap();
write_file(tmp.path(), "Cargo.lock", b"[[package]]\nname=\"z\"\n");
let (inputs, fp) =
compute_workspace_fingerprint(tmp.path(), "release", &["one".into()]).unwrap();
assert_eq!(inputs.profile, "release");
assert_eq!(inputs.features, vec!["one".to_string()]);
// Recomputing must match.
assert_eq!(fp, inputs.compute());
}
// ── Full pipeline: fingerprint → capture → blob → restore ────────
#[tokio::test]
async fn end_to_end_fingerprint_capture_blob_restore() {
// Given the same workspace state on two machines, the
// fingerprint is identical → BlobId is deterministic once the
// captured bytes are stored → the second machine can find the
// cache by fingerprint alone. This is the full loop, sans RPC.
use crate::cluster::blob::{BlobId, BlobStore};
let tmp = tempfile::TempDir::new().unwrap();
let workspace = tmp.path().join("ws");
write_file(&workspace, "Cargo.lock", b"[[package]]\nname=\"x\"\n");
// Fake target dir populated with a couple of "deps".
let target = workspace.join("target").join("dev");
write_file(&target, "deps/foo.rlib", b"1234567890" .repeat(50).as_slice());
write_file(&target, "deps/bar.rlib", b"abcdefghij" .repeat(50).as_slice());
write_file(&target, ".fingerprint/foo/lib", b"cargo state");
let (_inputs, fp) =
compute_workspace_fingerprint(&workspace, "dev", &[]).unwrap();
let bytes = capture_target(&target).unwrap();
// Store via BlobStore — the BlobId is content-addressed and
// independent of the fingerprint. Both sides need to end up
// with the same bytes, which they do because capture is
// deterministic.
let store_dir = tmp.path().join("blobs");
let store = BlobStore::open(store_dir).unwrap();
let blob_id = store.put_bytes(&bytes).await.unwrap();
// A machine that knows only the fingerprint would look up
// `fingerprint → BlobId` via a metadata layer (Phase 3). For
// this spike, we assume that mapping via an out-of-band step
// — the test does it explicitly:
let claimed_bid = BlobId::from_bytes(blake3::hash(&bytes).into());
assert_eq!(blob_id, claimed_bid);
// Now: peer B fetches by BlobId and restores.
let round = store.get_bytes(&blob_id).await.unwrap().unwrap();
assert_eq!(round, bytes);
let restored_target = tmp.path().join("restored/target/dev");
restore_target(&round, &restored_target).unwrap();
// Every captured file lands intact.
assert_eq!(
std::fs::read(restored_target.join("deps/foo.rlib")).unwrap(),
"1234567890".repeat(50).as_bytes()
);
assert_eq!(
std::fs::read(restored_target.join(".fingerprint/foo/lib")).unwrap(),
b"cargo state"
);
// Same fingerprint is what a builder on another host would
// compute from the same workspace. So this end-to-end proves:
// workspace → fingerprint (deterministic)
// target → bytes → BlobId (deterministic)
// BlobId → bytes → target (round-trip)
let _ = fp; // usage-only assertion above
}
}