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 use camino::Utf8Path;
580
581 use super::{failures, target_failures, workflow_matches_configuration};
582
583 const CLEAN: &str = r#"
584[dist]
585pr-run-mode = "skip"
586github-attestations = true
587github-attestations-phase = "host"
588github-release = "host"
589
590[dist.github-action-commits]
591"actions/checkout" = "d23441a48e516b6c34aea4fa41551a30e30af803"
592"actions/download-artifact" = "3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c"
593"actions/upload-artifact" = "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"
594"actions/attest" = "1e69f48acb82d1966a394da916b4c1698aa569d6"
595"#;
596
597 #[test]
602 fn the_seeded_configuration_is_judged_effectively() {
603 assert!(failures("rust", "github", "dist-workspace.toml", CLEAN.as_bytes()).is_empty());
604 let seed = crate::embedded::SNIPPETS
605 .get_file("rust/github/dist-workspace.toml")
606 .and_then(|file| file.contents_utf8())
607 .expect("the seed is embedded");
608 assert!(
609 failures("rust", "github", "dist-workspace.toml", seed.as_bytes()).is_empty(),
610 "the payload's own seed satisfies the invariants it seeds"
611 );
612 }
613
614 #[test]
617 fn a_missing_or_stale_action_commit_table_fails() {
618 let missing = "[dist]\ngithub-attestations=true\ngithub-attestations-phase='host'\ngithub-release='host'\n";
619 let found = failures("rust", "github", "dist-workspace.toml", missing.as_bytes());
620 assert!(
621 found
622 .iter()
623 .any(|failure| failure.code == "action-commit-missing"),
624 "a missing entry falls back to the movable tag: {found:?}"
625 );
626 let stale = CLEAN.replace(
627 "d23441a48e516b6c34aea4fa41551a30e30af803",
628 "0000000000000000000000000000000000000000",
629 );
630 let found = failures("rust", "github", "dist-workspace.toml", stale.as_bytes());
631 assert!(
632 found
633 .iter()
634 .any(|failure| failure.code == "action-commit-stale"
635 && failure.reason.contains("actions/checkout")
636 && failure
637 .reason
638 .contains("0000000000000000000000000000000000000000")),
639 "a mismatch names the found and expected commits: {found:?}"
640 );
641 let invalid = CLEAN.replace("\"d23441a48e516b6c34aea4fa41551a30e30af803\"", "123");
642 let found_invalid = failures("rust", "github", "dist-workspace.toml", invalid.as_bytes());
643 assert!(
644 found_invalid
645 .iter()
646 .any(|failure| failure.code == "action-commit-invalid"
647 && failure.reason.contains("actions/checkout")),
648 "a non-string value is invalid configuration, not an absent pin: {found_invalid:?}"
649 );
650 assert!(
651 !found
652 .iter()
653 .any(|failure| failure.reason.contains("actions/attest")),
654 "only the stale action is named: {found:?}"
655 );
656 }
657
658 #[test]
662 fn each_degraded_form_fails_with_its_code() {
663 let cases: &[(&str, &str)] = &[
664 (
665 "[dist]\n# github-attestations = true\ngithub-attestations-phase='host'\ngithub-release='host'\n",
666 "attestations-disabled",
667 ),
668 (
669 "[dist]\ngithub-attestations = false\ngithub-attestations-phase='host'\ngithub-release='host'\n",
670 "attestations-disabled",
671 ),
672 (
673 "[dist]\ngithub-attestations = true\ngithub-release='host'\n",
674 "attestation-phase-not-host",
675 ),
676 (
677 "[dist]\ngithub-attestations = true\ngithub-attestations-phase='build-local-artifacts'\ngithub-release='host'\n",
678 "attestation-phase-not-host",
679 ),
680 (
681 "[dist]\ngithub-attestations = true\ngithub-attestations-phase='host'\ngithub-release='announce'\n",
682 "release-phase-unpaired",
683 ),
684 (
685 "[dist]\ngithub-attestations = true\ngithub-attestations-phase='host'\ngithub-release='host'\ngithub-attestations-filters=['*.tar.gz']\n",
686 "attestation-filters-narrowed",
687 ),
688 ("not toml at [all", "unparsable-configuration"),
689 ];
690 for (text, code) in cases {
691 let found = failures("rust", "github", "dist-workspace.toml", text.as_bytes());
692 assert!(
693 found.iter().any(|failure| failure.code == *code),
694 "{text:?} must fail with {code}, got {found:?}"
695 );
696 }
697 }
698
699 #[test]
703 fn a_configuration_that_reports_on_a_request_fails() {
704 let absent = CLEAN.replace("pr-run-mode = \"skip\"\n", "");
705 let found = failures("rust", "github", "dist-workspace.toml", absent.as_bytes());
706 assert!(
707 found
708 .iter()
709 .any(|failure| failure.code == "pr-run-mode-not-skip"
710 && failure.reason.contains("unset")
711 && failure.reason.contains("plan")),
712 "an unset key defaults to plan and says so: {found:?}"
713 );
714 for other in ["plan", "upload"] {
715 let text = CLEAN.replace("\"skip\"", &format!("\"{other}\""));
716 let found = failures("rust", "github", "dist-workspace.toml", text.as_bytes());
717 assert!(
718 found
719 .iter()
720 .any(|failure| failure.code == "pr-run-mode-not-skip"
721 && failure.reason.contains(other)),
722 "{other} fails and is named: {found:?}"
723 );
724 }
725 let non_string = CLEAN.replace("\"skip\"", "3");
728 assert!(
729 failures(
730 "rust",
731 "github",
732 "dist-workspace.toml",
733 non_string.as_bytes()
734 )
735 .iter()
736 .any(|failure| failure.code == "pr-run-mode-not-skip"),
737 "a non-string run mode is not skip"
738 );
739 assert!(
740 !failures("rust", "github", "dist-workspace.toml", CLEAN.as_bytes())
741 .iter()
742 .any(|failure| failure.code == "pr-run-mode-not-skip"),
743 "skip fails nothing"
744 );
745 }
746
747 #[test]
751 fn a_generated_workflow_that_triggers_on_a_request_fails() {
752 let attest = " - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
753 for trigger in [
754 "on:\n pull_request:\n",
755 "on: [push, pull_request]\n",
756 "on: pull_request_target\n",
757 ] {
758 let workflow = format!("{trigger}jobs:\n plan:\n steps:\n{attest}");
759 assert!(
760 workflow_matches_configuration(CLEAN, &workflow)
761 .iter()
762 .any(|failure| failure.code == "workflow-runs-on-a-request"
763 && failure.destination == super::GENERATED_WORKFLOW),
764 "{trigger:?} reports a check no gate can need"
765 );
766 }
767 let tag_only =
768 format!("on:\n push:\n tags:\n - '**'\njobs:\n plan:\n steps:\n{attest}");
769 assert!(
770 workflow_matches_configuration(CLEAN, &tag_only).is_empty(),
771 "a tag-only workflow reports nothing on a request"
772 );
773 }
774
775 #[test]
779 fn the_generated_workflow_at_the_configured_commits_fails_nothing() {
780 let workflow = "\
781jobs:
782 plan:
783 steps:
784 - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803
785 # - uses: actions/checkout@v4
786 - name: Upload
787 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
788 - name: Attest
789 uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v3
790";
791 let found = workflow_matches_configuration(CLEAN, workflow);
792 assert!(
793 found.is_empty(),
794 "the generated workflow is clean: {found:?}"
795 );
796 }
797
798 #[test]
802 fn a_workflow_left_at_a_movable_tag_fails() {
803 for stale in ["v4", "0000000000000000000000000000000000000000"] {
804 let workflow = format!(
805 "steps:\n - uses: actions/checkout@{stale}\n - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n"
806 );
807 let found = workflow_matches_configuration(CLEAN, &workflow);
808 assert!(
809 found
810 .iter()
811 .any(|failure| failure.code == "workflow-action-stale"
812 && failure.destination == ".github/workflows/release.yml"
813 && failure.reason.contains("actions/checkout")
814 && failure.reason.contains(stale)
815 && failure
816 .reason
817 .contains("d23441a48e516b6c34aea4fa41551a30e30af803")),
818 "{stale} names both sides of the disagreement: {found:?}"
819 );
820 }
821 let twice = "steps:\n - uses: actions/checkout@v4\n - uses: actions/checkout@v4\n - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
822 assert_eq!(
823 workflow_matches_configuration(CLEAN, twice).len(),
824 1,
825 "one reference is one failure, however many jobs run it"
826 );
827 }
828
829 #[test]
834 fn a_configured_attestation_with_no_attest_step_fails() {
835 let bare = "steps:\n - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803\n";
836 let found = workflow_matches_configuration(CLEAN, bare);
837 assert!(
838 found
839 .iter()
840 .any(|failure| failure.code == "workflow-attestation-missing"),
841 "an unattested workflow fails: {found:?}"
842 );
843 assert!(
844 !found
845 .iter()
846 .any(|failure| failure.code.starts_with("workflow-action-")),
847 "the pinned step itself is clean: {found:?}"
848 );
849 let variant = format!(
850 "{bare} - uses: actions/attest-build-provenance@1e69f48acb82d1966a394da916b4c1698aa569d6\n"
851 );
852 assert!(
853 workflow_matches_configuration(CLEAN, &variant).is_empty(),
854 "the build-provenance variant is an attest step"
855 );
856 }
857
858 #[test]
865 fn a_movable_reference_fails_whatever_the_configuration_says() {
866 let attest = " - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
867 let movable = format!("steps:\n - uses: third/party@v1\n{attest}");
868 let found = workflow_matches_configuration(CLEAN, &movable);
869 assert!(
870 found
871 .iter()
872 .any(|failure| failure.code == "workflow-action-unpinned"
873 && failure.reason.contains("third/party")),
874 "an action the configuration never names fails: {found:?}"
875 );
876 let agreed = CLEAN.replace(
879 "[dist.github-action-commits]",
880 "[dist.github-action-commits]\n\"third/party\" = \"v1\"",
881 );
882 let found = workflow_matches_configuration(&agreed, &movable);
883 assert!(
884 found
885 .iter()
886 .any(|failure| failure.code == "workflow-action-unpinned"
887 && failure.reason.contains("third/party")),
888 "a table entry naming the same movable tag pins nothing: {found:?}"
889 );
890 let non_string = CLEAN.replace(
891 "[dist.github-action-commits]",
892 "[dist.github-action-commits]\n\"third/party\" = 1",
893 );
894 assert!(
895 workflow_matches_configuration(&non_string, &movable)
896 .iter()
897 .any(|failure| failure.code == "workflow-action-unpinned"),
898 "a non-string entry pins nothing either"
899 );
900 let pinned = format!(
901 "steps:\n - uses: third/party@1111111111111111111111111111111111111111\n{attest}"
902 );
903 assert!(
904 workflow_matches_configuration(CLEAN, &pinned).is_empty(),
905 "a commit-pinned action the configuration does not name is the target's own"
906 );
907 assert!(
908 workflow_matches_configuration(CLEAN, &format!("steps:\n{attest}")).is_empty(),
909 "a pin no step runs is the target's tuning, not drift"
910 );
911 }
912
913 #[test]
919 fn every_real_step_shape_reaches_the_judgment() {
920 let attest = " - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
921 let padded = format!("steps:\n - uses : actions/checkout@v4\n{attest}");
922 assert!(
923 workflow_matches_configuration(CLEAN, &padded)
924 .iter()
925 .any(|failure| failure.code == "workflow-action-stale"),
926 "a padded key is the same mapping"
927 );
928 let quoted = format!("steps:\n - \"uses\": actions/checkout@v4\n{attest}");
929 assert!(
930 workflow_matches_configuration(CLEAN, "ed)
931 .iter()
932 .any(|failure| failure.code == "workflow-action-stale"),
933 "a quoted key is the same mapping"
934 );
935 let flow =
936 format!("steps:\n - {{ uses: actions/checkout@v4, with: {{ ref: main }} }}\n{attest}");
937 assert!(
938 workflow_matches_configuration(CLEAN, &flow)
939 .iter()
940 .any(|failure| failure.code == "workflow-action-stale"),
941 "a flow-style step is the same mapping"
942 );
943 let comma = format!(
946 "steps:\n - uses: third/party@1111111111111111111111111111111111111111,dev\n{attest}"
947 );
948 assert!(
949 workflow_matches_configuration(CLEAN, &comma)
950 .iter()
951 .any(|failure| failure.code == "workflow-action-unpinned"
952 && failure.reason.contains(",dev")),
953 "the whole reference is judged, never its prefix"
954 );
955 let hashed = format!(
958 "steps:\n - uses: third/party@1111111111111111111111111111111111111111#dev\n{attest}"
959 );
960 assert!(
961 workflow_matches_configuration(CLEAN, &hashed)
962 .iter()
963 .any(|failure| failure.code == "workflow-action-unpinned"
964 && failure.reason.contains("#dev")),
965 "an adjacent hash is scalar content, not a comment"
966 );
967 let compact = format!("steps: [ uses: third/party@v1 ]\n{attest}");
969 assert!(
970 workflow_matches_configuration(CLEAN, &compact)
971 .iter()
972 .any(|failure| failure.code == "workflow-action-unpinned"
973 && failure.reason.contains("third/party")),
974 "a compact flow sequence carries its uses key"
975 );
976 assert!(
977 workflow_matches_configuration(CLEAN, &format!("steps:\n - usesful: no\n{attest}"))
978 .is_empty(),
979 "a key that merely starts with uses is another key"
980 );
981 for same_repository in ["./.github/actions/build", "$/.github/actions/build"] {
982 let local = format!("steps:\n - uses: {same_repository}\n{attest}");
983 assert!(
984 workflow_matches_configuration(CLEAN, &local).is_empty(),
985 "{same_repository} is the repository's own file at the running commit"
986 );
987 }
988 let tagged = format!("steps:\n - uses: docker://alpine:3.8\n{attest}");
989 assert!(
990 workflow_matches_configuration(CLEAN, &tagged)
991 .iter()
992 .any(|failure| failure.code == "workflow-step-unreadable"),
993 "a docker image with no digest is not immutable"
994 );
995 let digested = format!(
996 "steps:\n - uses: docker://alpine@sha256:0000000000000000000000000000000000000000000000000000000000000000\n{attest}"
997 );
998 assert!(
999 workflow_matches_configuration(CLEAN, &digested).is_empty(),
1000 "a docker image pinned by digest is immutable"
1001 );
1002 }
1003
1004 #[test]
1011 fn a_step_the_reader_cannot_resolve_is_reported() {
1012 let attest = " - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
1013 let aliased = format!("steps:\n - uses: *checkout\n{attest}");
1014 assert!(
1015 workflow_matches_configuration(CLEAN, &aliased)
1016 .iter()
1017 .any(|failure| failure.code == "workflow-step-unreadable"
1018 && failure.reason.contains("*checkout")),
1019 "an alias is unreadable, never clean"
1020 );
1021 let continued = format!("steps:\n - uses:\n actions/checkout@v4\n{attest}");
1022 assert!(
1023 workflow_matches_configuration(CLEAN, &continued)
1024 .iter()
1025 .any(|failure| failure.code == "workflow-step-unreadable"),
1026 "a value on another line is unreadable, never clean"
1027 );
1028 let quoted_flow = format!("steps:\n - {{ uses: \"third/party@1,dev\" }}\n{attest}");
1029 assert!(
1030 workflow_matches_configuration(CLEAN, "ed_flow)
1031 .iter()
1032 .any(|failure| failure.code == "workflow-step-unreadable"),
1033 "a quoted flow line is not split on a guess"
1034 );
1035 let expression = format!(
1036 "jobs:\n host:\n if: ${{{{ fromJson(needs.plan.outputs.val).ci != null && x == 'true' }}}}\n steps:\n{attest}"
1037 );
1038 assert!(
1039 workflow_matches_configuration(CLEAN, &expression).is_empty(),
1040 "an expression is not a step this reader cannot resolve"
1041 );
1042 }
1043
1044 #[test]
1049 fn the_cross_file_judgment_needs_both_files() {
1050 let dir = tempfile::tempdir().expect("a scratch directory");
1051 let target = Utf8Path::from_path(dir.path()).expect("a utf-8 path");
1052 let broken = "steps:\n - uses: actions/checkout@v4\n";
1053 assert!(
1054 target_failures("rust", "github", target).is_empty(),
1055 "an empty target"
1056 );
1057 std::fs::write(target.join("dist-workspace.toml"), CLEAN).expect("the configuration");
1058 assert!(
1059 target_failures("rust", "github", target).is_empty(),
1060 "a configuration with no generated workflow"
1061 );
1062 std::fs::create_dir_all(target.join(".github/workflows")).expect("the workflow directory");
1063 std::fs::write(target.join(".github/workflows/release.yml"), broken).expect("the workflow");
1064 assert!(
1065 !target_failures("rust", "github", target).is_empty(),
1066 "both files present, and they disagree"
1067 );
1068 for (tech, forge) in [("rust", "gitlab"), ("bash", "github")] {
1069 assert!(
1070 target_failures(tech, forge, target).is_empty(),
1071 "{tech}/{forge} generates no artifact workflow"
1072 );
1073 }
1074 std::fs::write(
1077 target.join(".github/workflows/release.yml"),
1078 [0x66, 0xff, 0xfe],
1079 )
1080 .expect("the workflow");
1081 assert!(
1082 target_failures("rust", "github", target)
1083 .iter()
1084 .any(|failure| failure.code == "workflow-file-unreadable"),
1085 "a present workflow that does not read as text is reported"
1086 );
1087 std::fs::remove_file(target.join("dist-workspace.toml")).expect("the configuration");
1088 assert!(
1089 target_failures("rust", "github", target).is_empty(),
1090 "a workflow with no configuration to judge it against"
1091 );
1092 }
1093
1094 #[test]
1098 fn the_rule_is_keyed_by_pair_and_destination() {
1099 let broken = b"[dist]\ngithub-attestations = false\n";
1100 assert!(failures("rust", "gitlab", "dist-workspace.toml", broken).is_empty());
1101 assert!(failures("bash", "github", "dist-workspace.toml", broken).is_empty());
1102 assert!(failures("rust", "github", "release-plz.toml", broken).is_empty());
1103 }
1104}