Skip to main content

max / makenotwork

4.5 KB · 127 lines History Blame Raw
1 //! What source the bytes came from, and which machine turned one into the other.
2
3 use crate::ContractError;
4 use chrono::{DateTime, Utc};
5
6 /// Everything needed to rebuild the artifact, or to prove it was not rebuilt.
7 ///
8 /// `git_sha` is the commit the build host was pinned to, not the branch tip at
9 /// pull time. That distinction is the whole reason this field is here: a
10 /// multi-arch release whose hosts each pulled independently ships several
11 /// commits under one version label, and nothing downstream can tell.
12 #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
13 pub struct Provenance {
14 /// The app this release is of, as the controller keys it (`goingson`).
15 pub app: String,
16 /// The release version. A label, not an identity: it does not change per
17 /// commit, and two builds can share it. The digest is the identity.
18 pub version: String,
19 /// The release tag the build was pinned to (`v0.4.1`, `pom-v0.4.1`).
20 pub tag: String,
21 /// The commit that tag resolved to, full 40 hex.
22 pub git_sha: String,
23 /// `os/arch` as the topology names it (`linux/x86_64`, `macos/aarch64`).
24 pub target: String,
25 /// The host that compiled it. A release is native per architecture, so this
26 /// also says which machine to look at when only one target is wrong.
27 pub build_host: String,
28 /// `rustc --version` verbatim from the build host.
29 pub toolchain: String,
30 pub built_at: DateTime<Utc>,
31 }
32
33 impl Provenance {
34 /// Reject a provenance that cannot identify what it describes.
35 ///
36 /// Every field here is load-bearing at intake, and an empty one is worse
37 /// than a missing document: it satisfies a shape check while telling the
38 /// evaluator nothing. The git sha is length-checked because a short sha
39 /// silently stops matching a full one, and comparing provenance across
40 /// hosts is the point.
41 pub fn validate(&self) -> Result<(), ContractError> {
42 for (name, value) in [
43 ("app", &self.app),
44 ("version", &self.version),
45 ("tag", &self.tag),
46 ("target", &self.target),
47 ("build_host", &self.build_host),
48 ("toolchain", &self.toolchain),
49 ] {
50 if value.trim().is_empty() {
51 return Err(ContractError::MissingProvenance(name));
52 }
53 }
54 let sha_ok = self.git_sha.len() == 40
55 && self
56 .git_sha
57 .bytes()
58 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
59 if !sha_ok {
60 return Err(ContractError::NotAGitSha(self.git_sha.clone()));
61 }
62 Ok(())
63 }
64 }
65
66 #[cfg(test)]
67 mod tests {
68 use super::*;
69
70 fn good() -> Provenance {
71 Provenance {
72 app: "goingson".into(),
73 version: "0.4.1".into(),
74 tag: "v0.4.1".into(),
75 git_sha: "a".repeat(40),
76 target: "linux/x86_64".into(),
77 build_host: "fw13".into(),
78 toolchain: "rustc 1.97.0 (deadbeef 2026-07-01)".into(),
79 built_at: DateTime::<Utc>::from_timestamp(1_754_000_000, 0).unwrap(),
80 }
81 }
82
83 #[test]
84 fn a_complete_provenance_validates() {
85 good().validate().unwrap();
86 }
87
88 #[test]
89 fn each_empty_field_is_named() {
90 let cases: [(&str, fn(&mut Provenance)); 6] = [
91 ("app", |p| p.app = String::new()),
92 ("version", |p| p.version = String::new()),
93 ("tag", |p| p.tag = String::new()),
94 ("target", |p| p.target = String::new()),
95 ("build_host", |p| p.build_host = " ".into()),
96 ("toolchain", |p| p.toolchain = String::new()),
97 ];
98 for (field, break_it) in cases {
99 let mut p = good();
100 break_it(&mut p);
101 assert!(
102 matches!(p.validate().unwrap_err(), ContractError::MissingProvenance(f) if f == field),
103 "{field} should be reported by name"
104 );
105 }
106 }
107
108 #[test]
109 fn a_short_sha_is_refused() {
110 // Abbreviated shas compare unequal to full ones without being wrong,
111 // which turns a provenance mismatch into a provenance non-answer.
112 let mut p = good();
113 p.git_sha = "a".repeat(12);
114 assert!(matches!(
115 p.validate().unwrap_err(),
116 ContractError::NotAGitSha(_)
117 ));
118 }
119
120 #[test]
121 fn round_trips_through_json() {
122 let p = good();
123 let back: Provenance = serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap();
124 assert_eq!(p, back);
125 }
126 }
127