1use camino::Utf8Path;
25use serde::Serialize;
26
27use crate::embedded;
28
29#[derive(Debug, Clone, Serialize)]
33pub struct InvariantFailure {
34 pub code: &'static str,
36 pub destination: String,
38 pub reason: String,
40 pub remediation: &'static str,
42}
43
44impl InvariantFailure {
45 fn new(
46 code: &'static str,
47 destination: &str,
48 reason: impl Into<String>,
49 remediation: &'static str,
50 ) -> Self {
51 Self {
52 code,
53 destination: destination.to_owned(),
54 reason: reason.into(),
55 remediation,
56 }
57 }
58}
59
60#[must_use]
63pub fn failures(tech: &str, forge: &str, destination: &str, bytes: &[u8]) -> Vec<InvariantFailure> {
64 match (tech, forge, destination) {
65 ("rust", "github", "dist-workspace.toml") => dist_workspace(destination, bytes),
66 _ => Vec::new(),
67 }
68}
69
70fn dist_workspace(destination: &str, bytes: &[u8]) -> Vec<InvariantFailure> {
76 let Ok(text) = std::str::from_utf8(bytes) else {
77 return vec![InvariantFailure::new(
78 "unparsable-configuration",
79 destination,
80 "the file is not UTF-8, so its configuration cannot be judged",
81 "repair the file so it parses as TOML",
82 )];
83 };
84 let table: toml::Table = match text.parse() {
85 Ok(table) => table,
86 Err(error) => {
87 return vec![InvariantFailure::new(
88 "unparsable-configuration",
89 destination,
90 format!("the file does not parse as TOML: {error}"),
91 "repair the file so it parses as TOML",
92 )];
93 }
94 };
95 let dist = table.get("dist").and_then(toml::Value::as_table);
96 let mut failures = Vec::new();
97 let value = |key: &str| dist.and_then(|dist| dist.get(key));
98 if value("github-attestations").and_then(toml::Value::as_bool) != Some(true) {
99 failures.push(InvariantFailure::new(
100 "attestations-disabled",
101 destination,
102 "github-attestations is not effectively true, so no release artifact is attested",
103 "set github-attestations = true in [dist]",
104 ));
105 }
106 let phase = value("github-attestations-phase").and_then(toml::Value::as_str);
107 if phase != Some("host") {
108 failures.push(InvariantFailure::new(
109 "attestation-phase-not-host",
110 destination,
111 phase.map_or_else(
112 || "github-attestations-phase is unset, so the default phase attests only the per-platform archives and the curled installers ship unattested".to_owned(),
113 |other| format!(
114 "github-attestations-phase is \"{other}\"; only the host phase attests every asset before the release page exists"
115 ),
116 ),
117 "set github-attestations-phase = \"host\" in [dist]",
118 ));
119 }
120 if value("github-release").and_then(toml::Value::as_str) != Some("host") {
121 failures.push(InvariantFailure::new(
122 "release-phase-unpaired",
123 destination,
124 "github-release is not \"host\", leaving the release creation unpaired with the attest phase",
125 "set github-release = \"host\" in [dist], pairing the release creation with the phase that attests",
126 ));
127 }
128 if value("github-attestations-filters").is_some() {
129 failures.push(InvariantFailure::new(
130 "attestation-filters-narrowed",
131 destination,
132 "github-attestations-filters narrows what is attested below the whole release payload",
133 "remove github-attestations-filters from [dist]; the default [\"*\"] attests every hosted file",
134 ));
135 }
136 let mode = value("pr-run-mode").and_then(toml::Value::as_str);
142 if mode != Some("skip") {
143 failures.push(InvariantFailure::new(
144 "pr-run-mode-not-skip",
145 destination,
146 mode.map_or_else(
147 || "pr-run-mode is unset, so it defaults to plan and the generated workflow reports a job on every pull request that no gate can need".to_owned(),
148 |other| format!(
149 "pr-run-mode is \"{other}\", so the generated workflow reports a job on every pull request that no gate can need"
150 ),
151 ),
152 "set pr-run-mode = \"skip\" in [dist] and regenerate with dist generate, then run dist plan and the dist generate proof as a job of the workflow the required check gates",
153 ));
154 }
155 failures.extend(action_commit_failures(
156 destination,
157 value("github-action-commits").and_then(toml::Value::as_table),
158 ));
159 failures
160}
161
162fn action_commit_failures(destination: &str, found: Option<&toml::Table>) -> Vec<InvariantFailure> {
168 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";
169 let mut failures = Vec::new();
170 for (action, commit) in &seed_action_commits() {
171 match found.and_then(|table| table.get(action)) {
176 Some(value) => match value.as_str() {
177 Some(pinned) if pinned == commit.as_str() => {}
178 Some(pinned) => failures.push(InvariantFailure::new(
179 "action-commit-stale",
180 destination,
181 format!(
182 "[dist.github-action-commits] pins {action} at {pinned}, where the payload pins {commit}"
183 ),
184 remediation,
185 )),
186 None => failures.push(InvariantFailure::new(
187 "action-commit-invalid",
188 destination,
189 format!(
190 "[dist.github-action-commits] pins {action} with a non-string value; a pin is a full commit SHA string"
191 ),
192 remediation,
193 )),
194 },
195 None => failures.push(InvariantFailure::new(
196 "action-commit-missing",
197 destination,
198 format!(
199 "[dist.github-action-commits] does not pin {action}, so the workflow runs whatever the movable tag names"
200 ),
201 remediation,
202 )),
203 }
204 }
205 failures
206}
207
208fn seed_action_commits() -> Vec<(String, String)> {
211 let Some(text) = embedded::SNIPPETS
212 .get_file("rust/github/dist-workspace.toml")
213 .and_then(|file| file.contents_utf8())
214 else {
215 return Vec::new();
216 };
217 let Ok(table) = text.parse::<toml::Table>() else {
218 return Vec::new();
219 };
220 table
221 .get("dist")
222 .and_then(toml::Value::as_table)
223 .and_then(|dist| dist.get("github-action-commits"))
224 .and_then(toml::Value::as_table)
225 .map(|commits| {
226 commits
227 .iter()
228 .filter_map(|(action, commit)| {
229 commit
230 .as_str()
231 .map(|commit| (action.clone(), commit.to_owned()))
232 })
233 .collect()
234 })
235 .unwrap_or_default()
236}
237
238const GENERATED_WORKFLOW: &str = ".github/workflows/release.yml";
242
243#[must_use]
250pub fn target_failures(tech: &str, forge: &str, target: &Utf8Path) -> Vec<InvariantFailure> {
251 match (tech, forge) {
252 ("rust", "github") => {
253 let mut failures = generated_release_workflow(target);
254 failures.extend(dist_profile(target));
255 failures
256 }
257 _ => Vec::new(),
258 }
259}
260
261fn dist_profile(target: &Utf8Path) -> Vec<InvariantFailure> {
265 let Ok(config) = std::fs::read_to_string(target.join("dist-workspace.toml")) else {
266 return Vec::new();
267 };
268 let Ok(config) = config.parse::<toml::Table>() else {
269 return Vec::new();
270 };
271 let Some(ci) = config
272 .get("dist")
273 .and_then(toml::Value::as_table)
274 .and_then(|dist| dist.get("ci"))
275 else {
276 return Vec::new();
277 };
278 let github = ci.as_str() == Some("github")
279 || ci
280 .as_array()
281 .is_some_and(|values| values.iter().any(|value| value.as_str() == Some("github")));
282 if !github {
283 return Vec::new();
284 }
285
286 let Ok(manifest) = std::fs::read_to_string(target.join("Cargo.toml")) else {
287 return Vec::new();
288 };
289 let Ok(manifest) = manifest.parse::<toml::Table>() else {
290 return Vec::new();
291 };
292 if manifest
293 .get("profile")
294 .and_then(toml::Value::as_table)
295 .and_then(|profile| profile.get("dist"))
296 .is_some_and(toml::Value::is_table)
297 {
298 return Vec::new();
299 }
300
301 vec![InvariantFailure::new(
302 "dist-profile-missing",
303 "Cargo.toml",
304 "dist-workspace.toml enables GitHub CI, whose generated workflow builds with `--profile dist`, but the root Cargo.toml defines no [profile.dist] table",
305 concat!(
306 "add this exact block to Cargo.toml:\n",
307 "\n",
308 "[profile.dist]\n",
309 "inherits = \"release\""
310 ),
311 )]
312}
313
314fn generated_release_workflow(target: &Utf8Path) -> Vec<InvariantFailure> {
319 let Ok(config) = std::fs::read_to_string(target.join("dist-workspace.toml")) else {
320 return Vec::new();
321 };
322 let workflow = match std::fs::read_to_string(target.join(GENERATED_WORKFLOW)) {
323 Ok(text) => text,
324 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
328 Err(error) => {
329 return vec![InvariantFailure::new(
330 "workflow-file-unreadable",
331 GENERATED_WORKFLOW,
332 format!("the workflow is present and cannot be read as text: {error}"),
333 "repair the file so it reads as UTF-8 text, or regenerate it with dist generate --mode ci",
334 )];
335 }
336 };
337 workflow_matches_configuration(&config, &workflow)
338}
339
340fn workflow_matches_configuration(config: &str, workflow: &str) -> Vec<InvariantFailure> {
349 let Ok(table) = config.parse::<toml::Table>() else {
350 return Vec::new();
351 };
352 let dist = table.get("dist").and_then(toml::Value::as_table);
353 let pinned = dist
354 .and_then(|dist| dist.get("github-action-commits"))
355 .and_then(toml::Value::as_table);
356 let attested = dist
357 .and_then(|dist| dist.get("github-attestations"))
358 .and_then(toml::Value::as_bool)
359 == Some(true);
360
361 let mut failures = Vec::new();
362 let steps = workflow_uses(workflow);
363 for step in &steps {
364 let (action, reference) = match step {
365 Step::Opaque(value) => {
369 failures.push(InvariantFailure::new(
370 "workflow-step-unreadable",
371 GENERATED_WORKFLOW,
372 format!(
373 "the workflow runs `uses: {value}`, which this check cannot resolve into an action and an immutable reference"
374 ),
375 "write the step as <action>@<full commit SHA>, resolving any alias, so what the workflow runs can be read; regenerating with dist generate --mode ci writes that form",
376 ));
377 continue;
378 }
379 Step::Action(action, reference) => (action, reference),
380 };
381 let pin = pinned
385 .and_then(|table| table.get(action.as_str()))
386 .and_then(toml::Value::as_str);
387 if let Some(commit) = pin
388 && commit != reference
389 {
390 failures.push(InvariantFailure::new(
391 "workflow-action-stale",
392 GENERATED_WORKFLOW,
393 format!(
394 "the workflow runs {action}@{reference}, where dist-workspace.toml pins {commit}"
395 ),
396 "regenerate the workflow from the configuration with dist generate --mode ci and commit it; a hand edit is reverted at the next generate",
397 ));
398 continue;
399 }
400 if !is_immutable(reference) {
401 failures.push(InvariantFailure::new(
402 "workflow-action-unpinned",
403 GENERATED_WORKFLOW,
404 format!(
405 "the workflow runs {action}@{reference}, which is no immutable reference, so the step runs whatever that name points at today"
406 ),
407 "pin the action at a full commit SHA in [dist.github-action-commits] in dist-workspace.toml, then regenerate with dist generate --mode ci",
408 ));
409 }
410 }
411 if attested
412 && !steps.iter().any(|step| match step {
413 Step::Action(action, _) => {
414 action == "actions/attest" || action.starts_with("actions/attest-")
415 }
416 Step::Opaque(_) => false,
417 })
418 {
419 failures.push(InvariantFailure::new(
420 "workflow-attestation-missing",
421 GENERATED_WORKFLOW,
422 "dist-workspace.toml sets github-attestations = true, and the workflow carries no attest step, so what this workflow builds ships unattested",
423 "regenerate the workflow with dist generate --mode ci and commit it, so the configured attest step is what runs",
424 ));
425 }
426 if crate::setup::workflow_jobs::request_trigger(workflow, crate::config::TRUNK_DEFAULT)
431 .is_some()
432 {
433 failures.push(InvariantFailure::new(
434 "workflow-runs-on-a-request",
435 GENERATED_WORKFLOW,
436 "the workflow triggers on a pull request, and no gate in another file can need a job declared here, so the one required check does not hold what this workflow reports",
437 "set pr-run-mode = \"skip\" in [dist] in dist-workspace.toml and regenerate with dist generate, so the artifact workflow is tag-only; the dist plan and dist generate proofs belong to the workflow the required check gates",
438 ));
439 }
440 failures
441}
442
443enum Step {
445 Action(String, String),
447 Opaque(String),
452}
453
454fn workflow_uses(workflow: &str) -> Vec<Step> {
466 let mut seen: Vec<String> = Vec::new();
467 let mut steps = Vec::new();
468 for fragment in workflow.lines().flat_map(line_fragments) {
469 let fragment = fragment.trim_start();
470 let fragment = fragment
472 .strip_prefix("- ")
473 .map_or(fragment, str::trim_start);
474 let Some(rest) = uses_value(fragment) else {
475 continue;
476 };
477 let rest = before_comment(rest).trim();
478 let rest = rest
479 .strip_prefix('"')
480 .and_then(|rest| rest.strip_suffix('"'))
481 .or_else(|| {
482 rest.strip_prefix('\'')
483 .and_then(|rest| rest.strip_suffix('\''))
484 })
485 .unwrap_or(rest);
486 if rest.starts_with("./") || rest.starts_with("$/") {
487 continue;
488 }
489 if seen.iter().any(|value| value == rest) {
490 continue;
491 }
492 seen.push(rest.to_owned());
493 steps.push(match rest.split_once('@') {
494 Some((action, reference)) => Step::Action(action.to_owned(), reference.to_owned()),
495 None if rest.is_empty() => Step::Opaque("a value carried on another line".to_owned()),
499 None => Step::Opaque(rest.to_owned()),
500 });
501 }
502 steps
503}
504
505fn line_fragments(line: &str) -> Vec<&str> {
517 let item = line.trim_start();
518 let item = item.strip_prefix("- ").map_or(item, str::trim_start);
519 let flow = item.starts_with('{')
520 || item.starts_with('[')
521 || ((line.contains('{') || line.contains('[')) && line.contains("uses"));
522 if !flow {
523 return vec![line];
524 }
525 if line.contains(QUOTES) {
526 return vec![UNSPLITTABLE_FLOW_LINE];
527 }
528 line.split(['{', '}', '[', ']', ',']).collect()
529}
530
531pub(crate) fn before_comment(value: &str) -> &str {
535 let mut previous = ' ';
536 for (index, character) in value.char_indices() {
537 if character == '#' && (previous == ' ' || previous == '\t') {
538 return &value[..index];
539 }
540 previous = character;
541 }
542 value
543}
544
545const QUOTES: [char; 2] = ['\u{22}', '\u{27}'];
549
550const UNSPLITTABLE_FLOW_LINE: &str = "uses: a flow-style step carrying a quoted value";
553
554fn uses_value(line: &str) -> Option<&str> {
558 let rest = line
559 .strip_prefix("\"uses\"")
560 .or_else(|| line.strip_prefix("'uses'"))
561 .or_else(|| line.strip_prefix("uses"))?;
562 rest.trim_start().strip_prefix(':')
563}
564
565fn is_immutable(reference: &str) -> bool {
568 let digest = reference
569 .strip_prefix("sha256:")
570 .filter(|digest| digest.len() == 64);
571 let commit = Some(reference).filter(|reference| reference.len() == 40);
572 digest
573 .or(commit)
574 .is_some_and(|value| value.chars().all(|char| char.is_ascii_hexdigit()))
575}
576
577#[cfg(test)]
578mod tests {
579 #![allow(clippy::expect_used)]
580
581 use camino::Utf8Path;
582
583 use super::{failures, target_failures, workflow_matches_configuration};
584
585 const CLEAN: &str = r#"
586[dist]
587pr-run-mode = "skip"
588github-attestations = true
589github-attestations-phase = "host"
590github-release = "host"
591
592[dist.github-action-commits]
593"actions/checkout" = "d23441a48e516b6c34aea4fa41551a30e30af803"
594"actions/download-artifact" = "3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c"
595"actions/upload-artifact" = "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"
596"actions/attest" = "1e69f48acb82d1966a394da916b4c1698aa569d6"
597"#;
598
599 #[test]
604 fn the_seeded_configuration_is_judged_effectively() {
605 assert!(failures("rust", "github", "dist-workspace.toml", CLEAN.as_bytes()).is_empty());
606 let seed = crate::embedded::SNIPPETS
607 .get_file("rust/github/dist-workspace.toml")
608 .and_then(|file| file.contents_utf8())
609 .expect("the seed is embedded");
610 assert!(
611 failures("rust", "github", "dist-workspace.toml", seed.as_bytes()).is_empty(),
612 "the payload's own seed satisfies the invariants it seeds"
613 );
614 }
615
616 #[test]
619 fn a_missing_or_stale_action_commit_table_fails() {
620 let missing = "[dist]\ngithub-attestations=true\ngithub-attestations-phase='host'\ngithub-release='host'\n";
621 let found = failures("rust", "github", "dist-workspace.toml", missing.as_bytes());
622 assert!(
623 found
624 .iter()
625 .any(|failure| failure.code == "action-commit-missing"),
626 "a missing entry falls back to the movable tag: {found:?}"
627 );
628 let stale = CLEAN.replace(
629 "d23441a48e516b6c34aea4fa41551a30e30af803",
630 "0000000000000000000000000000000000000000",
631 );
632 let found = failures("rust", "github", "dist-workspace.toml", stale.as_bytes());
633 assert!(
634 found
635 .iter()
636 .any(|failure| failure.code == "action-commit-stale"
637 && failure.reason.contains("actions/checkout")
638 && failure
639 .reason
640 .contains("0000000000000000000000000000000000000000")),
641 "a mismatch names the found and expected commits: {found:?}"
642 );
643 let invalid = CLEAN.replace("\"d23441a48e516b6c34aea4fa41551a30e30af803\"", "123");
644 let found_invalid = failures("rust", "github", "dist-workspace.toml", invalid.as_bytes());
645 assert!(
646 found_invalid
647 .iter()
648 .any(|failure| failure.code == "action-commit-invalid"
649 && failure.reason.contains("actions/checkout")),
650 "a non-string value is invalid configuration, not an absent pin: {found_invalid:?}"
651 );
652 assert!(
653 !found
654 .iter()
655 .any(|failure| failure.reason.contains("actions/attest")),
656 "only the stale action is named: {found:?}"
657 );
658 }
659
660 #[test]
664 fn each_degraded_form_fails_with_its_code() {
665 let cases: &[(&str, &str)] = &[
666 (
667 "[dist]\n# github-attestations = true\ngithub-attestations-phase='host'\ngithub-release='host'\n",
668 "attestations-disabled",
669 ),
670 (
671 "[dist]\ngithub-attestations = false\ngithub-attestations-phase='host'\ngithub-release='host'\n",
672 "attestations-disabled",
673 ),
674 (
675 "[dist]\ngithub-attestations = true\ngithub-release='host'\n",
676 "attestation-phase-not-host",
677 ),
678 (
679 "[dist]\ngithub-attestations = true\ngithub-attestations-phase='build-local-artifacts'\ngithub-release='host'\n",
680 "attestation-phase-not-host",
681 ),
682 (
683 "[dist]\ngithub-attestations = true\ngithub-attestations-phase='host'\ngithub-release='announce'\n",
684 "release-phase-unpaired",
685 ),
686 (
687 "[dist]\ngithub-attestations = true\ngithub-attestations-phase='host'\ngithub-release='host'\ngithub-attestations-filters=['*.tar.gz']\n",
688 "attestation-filters-narrowed",
689 ),
690 ("not toml at [all", "unparsable-configuration"),
691 ];
692 for (text, code) in cases {
693 let found = failures("rust", "github", "dist-workspace.toml", text.as_bytes());
694 assert!(
695 found.iter().any(|failure| failure.code == *code),
696 "{text:?} must fail with {code}, got {found:?}"
697 );
698 }
699 }
700
701 #[test]
705 fn a_configuration_that_reports_on_a_request_fails() {
706 let absent = CLEAN.replace("pr-run-mode = \"skip\"\n", "");
707 let found = failures("rust", "github", "dist-workspace.toml", absent.as_bytes());
708 assert!(
709 found
710 .iter()
711 .any(|failure| failure.code == "pr-run-mode-not-skip"
712 && failure.reason.contains("unset")
713 && failure.reason.contains("plan")),
714 "an unset key defaults to plan and says so: {found:?}"
715 );
716 for other in ["plan", "upload"] {
717 let text = CLEAN.replace("\"skip\"", &format!("\"{other}\""));
718 let found = failures("rust", "github", "dist-workspace.toml", text.as_bytes());
719 assert!(
720 found
721 .iter()
722 .any(|failure| failure.code == "pr-run-mode-not-skip"
723 && failure.reason.contains(other)),
724 "{other} fails and is named: {found:?}"
725 );
726 }
727 let non_string = CLEAN.replace("\"skip\"", "3");
730 assert!(
731 failures(
732 "rust",
733 "github",
734 "dist-workspace.toml",
735 non_string.as_bytes()
736 )
737 .iter()
738 .any(|failure| failure.code == "pr-run-mode-not-skip"),
739 "a non-string run mode is not skip"
740 );
741 assert!(
742 !failures("rust", "github", "dist-workspace.toml", CLEAN.as_bytes())
743 .iter()
744 .any(|failure| failure.code == "pr-run-mode-not-skip"),
745 "skip fails nothing"
746 );
747 }
748
749 #[test]
753 fn a_generated_workflow_that_triggers_on_a_request_fails() {
754 let attest = " - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
755 for trigger in [
756 "on:\n pull_request:\n",
757 "on: [push, pull_request]\n",
758 "on: pull_request_target\n",
759 ] {
760 let workflow = format!("{trigger}jobs:\n plan:\n steps:\n{attest}");
761 assert!(
762 workflow_matches_configuration(CLEAN, &workflow)
763 .iter()
764 .any(|failure| failure.code == "workflow-runs-on-a-request"
765 && failure.destination == super::GENERATED_WORKFLOW),
766 "{trigger:?} reports a check no gate can need"
767 );
768 }
769 let tag_only =
770 format!("on:\n push:\n tags:\n - '**'\njobs:\n plan:\n steps:\n{attest}");
771 assert!(
772 workflow_matches_configuration(CLEAN, &tag_only).is_empty(),
773 "a tag-only workflow reports nothing on a request"
774 );
775 }
776
777 #[test]
781 fn the_generated_workflow_at_the_configured_commits_fails_nothing() {
782 let workflow = "\
783jobs:
784 plan:
785 steps:
786 - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803
787 # - uses: actions/checkout@v4
788 - name: Upload
789 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
790 - name: Attest
791 uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v3
792";
793 let found = workflow_matches_configuration(CLEAN, workflow);
794 assert!(
795 found.is_empty(),
796 "the generated workflow is clean: {found:?}"
797 );
798 }
799
800 #[test]
804 fn a_workflow_left_at_a_movable_tag_fails() {
805 for stale in ["v4", "0000000000000000000000000000000000000000"] {
806 let workflow = format!(
807 "steps:\n - uses: actions/checkout@{stale}\n - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n"
808 );
809 let found = workflow_matches_configuration(CLEAN, &workflow);
810 assert!(
811 found
812 .iter()
813 .any(|failure| failure.code == "workflow-action-stale"
814 && failure.destination == ".github/workflows/release.yml"
815 && failure.reason.contains("actions/checkout")
816 && failure.reason.contains(stale)
817 && failure
818 .reason
819 .contains("d23441a48e516b6c34aea4fa41551a30e30af803")),
820 "{stale} names both sides of the disagreement: {found:?}"
821 );
822 }
823 let twice = "steps:\n - uses: actions/checkout@v4\n - uses: actions/checkout@v4\n - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
824 assert_eq!(
825 workflow_matches_configuration(CLEAN, twice).len(),
826 1,
827 "one reference is one failure, however many jobs run it"
828 );
829 }
830
831 #[test]
836 fn a_configured_attestation_with_no_attest_step_fails() {
837 let bare = "steps:\n - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803\n";
838 let found = workflow_matches_configuration(CLEAN, bare);
839 assert!(
840 found
841 .iter()
842 .any(|failure| failure.code == "workflow-attestation-missing"),
843 "an unattested workflow fails: {found:?}"
844 );
845 assert!(
846 !found
847 .iter()
848 .any(|failure| failure.code.starts_with("workflow-action-")),
849 "the pinned step itself is clean: {found:?}"
850 );
851 let variant = format!(
852 "{bare} - uses: actions/attest-build-provenance@1e69f48acb82d1966a394da916b4c1698aa569d6\n"
853 );
854 assert!(
855 workflow_matches_configuration(CLEAN, &variant).is_empty(),
856 "the build-provenance variant is an attest step"
857 );
858 }
859
860 #[test]
867 fn a_movable_reference_fails_whatever_the_configuration_says() {
868 let attest = " - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
869 let movable = format!("steps:\n - uses: third/party@v1\n{attest}");
870 let found = workflow_matches_configuration(CLEAN, &movable);
871 assert!(
872 found
873 .iter()
874 .any(|failure| failure.code == "workflow-action-unpinned"
875 && failure.reason.contains("third/party")),
876 "an action the configuration never names fails: {found:?}"
877 );
878 let agreed = CLEAN.replace(
881 "[dist.github-action-commits]",
882 "[dist.github-action-commits]\n\"third/party\" = \"v1\"",
883 );
884 let found = workflow_matches_configuration(&agreed, &movable);
885 assert!(
886 found
887 .iter()
888 .any(|failure| failure.code == "workflow-action-unpinned"
889 && failure.reason.contains("third/party")),
890 "a table entry naming the same movable tag pins nothing: {found:?}"
891 );
892 let non_string = CLEAN.replace(
893 "[dist.github-action-commits]",
894 "[dist.github-action-commits]\n\"third/party\" = 1",
895 );
896 assert!(
897 workflow_matches_configuration(&non_string, &movable)
898 .iter()
899 .any(|failure| failure.code == "workflow-action-unpinned"),
900 "a non-string entry pins nothing either"
901 );
902 let pinned = format!(
903 "steps:\n - uses: third/party@1111111111111111111111111111111111111111\n{attest}"
904 );
905 assert!(
906 workflow_matches_configuration(CLEAN, &pinned).is_empty(),
907 "a commit-pinned action the configuration does not name is the target's own"
908 );
909 assert!(
910 workflow_matches_configuration(CLEAN, &format!("steps:\n{attest}")).is_empty(),
911 "a pin no step runs is the target's tuning, not drift"
912 );
913 }
914
915 #[test]
921 fn every_real_step_shape_reaches_the_judgment() {
922 let attest = " - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
923 let padded = format!("steps:\n - uses : actions/checkout@v4\n{attest}");
924 assert!(
925 workflow_matches_configuration(CLEAN, &padded)
926 .iter()
927 .any(|failure| failure.code == "workflow-action-stale"),
928 "a padded key is the same mapping"
929 );
930 let quoted = format!("steps:\n - \"uses\": actions/checkout@v4\n{attest}");
931 assert!(
932 workflow_matches_configuration(CLEAN, "ed)
933 .iter()
934 .any(|failure| failure.code == "workflow-action-stale"),
935 "a quoted key is the same mapping"
936 );
937 let flow =
938 format!("steps:\n - {{ uses: actions/checkout@v4, with: {{ ref: main }} }}\n{attest}");
939 assert!(
940 workflow_matches_configuration(CLEAN, &flow)
941 .iter()
942 .any(|failure| failure.code == "workflow-action-stale"),
943 "a flow-style step is the same mapping"
944 );
945 let comma = format!(
948 "steps:\n - uses: third/party@1111111111111111111111111111111111111111,dev\n{attest}"
949 );
950 assert!(
951 workflow_matches_configuration(CLEAN, &comma)
952 .iter()
953 .any(|failure| failure.code == "workflow-action-unpinned"
954 && failure.reason.contains(",dev")),
955 "the whole reference is judged, never its prefix"
956 );
957 let hashed = format!(
960 "steps:\n - uses: third/party@1111111111111111111111111111111111111111#dev\n{attest}"
961 );
962 assert!(
963 workflow_matches_configuration(CLEAN, &hashed)
964 .iter()
965 .any(|failure| failure.code == "workflow-action-unpinned"
966 && failure.reason.contains("#dev")),
967 "an adjacent hash is scalar content, not a comment"
968 );
969 let compact = format!("steps: [ uses: third/party@v1 ]\n{attest}");
971 assert!(
972 workflow_matches_configuration(CLEAN, &compact)
973 .iter()
974 .any(|failure| failure.code == "workflow-action-unpinned"
975 && failure.reason.contains("third/party")),
976 "a compact flow sequence carries its uses key"
977 );
978 assert!(
979 workflow_matches_configuration(CLEAN, &format!("steps:\n - usesful: no\n{attest}"))
980 .is_empty(),
981 "a key that merely starts with uses is another key"
982 );
983 for same_repository in ["./.github/actions/build", "$/.github/actions/build"] {
984 let local = format!("steps:\n - uses: {same_repository}\n{attest}");
985 assert!(
986 workflow_matches_configuration(CLEAN, &local).is_empty(),
987 "{same_repository} is the repository's own file at the running commit"
988 );
989 }
990 let tagged = format!("steps:\n - uses: docker://alpine:3.8\n{attest}");
991 assert!(
992 workflow_matches_configuration(CLEAN, &tagged)
993 .iter()
994 .any(|failure| failure.code == "workflow-step-unreadable"),
995 "a docker image with no digest is not immutable"
996 );
997 let digested = format!(
998 "steps:\n - uses: docker://alpine@sha256:0000000000000000000000000000000000000000000000000000000000000000\n{attest}"
999 );
1000 assert!(
1001 workflow_matches_configuration(CLEAN, &digested).is_empty(),
1002 "a docker image pinned by digest is immutable"
1003 );
1004 }
1005
1006 #[test]
1013 fn a_step_the_reader_cannot_resolve_is_reported() {
1014 let attest = " - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
1015 let aliased = format!("steps:\n - uses: *checkout\n{attest}");
1016 assert!(
1017 workflow_matches_configuration(CLEAN, &aliased)
1018 .iter()
1019 .any(|failure| failure.code == "workflow-step-unreadable"
1020 && failure.reason.contains("*checkout")),
1021 "an alias is unreadable, never clean"
1022 );
1023 let continued = format!("steps:\n - uses:\n actions/checkout@v4\n{attest}");
1024 assert!(
1025 workflow_matches_configuration(CLEAN, &continued)
1026 .iter()
1027 .any(|failure| failure.code == "workflow-step-unreadable"),
1028 "a value on another line is unreadable, never clean"
1029 );
1030 let quoted_flow = format!("steps:\n - {{ uses: \"third/party@1,dev\" }}\n{attest}");
1031 assert!(
1032 workflow_matches_configuration(CLEAN, "ed_flow)
1033 .iter()
1034 .any(|failure| failure.code == "workflow-step-unreadable"),
1035 "a quoted flow line is not split on a guess"
1036 );
1037 let expression = format!(
1038 "jobs:\n host:\n if: ${{{{ fromJson(needs.plan.outputs.val).ci != null && x == 'true' }}}}\n steps:\n{attest}"
1039 );
1040 assert!(
1041 workflow_matches_configuration(CLEAN, &expression).is_empty(),
1042 "an expression is not a step this reader cannot resolve"
1043 );
1044 }
1045
1046 #[test]
1051 fn the_cross_file_judgment_needs_both_files() {
1052 let dir = tempfile::tempdir().expect("a scratch directory");
1053 let target = Utf8Path::from_path(dir.path()).expect("a utf-8 path");
1054 let broken = "steps:\n - uses: actions/checkout@v4\n";
1055 assert!(
1056 target_failures("rust", "github", target).is_empty(),
1057 "an empty target"
1058 );
1059 std::fs::write(target.join("dist-workspace.toml"), CLEAN).expect("the configuration");
1060 assert!(
1061 target_failures("rust", "github", target).is_empty(),
1062 "a configuration with no generated workflow"
1063 );
1064 std::fs::create_dir_all(target.join(".github/workflows")).expect("the workflow directory");
1065 std::fs::write(target.join(".github/workflows/release.yml"), broken).expect("the workflow");
1066 assert!(
1067 !target_failures("rust", "github", target).is_empty(),
1068 "both files present, and they disagree"
1069 );
1070 for (tech, forge) in [("rust", "gitlab"), ("bash", "github")] {
1071 assert!(
1072 target_failures(tech, forge, target).is_empty(),
1073 "{tech}/{forge} generates no artifact workflow"
1074 );
1075 }
1076 std::fs::write(
1079 target.join(".github/workflows/release.yml"),
1080 [0x66, 0xff, 0xfe],
1081 )
1082 .expect("the workflow");
1083 assert!(
1084 target_failures("rust", "github", target)
1085 .iter()
1086 .any(|failure| failure.code == "workflow-file-unreadable"),
1087 "a present workflow that does not read as text is reported"
1088 );
1089 std::fs::remove_file(target.join("dist-workspace.toml")).expect("the configuration");
1090 assert!(
1091 target_failures("rust", "github", target).is_empty(),
1092 "a workflow with no configuration to judge it against"
1093 );
1094 }
1095
1096 #[test]
1100 fn the_rule_is_keyed_by_pair_and_destination() {
1101 let broken = b"[dist]\ngithub-attestations = false\n";
1102 assert!(failures("rust", "gitlab", "dist-workspace.toml", broken).is_empty());
1103 assert!(failures("bash", "github", "dist-workspace.toml", broken).is_empty());
1104 assert!(failures("rust", "github", "release-plz.toml", broken).is_empty());
1105 }
1106}