1use serde::Serialize;
14
15use crate::embedded;
16
17#[derive(Debug, Clone, Serialize)]
21pub struct InvariantFailure {
22 pub code: &'static str,
24 pub destination: String,
26 pub reason: String,
28 pub remediation: &'static str,
30}
31
32impl InvariantFailure {
33 fn new(
34 code: &'static str,
35 destination: &str,
36 reason: impl Into<String>,
37 remediation: &'static str,
38 ) -> Self {
39 Self {
40 code,
41 destination: destination.to_owned(),
42 reason: reason.into(),
43 remediation,
44 }
45 }
46}
47
48#[must_use]
51pub fn failures(tech: &str, forge: &str, destination: &str, bytes: &[u8]) -> Vec<InvariantFailure> {
52 match (tech, forge, destination) {
53 ("rust", "github", "dist-workspace.toml") => dist_workspace(destination, bytes),
54 _ => Vec::new(),
55 }
56}
57
58fn dist_workspace(destination: &str, bytes: &[u8]) -> Vec<InvariantFailure> {
64 let Ok(text) = std::str::from_utf8(bytes) else {
65 return vec![InvariantFailure::new(
66 "unparsable-configuration",
67 destination,
68 "the file is not UTF-8, so its configuration cannot be judged",
69 "repair the file so it parses as TOML",
70 )];
71 };
72 let table: toml::Table = match text.parse() {
73 Ok(table) => table,
74 Err(error) => {
75 return vec![InvariantFailure::new(
76 "unparsable-configuration",
77 destination,
78 format!("the file does not parse as TOML: {error}"),
79 "repair the file so it parses as TOML",
80 )];
81 }
82 };
83 let dist = table.get("dist").and_then(toml::Value::as_table);
84 let mut failures = Vec::new();
85 let value = |key: &str| dist.and_then(|dist| dist.get(key));
86 if value("github-attestations").and_then(toml::Value::as_bool) != Some(true) {
87 failures.push(InvariantFailure::new(
88 "attestations-disabled",
89 destination,
90 "github-attestations is not effectively true, so no release artifact is attested",
91 "set github-attestations = true in [dist]",
92 ));
93 }
94 let phase = value("github-attestations-phase").and_then(toml::Value::as_str);
95 if phase != Some("host") {
96 failures.push(InvariantFailure::new(
97 "attestation-phase-not-host",
98 destination,
99 phase.map_or_else(
100 || "github-attestations-phase is unset, so the default phase attests only the per-platform archives and the curled installers ship unattested".to_owned(),
101 |other| format!(
102 "github-attestations-phase is \"{other}\"; only the host phase attests every asset before the release page exists"
103 ),
104 ),
105 "set github-attestations-phase = \"host\" in [dist]",
106 ));
107 }
108 if value("github-release").and_then(toml::Value::as_str) != Some("host") {
109 failures.push(InvariantFailure::new(
110 "release-phase-unpaired",
111 destination,
112 "github-release is not \"host\", leaving the release creation unpaired with the attest phase",
113 "set github-release = \"host\" in [dist], pairing the release creation with the phase that attests",
114 ));
115 }
116 if value("github-attestations-filters").is_some() {
117 failures.push(InvariantFailure::new(
118 "attestation-filters-narrowed",
119 destination,
120 "github-attestations-filters narrows what is attested below the whole release payload",
121 "remove github-attestations-filters from [dist]; the default [\"*\"] attests every hosted file",
122 ));
123 }
124 let expected = seed_action_commits();
130 let found = value("github-action-commits").and_then(toml::Value::as_table);
131 for (action, commit) in &expected {
132 let remediation = "bring the [dist.github-action-commits] table to the payload seed's (rk snippet rust/github/dist-workspace.toml) and regenerate with dist generate --mode ci";
133 match found.and_then(|table| table.get(action)) {
138 Some(value) => match value.as_str() {
139 Some(pinned) if pinned == commit.as_str() => {}
140 Some(pinned) => failures.push(InvariantFailure::new(
141 "action-commit-stale",
142 destination,
143 format!(
144 "[dist.github-action-commits] pins {action} at {pinned}, where the payload pins {commit}"
145 ),
146 remediation,
147 )),
148 None => failures.push(InvariantFailure::new(
149 "action-commit-invalid",
150 destination,
151 format!(
152 "[dist.github-action-commits] pins {action} with a non-string value; a pin is a full commit SHA string"
153 ),
154 remediation,
155 )),
156 },
157 None => failures.push(InvariantFailure::new(
158 "action-commit-missing",
159 destination,
160 format!(
161 "[dist.github-action-commits] does not pin {action}, so the workflow runs whatever the movable tag names"
162 ),
163 remediation,
164 )),
165 }
166 }
167 failures
168}
169
170fn seed_action_commits() -> Vec<(String, String)> {
173 let Some(text) = embedded::SNIPPETS
174 .get_file("rust/github/dist-workspace.toml")
175 .and_then(|file| file.contents_utf8())
176 else {
177 return Vec::new();
178 };
179 let Ok(table) = text.parse::<toml::Table>() else {
180 return Vec::new();
181 };
182 table
183 .get("dist")
184 .and_then(toml::Value::as_table)
185 .and_then(|dist| dist.get("github-action-commits"))
186 .and_then(toml::Value::as_table)
187 .map(|commits| {
188 commits
189 .iter()
190 .filter_map(|(action, commit)| {
191 commit
192 .as_str()
193 .map(|commit| (action.clone(), commit.to_owned()))
194 })
195 .collect()
196 })
197 .unwrap_or_default()
198}
199
200#[cfg(test)]
201mod tests {
202 #![allow(clippy::expect_used)]
203
204 use super::failures;
205
206 const CLEAN: &str = r#"
207[dist]
208github-attestations = true
209github-attestations-phase = "host"
210github-release = "host"
211
212[dist.github-action-commits]
213"actions/checkout" = "d23441a48e516b6c34aea4fa41551a30e30af803"
214"actions/download-artifact" = "3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c"
215"actions/upload-artifact" = "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"
216"actions/attest" = "1e69f48acb82d1966a394da916b4c1698aa569d6"
217"#;
218
219 #[test]
224 fn the_seeded_configuration_is_judged_effectively() {
225 assert!(failures("rust", "github", "dist-workspace.toml", CLEAN.as_bytes()).is_empty());
226 let seed = crate::embedded::SNIPPETS
227 .get_file("rust/github/dist-workspace.toml")
228 .and_then(|file| file.contents_utf8())
229 .expect("the seed is embedded");
230 assert!(
231 failures("rust", "github", "dist-workspace.toml", seed.as_bytes()).is_empty(),
232 "the payload's own seed satisfies the invariants it seeds"
233 );
234 }
235
236 #[test]
239 fn a_missing_or_stale_action_commit_table_fails() {
240 let missing = "[dist]\ngithub-attestations=true\ngithub-attestations-phase='host'\ngithub-release='host'\n";
241 let found = failures("rust", "github", "dist-workspace.toml", missing.as_bytes());
242 assert!(
243 found
244 .iter()
245 .any(|failure| failure.code == "action-commit-missing"),
246 "a missing entry falls back to the movable tag: {found:?}"
247 );
248 let stale = CLEAN.replace(
249 "d23441a48e516b6c34aea4fa41551a30e30af803",
250 "0000000000000000000000000000000000000000",
251 );
252 let found = failures("rust", "github", "dist-workspace.toml", stale.as_bytes());
253 assert!(
254 found
255 .iter()
256 .any(|failure| failure.code == "action-commit-stale"
257 && failure.reason.contains("actions/checkout")
258 && failure
259 .reason
260 .contains("0000000000000000000000000000000000000000")),
261 "a mismatch names the found and expected commits: {found:?}"
262 );
263 let invalid = CLEAN.replace("\"d23441a48e516b6c34aea4fa41551a30e30af803\"", "123");
264 let found_invalid = failures("rust", "github", "dist-workspace.toml", invalid.as_bytes());
265 assert!(
266 found_invalid
267 .iter()
268 .any(|failure| failure.code == "action-commit-invalid"
269 && failure.reason.contains("actions/checkout")),
270 "a non-string value is invalid configuration, not an absent pin: {found_invalid:?}"
271 );
272 assert!(
273 !found
274 .iter()
275 .any(|failure| failure.reason.contains("actions/attest")),
276 "only the stale action is named: {found:?}"
277 );
278 }
279
280 #[test]
284 fn each_degraded_form_fails_with_its_code() {
285 let cases: &[(&str, &str)] = &[
286 (
287 "[dist]\n# github-attestations = true\ngithub-attestations-phase='host'\ngithub-release='host'\n",
288 "attestations-disabled",
289 ),
290 (
291 "[dist]\ngithub-attestations = false\ngithub-attestations-phase='host'\ngithub-release='host'\n",
292 "attestations-disabled",
293 ),
294 (
295 "[dist]\ngithub-attestations = true\ngithub-release='host'\n",
296 "attestation-phase-not-host",
297 ),
298 (
299 "[dist]\ngithub-attestations = true\ngithub-attestations-phase='build-local-artifacts'\ngithub-release='host'\n",
300 "attestation-phase-not-host",
301 ),
302 (
303 "[dist]\ngithub-attestations = true\ngithub-attestations-phase='host'\ngithub-release='announce'\n",
304 "release-phase-unpaired",
305 ),
306 (
307 "[dist]\ngithub-attestations = true\ngithub-attestations-phase='host'\ngithub-release='host'\ngithub-attestations-filters=['*.tar.gz']\n",
308 "attestation-filters-narrowed",
309 ),
310 ("not toml at [all", "unparsable-configuration"),
311 ];
312 for (text, code) in cases {
313 let found = failures("rust", "github", "dist-workspace.toml", text.as_bytes());
314 assert!(
315 found.iter().any(|failure| failure.code == *code),
316 "{text:?} must fail with {code}, got {found:?}"
317 );
318 }
319 }
320
321 #[test]
325 fn the_rule_is_keyed_by_pair_and_destination() {
326 let broken = b"[dist]\ngithub-attestations = false\n";
327 assert!(failures("rust", "gitlab", "dist-workspace.toml", broken).is_empty());
328 assert!(failures("bash", "github", "dist-workspace.toml", broken).is_empty());
329 assert!(failures("rust", "github", "release-plz.toml", broken).is_empty());
330 }
331}