Skip to main content

max / makenotwork

5.7 KB · 132 lines History Blame Raw
1 //! The artifact and evidence contract between the system that builds a release
2 //! and the system that advances it.
3 //!
4 //! Design + rationale: maintainer wiki.
5 //! <!-- wiki: sando-bento-boundary -->
6 //!
7 //! Sando and Bento independently grew the same defect: gates vouch for one
8 //! thing, the deploy ships another, and nothing guarantees they match. Both had
9 //! to remember to bind evidence to bytes, and both forgot. Two systems that
10 //! forgot the same thing separately will forget it again, so this crate makes
11 //! the binding the wire format between them rather than a discipline inside
12 //! each. A builder cannot hand over an artifact without a digest and a
13 //! provenance; an evaluator cannot evaluate one it was not handed. There is no
14 //! second channel.
15 //!
16 //! # The documents
17 //!
18 //! - [`ArtifactRecord`] — the handover. These bytes ([`Manifest`],
19 //! [`BundleDigest`]), from this source ([`Provenance`]), and what the builder
20 //! proved about them.
21 //! - [`EvidenceRecord`] — an append. What a later producer proved about a
22 //! bundle it did not build, naming it by digest.
23 //!
24 //! Both sit **beside** a bundle, never inside it. Evidence names the digest of
25 //! what it vouches for, and the digest covers every file in the bundle, so
26 //! evidence stored inside would change the digest it names. That circularity is
27 //! the short argument; the longer one is that evidence keeps arriving after the
28 //! build, and if it lived in the bundle then vouching for an artifact would
29 //! change the artifact's identity, so the thing that passed would never be the
30 //! thing that ships.
31 //!
32 //! # What this crate does not do
33 //!
34 //! It has no opinion on where the documents land, and no I/O. Bento's dist tree
35 //! and Sando's content-addressed release directories are laid out differently
36 //! and neither has to move for this to be shared. It also does not model either
37 //! daemon's failure vocabulary: [`GateRecord::detail`] carries a producer's own
38 //! typed outcome verbatim, so learning a new failure mode stays a one-repo
39 //! change.
40
41 mod evidence;
42 mod manifest;
43 mod provenance;
44 mod record;
45
46 pub use evidence::{GateRecord, Scope, Verdict};
47 pub use manifest::{BundleDigest, Manifest, ManifestEntry};
48 pub use provenance::Provenance;
49 pub use record::{ArtifactRecord, CONTRACT_VERSION, EvidenceRecord};
50
51 /// Every way a document can fail to be one.
52 #[derive(Debug)]
53 pub enum ContractError {
54 /// A hash field was not 64 lowercase hex characters.
55 NotASha256(String),
56 /// A provenance git sha was not 40 lowercase hex characters. Abbreviated
57 /// shas compare unequal to full ones without being wrong, which turns a
58 /// mismatch into a non-answer.
59 NotAGitSha(String),
60 /// A manifest path was absolute, backslashed, or walked upward.
61 UnsafePath(String),
62 /// Two manifest entries named the same path.
63 DuplicatePath(String),
64 /// A manifest with no files. It hashes fine, which is the problem: it is a
65 /// stable identity for nothing, shared by every empty bundle.
66 EmptyManifest,
67 /// A `MANIFEST` line was not `<sha256> <path>`. Carries the 1-based line.
68 MalformedManifestLine(usize),
69 /// A required provenance field was empty.
70 MissingProvenance(&'static str),
71 /// The record's digest is not the digest of the manifest it carries.
72 DigestMismatch { claimed: String, computed: String },
73 /// A builder handed over evidence about an environment it does not have.
74 EnvironmentEvidenceFromBuilder { gate: String, env: String },
75 /// A document written against a contract version this reader does not know.
76 UnknownContractVersion(u32),
77 /// The JSON did not parse as the document it claimed to be.
78 Malformed(serde_json::Error),
79 }
80
81 impl std::fmt::Display for ContractError {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 match self {
84 ContractError::NotASha256(s) => {
85 write!(f, "`{s}` is not a sha256 (64 lowercase hex characters)")
86 }
87 ContractError::NotAGitSha(s) => {
88 write!(
89 f,
90 "`{s}` is not a full git sha (40 lowercase hex characters)"
91 )
92 }
93 ContractError::UnsafePath(p) => write!(
94 f,
95 "manifest path `{p}` must be relative, forward-slashed, and stay inside the bundle"
96 ),
97 ContractError::DuplicatePath(p) => {
98 write!(f, "manifest lists `{p}` twice, so it describes two bundles")
99 }
100 ContractError::EmptyManifest => write!(f, "manifest lists no files"),
101 ContractError::MalformedManifestLine(n) => {
102 write!(f, "manifest line {n} is not `<sha256> <path>`")
103 }
104 ContractError::MissingProvenance(field) => write!(f, "`{field}` is empty"),
105 ContractError::DigestMismatch { claimed, computed } => write!(
106 f,
107 "record claims digest {claimed} but its manifest hashes to {computed}"
108 ),
109 ContractError::EnvironmentEvidenceFromBuilder { gate, env } => write!(
110 f,
111 "gate `{gate}` is scoped to environment `{env}`, which a build host does not have"
112 ),
113 ContractError::UnknownContractVersion(v) => {
114 write!(
115 f,
116 "contract version {v}, this reader knows {CONTRACT_VERSION}"
117 )
118 }
119 ContractError::Malformed(e) => write!(f, "malformed document: {e}"),
120 }
121 }
122 }
123
124 impl std::error::Error for ContractError {
125 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
126 match self {
127 ContractError::Malformed(e) => Some(e),
128 _ => None,
129 }
130 }
131 }
132