use std::path::PathBuf;
use crate::toolchain;
use crate::util;
#[derive(Debug, Clone)]
pub struct FreshnessSnapshot {
pub managed_dylib_path: PathBuf,
pub original_mtime_unix_secs: i64,
pub original_sha256: String,
pub original_toolchain: toolchain::Toolchain,
}
#[derive(Debug, Clone)]
pub enum FreshnessFailure {
DylibMissing {
path: PathBuf,
},
DylibMtimeBackward {
path: PathBuf,
original_mtime: i64,
observed_mtime: i64,
},
DylibShaMismatch {
path: PathBuf,
original_sha256: String,
observed_sha256: String,
},
RustcDrift {
original: Box<toolchain::Toolchain>,
observed: Box<toolchain::Toolchain>,
},
}
impl FreshnessFailure {
pub fn invariant_label(&self) -> &'static str {
match self {
Self::DylibMissing { .. } => "managed_dylib_path",
Self::DylibMtimeBackward { .. } => "dylib_mtime",
Self::DylibShaMismatch { .. } => "dylib_sha256",
Self::RustcDrift { .. } => "rustc_release",
}
}
pub fn detail(&self) -> String {
match self {
Self::DylibMissing { path } => {
format!("managed dylib no longer exists at {}", path.display())
}
Self::DylibMtimeBackward {
path,
original_mtime,
observed_mtime,
} => format!(
"managed dylib mtime moved backward at {} (original: {original_mtime}, observed: {observed_mtime})",
path.display()
),
Self::DylibShaMismatch {
path,
original_sha256,
observed_sha256,
} => format!(
"managed dylib SHA-256 changed at {} (original: {original_sha256}, observed: {observed_sha256})",
path.display()
),
Self::RustcDrift { original, observed } => {
let mut changed: Vec<&'static str> = Vec::new();
if original.release_line != observed.release_line {
changed.push("release_line");
}
if original.host != observed.host {
changed.push("host");
}
if original.commit_hash != observed.commit_hash {
changed.push("commit_hash");
}
if original.sysroot != observed.sysroot {
changed.push("sysroot");
}
let changed_list = if changed.is_empty() {
"(none detected)".to_string()
} else {
changed.join(", ")
};
format!(
"rustc toolchain drifted (changed fields: {changed_list}; original: {orig_rl}, host: {orig_host}, commit-hash: {orig_ch}, sysroot: {orig_sr}; observed: {obs_rl}, host: {obs_host}, commit-hash: {obs_ch}, sysroot: {obs_sr})",
orig_rl = original.release_line,
orig_host = original.host,
orig_ch = original.commit_hash,
orig_sr = original.sysroot.display(),
obs_rl = observed.release_line,
obs_host = observed.host,
obs_ch = observed.commit_hash,
obs_sr = observed.sysroot.display(),
)
}
}
}
}
pub fn check(snapshot: &FreshnessSnapshot) -> Result<(), FreshnessFailure> {
let path = &snapshot.managed_dylib_path;
let meta = match std::fs::metadata(path) {
Ok(m) => m,
Err(_) => {
return Err(FreshnessFailure::DylibMissing { path: path.clone() });
}
};
let observed_mtime = match meta.modified() {
Ok(t) => t
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0),
Err(_) => 0,
};
if observed_mtime < snapshot.original_mtime_unix_secs {
return Err(FreshnessFailure::DylibMtimeBackward {
path: path.clone(),
original_mtime: snapshot.original_mtime_unix_secs,
observed_mtime,
});
}
let observed_sha = match util::sha256_file(path) {
Ok(s) => s,
Err(_) => {
return Err(FreshnessFailure::DylibMissing { path: path.clone() });
}
};
if observed_sha != snapshot.original_sha256 {
return Err(FreshnessFailure::DylibShaMismatch {
path: path.clone(),
original_sha256: snapshot.original_sha256.clone(),
observed_sha256: observed_sha,
});
}
match toolchain::capture() {
Ok(observed) => {
if !toolchain::matches(&snapshot.original_toolchain, &observed) {
return Err(FreshnessFailure::RustcDrift {
original: Box::new(snapshot.original_toolchain.clone()),
observed: Box::new(observed),
});
}
}
Err(_) => {
return Err(FreshnessFailure::RustcDrift {
original: Box::new(snapshot.original_toolchain.clone()),
observed: Box::new(toolchain::Toolchain {
release_line: String::new(),
release: String::new(),
host: String::new(),
commit_hash: String::new(),
sysroot: PathBuf::new(),
}),
});
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::tempdir;
fn write_dylib_stub(dir: &std::path::Path, contents: &[u8]) -> PathBuf {
let p = dir.join("libstub.so");
let mut f = std::fs::File::create(&p).unwrap();
f.write_all(contents).unwrap();
f.sync_all().unwrap();
p
}
fn placeholder_toolchain() -> toolchain::Toolchain {
toolchain::Toolchain {
release_line: "rustc 1.95.0 (abc 2026-01-01)".into(),
release: "1.95.0".into(),
host: "x86_64-unknown-linux-gnu".into(),
commit_hash: "59807616e2031c7c44a76b1b0c1bbd0fed9a07cf".into(),
sysroot: PathBuf::from("/usr/local/rustup/toolchains/stable-x86_64"),
}
}
fn snapshot_with_synthetic_toolchain(
dir: &std::path::Path,
original_toolchain: toolchain::Toolchain,
) -> FreshnessSnapshot {
let p = write_dylib_stub(dir, b"hello world");
let meta = std::fs::metadata(&p).unwrap();
let mtime = meta
.modified()
.unwrap()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let sha = crate::util::sha256_file(&p).unwrap();
FreshnessSnapshot {
managed_dylib_path: p,
original_mtime_unix_secs: mtime,
original_sha256: sha,
original_toolchain,
}
}
#[test]
fn missing_dylib_fails_invariant_1() {
let snap = FreshnessSnapshot {
managed_dylib_path: PathBuf::from("/path/that/does/not/exist.so"),
original_mtime_unix_secs: 0,
original_sha256: "deadbeef".into(),
original_toolchain: placeholder_toolchain(),
};
let r = check(&snap).unwrap_err();
assert_eq!(r.invariant_label(), "managed_dylib_path");
}
#[test]
fn sha_mismatch_fails_invariant_3() {
let tmp = tempdir().unwrap();
let p = write_dylib_stub(tmp.path(), b"hello world");
let mtime = std::fs::metadata(&p)
.unwrap()
.modified()
.unwrap()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let snap = FreshnessSnapshot {
managed_dylib_path: p.clone(),
original_mtime_unix_secs: mtime,
original_sha256: "0000000000000000000000000000000000000000000000000000000000000000"
.into(),
original_toolchain: placeholder_toolchain(),
};
let err = check(&snap).unwrap_err();
match &err {
FreshnessFailure::DylibShaMismatch { .. } => {}
other => panic!("expected DylibShaMismatch, got {other:?}"),
}
assert_eq!(err.invariant_label(), "dylib_sha256");
}
#[test]
fn detail_messages_are_byte_deterministic() {
let f = FreshnessFailure::DylibShaMismatch {
path: PathBuf::from("/p/lib.so"),
original_sha256: "abc".into(),
observed_sha256: "def".into(),
};
let a = f.detail();
let b = f.detail();
assert_eq!(a, b);
assert!(a.contains("/p/lib.so"));
assert!(a.contains("abc"));
assert!(a.contains("def"));
}
fn assert_only_field_drifts(
field_name: &'static str,
mutate: impl FnOnce(&mut toolchain::Toolchain),
) {
let tmp = tempdir().unwrap();
let live = toolchain::capture().expect("rustc must be on PATH for this test");
let mut original = live.clone();
mutate(&mut original);
let snap = snapshot_with_synthetic_toolchain(tmp.path(), original);
let err = check(&snap).unwrap_err();
assert!(
matches!(err, FreshnessFailure::RustcDrift { .. }),
"expected RustcDrift, got {err:?}"
);
assert_eq!(err.invariant_label(), "rustc_release");
let detail = err.detail();
let changed_prefix = detail
.split(';')
.next()
.filter(|s| s.contains("changed fields:"))
.expect("changed-fields prefix must be present");
assert!(
changed_prefix.contains(field_name),
"changed-fields list must name {field_name}: {changed_prefix}"
);
for other in ["release_line", "host", "commit_hash", "sysroot"] {
if other != field_name {
assert!(
!changed_prefix.contains(other),
"{other} must NOT appear in changed-fields: {changed_prefix}"
);
}
}
}
#[test]
fn freshness_check_detects_release_line_drift() {
assert_only_field_drifts("release_line", |tc| {
tc.release_line = "rustc 0.0.0 (fake 1970-01-01)".into();
});
}
#[test]
fn freshness_check_detects_host_drift() {
assert_only_field_drifts("host", |tc| {
tc.host = "fake-host-target".into();
});
}
#[test]
fn freshness_check_detects_commit_hash_drift() {
assert_only_field_drifts("commit_hash", |tc| {
tc.commit_hash = "00000000000000000000000000000000fakehash".into();
});
}
#[test]
fn freshness_check_detects_sysroot_drift() {
assert_only_field_drifts("sysroot", |tc| {
tc.sysroot = PathBuf::from("/nonexistent/fake/toolchains/stable");
});
}
#[test]
fn rustc_drift_detail_is_byte_deterministic_and_lists_changed_fields() {
let original = toolchain::Toolchain {
release_line: "rustc 1.95.0 (abc 2026-01-01)".into(),
release: "1.95.0".into(),
host: "x86_64-unknown-linux-gnu".into(),
commit_hash: "59807616e2031c7c44a76b1b0c1bbd0fed9a07cf".into(),
sysroot: PathBuf::from("/usr/local/rustup/toolchains/stable-x86_64"),
};
let observed = toolchain::Toolchain {
release_line: "rustc 1.96.0 (def 2026-07-01)".into(),
release: "1.96.0".into(),
host: "aarch64-apple-darwin".into(),
commit_hash: "59807616e2031c7c44a76b1b0c1bbd0fed9a07cf".into(),
sysroot: PathBuf::from("/usr/local/rustup/toolchains/stable-x86_64"),
};
let f = FreshnessFailure::RustcDrift {
original: Box::new(original),
observed: Box::new(observed),
};
let a = f.detail();
let b = f.detail();
assert_eq!(a, b);
let ri = a.find("release_line").expect("release_line in detail");
let hi = a.find("host").expect("host in detail");
assert!(
ri < hi,
"changed-fields list must list release_line before host: {a}"
);
let header = "changed fields: release_line, host;";
assert!(
a.contains(header),
"expected changed-fields header `{header}`, got: {a}"
);
}
}