1use camino::Utf8Path;
23use serde::Serialize;
24
25use crate::embedded;
26
27#[derive(Debug, Clone, Serialize)]
31pub struct InvariantFailure {
32 pub code: &'static str,
34 pub destination: String,
36 pub reason: String,
38 pub remediation: &'static str,
40}
41
42impl InvariantFailure {
43 fn new(
44 code: &'static str,
45 destination: &str,
46 reason: impl Into<String>,
47 remediation: &'static str,
48 ) -> Self {
49 Self {
50 code,
51 destination: destination.to_owned(),
52 reason: reason.into(),
53 remediation,
54 }
55 }
56}
57
58#[must_use]
61pub fn failures(tech: &str, forge: &str, destination: &str, bytes: &[u8]) -> Vec<InvariantFailure> {
62 match (tech, forge, destination) {
63 ("rust", "github", "dist-workspace.toml") => dist_workspace(destination, bytes),
64 _ => Vec::new(),
65 }
66}
67
68fn dist_workspace(destination: &str, bytes: &[u8]) -> Vec<InvariantFailure> {
74 let Ok(text) = std::str::from_utf8(bytes) else {
75 return vec![InvariantFailure::new(
76 "unparsable-configuration",
77 destination,
78 "the file is not UTF-8, so its configuration cannot be judged",
79 "repair the file so it parses as TOML",
80 )];
81 };
82 let table: toml::Table = match text.parse() {
83 Ok(table) => table,
84 Err(error) => {
85 return vec![InvariantFailure::new(
86 "unparsable-configuration",
87 destination,
88 format!("the file does not parse as TOML: {error}"),
89 "repair the file so it parses as TOML",
90 )];
91 }
92 };
93 let dist = table.get("dist").and_then(toml::Value::as_table);
94 let mut failures = Vec::new();
95 let value = |key: &str| dist.and_then(|dist| dist.get(key));
96 if value("github-attestations").and_then(toml::Value::as_bool) != Some(true) {
97 failures.push(InvariantFailure::new(
98 "attestations-disabled",
99 destination,
100 "github-attestations is not effectively true, so no release artifact is attested",
101 "set github-attestations = true in [dist]",
102 ));
103 }
104 let phase = value("github-attestations-phase").and_then(toml::Value::as_str);
105 if phase != Some("host") {
106 failures.push(InvariantFailure::new(
107 "attestation-phase-not-host",
108 destination,
109 phase.map_or_else(
110 || "github-attestations-phase is unset, so the default phase attests only the per-platform archives and the curled installers ship unattested".to_owned(),
111 |other| format!(
112 "github-attestations-phase is \"{other}\"; only the host phase attests every asset before the release page exists"
113 ),
114 ),
115 "set github-attestations-phase = \"host\" in [dist]",
116 ));
117 }
118 if value("github-release").and_then(toml::Value::as_str) != Some("host") {
119 failures.push(InvariantFailure::new(
120 "release-phase-unpaired",
121 destination,
122 "github-release is not \"host\", leaving the release creation unpaired with the attest phase",
123 "set github-release = \"host\" in [dist], pairing the release creation with the phase that attests",
124 ));
125 }
126 if value("github-attestations-filters").is_some() {
127 failures.push(InvariantFailure::new(
128 "attestation-filters-narrowed",
129 destination,
130 "github-attestations-filters narrows what is attested below the whole release payload",
131 "remove github-attestations-filters from [dist]; the default [\"*\"] attests every hosted file",
132 ));
133 }
134 let mode = value("pr-run-mode").and_then(toml::Value::as_str);
140 if mode != Some("skip") {
141 failures.push(InvariantFailure::new(
142 "pr-run-mode-not-skip",
143 destination,
144 mode.map_or_else(
145 || "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(),
146 |other| format!(
147 "pr-run-mode is \"{other}\", so the generated workflow reports a job on every pull request that no gate can need"
148 ),
149 ),
150 "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",
151 ));
152 }
153 failures.extend(action_commit_failures(
154 destination,
155 value("github-action-commits").and_then(toml::Value::as_table),
156 ));
157 failures
158}
159
160fn action_commit_failures(destination: &str, found: Option<&toml::Table>) -> Vec<InvariantFailure> {
166 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";
167 let mut failures = Vec::new();
168 for (action, commit) in &seed_action_commits() {
169 match found.and_then(|table| table.get(action)) {
174 Some(value) => match value.as_str() {
175 Some(pinned) if pinned == commit.as_str() => {}
176 Some(pinned) => failures.push(InvariantFailure::new(
177 "action-commit-stale",
178 destination,
179 format!(
180 "[dist.github-action-commits] pins {action} at {pinned}, where the payload pins {commit}"
181 ),
182 remediation,
183 )),
184 None => failures.push(InvariantFailure::new(
185 "action-commit-invalid",
186 destination,
187 format!(
188 "[dist.github-action-commits] pins {action} with a non-string value; a pin is a full commit SHA string"
189 ),
190 remediation,
191 )),
192 },
193 None => failures.push(InvariantFailure::new(
194 "action-commit-missing",
195 destination,
196 format!(
197 "[dist.github-action-commits] does not pin {action}, so the workflow runs whatever the movable tag names"
198 ),
199 remediation,
200 )),
201 }
202 }
203 failures
204}
205
206fn seed_action_commits() -> Vec<(String, String)> {
209 let Some(text) = embedded::SNIPPETS
210 .get_file("rust/github/dist-workspace.toml")
211 .and_then(|file| file.contents_utf8())
212 else {
213 return Vec::new();
214 };
215 let Ok(table) = text.parse::<toml::Table>() else {
216 return Vec::new();
217 };
218 table
219 .get("dist")
220 .and_then(toml::Value::as_table)
221 .and_then(|dist| dist.get("github-action-commits"))
222 .and_then(toml::Value::as_table)
223 .map(|commits| {
224 commits
225 .iter()
226 .filter_map(|(action, commit)| {
227 commit
228 .as_str()
229 .map(|commit| (action.clone(), commit.to_owned()))
230 })
231 .collect()
232 })
233 .unwrap_or_default()
234}
235
236const GENERATED_WORKFLOW: &str = ".github/workflows/release.yml";
240
241#[must_use]
248pub fn target_failures(tech: &str, forge: &str, target: &Utf8Path) -> Vec<InvariantFailure> {
249 match (tech, forge) {
250 ("rust", "github") => generated_release_workflow(target),
251 _ => Vec::new(),
252 }
253}
254
255fn generated_release_workflow(target: &Utf8Path) -> Vec<InvariantFailure> {
260 let Ok(config) = std::fs::read_to_string(target.join("dist-workspace.toml")) else {
261 return Vec::new();
262 };
263 let workflow = match std::fs::read_to_string(target.join(GENERATED_WORKFLOW)) {
264 Ok(text) => text,
265 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
269 Err(error) => {
270 return vec![InvariantFailure::new(
271 "workflow-file-unreadable",
272 GENERATED_WORKFLOW,
273 format!("the workflow is present and cannot be read as text: {error}"),
274 "repair the file so it reads as UTF-8 text, or regenerate it with dist generate --mode ci",
275 )];
276 }
277 };
278 workflow_matches_configuration(&config, &workflow)
279}
280
281fn workflow_matches_configuration(config: &str, workflow: &str) -> Vec<InvariantFailure> {
290 let Ok(table) = config.parse::<toml::Table>() else {
291 return Vec::new();
292 };
293 let dist = table.get("dist").and_then(toml::Value::as_table);
294 let pinned = dist
295 .and_then(|dist| dist.get("github-action-commits"))
296 .and_then(toml::Value::as_table);
297 let attested = dist
298 .and_then(|dist| dist.get("github-attestations"))
299 .and_then(toml::Value::as_bool)
300 == Some(true);
301
302 let mut failures = Vec::new();
303 let steps = workflow_uses(workflow);
304 for step in &steps {
305 let (action, reference) = match step {
306 Step::Opaque(value) => {
310 failures.push(InvariantFailure::new(
311 "workflow-step-unreadable",
312 GENERATED_WORKFLOW,
313 format!(
314 "the workflow runs `uses: {value}`, which this check cannot resolve into an action and an immutable reference"
315 ),
316 "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",
317 ));
318 continue;
319 }
320 Step::Action(action, reference) => (action, reference),
321 };
322 let pin = pinned
326 .and_then(|table| table.get(action.as_str()))
327 .and_then(toml::Value::as_str);
328 if let Some(commit) = pin
329 && commit != reference
330 {
331 failures.push(InvariantFailure::new(
332 "workflow-action-stale",
333 GENERATED_WORKFLOW,
334 format!(
335 "the workflow runs {action}@{reference}, where dist-workspace.toml pins {commit}"
336 ),
337 "regenerate the workflow from the configuration with dist generate --mode ci and commit it; a hand edit is reverted at the next generate",
338 ));
339 continue;
340 }
341 if !is_immutable(reference) {
342 failures.push(InvariantFailure::new(
343 "workflow-action-unpinned",
344 GENERATED_WORKFLOW,
345 format!(
346 "the workflow runs {action}@{reference}, which is no immutable reference, so the step runs whatever that name points at today"
347 ),
348 "pin the action at a full commit SHA in [dist.github-action-commits] in dist-workspace.toml, then regenerate with dist generate --mode ci",
349 ));
350 }
351 }
352 if attested
353 && !steps.iter().any(|step| match step {
354 Step::Action(action, _) => {
355 action == "actions/attest" || action.starts_with("actions/attest-")
356 }
357 Step::Opaque(_) => false,
358 })
359 {
360 failures.push(InvariantFailure::new(
361 "workflow-attestation-missing",
362 GENERATED_WORKFLOW,
363 "dist-workspace.toml sets github-attestations = true, and the workflow carries no attest step, so what this workflow builds ships unattested",
364 "regenerate the workflow with dist generate --mode ci and commit it, so the configured attest step is what runs",
365 ));
366 }
367 if crate::setup::workflow_jobs::request_trigger(workflow).is_some() {
370 failures.push(InvariantFailure::new(
371 "workflow-runs-on-a-request",
372 GENERATED_WORKFLOW,
373 "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",
374 "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",
375 ));
376 }
377 failures
378}
379
380enum Step {
382 Action(String, String),
384 Opaque(String),
389}
390
391fn workflow_uses(workflow: &str) -> Vec<Step> {
403 let mut seen: Vec<String> = Vec::new();
404 let mut steps = Vec::new();
405 for fragment in workflow.lines().flat_map(line_fragments) {
406 let fragment = fragment.trim_start();
407 let fragment = fragment
409 .strip_prefix("- ")
410 .map_or(fragment, str::trim_start);
411 let Some(rest) = uses_value(fragment) else {
412 continue;
413 };
414 let rest = before_comment(rest).trim();
415 let rest = rest
416 .strip_prefix('"')
417 .and_then(|rest| rest.strip_suffix('"'))
418 .or_else(|| {
419 rest.strip_prefix('\'')
420 .and_then(|rest| rest.strip_suffix('\''))
421 })
422 .unwrap_or(rest);
423 if rest.starts_with("./") || rest.starts_with("$/") {
424 continue;
425 }
426 if seen.iter().any(|value| value == rest) {
427 continue;
428 }
429 seen.push(rest.to_owned());
430 steps.push(match rest.split_once('@') {
431 Some((action, reference)) => Step::Action(action.to_owned(), reference.to_owned()),
432 None if rest.is_empty() => Step::Opaque("a value carried on another line".to_owned()),
436 None => Step::Opaque(rest.to_owned()),
437 });
438 }
439 steps
440}
441
442fn line_fragments(line: &str) -> Vec<&str> {
454 let item = line.trim_start();
455 let item = item.strip_prefix("- ").map_or(item, str::trim_start);
456 let flow = item.starts_with('{')
457 || item.starts_with('[')
458 || ((line.contains('{') || line.contains('[')) && line.contains("uses"));
459 if !flow {
460 return vec![line];
461 }
462 if line.contains(QUOTES) {
463 return vec![UNSPLITTABLE_FLOW_LINE];
464 }
465 line.split(['{', '}', '[', ']', ',']).collect()
466}
467
468pub(crate) fn before_comment(value: &str) -> &str {
472 let mut previous = ' ';
473 for (index, character) in value.char_indices() {
474 if character == '#' && (previous == ' ' || previous == '\t') {
475 return &value[..index];
476 }
477 previous = character;
478 }
479 value
480}
481
482const QUOTES: [char; 2] = ['\u{22}', '\u{27}'];
486
487const UNSPLITTABLE_FLOW_LINE: &str = "uses: a flow-style step carrying a quoted value";
490
491fn uses_value(line: &str) -> Option<&str> {
495 let rest = line
496 .strip_prefix("\"uses\"")
497 .or_else(|| line.strip_prefix("'uses'"))
498 .or_else(|| line.strip_prefix("uses"))?;
499 rest.trim_start().strip_prefix(':')
500}
501
502fn is_immutable(reference: &str) -> bool {
505 let digest = reference
506 .strip_prefix("sha256:")
507 .filter(|digest| digest.len() == 64);
508 let commit = Some(reference).filter(|reference| reference.len() == 40);
509 digest
510 .or(commit)
511 .is_some_and(|value| value.chars().all(|char| char.is_ascii_hexdigit()))
512}
513
514#[cfg(test)]
515mod tests {
516 #![allow(clippy::expect_used)]
517
518 use camino::Utf8Path;
519
520 use super::{failures, target_failures, workflow_matches_configuration};
521
522 const CLEAN: &str = r#"
523[dist]
524pr-run-mode = "skip"
525github-attestations = true
526github-attestations-phase = "host"
527github-release = "host"
528
529[dist.github-action-commits]
530"actions/checkout" = "d23441a48e516b6c34aea4fa41551a30e30af803"
531"actions/download-artifact" = "3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c"
532"actions/upload-artifact" = "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"
533"actions/attest" = "1e69f48acb82d1966a394da916b4c1698aa569d6"
534"#;
535
536 #[test]
541 fn the_seeded_configuration_is_judged_effectively() {
542 assert!(failures("rust", "github", "dist-workspace.toml", CLEAN.as_bytes()).is_empty());
543 let seed = crate::embedded::SNIPPETS
544 .get_file("rust/github/dist-workspace.toml")
545 .and_then(|file| file.contents_utf8())
546 .expect("the seed is embedded");
547 assert!(
548 failures("rust", "github", "dist-workspace.toml", seed.as_bytes()).is_empty(),
549 "the payload's own seed satisfies the invariants it seeds"
550 );
551 }
552
553 #[test]
556 fn a_missing_or_stale_action_commit_table_fails() {
557 let missing = "[dist]\ngithub-attestations=true\ngithub-attestations-phase='host'\ngithub-release='host'\n";
558 let found = failures("rust", "github", "dist-workspace.toml", missing.as_bytes());
559 assert!(
560 found
561 .iter()
562 .any(|failure| failure.code == "action-commit-missing"),
563 "a missing entry falls back to the movable tag: {found:?}"
564 );
565 let stale = CLEAN.replace(
566 "d23441a48e516b6c34aea4fa41551a30e30af803",
567 "0000000000000000000000000000000000000000",
568 );
569 let found = failures("rust", "github", "dist-workspace.toml", stale.as_bytes());
570 assert!(
571 found
572 .iter()
573 .any(|failure| failure.code == "action-commit-stale"
574 && failure.reason.contains("actions/checkout")
575 && failure
576 .reason
577 .contains("0000000000000000000000000000000000000000")),
578 "a mismatch names the found and expected commits: {found:?}"
579 );
580 let invalid = CLEAN.replace("\"d23441a48e516b6c34aea4fa41551a30e30af803\"", "123");
581 let found_invalid = failures("rust", "github", "dist-workspace.toml", invalid.as_bytes());
582 assert!(
583 found_invalid
584 .iter()
585 .any(|failure| failure.code == "action-commit-invalid"
586 && failure.reason.contains("actions/checkout")),
587 "a non-string value is invalid configuration, not an absent pin: {found_invalid:?}"
588 );
589 assert!(
590 !found
591 .iter()
592 .any(|failure| failure.reason.contains("actions/attest")),
593 "only the stale action is named: {found:?}"
594 );
595 }
596
597 #[test]
601 fn each_degraded_form_fails_with_its_code() {
602 let cases: &[(&str, &str)] = &[
603 (
604 "[dist]\n# github-attestations = true\ngithub-attestations-phase='host'\ngithub-release='host'\n",
605 "attestations-disabled",
606 ),
607 (
608 "[dist]\ngithub-attestations = false\ngithub-attestations-phase='host'\ngithub-release='host'\n",
609 "attestations-disabled",
610 ),
611 (
612 "[dist]\ngithub-attestations = true\ngithub-release='host'\n",
613 "attestation-phase-not-host",
614 ),
615 (
616 "[dist]\ngithub-attestations = true\ngithub-attestations-phase='build-local-artifacts'\ngithub-release='host'\n",
617 "attestation-phase-not-host",
618 ),
619 (
620 "[dist]\ngithub-attestations = true\ngithub-attestations-phase='host'\ngithub-release='announce'\n",
621 "release-phase-unpaired",
622 ),
623 (
624 "[dist]\ngithub-attestations = true\ngithub-attestations-phase='host'\ngithub-release='host'\ngithub-attestations-filters=['*.tar.gz']\n",
625 "attestation-filters-narrowed",
626 ),
627 ("not toml at [all", "unparsable-configuration"),
628 ];
629 for (text, code) in cases {
630 let found = failures("rust", "github", "dist-workspace.toml", text.as_bytes());
631 assert!(
632 found.iter().any(|failure| failure.code == *code),
633 "{text:?} must fail with {code}, got {found:?}"
634 );
635 }
636 }
637
638 #[test]
642 fn a_configuration_that_reports_on_a_request_fails() {
643 let absent = CLEAN.replace("pr-run-mode = \"skip\"\n", "");
644 let found = failures("rust", "github", "dist-workspace.toml", absent.as_bytes());
645 assert!(
646 found
647 .iter()
648 .any(|failure| failure.code == "pr-run-mode-not-skip"
649 && failure.reason.contains("unset")
650 && failure.reason.contains("plan")),
651 "an unset key defaults to plan and says so: {found:?}"
652 );
653 for other in ["plan", "upload"] {
654 let text = CLEAN.replace("\"skip\"", &format!("\"{other}\""));
655 let found = failures("rust", "github", "dist-workspace.toml", text.as_bytes());
656 assert!(
657 found
658 .iter()
659 .any(|failure| failure.code == "pr-run-mode-not-skip"
660 && failure.reason.contains(other)),
661 "{other} fails and is named: {found:?}"
662 );
663 }
664 let non_string = CLEAN.replace("\"skip\"", "3");
667 assert!(
668 failures(
669 "rust",
670 "github",
671 "dist-workspace.toml",
672 non_string.as_bytes()
673 )
674 .iter()
675 .any(|failure| failure.code == "pr-run-mode-not-skip"),
676 "a non-string run mode is not skip"
677 );
678 assert!(
679 !failures("rust", "github", "dist-workspace.toml", CLEAN.as_bytes())
680 .iter()
681 .any(|failure| failure.code == "pr-run-mode-not-skip"),
682 "skip fails nothing"
683 );
684 }
685
686 #[test]
690 fn a_generated_workflow_that_triggers_on_a_request_fails() {
691 let attest = " - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
692 for trigger in [
693 "on:\n pull_request:\n",
694 "on: [push, pull_request]\n",
695 "on: pull_request_target\n",
696 ] {
697 let workflow = format!("{trigger}jobs:\n plan:\n steps:\n{attest}");
698 assert!(
699 workflow_matches_configuration(CLEAN, &workflow)
700 .iter()
701 .any(|failure| failure.code == "workflow-runs-on-a-request"
702 && failure.destination == super::GENERATED_WORKFLOW),
703 "{trigger:?} reports a check no gate can need"
704 );
705 }
706 let tag_only =
707 format!("on:\n push:\n tags:\n - '**'\njobs:\n plan:\n steps:\n{attest}");
708 assert!(
709 workflow_matches_configuration(CLEAN, &tag_only).is_empty(),
710 "a tag-only workflow reports nothing on a request"
711 );
712 }
713
714 #[test]
718 fn the_generated_workflow_at_the_configured_commits_fails_nothing() {
719 let workflow = "\
720jobs:
721 plan:
722 steps:
723 - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803
724 # - uses: actions/checkout@v4
725 - name: Upload
726 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
727 - name: Attest
728 uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v3
729";
730 let found = workflow_matches_configuration(CLEAN, workflow);
731 assert!(
732 found.is_empty(),
733 "the generated workflow is clean: {found:?}"
734 );
735 }
736
737 #[test]
741 fn a_workflow_left_at_a_movable_tag_fails() {
742 for stale in ["v4", "0000000000000000000000000000000000000000"] {
743 let workflow = format!(
744 "steps:\n - uses: actions/checkout@{stale}\n - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n"
745 );
746 let found = workflow_matches_configuration(CLEAN, &workflow);
747 assert!(
748 found
749 .iter()
750 .any(|failure| failure.code == "workflow-action-stale"
751 && failure.destination == ".github/workflows/release.yml"
752 && failure.reason.contains("actions/checkout")
753 && failure.reason.contains(stale)
754 && failure
755 .reason
756 .contains("d23441a48e516b6c34aea4fa41551a30e30af803")),
757 "{stale} names both sides of the disagreement: {found:?}"
758 );
759 }
760 let twice = "steps:\n - uses: actions/checkout@v4\n - uses: actions/checkout@v4\n - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
761 assert_eq!(
762 workflow_matches_configuration(CLEAN, twice).len(),
763 1,
764 "one reference is one failure, however many jobs run it"
765 );
766 }
767
768 #[test]
773 fn a_configured_attestation_with_no_attest_step_fails() {
774 let bare = "steps:\n - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803\n";
775 let found = workflow_matches_configuration(CLEAN, bare);
776 assert!(
777 found
778 .iter()
779 .any(|failure| failure.code == "workflow-attestation-missing"),
780 "an unattested workflow fails: {found:?}"
781 );
782 assert!(
783 !found
784 .iter()
785 .any(|failure| failure.code.starts_with("workflow-action-")),
786 "the pinned step itself is clean: {found:?}"
787 );
788 let variant = format!(
789 "{bare} - uses: actions/attest-build-provenance@1e69f48acb82d1966a394da916b4c1698aa569d6\n"
790 );
791 assert!(
792 workflow_matches_configuration(CLEAN, &variant).is_empty(),
793 "the build-provenance variant is an attest step"
794 );
795 }
796
797 #[test]
804 fn a_movable_reference_fails_whatever_the_configuration_says() {
805 let attest = " - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
806 let movable = format!("steps:\n - uses: third/party@v1\n{attest}");
807 let found = workflow_matches_configuration(CLEAN, &movable);
808 assert!(
809 found
810 .iter()
811 .any(|failure| failure.code == "workflow-action-unpinned"
812 && failure.reason.contains("third/party")),
813 "an action the configuration never names fails: {found:?}"
814 );
815 let agreed = CLEAN.replace(
818 "[dist.github-action-commits]",
819 "[dist.github-action-commits]\n\"third/party\" = \"v1\"",
820 );
821 let found = workflow_matches_configuration(&agreed, &movable);
822 assert!(
823 found
824 .iter()
825 .any(|failure| failure.code == "workflow-action-unpinned"
826 && failure.reason.contains("third/party")),
827 "a table entry naming the same movable tag pins nothing: {found:?}"
828 );
829 let non_string = CLEAN.replace(
830 "[dist.github-action-commits]",
831 "[dist.github-action-commits]\n\"third/party\" = 1",
832 );
833 assert!(
834 workflow_matches_configuration(&non_string, &movable)
835 .iter()
836 .any(|failure| failure.code == "workflow-action-unpinned"),
837 "a non-string entry pins nothing either"
838 );
839 let pinned = format!(
840 "steps:\n - uses: third/party@1111111111111111111111111111111111111111\n{attest}"
841 );
842 assert!(
843 workflow_matches_configuration(CLEAN, &pinned).is_empty(),
844 "a commit-pinned action the configuration does not name is the target's own"
845 );
846 assert!(
847 workflow_matches_configuration(CLEAN, &format!("steps:\n{attest}")).is_empty(),
848 "a pin no step runs is the target's tuning, not drift"
849 );
850 }
851
852 #[test]
858 fn every_real_step_shape_reaches_the_judgment() {
859 let attest = " - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
860 let padded = format!("steps:\n - uses : actions/checkout@v4\n{attest}");
861 assert!(
862 workflow_matches_configuration(CLEAN, &padded)
863 .iter()
864 .any(|failure| failure.code == "workflow-action-stale"),
865 "a padded key is the same mapping"
866 );
867 let quoted = format!("steps:\n - \"uses\": actions/checkout@v4\n{attest}");
868 assert!(
869 workflow_matches_configuration(CLEAN, "ed)
870 .iter()
871 .any(|failure| failure.code == "workflow-action-stale"),
872 "a quoted key is the same mapping"
873 );
874 let flow =
875 format!("steps:\n - {{ uses: actions/checkout@v4, with: {{ ref: main }} }}\n{attest}");
876 assert!(
877 workflow_matches_configuration(CLEAN, &flow)
878 .iter()
879 .any(|failure| failure.code == "workflow-action-stale"),
880 "a flow-style step is the same mapping"
881 );
882 let comma = format!(
885 "steps:\n - uses: third/party@1111111111111111111111111111111111111111,dev\n{attest}"
886 );
887 assert!(
888 workflow_matches_configuration(CLEAN, &comma)
889 .iter()
890 .any(|failure| failure.code == "workflow-action-unpinned"
891 && failure.reason.contains(",dev")),
892 "the whole reference is judged, never its prefix"
893 );
894 let hashed = format!(
897 "steps:\n - uses: third/party@1111111111111111111111111111111111111111#dev\n{attest}"
898 );
899 assert!(
900 workflow_matches_configuration(CLEAN, &hashed)
901 .iter()
902 .any(|failure| failure.code == "workflow-action-unpinned"
903 && failure.reason.contains("#dev")),
904 "an adjacent hash is scalar content, not a comment"
905 );
906 let compact = format!("steps: [ uses: third/party@v1 ]\n{attest}");
908 assert!(
909 workflow_matches_configuration(CLEAN, &compact)
910 .iter()
911 .any(|failure| failure.code == "workflow-action-unpinned"
912 && failure.reason.contains("third/party")),
913 "a compact flow sequence carries its uses key"
914 );
915 assert!(
916 workflow_matches_configuration(CLEAN, &format!("steps:\n - usesful: no\n{attest}"))
917 .is_empty(),
918 "a key that merely starts with uses is another key"
919 );
920 for same_repository in ["./.github/actions/build", "$/.github/actions/build"] {
921 let local = format!("steps:\n - uses: {same_repository}\n{attest}");
922 assert!(
923 workflow_matches_configuration(CLEAN, &local).is_empty(),
924 "{same_repository} is the repository's own file at the running commit"
925 );
926 }
927 let tagged = format!("steps:\n - uses: docker://alpine:3.8\n{attest}");
928 assert!(
929 workflow_matches_configuration(CLEAN, &tagged)
930 .iter()
931 .any(|failure| failure.code == "workflow-step-unreadable"),
932 "a docker image with no digest is not immutable"
933 );
934 let digested = format!(
935 "steps:\n - uses: docker://alpine@sha256:0000000000000000000000000000000000000000000000000000000000000000\n{attest}"
936 );
937 assert!(
938 workflow_matches_configuration(CLEAN, &digested).is_empty(),
939 "a docker image pinned by digest is immutable"
940 );
941 }
942
943 #[test]
950 fn a_step_the_reader_cannot_resolve_is_reported() {
951 let attest = " - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
952 let aliased = format!("steps:\n - uses: *checkout\n{attest}");
953 assert!(
954 workflow_matches_configuration(CLEAN, &aliased)
955 .iter()
956 .any(|failure| failure.code == "workflow-step-unreadable"
957 && failure.reason.contains("*checkout")),
958 "an alias is unreadable, never clean"
959 );
960 let continued = format!("steps:\n - uses:\n actions/checkout@v4\n{attest}");
961 assert!(
962 workflow_matches_configuration(CLEAN, &continued)
963 .iter()
964 .any(|failure| failure.code == "workflow-step-unreadable"),
965 "a value on another line is unreadable, never clean"
966 );
967 let quoted_flow = format!("steps:\n - {{ uses: \"third/party@1,dev\" }}\n{attest}");
968 assert!(
969 workflow_matches_configuration(CLEAN, "ed_flow)
970 .iter()
971 .any(|failure| failure.code == "workflow-step-unreadable"),
972 "a quoted flow line is not split on a guess"
973 );
974 let expression = format!(
975 "jobs:\n host:\n if: ${{{{ fromJson(needs.plan.outputs.val).ci != null && x == 'true' }}}}\n steps:\n{attest}"
976 );
977 assert!(
978 workflow_matches_configuration(CLEAN, &expression).is_empty(),
979 "an expression is not a step this reader cannot resolve"
980 );
981 }
982
983 #[test]
988 fn the_cross_file_judgment_needs_both_files() {
989 let dir = tempfile::tempdir().expect("a scratch directory");
990 let target = Utf8Path::from_path(dir.path()).expect("a utf-8 path");
991 let broken = "steps:\n - uses: actions/checkout@v4\n";
992 assert!(
993 target_failures("rust", "github", target).is_empty(),
994 "an empty target"
995 );
996 std::fs::write(target.join("dist-workspace.toml"), CLEAN).expect("the configuration");
997 assert!(
998 target_failures("rust", "github", target).is_empty(),
999 "a configuration with no generated workflow"
1000 );
1001 std::fs::create_dir_all(target.join(".github/workflows")).expect("the workflow directory");
1002 std::fs::write(target.join(".github/workflows/release.yml"), broken).expect("the workflow");
1003 assert!(
1004 !target_failures("rust", "github", target).is_empty(),
1005 "both files present, and they disagree"
1006 );
1007 for (tech, forge) in [("rust", "gitlab"), ("bash", "github")] {
1008 assert!(
1009 target_failures(tech, forge, target).is_empty(),
1010 "{tech}/{forge} generates no artifact workflow"
1011 );
1012 }
1013 std::fs::write(
1016 target.join(".github/workflows/release.yml"),
1017 [0x66, 0xff, 0xfe],
1018 )
1019 .expect("the workflow");
1020 assert!(
1021 target_failures("rust", "github", target)
1022 .iter()
1023 .any(|failure| failure.code == "workflow-file-unreadable"),
1024 "a present workflow that does not read as text is reported"
1025 );
1026 std::fs::remove_file(target.join("dist-workspace.toml")).expect("the configuration");
1027 assert!(
1028 target_failures("rust", "github", target).is_empty(),
1029 "a workflow with no configuration to judge it against"
1030 );
1031 }
1032
1033 #[test]
1037 fn the_rule_is_keyed_by_pair_and_destination() {
1038 let broken = b"[dist]\ngithub-attestations = false\n";
1039 assert!(failures("rust", "gitlab", "dist-workspace.toml", broken).is_empty());
1040 assert!(failures("bash", "github", "dist-workspace.toml", broken).is_empty());
1041 assert!(failures("rust", "github", "release-plz.toml", broken).is_empty());
1042 }
1043}