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 expected = seed_action_commits();
140 let found = value("github-action-commits").and_then(toml::Value::as_table);
141 for (action, commit) in &expected {
142 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";
143 match found.and_then(|table| table.get(action)) {
148 Some(value) => match value.as_str() {
149 Some(pinned) if pinned == commit.as_str() => {}
150 Some(pinned) => failures.push(InvariantFailure::new(
151 "action-commit-stale",
152 destination,
153 format!(
154 "[dist.github-action-commits] pins {action} at {pinned}, where the payload pins {commit}"
155 ),
156 remediation,
157 )),
158 None => failures.push(InvariantFailure::new(
159 "action-commit-invalid",
160 destination,
161 format!(
162 "[dist.github-action-commits] pins {action} with a non-string value; a pin is a full commit SHA string"
163 ),
164 remediation,
165 )),
166 },
167 None => failures.push(InvariantFailure::new(
168 "action-commit-missing",
169 destination,
170 format!(
171 "[dist.github-action-commits] does not pin {action}, so the workflow runs whatever the movable tag names"
172 ),
173 remediation,
174 )),
175 }
176 }
177 failures
178}
179
180fn seed_action_commits() -> Vec<(String, String)> {
183 let Some(text) = embedded::SNIPPETS
184 .get_file("rust/github/dist-workspace.toml")
185 .and_then(|file| file.contents_utf8())
186 else {
187 return Vec::new();
188 };
189 let Ok(table) = text.parse::<toml::Table>() else {
190 return Vec::new();
191 };
192 table
193 .get("dist")
194 .and_then(toml::Value::as_table)
195 .and_then(|dist| dist.get("github-action-commits"))
196 .and_then(toml::Value::as_table)
197 .map(|commits| {
198 commits
199 .iter()
200 .filter_map(|(action, commit)| {
201 commit
202 .as_str()
203 .map(|commit| (action.clone(), commit.to_owned()))
204 })
205 .collect()
206 })
207 .unwrap_or_default()
208}
209
210const GENERATED_WORKFLOW: &str = ".github/workflows/release.yml";
214
215#[must_use]
222pub fn target_failures(tech: &str, forge: &str, target: &Utf8Path) -> Vec<InvariantFailure> {
223 match (tech, forge) {
224 ("rust", "github") => generated_release_workflow(target),
225 _ => Vec::new(),
226 }
227}
228
229fn generated_release_workflow(target: &Utf8Path) -> Vec<InvariantFailure> {
234 let Ok(config) = std::fs::read_to_string(target.join("dist-workspace.toml")) else {
235 return Vec::new();
236 };
237 let workflow = match std::fs::read_to_string(target.join(GENERATED_WORKFLOW)) {
238 Ok(text) => text,
239 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
243 Err(error) => {
244 return vec![InvariantFailure::new(
245 "workflow-file-unreadable",
246 GENERATED_WORKFLOW,
247 format!("the workflow is present and cannot be read as text: {error}"),
248 "repair the file so it reads as UTF-8 text, or regenerate it with dist generate --mode ci",
249 )];
250 }
251 };
252 workflow_matches_configuration(&config, &workflow)
253}
254
255fn workflow_matches_configuration(config: &str, workflow: &str) -> Vec<InvariantFailure> {
264 let Ok(table) = config.parse::<toml::Table>() else {
265 return Vec::new();
266 };
267 let dist = table.get("dist").and_then(toml::Value::as_table);
268 let pinned = dist
269 .and_then(|dist| dist.get("github-action-commits"))
270 .and_then(toml::Value::as_table);
271 let attested = dist
272 .and_then(|dist| dist.get("github-attestations"))
273 .and_then(toml::Value::as_bool)
274 == Some(true);
275
276 let mut failures = Vec::new();
277 let steps = workflow_uses(workflow);
278 for step in &steps {
279 let (action, reference) = match step {
280 Step::Opaque(value) => {
284 failures.push(InvariantFailure::new(
285 "workflow-step-unreadable",
286 GENERATED_WORKFLOW,
287 format!(
288 "the workflow runs `uses: {value}`, which this check cannot resolve into an action and an immutable reference"
289 ),
290 "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",
291 ));
292 continue;
293 }
294 Step::Action(action, reference) => (action, reference),
295 };
296 let pin = pinned
300 .and_then(|table| table.get(action.as_str()))
301 .and_then(toml::Value::as_str);
302 if let Some(commit) = pin
303 && commit != reference
304 {
305 failures.push(InvariantFailure::new(
306 "workflow-action-stale",
307 GENERATED_WORKFLOW,
308 format!(
309 "the workflow runs {action}@{reference}, where dist-workspace.toml pins {commit}"
310 ),
311 "regenerate the workflow from the configuration with dist generate --mode ci and commit it; a hand edit is reverted at the next generate",
312 ));
313 continue;
314 }
315 if !is_immutable(reference) {
316 failures.push(InvariantFailure::new(
317 "workflow-action-unpinned",
318 GENERATED_WORKFLOW,
319 format!(
320 "the workflow runs {action}@{reference}, which is no immutable reference, so the step runs whatever that name points at today"
321 ),
322 "pin the action at a full commit SHA in [dist.github-action-commits] in dist-workspace.toml, then regenerate with dist generate --mode ci",
323 ));
324 }
325 }
326 if attested
327 && !steps.iter().any(|step| match step {
328 Step::Action(action, _) => {
329 action == "actions/attest" || action.starts_with("actions/attest-")
330 }
331 Step::Opaque(_) => false,
332 })
333 {
334 failures.push(InvariantFailure::new(
335 "workflow-attestation-missing",
336 GENERATED_WORKFLOW,
337 "dist-workspace.toml sets github-attestations = true, and the workflow carries no attest step, so what this workflow builds ships unattested",
338 "regenerate the workflow with dist generate --mode ci and commit it, so the configured attest step is what runs",
339 ));
340 }
341 failures
342}
343
344enum Step {
346 Action(String, String),
348 Opaque(String),
353}
354
355fn workflow_uses(workflow: &str) -> Vec<Step> {
367 let mut seen: Vec<String> = Vec::new();
368 let mut steps = Vec::new();
369 for fragment in workflow.lines().flat_map(line_fragments) {
370 let fragment = fragment.trim_start();
371 let fragment = fragment
373 .strip_prefix("- ")
374 .map_or(fragment, str::trim_start);
375 let Some(rest) = uses_value(fragment) else {
376 continue;
377 };
378 let rest = before_comment(rest).trim();
379 let rest = rest
380 .strip_prefix('"')
381 .and_then(|rest| rest.strip_suffix('"'))
382 .or_else(|| {
383 rest.strip_prefix('\'')
384 .and_then(|rest| rest.strip_suffix('\''))
385 })
386 .unwrap_or(rest);
387 if rest.starts_with("./") || rest.starts_with("$/") {
388 continue;
389 }
390 if seen.iter().any(|value| value == rest) {
391 continue;
392 }
393 seen.push(rest.to_owned());
394 steps.push(match rest.split_once('@') {
395 Some((action, reference)) => Step::Action(action.to_owned(), reference.to_owned()),
396 None if rest.is_empty() => Step::Opaque("a value carried on another line".to_owned()),
400 None => Step::Opaque(rest.to_owned()),
401 });
402 }
403 steps
404}
405
406fn line_fragments(line: &str) -> Vec<&str> {
418 let item = line.trim_start();
419 let item = item.strip_prefix("- ").map_or(item, str::trim_start);
420 let flow = item.starts_with('{')
421 || item.starts_with('[')
422 || ((line.contains('{') || line.contains('[')) && line.contains("uses"));
423 if !flow {
424 return vec![line];
425 }
426 if line.contains(QUOTES) {
427 return vec![UNSPLITTABLE_FLOW_LINE];
428 }
429 line.split(['{', '}', '[', ']', ',']).collect()
430}
431
432pub(crate) fn before_comment(value: &str) -> &str {
436 let mut previous = ' ';
437 for (index, character) in value.char_indices() {
438 if character == '#' && (previous == ' ' || previous == '\t') {
439 return &value[..index];
440 }
441 previous = character;
442 }
443 value
444}
445
446const QUOTES: [char; 2] = ['\u{22}', '\u{27}'];
450
451const UNSPLITTABLE_FLOW_LINE: &str = "uses: a flow-style step carrying a quoted value";
454
455fn uses_value(line: &str) -> Option<&str> {
459 let rest = line
460 .strip_prefix("\"uses\"")
461 .or_else(|| line.strip_prefix("'uses'"))
462 .or_else(|| line.strip_prefix("uses"))?;
463 rest.trim_start().strip_prefix(':')
464}
465
466fn is_immutable(reference: &str) -> bool {
469 let digest = reference
470 .strip_prefix("sha256:")
471 .filter(|digest| digest.len() == 64);
472 let commit = Some(reference).filter(|reference| reference.len() == 40);
473 digest
474 .or(commit)
475 .is_some_and(|value| value.chars().all(|char| char.is_ascii_hexdigit()))
476}
477
478#[cfg(test)]
479mod tests {
480 #![allow(clippy::expect_used)]
481
482 use camino::Utf8Path;
483
484 use super::{failures, target_failures, workflow_matches_configuration};
485
486 const CLEAN: &str = r#"
487[dist]
488github-attestations = true
489github-attestations-phase = "host"
490github-release = "host"
491
492[dist.github-action-commits]
493"actions/checkout" = "d23441a48e516b6c34aea4fa41551a30e30af803"
494"actions/download-artifact" = "3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c"
495"actions/upload-artifact" = "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"
496"actions/attest" = "1e69f48acb82d1966a394da916b4c1698aa569d6"
497"#;
498
499 #[test]
504 fn the_seeded_configuration_is_judged_effectively() {
505 assert!(failures("rust", "github", "dist-workspace.toml", CLEAN.as_bytes()).is_empty());
506 let seed = crate::embedded::SNIPPETS
507 .get_file("rust/github/dist-workspace.toml")
508 .and_then(|file| file.contents_utf8())
509 .expect("the seed is embedded");
510 assert!(
511 failures("rust", "github", "dist-workspace.toml", seed.as_bytes()).is_empty(),
512 "the payload's own seed satisfies the invariants it seeds"
513 );
514 }
515
516 #[test]
519 fn a_missing_or_stale_action_commit_table_fails() {
520 let missing = "[dist]\ngithub-attestations=true\ngithub-attestations-phase='host'\ngithub-release='host'\n";
521 let found = failures("rust", "github", "dist-workspace.toml", missing.as_bytes());
522 assert!(
523 found
524 .iter()
525 .any(|failure| failure.code == "action-commit-missing"),
526 "a missing entry falls back to the movable tag: {found:?}"
527 );
528 let stale = CLEAN.replace(
529 "d23441a48e516b6c34aea4fa41551a30e30af803",
530 "0000000000000000000000000000000000000000",
531 );
532 let found = failures("rust", "github", "dist-workspace.toml", stale.as_bytes());
533 assert!(
534 found
535 .iter()
536 .any(|failure| failure.code == "action-commit-stale"
537 && failure.reason.contains("actions/checkout")
538 && failure
539 .reason
540 .contains("0000000000000000000000000000000000000000")),
541 "a mismatch names the found and expected commits: {found:?}"
542 );
543 let invalid = CLEAN.replace("\"d23441a48e516b6c34aea4fa41551a30e30af803\"", "123");
544 let found_invalid = failures("rust", "github", "dist-workspace.toml", invalid.as_bytes());
545 assert!(
546 found_invalid
547 .iter()
548 .any(|failure| failure.code == "action-commit-invalid"
549 && failure.reason.contains("actions/checkout")),
550 "a non-string value is invalid configuration, not an absent pin: {found_invalid:?}"
551 );
552 assert!(
553 !found
554 .iter()
555 .any(|failure| failure.reason.contains("actions/attest")),
556 "only the stale action is named: {found:?}"
557 );
558 }
559
560 #[test]
564 fn each_degraded_form_fails_with_its_code() {
565 let cases: &[(&str, &str)] = &[
566 (
567 "[dist]\n# github-attestations = true\ngithub-attestations-phase='host'\ngithub-release='host'\n",
568 "attestations-disabled",
569 ),
570 (
571 "[dist]\ngithub-attestations = false\ngithub-attestations-phase='host'\ngithub-release='host'\n",
572 "attestations-disabled",
573 ),
574 (
575 "[dist]\ngithub-attestations = true\ngithub-release='host'\n",
576 "attestation-phase-not-host",
577 ),
578 (
579 "[dist]\ngithub-attestations = true\ngithub-attestations-phase='build-local-artifacts'\ngithub-release='host'\n",
580 "attestation-phase-not-host",
581 ),
582 (
583 "[dist]\ngithub-attestations = true\ngithub-attestations-phase='host'\ngithub-release='announce'\n",
584 "release-phase-unpaired",
585 ),
586 (
587 "[dist]\ngithub-attestations = true\ngithub-attestations-phase='host'\ngithub-release='host'\ngithub-attestations-filters=['*.tar.gz']\n",
588 "attestation-filters-narrowed",
589 ),
590 ("not toml at [all", "unparsable-configuration"),
591 ];
592 for (text, code) in cases {
593 let found = failures("rust", "github", "dist-workspace.toml", text.as_bytes());
594 assert!(
595 found.iter().any(|failure| failure.code == *code),
596 "{text:?} must fail with {code}, got {found:?}"
597 );
598 }
599 }
600
601 #[test]
605 fn the_generated_workflow_at_the_configured_commits_fails_nothing() {
606 let workflow = "\
607jobs:
608 plan:
609 steps:
610 - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803
611 # - uses: actions/checkout@v4
612 - name: Upload
613 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
614 - name: Attest
615 uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v3
616";
617 let found = workflow_matches_configuration(CLEAN, workflow);
618 assert!(
619 found.is_empty(),
620 "the generated workflow is clean: {found:?}"
621 );
622 }
623
624 #[test]
628 fn a_workflow_left_at_a_movable_tag_fails() {
629 for stale in ["v4", "0000000000000000000000000000000000000000"] {
630 let workflow = format!(
631 "steps:\n - uses: actions/checkout@{stale}\n - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n"
632 );
633 let found = workflow_matches_configuration(CLEAN, &workflow);
634 assert!(
635 found
636 .iter()
637 .any(|failure| failure.code == "workflow-action-stale"
638 && failure.destination == ".github/workflows/release.yml"
639 && failure.reason.contains("actions/checkout")
640 && failure.reason.contains(stale)
641 && failure
642 .reason
643 .contains("d23441a48e516b6c34aea4fa41551a30e30af803")),
644 "{stale} names both sides of the disagreement: {found:?}"
645 );
646 }
647 let twice = "steps:\n - uses: actions/checkout@v4\n - uses: actions/checkout@v4\n - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
648 assert_eq!(
649 workflow_matches_configuration(CLEAN, twice).len(),
650 1,
651 "one reference is one failure, however many jobs run it"
652 );
653 }
654
655 #[test]
660 fn a_configured_attestation_with_no_attest_step_fails() {
661 let bare = "steps:\n - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803\n";
662 let found = workflow_matches_configuration(CLEAN, bare);
663 assert!(
664 found
665 .iter()
666 .any(|failure| failure.code == "workflow-attestation-missing"),
667 "an unattested workflow fails: {found:?}"
668 );
669 assert!(
670 !found
671 .iter()
672 .any(|failure| failure.code.starts_with("workflow-action-")),
673 "the pinned step itself is clean: {found:?}"
674 );
675 let variant = format!(
676 "{bare} - uses: actions/attest-build-provenance@1e69f48acb82d1966a394da916b4c1698aa569d6\n"
677 );
678 assert!(
679 workflow_matches_configuration(CLEAN, &variant).is_empty(),
680 "the build-provenance variant is an attest step"
681 );
682 }
683
684 #[test]
691 fn a_movable_reference_fails_whatever_the_configuration_says() {
692 let attest = " - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
693 let movable = format!("steps:\n - uses: third/party@v1\n{attest}");
694 let found = workflow_matches_configuration(CLEAN, &movable);
695 assert!(
696 found
697 .iter()
698 .any(|failure| failure.code == "workflow-action-unpinned"
699 && failure.reason.contains("third/party")),
700 "an action the configuration never names fails: {found:?}"
701 );
702 let agreed = CLEAN.replace(
705 "[dist.github-action-commits]",
706 "[dist.github-action-commits]\n\"third/party\" = \"v1\"",
707 );
708 let found = workflow_matches_configuration(&agreed, &movable);
709 assert!(
710 found
711 .iter()
712 .any(|failure| failure.code == "workflow-action-unpinned"
713 && failure.reason.contains("third/party")),
714 "a table entry naming the same movable tag pins nothing: {found:?}"
715 );
716 let non_string = CLEAN.replace(
717 "[dist.github-action-commits]",
718 "[dist.github-action-commits]\n\"third/party\" = 1",
719 );
720 assert!(
721 workflow_matches_configuration(&non_string, &movable)
722 .iter()
723 .any(|failure| failure.code == "workflow-action-unpinned"),
724 "a non-string entry pins nothing either"
725 );
726 let pinned = format!(
727 "steps:\n - uses: third/party@1111111111111111111111111111111111111111\n{attest}"
728 );
729 assert!(
730 workflow_matches_configuration(CLEAN, &pinned).is_empty(),
731 "a commit-pinned action the configuration does not name is the target's own"
732 );
733 assert!(
734 workflow_matches_configuration(CLEAN, &format!("steps:\n{attest}")).is_empty(),
735 "a pin no step runs is the target's tuning, not drift"
736 );
737 }
738
739 #[test]
745 fn every_real_step_shape_reaches_the_judgment() {
746 let attest = " - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
747 let padded = format!("steps:\n - uses : actions/checkout@v4\n{attest}");
748 assert!(
749 workflow_matches_configuration(CLEAN, &padded)
750 .iter()
751 .any(|failure| failure.code == "workflow-action-stale"),
752 "a padded key is the same mapping"
753 );
754 let quoted = format!("steps:\n - \"uses\": actions/checkout@v4\n{attest}");
755 assert!(
756 workflow_matches_configuration(CLEAN, "ed)
757 .iter()
758 .any(|failure| failure.code == "workflow-action-stale"),
759 "a quoted key is the same mapping"
760 );
761 let flow =
762 format!("steps:\n - {{ uses: actions/checkout@v4, with: {{ ref: main }} }}\n{attest}");
763 assert!(
764 workflow_matches_configuration(CLEAN, &flow)
765 .iter()
766 .any(|failure| failure.code == "workflow-action-stale"),
767 "a flow-style step is the same mapping"
768 );
769 let comma = format!(
772 "steps:\n - uses: third/party@1111111111111111111111111111111111111111,dev\n{attest}"
773 );
774 assert!(
775 workflow_matches_configuration(CLEAN, &comma)
776 .iter()
777 .any(|failure| failure.code == "workflow-action-unpinned"
778 && failure.reason.contains(",dev")),
779 "the whole reference is judged, never its prefix"
780 );
781 let hashed = format!(
784 "steps:\n - uses: third/party@1111111111111111111111111111111111111111#dev\n{attest}"
785 );
786 assert!(
787 workflow_matches_configuration(CLEAN, &hashed)
788 .iter()
789 .any(|failure| failure.code == "workflow-action-unpinned"
790 && failure.reason.contains("#dev")),
791 "an adjacent hash is scalar content, not a comment"
792 );
793 let compact = format!("steps: [ uses: third/party@v1 ]\n{attest}");
795 assert!(
796 workflow_matches_configuration(CLEAN, &compact)
797 .iter()
798 .any(|failure| failure.code == "workflow-action-unpinned"
799 && failure.reason.contains("third/party")),
800 "a compact flow sequence carries its uses key"
801 );
802 assert!(
803 workflow_matches_configuration(CLEAN, &format!("steps:\n - usesful: no\n{attest}"))
804 .is_empty(),
805 "a key that merely starts with uses is another key"
806 );
807 for same_repository in ["./.github/actions/build", "$/.github/actions/build"] {
808 let local = format!("steps:\n - uses: {same_repository}\n{attest}");
809 assert!(
810 workflow_matches_configuration(CLEAN, &local).is_empty(),
811 "{same_repository} is the repository's own file at the running commit"
812 );
813 }
814 let tagged = format!("steps:\n - uses: docker://alpine:3.8\n{attest}");
815 assert!(
816 workflow_matches_configuration(CLEAN, &tagged)
817 .iter()
818 .any(|failure| failure.code == "workflow-step-unreadable"),
819 "a docker image with no digest is not immutable"
820 );
821 let digested = format!(
822 "steps:\n - uses: docker://alpine@sha256:0000000000000000000000000000000000000000000000000000000000000000\n{attest}"
823 );
824 assert!(
825 workflow_matches_configuration(CLEAN, &digested).is_empty(),
826 "a docker image pinned by digest is immutable"
827 );
828 }
829
830 #[test]
837 fn a_step_the_reader_cannot_resolve_is_reported() {
838 let attest = " - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
839 let aliased = format!("steps:\n - uses: *checkout\n{attest}");
840 assert!(
841 workflow_matches_configuration(CLEAN, &aliased)
842 .iter()
843 .any(|failure| failure.code == "workflow-step-unreadable"
844 && failure.reason.contains("*checkout")),
845 "an alias is unreadable, never clean"
846 );
847 let continued = format!("steps:\n - uses:\n actions/checkout@v4\n{attest}");
848 assert!(
849 workflow_matches_configuration(CLEAN, &continued)
850 .iter()
851 .any(|failure| failure.code == "workflow-step-unreadable"),
852 "a value on another line is unreadable, never clean"
853 );
854 let quoted_flow = format!("steps:\n - {{ uses: \"third/party@1,dev\" }}\n{attest}");
855 assert!(
856 workflow_matches_configuration(CLEAN, "ed_flow)
857 .iter()
858 .any(|failure| failure.code == "workflow-step-unreadable"),
859 "a quoted flow line is not split on a guess"
860 );
861 let expression = format!(
862 "jobs:\n host:\n if: ${{{{ fromJson(needs.plan.outputs.val).ci != null && x == 'true' }}}}\n steps:\n{attest}"
863 );
864 assert!(
865 workflow_matches_configuration(CLEAN, &expression).is_empty(),
866 "an expression is not a step this reader cannot resolve"
867 );
868 }
869
870 #[test]
875 fn the_cross_file_judgment_needs_both_files() {
876 let dir = tempfile::tempdir().expect("a scratch directory");
877 let target = Utf8Path::from_path(dir.path()).expect("a utf-8 path");
878 let broken = "steps:\n - uses: actions/checkout@v4\n";
879 assert!(
880 target_failures("rust", "github", target).is_empty(),
881 "an empty target"
882 );
883 std::fs::write(target.join("dist-workspace.toml"), CLEAN).expect("the configuration");
884 assert!(
885 target_failures("rust", "github", target).is_empty(),
886 "a configuration with no generated workflow"
887 );
888 std::fs::create_dir_all(target.join(".github/workflows")).expect("the workflow directory");
889 std::fs::write(target.join(".github/workflows/release.yml"), broken).expect("the workflow");
890 assert!(
891 !target_failures("rust", "github", target).is_empty(),
892 "both files present, and they disagree"
893 );
894 for (tech, forge) in [("rust", "gitlab"), ("bash", "github")] {
895 assert!(
896 target_failures(tech, forge, target).is_empty(),
897 "{tech}/{forge} generates no artifact workflow"
898 );
899 }
900 std::fs::write(
903 target.join(".github/workflows/release.yml"),
904 [0x66, 0xff, 0xfe],
905 )
906 .expect("the workflow");
907 assert!(
908 target_failures("rust", "github", target)
909 .iter()
910 .any(|failure| failure.code == "workflow-file-unreadable"),
911 "a present workflow that does not read as text is reported"
912 );
913 std::fs::remove_file(target.join("dist-workspace.toml")).expect("the configuration");
914 assert!(
915 target_failures("rust", "github", target).is_empty(),
916 "a workflow with no configuration to judge it against"
917 );
918 }
919
920 #[test]
924 fn the_rule_is_keyed_by_pair_and_destination() {
925 let broken = b"[dist]\ngithub-attestations = false\n";
926 assert!(failures("rust", "gitlab", "dist-workspace.toml", broken).is_empty());
927 assert!(failures("bash", "github", "dist-workspace.toml", broken).is_empty());
928 assert!(failures("rust", "github", "release-plz.toml", broken).is_empty());
929 }
930}