//! The two documents that cross the boundary. use crate::{BundleDigest, ContractError, GateRecord, Manifest, Provenance, Scope}; /// The version of this contract. Bumped when a change would make an older /// reader misread a newer document, not when a field is added. pub const CONTRACT_VERSION: u32 = 1; /// What the builder hands over: these bytes, from this source, and here is what /// I proved about them. /// /// Written **beside** the bundle, never inside it. Evidence names the digest of /// what it vouches for, and the digest covers every file in the bundle, so /// evidence stored inside would change the digest it names. Beyond the /// circularity, the evaluator appends its own evidence later; if evidence lived /// in the bundle, vouching for an artifact would alter the artifact's identity, /// and the thing that passed would no longer be the thing that ships. #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct ArtifactRecord { pub contract: u32, /// Which system produced this (`bento`). pub producer: String, /// Identity of the bundle. Always equal to `manifest.digest()`; carried /// explicitly so a reader can check the manifest it was handed is the one /// the digest was computed from. pub digest: BundleDigest, pub manifest: Manifest, pub provenance: Provenance, /// Gates the builder ran. Artifact-scoped by construction. pub gates: Vec, } impl ArtifactRecord { pub fn new( producer: impl Into, manifest: Manifest, provenance: Provenance, gates: Vec, ) -> Result { let record = Self { contract: CONTRACT_VERSION, producer: producer.into(), digest: manifest.digest(), manifest, provenance, gates, }; record.validate()?; Ok(record) } /// Everything an intake has to check before it may believe the document. /// /// Run on write as well as on read. A producer that emits a record it would /// itself reject has a bug now, not at the far end of a transfer where the /// error reads as corruption. pub fn validate(&self) -> Result<(), ContractError> { if self.contract != CONTRACT_VERSION { return Err(ContractError::UnknownContractVersion(self.contract)); } if self.producer.trim().is_empty() { return Err(ContractError::MissingProvenance("producer")); } if self.digest != self.manifest.digest() { return Err(ContractError::DigestMismatch { claimed: self.digest.to_string(), computed: self.manifest.digest().to_string(), }); } self.provenance.validate()?; // A builder has no environment, so it cannot have observed one. This is // the boundary refusing to be crossed by accident rather than a style // rule: an environment-scoped gate from a build host is either // mislabelled or measures a machine nothing will run on. for g in &self.gates { if let Scope::Environment { env } = &g.scope { return Err(ContractError::EnvironmentEvidenceFromBuilder { gate: g.gate.clone(), env: env.clone(), }); } } Ok(()) } /// True iff every gate the builder ran passed. Not a decision: whether the /// artifact advances is the evaluator's call, and it will have gates of its /// own that this record cannot speak to. pub fn all_gates_passed(&self) -> bool { self.gates.iter().all(|g| g.verdict.is_passed()) } pub fn to_json(&self) -> String { // Pretty, with a trailing newline: these land on disk next to a bundle // and get read by a person before they are ever read by a program. let mut s = serde_json::to_string_pretty(self).unwrap_or_default(); s.push('\n'); s } pub fn parse(json: &str) -> Result { let record: Self = serde_json::from_str(json).map_err(ContractError::Malformed)?; record.validate()?; Ok(record) } } /// What a later producer adds: I did not build this, and here is what I proved /// about it. /// /// Separate from [`ArtifactRecord`] so that appending evidence never rewrites /// the handover. Each producer owns one document; nobody edits anybody else's. /// `subject` is the only link, which is what makes the binding between evidence /// and bytes structural rather than a discipline each system has to remember. #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct EvidenceRecord { pub contract: u32, /// Which system produced this (`sando`). pub producer: String, /// The bundle this evidence is about. pub subject: BundleDigest, pub gates: Vec, } impl EvidenceRecord { pub fn new( producer: impl Into, subject: BundleDigest, gates: Vec, ) -> Result { let record = Self { contract: CONTRACT_VERSION, producer: producer.into(), subject, gates, }; record.validate()?; Ok(record) } pub fn validate(&self) -> Result<(), ContractError> { if self.contract != CONTRACT_VERSION { return Err(ContractError::UnknownContractVersion(self.contract)); } if self.producer.trim().is_empty() { return Err(ContractError::MissingProvenance("producer")); } Ok(()) } pub fn to_json(&self) -> String { let mut s = serde_json::to_string_pretty(self).unwrap_or_default(); s.push('\n'); s } pub fn parse(json: &str) -> Result { let record: Self = serde_json::from_str(json).map_err(ContractError::Malformed)?; record.validate()?; Ok(record) } } #[cfg(test)] mod tests { use super::*; use crate::Verdict; use chrono::{DateTime, Utc}; fn at() -> DateTime { DateTime::::from_timestamp(1_754_000_000, 0).unwrap() } fn manifest() -> Manifest { Manifest::new([ ("GoingsOn.AppImage", "1".repeat(64)), ("GoingsOn.AppImage.minisig", "2".repeat(64)), ]) .unwrap() } fn provenance() -> 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".into(), built_at: at(), } } fn gate(name: &str, verdict: Verdict) -> GateRecord { GateRecord::new(name, Scope::Artifact, verdict, "ok", at()) } fn record() -> ArtifactRecord { ArtifactRecord::new( "bento", manifest(), provenance(), vec![ gate("clippy", Verdict::Passed), gate("cargo_test", Verdict::Passed), ], ) .unwrap() } #[test] fn the_digest_is_the_manifests_and_the_record_round_trips() { let r = record(); assert_eq!(r.digest, manifest().digest()); assert!(r.all_gates_passed()); let back = ArtifactRecord::parse(&r.to_json()).unwrap(); assert_eq!(r, back); } #[test] fn a_record_whose_digest_does_not_match_its_manifest_is_refused() { // The whole point of the handover: a document claiming to describe // bytes it does not describe must not survive a read. let mut r = record(); r.digest = BundleDigest::parse(&"f".repeat(64)).unwrap(); assert!(matches!( r.validate().unwrap_err(), ContractError::DigestMismatch { .. } )); assert!(ArtifactRecord::parse(&serde_json::to_string(&r).unwrap()).is_err()); } #[test] fn a_builder_cannot_hand_over_environment_evidence() { let g = GateRecord::new( "boot_smoke", Scope::Environment { env: "tier:a".into(), }, Verdict::Passed, "served /health", at(), ); let err = ArtifactRecord::new("bento", manifest(), provenance(), vec![g]).unwrap_err(); assert!( matches!(err, ContractError::EnvironmentEvidenceFromBuilder { gate, env } if gate == "boot_smoke" && env == "tier:a") ); } #[test] fn a_failing_gate_does_not_invalidate_the_record() { // A record of a failed build is a legitimate document. Refusing to emit // one would leave the only evidence of the failure in a log. let r = ArtifactRecord::new( "bento", manifest(), provenance(), vec![gate("cargo_test", Verdict::Failed)], ) .unwrap(); assert!(!r.all_gates_passed()); } #[test] fn a_future_contract_version_is_refused_rather_than_guessed_at() { let mut r = record(); r.contract = CONTRACT_VERSION + 1; assert!(matches!( r.validate().unwrap_err(), ContractError::UnknownContractVersion(_) )); } #[test] fn evidence_names_the_bundle_it_is_about_and_may_be_environment_scoped() { let e = EvidenceRecord::new( "sando", manifest().digest(), vec![GateRecord::new( "burn_in", Scope::Environment { env: "tier:a".into(), }, Verdict::Blocked, "12 hours remaining of 48", at(), )], ) .unwrap(); let back = EvidenceRecord::parse(&e.to_json()).unwrap(); assert_eq!(back.subject, manifest().digest()); assert_eq!(back, e); } #[test] fn appending_evidence_does_not_touch_the_handover() { // Two documents, one subject. The evaluator's write cannot change the // builder's document, and neither can change the digest. let handover = record(); let _ = EvidenceRecord::new("sando", handover.digest.clone(), vec![]).unwrap(); assert_eq!(handover, record()); assert_eq!(handover.digest, manifest().digest()); } }