1use serde::Serialize;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
23#[serde(rename_all = "snake_case")]
24pub enum KeyKind {
25 SshPrivate,
27 PgpPrivate,
30 Cosign,
32}
33
34impl KeyKind {
35 pub fn label(self) -> &'static str {
37 match self {
38 KeyKind::SshPrivate => "SSH private key",
39 KeyKind::PgpPrivate => "PGP private key",
40 KeyKind::Cosign => "cosign private key",
41 }
42 }
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
48#[serde(tag = "type", rename_all = "snake_case")]
49pub enum EnvRequirement {
50 Tool { name: String },
52 ToolAnyOf { names: Vec<String> },
56 EnvAllOf { vars: Vec<String> },
58 EnvAnyOf { vars: Vec<String> },
60 Endpoint { url: String },
62 DockerDaemon,
64 KeyEnv { kind: KeyKind, var: String },
66 KeyFile { kind: KeyKind, path: String },
69}
70
71#[derive(Debug, Clone)]
73pub struct SourcedRequirement {
74 pub source: String,
76 pub requirement: EnvRequirement,
77 pub advisory: bool,
85}
86
87impl SourcedRequirement {
88 pub fn new(source: impl Into<String>, requirement: EnvRequirement) -> Self {
90 Self {
91 source: source.into(),
92 requirement,
93 advisory: false,
94 }
95 }
96
97 pub fn new_advisory(source: impl Into<String>, requirement: EnvRequirement) -> Self {
101 Self {
102 source: source.into(),
103 requirement,
104 advisory: true,
105 }
106 }
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
115#[serde(rename_all = "snake_case")]
116pub enum FailureKind {
117 MissingTool,
118 MissingEnv,
119 EndpointUnreachable,
120 DockerUnavailable,
121 BadKeyMaterial,
122}
123
124#[derive(Debug, Clone, Serialize)]
126pub struct EnvCheckFailure {
127 pub kind: FailureKind,
128 pub message: String,
130 pub needed_by: Vec<String>,
132}
133
134#[derive(Debug, Clone, Serialize)]
136pub struct EnvPreflightReport {
137 pub checks: usize,
139 pub failures: Vec<EnvCheckFailure>,
140 #[serde(default)]
145 pub warnings: Vec<EnvCheckFailure>,
146}
147
148impl EnvPreflightReport {
149 pub fn ok(&self) -> bool {
150 self.failures.is_empty()
151 }
152
153 pub fn note_failure(&mut self, needed_by: &str, message: &str) {
159 self.checks += 1;
160 self.failures.push(EnvCheckFailure {
161 kind: FailureKind::BadKeyMaterial,
162 message: message.to_string(),
163 needed_by: vec![needed_by.to_string()],
164 });
165 }
166}
167
168impl std::fmt::Display for EnvPreflightReport {
169 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170 let warned = self.warnings.len();
175 let advisory_note = if warned > 0 {
176 format!(" ({warned} advisory warning(s))")
177 } else {
178 String::new()
179 };
180 if self.failures.is_empty() {
181 return write!(
182 f,
183 "{} preflight check(s) passed{advisory_note}",
184 self.checks.saturating_sub(warned)
185 );
186 }
187 writeln!(
188 f,
189 "{} of {} preflight check(s) failed{advisory_note}:",
190 self.failures.len(),
191 self.checks
192 )?;
193 for failure in &self.failures {
194 writeln!(
195 f,
196 " ✗ {} [needed by: {}]",
197 failure.message,
198 failure.needed_by.join(", ")
199 )?;
200 }
201 Ok(())
202 }
203}
204
205pub struct EnvProbes<'a> {
214 pub tool: &'a dyn Fn(&str) -> bool,
216 pub endpoint: &'a dyn Fn(&str) -> Result<(), String>,
218 pub docker: &'a dyn Fn() -> bool,
220}
221
222pub fn evaluate(
234 requirements: &[SourcedRequirement],
235 env: &dyn Fn(&str) -> Option<String>,
236 probes: &EnvProbes<'_>,
237) -> EnvPreflightReport {
238 let mut unique: Vec<(EnvRequirement, Vec<String>, bool)> = Vec::new();
243 for sr in requirements {
244 match unique.iter_mut().find(|(r, _, _)| *r == sr.requirement) {
245 Some((_, sources, advisory)) => {
246 if !sources.contains(&sr.source) {
247 sources.push(sr.source.clone());
248 }
249 *advisory = *advisory && sr.advisory;
250 }
251 None => unique.push((sr.requirement.clone(), vec![sr.source.clone()], sr.advisory)),
252 }
253 }
254
255 let mut failures = Vec::new();
256 let mut warnings = Vec::new();
257 let checks = unique.len();
258 for (req, needed_by, advisory) in unique {
259 if let Some((kind, message)) = check_one(&req, env, probes) {
260 let failure = EnvCheckFailure {
261 kind,
262 message,
263 needed_by,
264 };
265 if advisory {
268 warnings.push(failure);
269 } else {
270 failures.push(failure);
271 }
272 }
273 }
274 EnvPreflightReport {
275 checks,
276 failures,
277 warnings,
278 }
279}
280
281fn present(env: &dyn Fn(&str) -> Option<String>, var: &str) -> bool {
282 env(var).is_some_and(|v| !v.is_empty())
283}
284
285fn check_one(
286 req: &EnvRequirement,
287 env: &dyn Fn(&str) -> Option<String>,
288 probes: &EnvProbes<'_>,
289) -> Option<(FailureKind, String)> {
290 match req {
291 EnvRequirement::Tool { name } => (!(probes.tool)(name)).then(|| {
296 (
297 FailureKind::MissingTool,
298 format!("tool '{name}' not found on PATH"),
299 )
300 }),
301 EnvRequirement::ToolAnyOf { names } => {
302 let any = names.iter().any(|n| (probes.tool)(n));
303 (!any).then(|| {
304 (
305 FailureKind::MissingTool,
306 format!("none of the tool(s) [{}] found on PATH", names.join(", ")),
307 )
308 })
309 }
310 EnvRequirement::EnvAllOf { vars } => {
311 let missing: Vec<&str> = vars
312 .iter()
313 .filter(|v| !present(env, v))
314 .map(String::as_str)
315 .collect();
316 (!missing.is_empty()).then(|| {
317 (
318 FailureKind::MissingEnv,
319 format!("env var(s) missing or empty: {}", missing.join(", ")),
320 )
321 })
322 }
323 EnvRequirement::EnvAnyOf { vars } => {
324 let any = vars.iter().any(|v| present(env, v));
325 (!any).then(|| {
326 (
327 FailureKind::MissingEnv,
328 format!(
329 "none of the env var(s) [{}] is set and non-empty",
330 vars.join(", ")
331 ),
332 )
333 })
334 }
335 EnvRequirement::Endpoint { url } => match (probes.endpoint)(url) {
336 Ok(()) => None,
337 Err(reason) => Some((
338 FailureKind::EndpointUnreachable,
339 format!("endpoint '{url}' unreachable: {reason}"),
340 )),
341 },
342 EnvRequirement::DockerDaemon => (!(probes.docker)()).then(|| {
343 (
344 FailureKind::DockerUnavailable,
345 "docker daemon unreachable ('docker info' failed)".to_string(),
346 )
347 }),
348 EnvRequirement::KeyEnv { kind, var } => match env(var).filter(|v| !v.is_empty()) {
349 None => Some((
350 FailureKind::MissingEnv,
351 format!("env var(s) missing or empty: {var}"),
352 )),
353 Some(value) => validate_key_material(*kind, &value).err().map(|reason| {
354 (
355 FailureKind::BadKeyMaterial,
356 format!(
357 "env var {var} does not hold a usable {}: {reason}",
358 kind.label()
359 ),
360 )
361 }),
362 },
363 EnvRequirement::KeyFile { kind, path } => match std::fs::read(path) {
364 Err(e) => Some((
365 FailureKind::BadKeyMaterial,
366 format!(
367 "key file '{path}' not readable ({}): {}",
368 kind.label(),
369 e.kind()
370 ),
371 )),
372 Ok(bytes) => {
373 match String::from_utf8(bytes) {
376 Err(_) if *kind == KeyKind::PgpPrivate => None,
377 Err(_) => Some((
378 FailureKind::BadKeyMaterial,
379 format!(
380 "key file '{path}' is not valid UTF-8 (expected {})",
381 kind.label()
382 ),
383 )),
384 Ok(text) => validate_key_material(*kind, &text).err().map(|reason| {
385 (
386 FailureKind::BadKeyMaterial,
387 format!(
388 "key file '{path}' does not hold a usable {}: {reason}",
389 kind.label()
390 ),
391 )
392 }),
393 }
394 }
395 },
396 }
397}
398
399pub fn validate_key_material(kind: KeyKind, content: &str) -> Result<(), String> {
406 match kind {
407 KeyKind::SshPrivate => validate_ssh_private_key(content),
408 KeyKind::PgpPrivate => validate_pgp_private_key(content),
409 KeyKind::Cosign => validate_cosign_key(content),
410 }
411}
412
413fn validate_ssh_private_key(content: &str) -> Result<(), String> {
414 let trimmed_start = content.trim_start();
415 if !trimmed_start.starts_with("-----BEGIN ") {
416 return Err("missing '-----BEGIN ... PRIVATE KEY-----' header".to_string());
417 }
418 let header_line = trimmed_start.lines().next().unwrap_or("");
419 if !header_line.contains("PRIVATE KEY-----") {
420 return Err("first line is not a PRIVATE KEY PEM header".to_string());
421 }
422 let Some(end_pos) = content.rfind("-----END ") else {
423 return Err("missing '-----END ... PRIVATE KEY-----' footer".to_string());
424 };
425 let footer = &content[end_pos..];
426 if !footer.contains("PRIVATE KEY-----") {
427 return Err("footer is not a PRIVATE KEY PEM footer".to_string());
428 }
429 Ok(())
435}
436
437fn validate_pgp_private_key(content: &str) -> Result<(), String> {
438 let trimmed = content.trim_start();
439 if trimmed.starts_with("-----BEGIN PGP PRIVATE KEY BLOCK-----") {
440 if !content.contains("-----END PGP PRIVATE KEY BLOCK-----") {
441 return Err("missing '-----END PGP PRIVATE KEY BLOCK-----' footer".to_string());
442 }
443 return Ok(());
444 }
445 if trimmed.as_bytes().first().is_some_and(|b| b & 0x80 == 0x80) {
447 return Ok(());
448 }
449 Err(
450 "missing '-----BEGIN PGP PRIVATE KEY BLOCK-----' header (not armored or binary OpenPGP)"
451 .to_string(),
452 )
453}
454
455fn validate_cosign_key(content: &str) -> Result<(), String> {
456 let trimmed = content.trim_start();
457 let known = [
458 "-----BEGIN ENCRYPTED SIGSTORE PRIVATE KEY-----",
459 "-----BEGIN ENCRYPTED COSIGN PRIVATE KEY-----",
460 "-----BEGIN PRIVATE KEY-----",
461 "-----BEGIN EC PRIVATE KEY-----",
462 ];
463 if known.iter().any(|h| trimmed.starts_with(h)) {
464 if !content.contains("-----END ") {
465 return Err("missing PEM END footer".to_string());
466 }
467 return Ok(());
468 }
469 Err("missing sigstore/cosign PEM header".to_string())
470}
471
472pub fn template_env_refs(s: &str) -> Vec<String> {
482 let mut out = Vec::new();
483 let mut rest = s;
484 while let Some(open) = rest.find("{{") {
485 let after = &rest[open + 2..];
486 let Some(close) = after.find("}}") else {
487 break;
488 };
489 let expr = &after[..close];
490 if !expr.contains("default(") {
491 collect_env_names(expr, &mut out);
492 }
493 rest = &after[close + 2..];
494 }
495 out
496}
497
498pub fn configured_producer_stages(
525 config: &crate::config::Config,
526) -> std::collections::BTreeSet<&'static str> {
527 let crates = config.crate_universe();
528 CONFIGURED_PRODUCERS
529 .iter()
530 .filter(|p| (p.configured)(config, &crates))
531 .map(|p| p.token)
532 .collect()
533}
534
535pub fn os_native_producer_tokens(host_os: &str) -> Vec<&'static str> {
545 CONFIGURED_PRODUCERS
546 .iter()
547 .filter(|p| p.native_os == host_os)
548 .map(|p| p.token)
549 .collect()
550}
551
552struct ConfiguredProducer {
561 token: &'static str,
564 native_os: &'static str,
567 configured: fn(&crate::config::Config, &[&crate::config::CrateConfig]) -> bool,
571}
572
573const CONFIGURED_PRODUCERS: &[ConfiguredProducer] = &[
584 ConfiguredProducer {
585 token: "nfpm",
586 native_os: "linux",
587 configured: |_, cs| cs.iter().any(|c| c.nfpms.iter().flatten().next().is_some()),
588 },
589 ConfiguredProducer {
590 token: "makeself",
591 native_os: "linux",
592 configured: |cfg, _| !cfg.makeselfs.is_empty(),
593 },
594 ConfiguredProducer {
595 token: "snapcraft",
596 native_os: "linux",
597 configured: |_, cs| {
598 cs.iter()
599 .any(|c| c.snapcrafts.iter().flatten().next().is_some())
600 },
601 },
602 ConfiguredProducer {
603 token: "srpm",
604 native_os: "linux",
605 configured: |cfg, _| cfg.srpms.is_some(),
606 },
607 ConfiguredProducer {
608 token: "docker",
609 native_os: "linux",
610 configured: |_, cs| {
611 cs.iter()
612 .any(|c| c.dockers_v2.iter().flatten().next().is_some())
613 },
614 },
615 ConfiguredProducer {
616 token: "appimage",
617 native_os: "linux",
618 configured: |cfg, _| !cfg.appimages.is_empty(),
619 },
620 ConfiguredProducer {
621 token: "flatpak",
622 native_os: "linux",
623 configured: |_, cs| {
624 cs.iter()
625 .any(|c| c.flatpaks.iter().flatten().next().is_some())
626 },
627 },
628 ConfiguredProducer {
629 token: "appbundle",
630 native_os: "macos",
631 configured: |_, cs| {
632 cs.iter()
633 .any(|c| c.app_bundles.iter().flatten().next().is_some())
634 },
635 },
636 ConfiguredProducer {
637 token: "dmg",
638 native_os: "macos",
639 configured: |_, cs| cs.iter().any(|c| c.dmgs.iter().flatten().next().is_some()),
640 },
641 ConfiguredProducer {
642 token: "pkg",
643 native_os: "macos",
644 configured: |_, cs| cs.iter().any(|c| c.pkgs.iter().flatten().next().is_some()),
645 },
646 ConfiguredProducer {
647 token: "msi",
648 native_os: "windows",
649 configured: |_, cs| cs.iter().any(|c| c.msis.iter().flatten().next().is_some()),
650 },
651 ConfiguredProducer {
652 token: "nsis",
653 native_os: "windows",
654 configured: |_, cs| cs.iter().any(|c| c.nsis.iter().flatten().next().is_some()),
655 },
656];
657
658pub fn sole_env_ref(s: &str) -> Option<String> {
663 let t = s.trim();
664 if !(t.starts_with("{{") && t.ends_with("}}")) || t[2..].contains("{{") {
665 return None;
666 }
667 let refs = template_env_refs(t);
668 let inner = t[2..t.len() - 2].trim();
669 let bare = inner.strip_prefix('.').unwrap_or(inner);
670 match refs.as_slice() {
671 [only] if bare == format!("Env.{only}") => Some(only.clone()),
672 _ => None,
673 }
674}
675
676pub fn env_scheme_refs(s: &str) -> Vec<String> {
678 let mut out = Vec::new();
679 let mut rest = s;
680 while let Some(pos) = rest.find("env://") {
681 let tail = &rest[pos + 6..];
682 let name: String = tail
683 .chars()
684 .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
685 .collect();
686 if !name.is_empty() && !out.contains(&name) {
687 out.push(name.clone());
688 }
689 rest = &tail[name.len()..];
690 }
691 out
692}
693
694fn collect_env_names(expr: &str, out: &mut Vec<String>) {
695 let mut rest = expr;
696 while let Some(pos) = rest.find("Env.") {
697 let preceded_ok = match rest[..pos].chars().next_back() {
701 None => true,
702 Some(c) => !(c.is_ascii_alphanumeric() || c == '_'),
703 };
704 let tail = &rest[pos + 4..];
705 let name: String = tail
706 .chars()
707 .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
708 .collect();
709 if preceded_ok && !name.is_empty() && !out.contains(&name) {
710 out.push(name.clone());
711 }
712 rest = &tail[name.len().min(tail.len())..];
713 }
714}
715
716pub fn env_ref_requirements(source: &str, value: &str) -> Vec<SourcedRequirement> {
720 let mut out = Vec::new();
721 let refs = template_env_refs(value);
722 if !refs.is_empty() {
723 out.push(SourcedRequirement::new(
724 source,
725 EnvRequirement::EnvAllOf { vars: refs },
726 ));
727 }
728 for var in env_scheme_refs(value) {
729 out.push(SourcedRequirement::new(
730 source,
731 EnvRequirement::EnvAllOf { vars: vec![var] },
732 ));
733 }
734 out
735}
736
737pub fn entry_inactive(
744 ctx: &crate::context::Context,
745 skip: Option<&crate::config::StringOrBool>,
746 skip_upload: Option<&crate::config::StringOrBool>,
747 if_condition: Option<&str>,
748) -> bool {
749 let truthy = |v: &crate::config::StringOrBool| {
750 v.try_evaluates_to_true(|t| ctx.render_template(t))
751 .unwrap_or(false)
752 };
753 if skip.is_some_and(truthy) || skip_upload.is_some_and(truthy) {
754 return true;
755 }
756 crate::config::active_if_gate(if_condition).is_some_and(|cond| {
757 matches!(
758 crate::config::evaluate_if_condition(Some(cond), "preflight", |t| ctx
759 .render_template(t)),
760 Ok(false)
761 )
762 })
763}
764
765pub fn secret_requirement(
770 config_value: Option<&str>,
771 fallback_env: &str,
772) -> Option<EnvRequirement> {
773 match config_value.filter(|v| !v.is_empty()) {
774 Some(v) => {
775 let refs = template_env_refs(v);
776 (!refs.is_empty()).then_some(EnvRequirement::EnvAllOf { vars: refs })
777 }
778 None => Some(EnvRequirement::EnvAllOf {
779 vars: vec![fallback_env.to_string()],
780 }),
781 }
782}
783
784pub fn configured_build_targets(ctx: &crate::context::Context) -> Vec<String> {
800 let default_targets: Vec<String> = ctx.config.effective_default_targets();
801 let mut out: Vec<String> = Vec::new();
802 let mut push = |t: &str| {
803 if !out.iter().any(|x| x == t) {
804 out.push(t.to_string());
805 }
806 };
807 for krate in ctx.config.crate_universe() {
808 for t in crate::build_plan::crate_target_list(krate, &default_targets, |build| {
809 entry_inactive(ctx, build.skip.as_ref(), None, None)
810 }) {
811 push(&t);
812 }
813 }
814 if let Some(single) = ctx.options.single_target.as_deref() {
815 if out.iter().any(|t| t == single) {
816 out.retain(|t| t == single);
817 } else {
818 out = crate::partial::find_runtime_target(single, &out)
819 .into_iter()
820 .collect();
821 }
822 }
823 out
824}
825
826#[cfg(test)]
827mod tests {
828 use super::*;
829
830 #[test]
831 fn configured_producer_stages_reports_only_present_blocks() {
832 use crate::config::{Config, CrateConfig, FlatpakConfig, NfpmConfig, SrpmConfig};
833
834 let empty = Config {
836 crates: vec![CrateConfig {
837 name: "x".to_string(),
838 path: ".".to_string(),
839 ..Default::default()
840 }],
841 ..Default::default()
842 };
843 assert!(configured_producer_stages(&empty).is_empty());
844
845 let configured = Config {
848 crates: vec![CrateConfig {
849 name: "x".to_string(),
850 path: ".".to_string(),
851 nfpms: Some(vec![NfpmConfig::default()]),
852 flatpaks: Some(vec![FlatpakConfig::default()]),
853 ..Default::default()
854 }],
855 srpms: Some(SrpmConfig::default()),
856 ..Default::default()
857 };
858 let got = configured_producer_stages(&configured);
859 assert!(got.contains("nfpm"));
860 assert!(got.contains("flatpak"));
861 assert!(got.contains("srpm"));
862 assert!(!got.contains("docker"));
863 assert!(!got.contains("appimage"));
864 assert!(!got.contains("build"));
866 assert!(!got.contains("archive"));
867
868 let empty_vec = Config {
870 crates: vec![CrateConfig {
871 name: "x".to_string(),
872 path: ".".to_string(),
873 nfpms: Some(vec![]),
874 ..Default::default()
875 }],
876 ..Default::default()
877 };
878 assert!(!configured_producer_stages(&empty_vec).contains("nfpm"));
879 }
880
881 fn no_env(_: &str) -> Option<String> {
882 None
883 }
884
885 fn all_pass_probes() -> EnvProbes<'static> {
886 EnvProbes {
887 tool: &|_| true,
888 endpoint: &|_| Ok(()),
889 docker: &|| true,
890 }
891 }
892
893 fn req(source: &str, r: EnvRequirement) -> SourcedRequirement {
894 SourcedRequirement::new(source, r)
895 }
896
897 #[test]
898 fn empty_requirements_pass() {
899 let report = evaluate(&[], &no_env, &all_pass_probes());
900 assert!(report.ok());
901 assert_eq!(report.checks, 0);
902 }
903
904 #[test]
905 fn advisory_tool_failure_warns_and_does_not_fail_the_gate() {
906 let none = EnvProbes {
911 tool: &|_| false,
912 endpoint: &|_| Ok(()),
913 docker: &|| true,
914 };
915 let reqs = vec![SourcedRequirement::new_advisory(
916 "stage:build",
917 EnvRequirement::Tool { name: "zig".into() },
918 )];
919 let report = evaluate(&reqs, &no_env, &none);
920 assert!(report.ok(), "advisory failure must not flip ok()");
921 assert!(report.failures.is_empty());
922 assert_eq!(report.warnings.len(), 1);
923 assert!(report.warnings[0].message.contains("zig"));
924 assert_eq!(report.checks, 1);
925 }
926
927 #[test]
928 fn hard_source_overrides_advisory_for_the_same_tool() {
929 let none = EnvProbes {
932 tool: &|_| false,
933 endpoint: &|_| Ok(()),
934 docker: &|| true,
935 };
936 let reqs = vec![
937 SourcedRequirement::new_advisory(
938 "stage:build",
939 EnvRequirement::Tool { name: "cc".into() },
940 ),
941 SourcedRequirement::new("stage:other", EnvRequirement::Tool { name: "cc".into() }),
942 ];
943 let report = evaluate(&reqs, &no_env, &none);
944 assert!(
945 !report.ok(),
946 "a hard source makes the merged check blocking"
947 );
948 assert_eq!(report.failures.len(), 1);
949 assert!(report.warnings.is_empty());
950 }
951
952 #[test]
953 fn advisory_merge_is_order_independent_and_holds_for_many_advisory_sources() {
954 let none = EnvProbes {
959 tool: &|_| false,
960 endpoint: &|_| Ok(()),
961 docker: &|| true,
962 };
963 let reqs = vec![
964 SourcedRequirement::new("stage:other", EnvRequirement::Tool { name: "cc".into() }),
965 SourcedRequirement::new_advisory(
966 "stage:build",
967 EnvRequirement::Tool { name: "cc".into() },
968 ),
969 SourcedRequirement::new_advisory(
970 "stage:build",
971 EnvRequirement::Tool { name: "zig".into() },
972 ),
973 SourcedRequirement::new_advisory(
974 "stage:archive",
975 EnvRequirement::Tool { name: "zig".into() },
976 ),
977 ];
978 let report = evaluate(&reqs, &no_env, &none);
979 assert_eq!(report.checks, 2, "cc and zig dedup to two checks");
980 assert!(
981 !report.ok(),
982 "hard-first cc still blocks regardless of order"
983 );
984 assert_eq!(report.failures.len(), 1);
985 assert!(report.failures[0].message.contains("cc"));
986 assert_eq!(
987 report.warnings.len(),
988 1,
989 "zig stays a single advisory warning"
990 );
991 assert!(report.warnings[0].message.contains("zig"));
992 assert_eq!(report.warnings[0].needed_by.len(), 2);
994 }
995
996 #[test]
997 fn tool_any_of_passes_when_one_is_present() {
998 let ladder = EnvRequirement::ToolAnyOf {
999 names: vec!["hdiutil".into(), "genisoimage".into(), "mkisofs".into()],
1000 };
1001 let probes = EnvProbes {
1002 tool: &|name| name == "mkisofs",
1003 endpoint: &|_| Ok(()),
1004 docker: &|| true,
1005 };
1006 let report = evaluate(&[req("stage:dmg", ladder.clone())], &no_env, &probes);
1007 assert!(report.ok(), "one available tool must satisfy the ladder");
1008
1009 let none_available = EnvProbes {
1010 tool: &|_| false,
1011 endpoint: &|_| Ok(()),
1012 docker: &|| true,
1013 };
1014 let report = evaluate(&[req("stage:dmg", ladder)], &no_env, &none_available);
1015 assert_eq!(report.failures.len(), 1);
1016 assert_eq!(report.failures[0].kind, FailureKind::MissingTool);
1017 assert!(
1018 report.failures[0].message.contains("hdiutil")
1019 && report.failures[0].message.contains("mkisofs"),
1020 "message must list the whole ladder: {}",
1021 report.failures[0].message
1022 );
1023 }
1024
1025 #[test]
1026 fn collects_all_failures_in_one_pass() {
1027 let reqs = vec![
1028 req(
1029 "stage:nfpm",
1030 EnvRequirement::Tool {
1031 name: "nfpm".into(),
1032 },
1033 ),
1034 req(
1035 "publish:cargo",
1036 EnvRequirement::EnvAllOf {
1037 vars: vec!["CARGO_REGISTRY_TOKEN".into()],
1038 },
1039 ),
1040 req("stage:docker", EnvRequirement::DockerDaemon),
1041 req(
1042 "stage:blob",
1043 EnvRequirement::Endpoint {
1044 url: "http://minio.example".into(),
1045 },
1046 ),
1047 ];
1048 let probes = EnvProbes {
1049 tool: &|_| false,
1050 endpoint: &|_| Err("connection refused".into()),
1051 docker: &|| false,
1052 };
1053 let report = evaluate(&reqs, &no_env, &probes);
1054 assert_eq!(report.checks, 4);
1055 assert_eq!(
1056 report.failures.len(),
1057 4,
1058 "every failure must be reported in one pass: {report}"
1059 );
1060 }
1061
1062 #[test]
1063 fn classifies_tool_vs_env_failures() {
1064 let reqs = vec![
1065 req(
1066 "a",
1067 EnvRequirement::Tool {
1068 name: "syft".into(),
1069 },
1070 ),
1071 req(
1072 "b",
1073 EnvRequirement::EnvAllOf {
1074 vars: vec!["NPM_TOKEN".into()],
1075 },
1076 ),
1077 ];
1078 let probes = EnvProbes {
1079 tool: &|_| false,
1080 endpoint: &|_| Ok(()),
1081 docker: &|| true,
1082 };
1083 let report = evaluate(&reqs, &no_env, &probes);
1084 assert_eq!(report.failures[0].kind, FailureKind::MissingTool);
1085 assert_eq!(report.failures[1].kind, FailureKind::MissingEnv);
1086 }
1087
1088 #[test]
1089 fn dedup_merges_sources_for_identical_requirements() {
1090 let reqs = vec![
1091 req(
1092 "publish:homebrew",
1093 EnvRequirement::Tool { name: "git".into() },
1094 ),
1095 req("publish:scoop", EnvRequirement::Tool { name: "git".into() }),
1096 req(
1097 "publish:homebrew",
1098 EnvRequirement::Tool { name: "git".into() },
1099 ),
1100 ];
1101 let probes = EnvProbes {
1102 tool: &|_| false,
1103 endpoint: &|_| Ok(()),
1104 docker: &|| true,
1105 };
1106 let report = evaluate(&reqs, &no_env, &probes);
1107 assert_eq!(report.checks, 1, "identical requirements must merge");
1108 assert_eq!(
1109 report.failures[0].needed_by,
1110 vec!["publish:homebrew".to_string(), "publish:scoop".to_string()]
1111 );
1112 }
1113
1114 #[test]
1115 fn env_any_of_passes_when_one_var_present() {
1116 let reqs = vec![req(
1117 "publish:release",
1118 EnvRequirement::EnvAnyOf {
1119 vars: vec!["ANODIZER_GITHUB_TOKEN".into(), "GITHUB_TOKEN".into()],
1120 },
1121 )];
1122 let env = |k: &str| (k == "GITHUB_TOKEN").then(|| "tok".to_string());
1123 let report = evaluate(&reqs, &env, &all_pass_probes());
1124 assert!(report.ok(), "{report}");
1125 }
1126
1127 #[test]
1128 fn empty_env_value_counts_as_missing() {
1129 let reqs = vec![req(
1130 "s",
1131 EnvRequirement::EnvAllOf {
1132 vars: vec!["EMPTY_VAR".into()],
1133 },
1134 )];
1135 let env = |_: &str| Some(String::new());
1136 let report = evaluate(&reqs, &env, &all_pass_probes());
1137 assert_eq!(report.failures.len(), 1);
1138 assert_eq!(report.failures[0].kind, FailureKind::MissingEnv);
1139 }
1140
1141 #[test]
1142 fn report_never_echoes_secret_values() {
1143 const SECRET: &str = "hunter2-super-secret-value";
1144 let reqs = vec![
1145 req(
1146 "publish:aur",
1147 EnvRequirement::KeyEnv {
1148 kind: KeyKind::SshPrivate,
1149 var: "AUR_SSH_KEY".into(),
1150 },
1151 ),
1152 req(
1153 "stage:sign",
1154 EnvRequirement::KeyEnv {
1155 kind: KeyKind::Cosign,
1156 var: "COSIGN_KEY".into(),
1157 },
1158 ),
1159 ];
1160 let env = |_: &str| Some(SECRET.to_string());
1163 let report = evaluate(&reqs, &env, &all_pass_probes());
1164 assert_eq!(report.failures.len(), 2);
1165 let rendered = report.to_string();
1166 assert!(
1167 !rendered.contains(SECRET),
1168 "report leaked a secret value: {rendered}"
1169 );
1170 let json = serde_json::to_string(&report).unwrap();
1171 assert!(!json.contains(SECRET), "json report leaked a secret value");
1172 }
1173
1174 #[test]
1175 fn ssh_key_valid_openssh_block_passes() {
1176 let key = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaA==\n-----END OPENSSH PRIVATE KEY-----\n";
1177 assert!(validate_key_material(KeyKind::SshPrivate, key).is_ok());
1178 }
1179
1180 #[test]
1181 fn ssh_key_missing_trailing_newline_passes() {
1182 let key =
1183 "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaA==\n-----END OPENSSH PRIVATE KEY-----";
1184 assert!(validate_key_material(KeyKind::SshPrivate, key).is_ok());
1185 }
1186
1187 #[test]
1188 fn ssh_key_garbage_is_rejected_without_echo() {
1189 let err = validate_key_material(KeyKind::SshPrivate, "not-a-key-material").unwrap_err();
1190 assert!(!err.contains("not-a-key-material"));
1191 }
1192
1193 #[test]
1194 fn pgp_armored_and_binary_pass_garbage_fails() {
1195 let armored =
1196 "-----BEGIN PGP PRIVATE KEY BLOCK-----\nxx\n-----END PGP PRIVATE KEY BLOCK-----\n";
1197 assert!(validate_key_material(KeyKind::PgpPrivate, armored).is_ok());
1198 let binary = "\u{95}binarystuff";
1200 assert!(validate_key_material(KeyKind::PgpPrivate, binary).is_ok());
1201 assert!(validate_key_material(KeyKind::PgpPrivate, "plain text").is_err());
1202 }
1203
1204 #[test]
1205 fn cosign_sigstore_header_passes() {
1206 let key = "-----BEGIN ENCRYPTED SIGSTORE PRIVATE KEY-----\nxx\n-----END ENCRYPTED SIGSTORE PRIVATE KEY-----\n";
1207 assert!(validate_key_material(KeyKind::Cosign, key).is_ok());
1208 assert!(validate_key_material(KeyKind::Cosign, "ghp_token").is_err());
1209 }
1210
1211 #[test]
1212 fn key_env_missing_var_classifies_as_missing_env() {
1213 let reqs = vec![req(
1214 "publish:aur",
1215 EnvRequirement::KeyEnv {
1216 kind: KeyKind::SshPrivate,
1217 var: "AUR_SSH_KEY".into(),
1218 },
1219 )];
1220 let report = evaluate(&reqs, &no_env, &all_pass_probes());
1221 assert_eq!(report.failures[0].kind, FailureKind::MissingEnv);
1222 }
1223
1224 #[test]
1225 fn key_file_missing_and_invalid_are_flagged() {
1226 let dir = tempfile::tempdir().unwrap();
1227 let missing = dir.path().join("absent.key");
1228 let invalid = dir.path().join("invalid.key");
1229 std::fs::write(&invalid, "not a pgp key").unwrap();
1230 let reqs = vec![
1231 req(
1232 "stage:nfpm",
1233 EnvRequirement::KeyFile {
1234 kind: KeyKind::PgpPrivate,
1235 path: missing.display().to_string(),
1236 },
1237 ),
1238 req(
1239 "stage:nfpm",
1240 EnvRequirement::KeyFile {
1241 kind: KeyKind::PgpPrivate,
1242 path: invalid.display().to_string(),
1243 },
1244 ),
1245 ];
1246 let report = evaluate(&reqs, &no_env, &all_pass_probes());
1247 assert_eq!(report.failures.len(), 2);
1248 assert!(
1249 report
1250 .failures
1251 .iter()
1252 .all(|f| f.kind == FailureKind::BadKeyMaterial)
1253 );
1254 }
1255
1256 #[test]
1257 fn template_env_refs_handles_both_styles_and_default_filter() {
1258 assert_eq!(
1259 template_env_refs("{{ .Env.AUR_SSH_KEY }}"),
1260 vec!["AUR_SSH_KEY"]
1261 );
1262 assert_eq!(
1263 template_env_refs("{{ Env.MINIO_ENDPOINT }}/bucket"),
1264 vec!["MINIO_ENDPOINT"]
1265 );
1266 assert_eq!(
1267 template_env_refs("{{ Env.A }}-{{ .Env.B }}"),
1268 vec!["A", "B"]
1269 );
1270 assert!(
1271 template_env_refs(r#"{{ Env.OPTIONAL | default(value="x") }}"#).is_empty(),
1272 "default()-filtered refs are satisfiable without the var"
1273 );
1274 assert!(template_env_refs("no refs here").is_empty());
1275 assert!(
1276 template_env_refs("{{ MyEnv.NOT_AN_ENV }}").is_empty(),
1277 "identifiers ending in 'Env.' must not match"
1278 );
1279 }
1280
1281 #[test]
1282 fn sole_env_ref_only_matches_whole_single_expressions() {
1283 assert_eq!(
1284 sole_env_ref("{{ .Env.AUR_SSH_KEY }}"),
1285 Some("AUR_SSH_KEY".to_string())
1286 );
1287 assert_eq!(
1288 sole_env_ref("{{ Env.AUR_SSH_KEY }}"),
1289 Some("AUR_SSH_KEY".to_string())
1290 );
1291 assert_eq!(sole_env_ref("prefix {{ .Env.X }}"), None);
1292 assert_eq!(sole_env_ref("{{ .Env.X }}{{ .Env.Y }}"), None);
1293 assert_eq!(sole_env_ref("/path/to/key"), None);
1294 }
1295
1296 #[test]
1297 fn env_scheme_refs_extracts_cosign_style() {
1298 assert_eq!(
1299 env_scheme_refs("--key=env://COSIGN_KEY"),
1300 vec!["COSIGN_KEY"]
1301 );
1302 assert!(env_scheme_refs("--key=cosign.key").is_empty());
1303 }
1304
1305 #[test]
1306 fn endpoint_failure_includes_url_and_reason() {
1307 let reqs = vec![req(
1308 "stage:blob",
1309 EnvRequirement::Endpoint {
1310 url: "http://minio.local:9000".into(),
1311 },
1312 )];
1313 let probes = EnvProbes {
1314 tool: &|_| true,
1315 endpoint: &|_| Err("timed out".into()),
1316 docker: &|| true,
1317 };
1318 let report = evaluate(&reqs, &no_env, &probes);
1319 assert!(
1320 report.failures[0]
1321 .message
1322 .contains("http://minio.local:9000")
1323 );
1324 assert!(report.failures[0].message.contains("timed out"));
1325 }
1326
1327 #[test]
1328 fn configured_build_targets_mirror_build_resolution() {
1329 use crate::config::{BuildConfig, Config, CrateConfig, StringOrBool};
1330 use crate::context::{Context, ContextOptions};
1331
1332 let krate = |name: &str, builds: Option<Vec<BuildConfig>>| CrateConfig {
1333 name: name.to_string(),
1334 builds,
1335 ..Default::default()
1336 };
1337
1338 let config = Config {
1342 crates: vec![krate(
1343 "app",
1344 Some(vec![BuildConfig {
1345 binary: Some("app".to_string()),
1346 ..Default::default()
1347 }]),
1348 )],
1349 ..Default::default()
1350 };
1351 let ctx = Context::new(config, ContextOptions::default());
1352 assert_eq!(
1353 configured_build_targets(&ctx),
1354 crate::target::DEFAULT_TARGETS
1355 .iter()
1356 .map(|s| (*s).to_string())
1357 .collect::<Vec<_>>()
1358 );
1359
1360 let config = Config {
1365 crates: vec![
1366 krate(
1367 "app",
1368 Some(vec![
1369 BuildConfig {
1370 binary: Some("app".to_string()),
1371 targets: Some(vec!["x86_64-unknown-linux-gnu".to_string()]),
1372 ..Default::default()
1373 },
1374 BuildConfig {
1375 binary: Some("app".to_string()),
1376 targets: Some(vec!["x86_64-pc-windows-msvc".to_string()]),
1377 skip: Some(StringOrBool::Bool(true)),
1378 ..Default::default()
1379 },
1380 ]),
1381 ),
1382 krate(
1383 "helper",
1384 Some(vec![BuildConfig {
1385 binary: Some("helper".to_string()),
1386 targets: Some(vec![
1387 "aarch64-apple-darwin".to_string(),
1388 "x86_64-unknown-linux-gnu".to_string(),
1389 ]),
1390 ..Default::default()
1391 }]),
1392 ),
1393 ],
1394 ..Default::default()
1395 };
1396 let ctx = Context::new(config.clone(), ContextOptions::default());
1397 assert_eq!(
1398 configured_build_targets(&ctx),
1399 vec![
1400 "x86_64-unknown-linux-gnu".to_string(),
1401 "aarch64-apple-darwin".to_string(),
1402 ]
1403 );
1404
1405 let ctx = Context::new(
1407 config,
1408 ContextOptions {
1409 single_target: Some("aarch64-apple-darwin".to_string()),
1410 ..Default::default()
1411 },
1412 );
1413 assert_eq!(
1414 configured_build_targets(&ctx),
1415 vec!["aarch64-apple-darwin".to_string()]
1416 );
1417 }
1418
1419 #[test]
1425 fn the_preflight_target_union_is_the_enumeration_ssots_answer() {
1426 use crate::config::{BuildConfig, Config, CrateConfig, StringOrBool};
1427 use crate::context::{Context, ContextOptions};
1428
1429 let build = |binary: &str, targets: Option<Vec<&str>>, skip: bool| BuildConfig {
1433 binary: Some(binary.to_string()),
1434 targets: targets.map(|ts| ts.iter().map(|t| (*t).to_string()).collect()),
1435 skip: skip.then_some(StringOrBool::Bool(true)),
1436 ..Default::default()
1437 };
1438 let config = Config {
1439 crates: vec![
1440 CrateConfig {
1441 name: "app".to_string(),
1442 builds: Some(vec![
1443 build("app", Some(vec!["x86_64-unknown-linux-gnu"]), false),
1444 build("app", Some(vec!["x86_64-pc-windows-msvc"]), true),
1445 build("app", Some(vec![]), false),
1446 ]),
1447 ..Default::default()
1448 },
1449 CrateConfig {
1450 name: "helper".to_string(),
1451 builds: Some(vec![build(
1452 "helper",
1453 Some(vec!["aarch64-apple-darwin", "x86_64-unknown-linux-gnu"]),
1454 false,
1455 )]),
1456 ..Default::default()
1457 },
1458 ],
1459 ..Default::default()
1460 };
1461 let ctx = Context::new(config, ContextOptions::default());
1462 let default_targets = ctx.config.effective_default_targets();
1463 let mut expected: Vec<String> = Vec::new();
1464 for krate in ctx.config.crate_universe() {
1465 for t in crate::build_plan::crate_target_list(krate, &default_targets, |b| {
1466 entry_inactive(&ctx, b.skip.as_ref(), None, None)
1467 }) {
1468 if !expected.contains(&t) {
1469 expected.push(t);
1470 }
1471 }
1472 }
1473 assert_eq!(configured_build_targets(&ctx), expected);
1474 }
1475
1476 #[test]
1477 fn configured_build_targets_no_bin_crate_with_defaults_builds_template_contributes_nothing() {
1478 use crate::config::{BuildConfig, Config, CrateConfig};
1479 use crate::context::{Context, ContextOptions};
1480
1481 let dir = tempfile::tempdir().unwrap();
1486 std::fs::write(
1487 dir.path().join("Cargo.toml"),
1488 "[package]\nname = \"lib\"\nversion = \"0.0.0\"\n",
1489 )
1490 .unwrap();
1491 let config = Config {
1492 crates: vec![CrateConfig {
1493 name: "lib".to_string(),
1494 path: dir.path().to_string_lossy().into_owned(),
1495 builds: Some(vec![BuildConfig {
1496 targets: Some(vec!["aarch64-unknown-linux-gnu".to_string()]),
1498 ..Default::default()
1499 }]),
1500 ..Default::default()
1501 }],
1502 ..Default::default()
1503 };
1504 let ctx = Context::new(config, ContextOptions::default());
1505 assert!(
1506 configured_build_targets(&ctx).is_empty(),
1507 "library crate with a binary:None template build compiles nothing",
1508 );
1509 }
1510}