//! What source the bytes came from, and which machine turned one into the other. use crate::ContractError; use chrono::{DateTime, Utc}; /// Everything needed to rebuild the artifact, or to prove it was not rebuilt. /// /// `git_sha` is the commit the build host was pinned to, not the branch tip at /// pull time. That distinction is the whole reason this field is here: a /// multi-arch release whose hosts each pulled independently ships several /// commits under one version label, and nothing downstream can tell. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct Provenance { /// The app this release is of, as the controller keys it (`goingson`). pub app: String, /// The release version. A label, not an identity: it does not change per /// commit, and two builds can share it. The digest is the identity. pub version: String, /// The release tag the build was pinned to (`v0.4.1`, `pom-v0.4.1`). pub tag: String, /// The commit that tag resolved to, full 40 hex. pub git_sha: String, /// `os/arch` as the topology names it (`linux/x86_64`, `macos/aarch64`). pub target: String, /// The host that compiled it. A release is native per architecture, so this /// also says which machine to look at when only one target is wrong. pub build_host: String, /// `rustc --version` verbatim from the build host. pub toolchain: String, pub built_at: DateTime, } impl Provenance { /// Reject a provenance that cannot identify what it describes. /// /// Every field here is load-bearing at intake, and an empty one is worse /// than a missing document: it satisfies a shape check while telling the /// evaluator nothing. The git sha is length-checked because a short sha /// silently stops matching a full one, and comparing provenance across /// hosts is the point. pub fn validate(&self) -> Result<(), ContractError> { for (name, value) in [ ("app", &self.app), ("version", &self.version), ("tag", &self.tag), ("target", &self.target), ("build_host", &self.build_host), ("toolchain", &self.toolchain), ] { if value.trim().is_empty() { return Err(ContractError::MissingProvenance(name)); } } let sha_ok = self.git_sha.len() == 40 && self .git_sha .bytes() .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)); if !sha_ok { return Err(ContractError::NotAGitSha(self.git_sha.clone())); } Ok(()) } } #[cfg(test)] mod tests { use super::*; fn good() -> Provenance { Provenance { app: "goingson".into(), version: "0.4.1".into(), tag: "v0.4.1".into(), git_sha: "a".repeat(40), target: "linux/x86_64".into(), build_host: "fw13".into(), toolchain: "rustc 1.97.0 (deadbeef 2026-07-01)".into(), built_at: DateTime::::from_timestamp(1_754_000_000, 0).unwrap(), } } #[test] fn a_complete_provenance_validates() { good().validate().unwrap(); } #[test] fn each_empty_field_is_named() { let cases: [(&str, fn(&mut Provenance)); 6] = [ ("app", |p| p.app = String::new()), ("version", |p| p.version = String::new()), ("tag", |p| p.tag = String::new()), ("target", |p| p.target = String::new()), ("build_host", |p| p.build_host = " ".into()), ("toolchain", |p| p.toolchain = String::new()), ]; for (field, break_it) in cases { let mut p = good(); break_it(&mut p); assert!( matches!(p.validate().unwrap_err(), ContractError::MissingProvenance(f) if f == field), "{field} should be reported by name" ); } } #[test] fn a_short_sha_is_refused() { // Abbreviated shas compare unequal to full ones without being wrong, // which turns a provenance mismatch into a provenance non-answer. let mut p = good(); p.git_sha = "a".repeat(12); assert!(matches!( p.validate().unwrap_err(), ContractError::NotAGitSha(_) )); } #[test] fn round_trips_through_json() { let p = good(); let back: Provenance = serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap(); assert_eq!(p, back); } }