//! The artifact and evidence contract between the system that builds a release //! and the system that advances it. //! //! Design + rationale: maintainer wiki. //! //! //! Sando and Bento independently grew the same defect: gates vouch for one //! thing, the deploy ships another, and nothing guarantees they match. Both had //! to remember to bind evidence to bytes, and both forgot. Two systems that //! forgot the same thing separately will forget it again, so this crate makes //! the binding the wire format between them rather than a discipline inside //! each. A builder cannot hand over an artifact without a digest and a //! provenance; an evaluator cannot evaluate one it was not handed. There is no //! second channel. //! //! # The documents //! //! - [`ArtifactRecord`] — the handover. These bytes ([`Manifest`], //! [`BundleDigest`]), from this source ([`Provenance`]), and what the builder //! proved about them. //! - [`EvidenceRecord`] — an append. What a later producer proved about a //! bundle it did not build, naming it by digest. //! //! Both sit **beside** a 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. That circularity is //! the short argument; the longer one is that evidence keeps arriving after the //! build, and if it lived in the bundle then vouching for an artifact would //! change the artifact's identity, so the thing that passed would never be the //! thing that ships. //! //! # What this crate does not do //! //! It has no opinion on where the documents land, and no I/O. Bento's dist tree //! and Sando's content-addressed release directories are laid out differently //! and neither has to move for this to be shared. It also does not model either //! daemon's failure vocabulary: [`GateRecord::detail`] carries a producer's own //! typed outcome verbatim, so learning a new failure mode stays a one-repo //! change. mod evidence; mod manifest; mod provenance; mod record; pub use evidence::{GateRecord, Scope, Verdict}; pub use manifest::{BundleDigest, Manifest, ManifestEntry}; pub use provenance::Provenance; pub use record::{ArtifactRecord, CONTRACT_VERSION, EvidenceRecord}; /// Every way a document can fail to be one. #[derive(Debug)] pub enum ContractError { /// A hash field was not 64 lowercase hex characters. NotASha256(String), /// A provenance git sha was not 40 lowercase hex characters. Abbreviated /// shas compare unequal to full ones without being wrong, which turns a /// mismatch into a non-answer. NotAGitSha(String), /// A manifest path was absolute, backslashed, or walked upward. UnsafePath(String), /// Two manifest entries named the same path. DuplicatePath(String), /// A manifest with no files. It hashes fine, which is the problem: it is a /// stable identity for nothing, shared by every empty bundle. EmptyManifest, /// A `MANIFEST` line was not ` `. Carries the 1-based line. MalformedManifestLine(usize), /// A required provenance field was empty. MissingProvenance(&'static str), /// The record's digest is not the digest of the manifest it carries. DigestMismatch { claimed: String, computed: String }, /// A builder handed over evidence about an environment it does not have. EnvironmentEvidenceFromBuilder { gate: String, env: String }, /// A document written against a contract version this reader does not know. UnknownContractVersion(u32), /// The JSON did not parse as the document it claimed to be. Malformed(serde_json::Error), } impl std::fmt::Display for ContractError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ContractError::NotASha256(s) => { write!(f, "`{s}` is not a sha256 (64 lowercase hex characters)") } ContractError::NotAGitSha(s) => { write!( f, "`{s}` is not a full git sha (40 lowercase hex characters)" ) } ContractError::UnsafePath(p) => write!( f, "manifest path `{p}` must be relative, forward-slashed, and stay inside the bundle" ), ContractError::DuplicatePath(p) => { write!(f, "manifest lists `{p}` twice, so it describes two bundles") } ContractError::EmptyManifest => write!(f, "manifest lists no files"), ContractError::MalformedManifestLine(n) => { write!(f, "manifest line {n} is not ` `") } ContractError::MissingProvenance(field) => write!(f, "`{field}` is empty"), ContractError::DigestMismatch { claimed, computed } => write!( f, "record claims digest {claimed} but its manifest hashes to {computed}" ), ContractError::EnvironmentEvidenceFromBuilder { gate, env } => write!( f, "gate `{gate}` is scoped to environment `{env}`, which a build host does not have" ), ContractError::UnknownContractVersion(v) => { write!( f, "contract version {v}, this reader knows {CONTRACT_VERSION}" ) } ContractError::Malformed(e) => write!(f, "malformed document: {e}"), } } } impl std::error::Error for ContractError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { ContractError::Malformed(e) => Some(e), _ => None, } } }