Skip to main content

release_kit/landing/
invariants.rs

1//! The invariants a seeded file still carries.
2//!
3//! A `seeded` file is the target's to tune — nothing here rewrites one —
4//! but the narrow part the invariants own is judged: a target may choose
5//! its platforms, its installers, and its install path; it may not choose
6//! to ship unattested. The judgment reads the effective configuration,
7//! never the text: a commented key, a `false` value, or an unpaired phase
8//! must fail, and whitespace or key order must not matter. The table is
9//! keyed by `(technology, forge, destination)` — the kind table is
10//! destination-keyed, and a second pair sharing a destination would
11//! otherwise silently inherit the wrong rule.
12
13use serde::Serialize;
14
15use crate::embedded;
16
17/// One invariant a landed file's effective configuration violates: a
18/// stable code, the destination, why, and exactly what to write — the
19/// operator is told the remediation, never just what was not found.
20#[derive(Debug, Clone, Serialize)]
21pub struct InvariantFailure {
22    /// The stable machine code of the failed rule.
23    pub code: &'static str,
24    /// The landed destination the failure is about.
25    pub destination: String,
26    /// Why the configuration violates the invariant.
27    pub reason: String,
28    /// Exactly what to write to satisfy the rule.
29    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/// Judge one landed file against the rules its `(tech, forge,
49/// destination)` key owns. A destination no rule owns fails nothing.
50#[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
58/// The rust/github attestation configuration: attestations on, minted in
59/// the `host` phase where every hosted asset is gathered before the
60/// release page exists, the release creation paired with that phase, and
61/// no narrowing filter — the default `["*"]` covers every hosted file,
62/// where an enumerated list goes quiet when an archive format moves.
63fn 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    // The build that signs is itself pinned by digest: the seed's
125    // [dist.github-action-commits] table pins the actions cargo-dist
126    // injects — the attest step among them — and a landed target must
127    // carry the same effective table, or its signer runs code a moved
128    // tag can swap.
129    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        // Three distinct states, each with its own true reason: an
134        // absent entry falls back to the movable tag, a non-string value
135        // is invalid configuration, and a mismatched string executes an
136        // immutable commit that is just not the payload's.
137        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
170/// The action commits the payload's own seed pins, read from the
171/// embedded snippet so the judgment and the seed cannot drift apart.
172fn 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    /// The correct configuration fails nothing, whatever the whitespace
220    /// and key order, and the payload's own seed is the exemplar: the
221    /// judgment is over the effective TOML, and the seed must satisfy
222    /// the rule it seeds.
223    #[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    /// A missing or stale action-commit table fails: the signer's own
237    /// steps would otherwise run whatever a movable tag names.
238    #[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    /// Every degraded form fails with its own code: a commented key, a
281    /// false value, the default phase, an unpaired release phase, a
282    /// narrowing filter, and malformed TOML.
283    #[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    /// The key is the pair plus the destination: the same bytes under
322    /// another pair or another destination fail nothing, so a second pair
323    /// sharing a destination cannot silently inherit this rule.
324    #[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}