Phase 5a: fingerprint + capture + restore for build-artifact cache #10
Generated
+33
@@ -388,6 +388,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sysinfo",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
@@ -396,6 +397,7 @@ dependencies = [
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -515,6 +517,16 @@ version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
@@ -1517,6 +1529,17 @@ dependencies = [
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -2285,6 +2308,16 @@ dependencies = [
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yasna"
|
||||
version = "0.5.2"
|
||||
|
||||
@@ -52,6 +52,14 @@ rustls-pemfile = "2"
|
||||
# addressing (dedup). Fast enough that a full-blob rehash on read
|
||||
# verification stays cheap.
|
||||
blake3 = "1"
|
||||
# v0.4 — tar archive writer/reader for the Phase 5 build-artifact
|
||||
# capture flow (cargo target/deps → tar → BlobPutStream). Preserves
|
||||
# file metadata (mtimes, perms) which cargo relies on for its own
|
||||
# incremental-rebuild fingerprint checks.
|
||||
tar = "0.4"
|
||||
# v0.13 — zstd wrapping around the tar stream. Level 3 is the default;
|
||||
# gets 5-10× compression on cargo .rlib without noticeable CPU cost.
|
||||
zstd = "0.13"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
//! propagate without a daemon restart.
|
||||
|
||||
pub mod blob;
|
||||
pub mod build_cache;
|
||||
pub mod gossip;
|
||||
pub mod rpc;
|
||||
pub mod services;
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
//! 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, ~5–10× 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".
|
||||
pub fn capture_target(target_dir: &Path) -> Result<Vec<u8>> {
|
||||
if !target_dir.is_dir() {
|
||||
bail!(
|
||||
"target dir {} does not exist or is not a directory",
|
||||
target_dir.display()
|
||||
);
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let encoder = zstd::stream::write::Encoder::new(&mut buf, 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() {
|
||||
tar.append_dir_all(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")?;
|
||||
encoder.finish().context("finalising zstd stream")?;
|
||||
}
|
||||
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_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_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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user