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).is_some() {
429 failures.push(InvariantFailure::new(
430 "workflow-runs-on-a-request",
431 GENERATED_WORKFLOW,
432 "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",
433 "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",
434 ));
435 }
436 failures
437}
438
439enum Step {
441 Action(String, String),
443 Opaque(String),
448}
449
450fn workflow_uses(workflow: &str) -> Vec<Step> {
462 let mut seen: Vec<String> = Vec::new();
463 let mut steps = Vec::new();
464 for fragment in workflow.lines().flat_map(line_fragments) {
465 let fragment = fragment.trim_start();
466 let fragment = fragment
468 .strip_prefix("- ")
469 .map_or(fragment, str::trim_start);
470 let Some(rest) = uses_value(fragment) else {
471 continue;
472 };
473 let rest = before_comment(rest).trim();
474 let rest = rest
475 .strip_prefix('"')
476 .and_then(|rest| rest.strip_suffix('"'))
477 .or_else(|| {
478 rest.strip_prefix('\'')
479 .and_then(|rest| rest.strip_suffix('\''))
480 })
481 .unwrap_or(rest);
482 if rest.starts_with("./") || rest.starts_with("$/") {
483 continue;
484 }
485 if seen.iter().any(|value| value == rest) {
486 continue;
487 }
488 seen.push(rest.to_owned());
489 steps.push(match rest.split_once('@') {
490 Some((action, reference)) => Step::Action(action.to_owned(), reference.to_owned()),
491 None if rest.is_empty() => Step::Opaque("a value carried on another line".to_owned()),
495 None => Step::Opaque(rest.to_owned()),
496 });
497 }
498 steps
499}
500
501fn line_fragments(line: &str) -> Vec<&str> {
513 let item = line.trim_start();
514 let item = item.strip_prefix("- ").map_or(item, str::trim_start);
515 let flow = item.starts_with('{')
516 || item.starts_with('[')
517 || ((line.contains('{') || line.contains('[')) && line.contains("uses"));
518 if !flow {
519 return vec![line];
520 }
521 if line.contains(QUOTES) {
522 return vec![UNSPLITTABLE_FLOW_LINE];
523 }
524 line.split(['{', '}', '[', ']', ',']).collect()
525}
526
527pub(crate) fn before_comment(value: &str) -> &str {
531 let mut previous = ' ';
532 for (index, character) in value.char_indices() {
533 if character == '#' && (previous == ' ' || previous == '\t') {
534 return &value[..index];
535 }
536 previous = character;
537 }
538 value
539}
540
541const QUOTES: [char; 2] = ['\u{22}', '\u{27}'];
545
546const UNSPLITTABLE_FLOW_LINE: &str = "uses: a flow-style step carrying a quoted value";
549
550fn uses_value(line: &str) -> Option<&str> {
554 let rest = line
555 .strip_prefix("\"uses\"")
556 .or_else(|| line.strip_prefix("'uses'"))
557 .or_else(|| line.strip_prefix("uses"))?;
558 rest.trim_start().strip_prefix(':')
559}
560
561fn is_immutable(reference: &str) -> bool {
564 let digest = reference
565 .strip_prefix("sha256:")
566 .filter(|digest| digest.len() == 64);
567 let commit = Some(reference).filter(|reference| reference.len() == 40);
568 digest
569 .or(commit)
570 .is_some_and(|value| value.chars().all(|char| char.is_ascii_hexdigit()))
571}
572
573#[cfg(test)]
574mod tests {
575 #![allow(clippy::expect_used)]
576
577 use camino::Utf8Path;
578
579 use super::{failures, target_failures, workflow_matches_configuration};
580
581 const CLEAN: &str = r#"
582[dist]
583pr-run-mode = "skip"
584github-attestations = true
585github-attestations-phase = "host"
586github-release = "host"
587
588[dist.github-action-commits]
589"actions/checkout" = "d23441a48e516b6c34aea4fa41551a30e30af803"
590"actions/download-artifact" = "3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c"
591"actions/upload-artifact" = "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"
592"actions/attest" = "1e69f48acb82d1966a394da916b4c1698aa569d6"
593"#;
594
595 #[test]
600 fn the_seeded_configuration_is_judged_effectively() {
601 assert!(failures("rust", "github", "dist-workspace.toml", CLEAN.as_bytes()).is_empty());
602 let seed = crate::embedded::SNIPPETS
603 .get_file("rust/github/dist-workspace.toml")
604 .and_then(|file| file.contents_utf8())
605 .expect("the seed is embedded");
606 assert!(
607 failures("rust", "github", "dist-workspace.toml", seed.as_bytes()).is_empty(),
608 "the payload's own seed satisfies the invariants it seeds"
609 );
610 }
611
612 #[test]
615 fn a_missing_or_stale_action_commit_table_fails() {
616 let missing = "[dist]\ngithub-attestations=true\ngithub-attestations-phase='host'\ngithub-release='host'\n";
617 let found = failures("rust", "github", "dist-workspace.toml", missing.as_bytes());
618 assert!(
619 found
620 .iter()
621 .any(|failure| failure.code == "action-commit-missing"),
622 "a missing entry falls back to the movable tag: {found:?}"
623 );
624 let stale = CLEAN.replace(
625 "d23441a48e516b6c34aea4fa41551a30e30af803",
626 "0000000000000000000000000000000000000000",
627 );
628 let found = failures("rust", "github", "dist-workspace.toml", stale.as_bytes());
629 assert!(
630 found
631 .iter()
632 .any(|failure| failure.code == "action-commit-stale"
633 && failure.reason.contains("actions/checkout")
634 && failure
635 .reason
636 .contains("0000000000000000000000000000000000000000")),
637 "a mismatch names the found and expected commits: {found:?}"
638 );
639 let invalid = CLEAN.replace("\"d23441a48e516b6c34aea4fa41551a30e30af803\"", "123");
640 let found_invalid = failures("rust", "github", "dist-workspace.toml", invalid.as_bytes());
641 assert!(
642 found_invalid
643 .iter()
644 .any(|failure| failure.code == "action-commit-invalid"
645 && failure.reason.contains("actions/checkout")),
646 "a non-string value is invalid configuration, not an absent pin: {found_invalid:?}"
647 );
648 assert!(
649 !found
650 .iter()
651 .any(|failure| failure.reason.contains("actions/attest")),
652 "only the stale action is named: {found:?}"
653 );
654 }
655
656 #[test]
660 fn each_degraded_form_fails_with_its_code() {
661 let cases: &[(&str, &str)] = &[
662 (
663 "[dist]\n# github-attestations = true\ngithub-attestations-phase='host'\ngithub-release='host'\n",
664 "attestations-disabled",
665 ),
666 (
667 "[dist]\ngithub-attestations = false\ngithub-attestations-phase='host'\ngithub-release='host'\n",
668 "attestations-disabled",
669 ),
670 (
671 "[dist]\ngithub-attestations = true\ngithub-release='host'\n",
672 "attestation-phase-not-host",
673 ),
674 (
675 "[dist]\ngithub-attestations = true\ngithub-attestations-phase='build-local-artifacts'\ngithub-release='host'\n",
676 "attestation-phase-not-host",
677 ),
678 (
679 "[dist]\ngithub-attestations = true\ngithub-attestations-phase='host'\ngithub-release='announce'\n",
680 "release-phase-unpaired",
681 ),
682 (
683 "[dist]\ngithub-attestations = true\ngithub-attestations-phase='host'\ngithub-release='host'\ngithub-attestations-filters=['*.tar.gz']\n",
684 "attestation-filters-narrowed",
685 ),
686 ("not toml at [all", "unparsable-configuration"),
687 ];
688 for (text, code) in cases {
689 let found = failures("rust", "github", "dist-workspace.toml", text.as_bytes());
690 assert!(
691 found.iter().any(|failure| failure.code == *code),
692 "{text:?} must fail with {code}, got {found:?}"
693 );
694 }
695 }
696
697 #[test]
701 fn a_configuration_that_reports_on_a_request_fails() {
702 let absent = CLEAN.replace("pr-run-mode = \"skip\"\n", "");
703 let found = failures("rust", "github", "dist-workspace.toml", absent.as_bytes());
704 assert!(
705 found
706 .iter()
707 .any(|failure| failure.code == "pr-run-mode-not-skip"
708 && failure.reason.contains("unset")
709 && failure.reason.contains("plan")),
710 "an unset key defaults to plan and says so: {found:?}"
711 );
712 for other in ["plan", "upload"] {
713 let text = CLEAN.replace("\"skip\"", &format!("\"{other}\""));
714 let found = failures("rust", "github", "dist-workspace.toml", text.as_bytes());
715 assert!(
716 found
717 .iter()
718 .any(|failure| failure.code == "pr-run-mode-not-skip"
719 && failure.reason.contains(other)),
720 "{other} fails and is named: {found:?}"
721 );
722 }
723 let non_string = CLEAN.replace("\"skip\"", "3");
726 assert!(
727 failures(
728 "rust",
729 "github",
730 "dist-workspace.toml",
731 non_string.as_bytes()
732 )
733 .iter()
734 .any(|failure| failure.code == "pr-run-mode-not-skip"),
735 "a non-string run mode is not skip"
736 );
737 assert!(
738 !failures("rust", "github", "dist-workspace.toml", CLEAN.as_bytes())
739 .iter()
740 .any(|failure| failure.code == "pr-run-mode-not-skip"),
741 "skip fails nothing"
742 );
743 }
744
745 #[test]
749 fn a_generated_workflow_that_triggers_on_a_request_fails() {
750 let attest = " - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
751 for trigger in [
752 "on:\n pull_request:\n",
753 "on: [push, pull_request]\n",
754 "on: pull_request_target\n",
755 ] {
756 let workflow = format!("{trigger}jobs:\n plan:\n steps:\n{attest}");
757 assert!(
758 workflow_matches_configuration(CLEAN, &workflow)
759 .iter()
760 .any(|failure| failure.code == "workflow-runs-on-a-request"
761 && failure.destination == super::GENERATED_WORKFLOW),
762 "{trigger:?} reports a check no gate can need"
763 );
764 }
765 let tag_only =
766 format!("on:\n push:\n tags:\n - '**'\njobs:\n plan:\n steps:\n{attest}");
767 assert!(
768 workflow_matches_configuration(CLEAN, &tag_only).is_empty(),
769 "a tag-only workflow reports nothing on a request"
770 );
771 }
772
773 #[test]
777 fn the_generated_workflow_at_the_configured_commits_fails_nothing() {
778 let workflow = "\
779jobs:
780 plan:
781 steps:
782 - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803
783 # - uses: actions/checkout@v4
784 - name: Upload
785 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
786 - name: Attest
787 uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v3
788";
789 let found = workflow_matches_configuration(CLEAN, workflow);
790 assert!(
791 found.is_empty(),
792 "the generated workflow is clean: {found:?}"
793 );
794 }
795
796 #[test]
800 fn a_workflow_left_at_a_movable_tag_fails() {
801 for stale in ["v4", "0000000000000000000000000000000000000000"] {
802 let workflow = format!(
803 "steps:\n - uses: actions/checkout@{stale}\n - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n"
804 );
805 let found = workflow_matches_configuration(CLEAN, &workflow);
806 assert!(
807 found
808 .iter()
809 .any(|failure| failure.code == "workflow-action-stale"
810 && failure.destination == ".github/workflows/release.yml"
811 && failure.reason.contains("actions/checkout")
812 && failure.reason.contains(stale)
813 && failure
814 .reason
815 .contains("d23441a48e516b6c34aea4fa41551a30e30af803")),
816 "{stale} names both sides of the disagreement: {found:?}"
817 );
818 }
819 let twice = "steps:\n - uses: actions/checkout@v4\n - uses: actions/checkout@v4\n - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
820 assert_eq!(
821 workflow_matches_configuration(CLEAN, twice).len(),
822 1,
823 "one reference is one failure, however many jobs run it"
824 );
825 }
826
827 #[test]
832 fn a_configured_attestation_with_no_attest_step_fails() {
833 let bare = "steps:\n - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803\n";
834 let found = workflow_matches_configuration(CLEAN, bare);
835 assert!(
836 found
837 .iter()
838 .any(|failure| failure.code == "workflow-attestation-missing"),
839 "an unattested workflow fails: {found:?}"
840 );
841 assert!(
842 !found
843 .iter()
844 .any(|failure| failure.code.starts_with("workflow-action-")),
845 "the pinned step itself is clean: {found:?}"
846 );
847 let variant = format!(
848 "{bare} - uses: actions/attest-build-provenance@1e69f48acb82d1966a394da916b4c1698aa569d6\n"
849 );
850 assert!(
851 workflow_matches_configuration(CLEAN, &variant).is_empty(),
852 "the build-provenance variant is an attest step"
853 );
854 }
855
856 #[test]
863 fn a_movable_reference_fails_whatever_the_configuration_says() {
864 let attest = " - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
865 let movable = format!("steps:\n - uses: third/party@v1\n{attest}");
866 let found = workflow_matches_configuration(CLEAN, &movable);
867 assert!(
868 found
869 .iter()
870 .any(|failure| failure.code == "workflow-action-unpinned"
871 && failure.reason.contains("third/party")),
872 "an action the configuration never names fails: {found:?}"
873 );
874 let agreed = CLEAN.replace(
877 "[dist.github-action-commits]",
878 "[dist.github-action-commits]\n\"third/party\" = \"v1\"",
879 );
880 let found = workflow_matches_configuration(&agreed, &movable);
881 assert!(
882 found
883 .iter()
884 .any(|failure| failure.code == "workflow-action-unpinned"
885 && failure.reason.contains("third/party")),
886 "a table entry naming the same movable tag pins nothing: {found:?}"
887 );
888 let non_string = CLEAN.replace(
889 "[dist.github-action-commits]",
890 "[dist.github-action-commits]\n\"third/party\" = 1",
891 );
892 assert!(
893 workflow_matches_configuration(&non_string, &movable)
894 .iter()
895 .any(|failure| failure.code == "workflow-action-unpinned"),
896 "a non-string entry pins nothing either"
897 );
898 let pinned = format!(
899 "steps:\n - uses: third/party@1111111111111111111111111111111111111111\n{attest}"
900 );
901 assert!(
902 workflow_matches_configuration(CLEAN, &pinned).is_empty(),
903 "a commit-pinned action the configuration does not name is the target's own"
904 );
905 assert!(
906 workflow_matches_configuration(CLEAN, &format!("steps:\n{attest}")).is_empty(),
907 "a pin no step runs is the target's tuning, not drift"
908 );
909 }
910
911 #[test]
917 fn every_real_step_shape_reaches_the_judgment() {
918 let attest = " - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
919 let padded = format!("steps:\n - uses : actions/checkout@v4\n{attest}");
920 assert!(
921 workflow_matches_configuration(CLEAN, &padded)
922 .iter()
923 .any(|failure| failure.code == "workflow-action-stale"),
924 "a padded key is the same mapping"
925 );
926 let quoted = format!("steps:\n - \"uses\": actions/checkout@v4\n{attest}");
927 assert!(
928 workflow_matches_configuration(CLEAN, "ed)
929 .iter()
930 .any(|failure| failure.code == "workflow-action-stale"),
931 "a quoted key is the same mapping"
932 );
933 let flow =
934 format!("steps:\n - {{ uses: actions/checkout@v4, with: {{ ref: main }} }}\n{attest}");
935 assert!(
936 workflow_matches_configuration(CLEAN, &flow)
937 .iter()
938 .any(|failure| failure.code == "workflow-action-stale"),
939 "a flow-style step is the same mapping"
940 );
941 let comma = format!(
944 "steps:\n - uses: third/party@1111111111111111111111111111111111111111,dev\n{attest}"
945 );
946 assert!(
947 workflow_matches_configuration(CLEAN, &comma)
948 .iter()
949 .any(|failure| failure.code == "workflow-action-unpinned"
950 && failure.reason.contains(",dev")),
951 "the whole reference is judged, never its prefix"
952 );
953 let hashed = format!(
956 "steps:\n - uses: third/party@1111111111111111111111111111111111111111#dev\n{attest}"
957 );
958 assert!(
959 workflow_matches_configuration(CLEAN, &hashed)
960 .iter()
961 .any(|failure| failure.code == "workflow-action-unpinned"
962 && failure.reason.contains("#dev")),
963 "an adjacent hash is scalar content, not a comment"
964 );
965 let compact = format!("steps: [ uses: third/party@v1 ]\n{attest}");
967 assert!(
968 workflow_matches_configuration(CLEAN, &compact)
969 .iter()
970 .any(|failure| failure.code == "workflow-action-unpinned"
971 && failure.reason.contains("third/party")),
972 "a compact flow sequence carries its uses key"
973 );
974 assert!(
975 workflow_matches_configuration(CLEAN, &format!("steps:\n - usesful: no\n{attest}"))
976 .is_empty(),
977 "a key that merely starts with uses is another key"
978 );
979 for same_repository in ["./.github/actions/build", "$/.github/actions/build"] {
980 let local = format!("steps:\n - uses: {same_repository}\n{attest}");
981 assert!(
982 workflow_matches_configuration(CLEAN, &local).is_empty(),
983 "{same_repository} is the repository's own file at the running commit"
984 );
985 }
986 let tagged = format!("steps:\n - uses: docker://alpine:3.8\n{attest}");
987 assert!(
988 workflow_matches_configuration(CLEAN, &tagged)
989 .iter()
990 .any(|failure| failure.code == "workflow-step-unreadable"),
991 "a docker image with no digest is not immutable"
992 );
993 let digested = format!(
994 "steps:\n - uses: docker://alpine@sha256:0000000000000000000000000000000000000000000000000000000000000000\n{attest}"
995 );
996 assert!(
997 workflow_matches_configuration(CLEAN, &digested).is_empty(),
998 "a docker image pinned by digest is immutable"
999 );
1000 }
1001
1002 #[test]
1009 fn a_step_the_reader_cannot_resolve_is_reported() {
1010 let attest = " - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
1011 let aliased = format!("steps:\n - uses: *checkout\n{attest}");
1012 assert!(
1013 workflow_matches_configuration(CLEAN, &aliased)
1014 .iter()
1015 .any(|failure| failure.code == "workflow-step-unreadable"
1016 && failure.reason.contains("*checkout")),
1017 "an alias is unreadable, never clean"
1018 );
1019 let continued = format!("steps:\n - uses:\n actions/checkout@v4\n{attest}");
1020 assert!(
1021 workflow_matches_configuration(CLEAN, &continued)
1022 .iter()
1023 .any(|failure| failure.code == "workflow-step-unreadable"),
1024 "a value on another line is unreadable, never clean"
1025 );
1026 let quoted_flow = format!("steps:\n - {{ uses: \"third/party@1,dev\" }}\n{attest}");
1027 assert!(
1028 workflow_matches_configuration(CLEAN, "ed_flow)
1029 .iter()
1030 .any(|failure| failure.code == "workflow-step-unreadable"),
1031 "a quoted flow line is not split on a guess"
1032 );
1033 let expression = format!(
1034 "jobs:\n host:\n if: ${{{{ fromJson(needs.plan.outputs.val).ci != null && x == 'true' }}}}\n steps:\n{attest}"
1035 );
1036 assert!(
1037 workflow_matches_configuration(CLEAN, &expression).is_empty(),
1038 "an expression is not a step this reader cannot resolve"
1039 );
1040 }
1041
1042 #[test]
1047 fn the_cross_file_judgment_needs_both_files() {
1048 let dir = tempfile::tempdir().expect("a scratch directory");
1049 let target = Utf8Path::from_path(dir.path()).expect("a utf-8 path");
1050 let broken = "steps:\n - uses: actions/checkout@v4\n";
1051 assert!(
1052 target_failures("rust", "github", target).is_empty(),
1053 "an empty target"
1054 );
1055 std::fs::write(target.join("dist-workspace.toml"), CLEAN).expect("the configuration");
1056 assert!(
1057 target_failures("rust", "github", target).is_empty(),
1058 "a configuration with no generated workflow"
1059 );
1060 std::fs::create_dir_all(target.join(".github/workflows")).expect("the workflow directory");
1061 std::fs::write(target.join(".github/workflows/release.yml"), broken).expect("the workflow");
1062 assert!(
1063 !target_failures("rust", "github", target).is_empty(),
1064 "both files present, and they disagree"
1065 );
1066 for (tech, forge) in [("rust", "gitlab"), ("bash", "github")] {
1067 assert!(
1068 target_failures(tech, forge, target).is_empty(),
1069 "{tech}/{forge} generates no artifact workflow"
1070 );
1071 }
1072 std::fs::write(
1075 target.join(".github/workflows/release.yml"),
1076 [0x66, 0xff, 0xfe],
1077 )
1078 .expect("the workflow");
1079 assert!(
1080 target_failures("rust", "github", target)
1081 .iter()
1082 .any(|failure| failure.code == "workflow-file-unreadable"),
1083 "a present workflow that does not read as text is reported"
1084 );
1085 std::fs::remove_file(target.join("dist-workspace.toml")).expect("the configuration");
1086 assert!(
1087 target_failures("rust", "github", target).is_empty(),
1088 "a workflow with no configuration to judge it against"
1089 );
1090 }
1091
1092 #[test]
1096 fn the_rule_is_keyed_by_pair_and_destination() {
1097 let broken = b"[dist]\ngithub-attestations = false\n";
1098 assert!(failures("rust", "gitlab", "dist-workspace.toml", broken).is_empty());
1099 assert!(failures("bash", "github", "dist-workspace.toml", broken).is_empty());
1100 assert!(failures("rust", "github", "release-plz.toml", broken).is_empty());
1101 }
1102}