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}
78
79impl SourcedRequirement {
80 pub fn new(source: impl Into<String>, requirement: EnvRequirement) -> Self {
81 Self {
82 source: source.into(),
83 requirement,
84 }
85 }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
94#[serde(rename_all = "snake_case")]
95pub enum FailureKind {
96 MissingTool,
97 MissingEnv,
98 EndpointUnreachable,
99 DockerUnavailable,
100 BadKeyMaterial,
101}
102
103#[derive(Debug, Clone, Serialize)]
105pub struct EnvCheckFailure {
106 pub kind: FailureKind,
107 pub message: String,
109 pub needed_by: Vec<String>,
111}
112
113#[derive(Debug, Clone, Serialize)]
115pub struct EnvPreflightReport {
116 pub checks: usize,
118 pub failures: Vec<EnvCheckFailure>,
119}
120
121impl EnvPreflightReport {
122 pub fn ok(&self) -> bool {
123 self.failures.is_empty()
124 }
125}
126
127impl std::fmt::Display for EnvPreflightReport {
128 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129 if self.failures.is_empty() {
130 return write!(f, "{} preflight check(s) passed", self.checks);
131 }
132 writeln!(
133 f,
134 "{} of {} preflight check(s) failed:",
135 self.failures.len(),
136 self.checks
137 )?;
138 for failure in &self.failures {
139 writeln!(
140 f,
141 " ✗ {} [needed by: {}]",
142 failure.message,
143 failure.needed_by.join(", ")
144 )?;
145 }
146 Ok(())
147 }
148}
149
150pub struct EnvProbes<'a> {
159 pub tool: &'a dyn Fn(&str) -> bool,
161 pub endpoint: &'a dyn Fn(&str) -> Result<(), String>,
163 pub docker: &'a dyn Fn() -> bool,
165}
166
167pub fn evaluate(
179 requirements: &[SourcedRequirement],
180 env: &dyn Fn(&str) -> Option<String>,
181 probes: &EnvProbes<'_>,
182) -> EnvPreflightReport {
183 let mut unique: Vec<(EnvRequirement, Vec<String>)> = Vec::new();
186 for sr in requirements {
187 match unique.iter_mut().find(|(r, _)| *r == sr.requirement) {
188 Some((_, sources)) => {
189 if !sources.contains(&sr.source) {
190 sources.push(sr.source.clone());
191 }
192 }
193 None => unique.push((sr.requirement.clone(), vec![sr.source.clone()])),
194 }
195 }
196
197 let mut failures = Vec::new();
198 let checks = unique.len();
199 for (req, needed_by) in unique {
200 if let Some((kind, message)) = check_one(&req, env, probes) {
201 failures.push(EnvCheckFailure {
202 kind,
203 message,
204 needed_by,
205 });
206 }
207 }
208 EnvPreflightReport { checks, failures }
209}
210
211fn present(env: &dyn Fn(&str) -> Option<String>, var: &str) -> bool {
212 env(var).is_some_and(|v| !v.is_empty())
213}
214
215fn check_one(
216 req: &EnvRequirement,
217 env: &dyn Fn(&str) -> Option<String>,
218 probes: &EnvProbes<'_>,
219) -> Option<(FailureKind, String)> {
220 match req {
221 EnvRequirement::Tool { name } => (!(probes.tool)(name)).then(|| {
222 (
223 FailureKind::MissingTool,
224 format!("required tool '{name}' not found on PATH"),
225 )
226 }),
227 EnvRequirement::ToolAnyOf { names } => {
228 let any = names.iter().any(|n| (probes.tool)(n));
229 (!any).then(|| {
230 (
231 FailureKind::MissingTool,
232 format!("none of the tool(s) [{}] found on PATH", names.join(", ")),
233 )
234 })
235 }
236 EnvRequirement::EnvAllOf { vars } => {
237 let missing: Vec<&str> = vars
238 .iter()
239 .filter(|v| !present(env, v))
240 .map(String::as_str)
241 .collect();
242 (!missing.is_empty()).then(|| {
243 (
244 FailureKind::MissingEnv,
245 format!("env var(s) missing or empty: {}", missing.join(", ")),
246 )
247 })
248 }
249 EnvRequirement::EnvAnyOf { vars } => {
250 let any = vars.iter().any(|v| present(env, v));
251 (!any).then(|| {
252 (
253 FailureKind::MissingEnv,
254 format!(
255 "none of the env var(s) [{}] is set and non-empty",
256 vars.join(", ")
257 ),
258 )
259 })
260 }
261 EnvRequirement::Endpoint { url } => match (probes.endpoint)(url) {
262 Ok(()) => None,
263 Err(reason) => Some((
264 FailureKind::EndpointUnreachable,
265 format!("endpoint '{url}' unreachable: {reason}"),
266 )),
267 },
268 EnvRequirement::DockerDaemon => (!(probes.docker)()).then(|| {
269 (
270 FailureKind::DockerUnavailable,
271 "docker daemon unreachable ('docker info' failed)".to_string(),
272 )
273 }),
274 EnvRequirement::KeyEnv { kind, var } => match env(var).filter(|v| !v.is_empty()) {
275 None => Some((
276 FailureKind::MissingEnv,
277 format!("env var(s) missing or empty: {var}"),
278 )),
279 Some(value) => validate_key_material(*kind, &value).err().map(|reason| {
280 (
281 FailureKind::BadKeyMaterial,
282 format!(
283 "env var {var} does not hold a usable {}: {reason}",
284 kind.label()
285 ),
286 )
287 }),
288 },
289 EnvRequirement::KeyFile { kind, path } => match std::fs::read(path) {
290 Err(e) => Some((
291 FailureKind::BadKeyMaterial,
292 format!(
293 "key file '{path}' not readable ({}): {}",
294 kind.label(),
295 e.kind()
296 ),
297 )),
298 Ok(bytes) => {
299 match String::from_utf8(bytes) {
302 Err(_) if *kind == KeyKind::PgpPrivate => None,
303 Err(_) => Some((
304 FailureKind::BadKeyMaterial,
305 format!(
306 "key file '{path}' is not valid UTF-8 (expected {})",
307 kind.label()
308 ),
309 )),
310 Ok(text) => validate_key_material(*kind, &text).err().map(|reason| {
311 (
312 FailureKind::BadKeyMaterial,
313 format!(
314 "key file '{path}' does not hold a usable {}: {reason}",
315 kind.label()
316 ),
317 )
318 }),
319 }
320 }
321 },
322 }
323}
324
325pub fn validate_key_material(kind: KeyKind, content: &str) -> Result<(), String> {
332 match kind {
333 KeyKind::SshPrivate => validate_ssh_private_key(content),
334 KeyKind::PgpPrivate => validate_pgp_private_key(content),
335 KeyKind::Cosign => validate_cosign_key(content),
336 }
337}
338
339fn validate_ssh_private_key(content: &str) -> Result<(), String> {
340 let trimmed_start = content.trim_start();
341 if !trimmed_start.starts_with("-----BEGIN ") {
342 return Err("missing '-----BEGIN ... PRIVATE KEY-----' header".to_string());
343 }
344 let header_line = trimmed_start.lines().next().unwrap_or("");
345 if !header_line.contains("PRIVATE KEY-----") {
346 return Err("first line is not a PRIVATE KEY PEM header".to_string());
347 }
348 let Some(end_pos) = content.rfind("-----END ") else {
349 return Err("missing '-----END ... PRIVATE KEY-----' footer".to_string());
350 };
351 let footer = &content[end_pos..];
352 if !footer.contains("PRIVATE KEY-----") {
353 return Err("footer is not a PRIVATE KEY PEM footer".to_string());
354 }
355 Ok(())
361}
362
363fn validate_pgp_private_key(content: &str) -> Result<(), String> {
364 let trimmed = content.trim_start();
365 if trimmed.starts_with("-----BEGIN PGP PRIVATE KEY BLOCK-----") {
366 if !content.contains("-----END PGP PRIVATE KEY BLOCK-----") {
367 return Err("missing '-----END PGP PRIVATE KEY BLOCK-----' footer".to_string());
368 }
369 return Ok(());
370 }
371 if trimmed.as_bytes().first().is_some_and(|b| b & 0x80 == 0x80) {
373 return Ok(());
374 }
375 Err(
376 "missing '-----BEGIN PGP PRIVATE KEY BLOCK-----' header (not armored or binary OpenPGP)"
377 .to_string(),
378 )
379}
380
381fn validate_cosign_key(content: &str) -> Result<(), String> {
382 let trimmed = content.trim_start();
383 let known = [
384 "-----BEGIN ENCRYPTED SIGSTORE PRIVATE KEY-----",
385 "-----BEGIN ENCRYPTED COSIGN PRIVATE KEY-----",
386 "-----BEGIN PRIVATE KEY-----",
387 "-----BEGIN EC PRIVATE KEY-----",
388 ];
389 if known.iter().any(|h| trimmed.starts_with(h)) {
390 if !content.contains("-----END ") {
391 return Err("missing PEM END footer".to_string());
392 }
393 return Ok(());
394 }
395 Err("missing sigstore/cosign PEM header".to_string())
396}
397
398pub fn template_env_refs(s: &str) -> Vec<String> {
408 let mut out = Vec::new();
409 let mut rest = s;
410 while let Some(open) = rest.find("{{") {
411 let after = &rest[open + 2..];
412 let Some(close) = after.find("}}") else {
413 break;
414 };
415 let expr = &after[..close];
416 if !expr.contains("default(") {
417 collect_env_names(expr, &mut out);
418 }
419 rest = &after[close + 2..];
420 }
421 out
422}
423
424pub fn crate_universe(config: &crate::config::Config) -> Vec<&crate::config::CrateConfig> {
430 let mut out: Vec<&crate::config::CrateConfig> = config.crates.iter().collect();
431 for ws in config.workspaces.iter().flatten() {
432 for c in &ws.crates {
433 if !out.iter().any(|e| e.name == c.name) {
434 out.push(c);
435 }
436 }
437 }
438 out
439}
440
441pub fn sole_env_ref(s: &str) -> Option<String> {
446 let t = s.trim();
447 if !(t.starts_with("{{") && t.ends_with("}}")) || t[2..].contains("{{") {
448 return None;
449 }
450 let refs = template_env_refs(t);
451 let inner = t[2..t.len() - 2].trim();
452 let bare = inner.strip_prefix('.').unwrap_or(inner);
453 match refs.as_slice() {
454 [only] if bare == format!("Env.{only}") => Some(only.clone()),
455 _ => None,
456 }
457}
458
459pub fn env_scheme_refs(s: &str) -> Vec<String> {
461 let mut out = Vec::new();
462 let mut rest = s;
463 while let Some(pos) = rest.find("env://") {
464 let tail = &rest[pos + 6..];
465 let name: String = tail
466 .chars()
467 .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
468 .collect();
469 if !name.is_empty() && !out.contains(&name) {
470 out.push(name.clone());
471 }
472 rest = &tail[name.len()..];
473 }
474 out
475}
476
477fn collect_env_names(expr: &str, out: &mut Vec<String>) {
478 let mut rest = expr;
479 while let Some(pos) = rest.find("Env.") {
480 let preceded_ok = match rest[..pos].chars().next_back() {
484 None => true,
485 Some(c) => !(c.is_ascii_alphanumeric() || c == '_'),
486 };
487 let tail = &rest[pos + 4..];
488 let name: String = tail
489 .chars()
490 .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
491 .collect();
492 if preceded_ok && !name.is_empty() && !out.contains(&name) {
493 out.push(name.clone());
494 }
495 rest = &tail[name.len().min(tail.len())..];
496 }
497}
498
499pub fn env_ref_requirements(source: &str, value: &str) -> Vec<SourcedRequirement> {
503 let mut out = Vec::new();
504 let refs = template_env_refs(value);
505 if !refs.is_empty() {
506 out.push(SourcedRequirement::new(
507 source,
508 EnvRequirement::EnvAllOf { vars: refs },
509 ));
510 }
511 for var in env_scheme_refs(value) {
512 out.push(SourcedRequirement::new(
513 source,
514 EnvRequirement::EnvAllOf { vars: vec![var] },
515 ));
516 }
517 out
518}
519
520pub fn entry_inactive(
527 ctx: &crate::context::Context,
528 skip: Option<&crate::config::StringOrBool>,
529 skip_upload: Option<&crate::config::StringOrBool>,
530 if_condition: Option<&str>,
531) -> bool {
532 let truthy = |v: &crate::config::StringOrBool| {
533 v.try_evaluates_to_true(|t| ctx.render_template(t))
534 .unwrap_or(false)
535 };
536 if skip.is_some_and(truthy) || skip_upload.is_some_and(truthy) {
537 return true;
538 }
539 if_condition.is_some_and(|cond| {
540 matches!(
541 crate::config::evaluate_if_condition(Some(cond), "preflight", |t| ctx
542 .render_template(t)),
543 Ok(false)
544 )
545 })
546}
547
548pub fn secret_requirement(
553 config_value: Option<&str>,
554 fallback_env: &str,
555) -> Option<EnvRequirement> {
556 match config_value.filter(|v| !v.is_empty()) {
557 Some(v) => {
558 let refs = template_env_refs(v);
559 (!refs.is_empty()).then_some(EnvRequirement::EnvAllOf { vars: refs })
560 }
561 None => Some(EnvRequirement::EnvAllOf {
562 vars: vec![fallback_env.to_string()],
563 }),
564 }
565}
566
567pub fn configured_build_targets(ctx: &crate::context::Context) -> Vec<String> {
576 let default_targets: Vec<String> = ctx
577 .config
578 .defaults
579 .as_ref()
580 .and_then(|d| d.targets.clone())
581 .filter(|t| !t.is_empty())
582 .unwrap_or_else(|| {
583 crate::target::DEFAULT_TARGETS
584 .iter()
585 .map(|s| (*s).to_string())
586 .collect()
587 });
588 let mut out: Vec<String> = Vec::new();
589 let mut push = |t: &str| {
590 if !out.iter().any(|x| x == t) {
591 out.push(t.to_string());
592 }
593 };
594 for krate in crate_universe(&ctx.config) {
595 match krate.builds.as_ref().filter(|b| !b.is_empty()) {
596 Some(builds) => {
597 for build in builds {
598 if entry_inactive(ctx, build.skip.as_ref(), None, None) {
599 continue;
600 }
601 match build.targets.as_ref() {
602 Some(targets) => targets.iter().for_each(|t| push(t)),
603 None => default_targets.iter().for_each(|t| push(t)),
604 }
605 }
606 }
607 None => default_targets.iter().for_each(|t| push(t)),
610 }
611 }
612 if let Some(single) = ctx.options.single_target.as_deref() {
613 if out.iter().any(|t| t == single) {
614 out.retain(|t| t == single);
615 } else {
616 out = crate::partial::find_runtime_target(single, &out)
617 .into_iter()
618 .collect();
619 }
620 }
621 out
622}
623
624#[cfg(test)]
625mod tests {
626 use super::*;
627
628 fn no_env(_: &str) -> Option<String> {
629 None
630 }
631
632 fn all_pass_probes() -> EnvProbes<'static> {
633 EnvProbes {
634 tool: &|_| true,
635 endpoint: &|_| Ok(()),
636 docker: &|| true,
637 }
638 }
639
640 fn req(source: &str, r: EnvRequirement) -> SourcedRequirement {
641 SourcedRequirement::new(source, r)
642 }
643
644 #[test]
645 fn empty_requirements_pass() {
646 let report = evaluate(&[], &no_env, &all_pass_probes());
647 assert!(report.ok());
648 assert_eq!(report.checks, 0);
649 }
650
651 #[test]
652 fn tool_any_of_passes_when_one_is_present() {
653 let ladder = EnvRequirement::ToolAnyOf {
654 names: vec!["hdiutil".into(), "genisoimage".into(), "mkisofs".into()],
655 };
656 let probes = EnvProbes {
657 tool: &|name| name == "mkisofs",
658 endpoint: &|_| Ok(()),
659 docker: &|| true,
660 };
661 let report = evaluate(&[req("stage:dmg", ladder.clone())], &no_env, &probes);
662 assert!(report.ok(), "one available tool must satisfy the ladder");
663
664 let none_available = EnvProbes {
665 tool: &|_| false,
666 endpoint: &|_| Ok(()),
667 docker: &|| true,
668 };
669 let report = evaluate(&[req("stage:dmg", ladder)], &no_env, &none_available);
670 assert_eq!(report.failures.len(), 1);
671 assert_eq!(report.failures[0].kind, FailureKind::MissingTool);
672 assert!(
673 report.failures[0].message.contains("hdiutil")
674 && report.failures[0].message.contains("mkisofs"),
675 "message must list the whole ladder: {}",
676 report.failures[0].message
677 );
678 }
679
680 #[test]
681 fn collects_all_failures_in_one_pass() {
682 let reqs = vec![
683 req(
684 "stage:nfpm",
685 EnvRequirement::Tool {
686 name: "nfpm".into(),
687 },
688 ),
689 req(
690 "publish:cargo",
691 EnvRequirement::EnvAllOf {
692 vars: vec!["CARGO_REGISTRY_TOKEN".into()],
693 },
694 ),
695 req("stage:docker", EnvRequirement::DockerDaemon),
696 req(
697 "stage:blob",
698 EnvRequirement::Endpoint {
699 url: "http://minio.example".into(),
700 },
701 ),
702 ];
703 let probes = EnvProbes {
704 tool: &|_| false,
705 endpoint: &|_| Err("connection refused".into()),
706 docker: &|| false,
707 };
708 let report = evaluate(&reqs, &no_env, &probes);
709 assert_eq!(report.checks, 4);
710 assert_eq!(
711 report.failures.len(),
712 4,
713 "every failure must be reported in one pass: {report}"
714 );
715 }
716
717 #[test]
718 fn classifies_tool_vs_env_failures() {
719 let reqs = vec![
720 req(
721 "a",
722 EnvRequirement::Tool {
723 name: "syft".into(),
724 },
725 ),
726 req(
727 "b",
728 EnvRequirement::EnvAllOf {
729 vars: vec!["NPM_TOKEN".into()],
730 },
731 ),
732 ];
733 let probes = EnvProbes {
734 tool: &|_| false,
735 endpoint: &|_| Ok(()),
736 docker: &|| true,
737 };
738 let report = evaluate(&reqs, &no_env, &probes);
739 assert_eq!(report.failures[0].kind, FailureKind::MissingTool);
740 assert_eq!(report.failures[1].kind, FailureKind::MissingEnv);
741 }
742
743 #[test]
744 fn dedup_merges_sources_for_identical_requirements() {
745 let reqs = vec![
746 req(
747 "publish:homebrew",
748 EnvRequirement::Tool { name: "git".into() },
749 ),
750 req("publish:scoop", EnvRequirement::Tool { name: "git".into() }),
751 req(
752 "publish:homebrew",
753 EnvRequirement::Tool { name: "git".into() },
754 ),
755 ];
756 let probes = EnvProbes {
757 tool: &|_| false,
758 endpoint: &|_| Ok(()),
759 docker: &|| true,
760 };
761 let report = evaluate(&reqs, &no_env, &probes);
762 assert_eq!(report.checks, 1, "identical requirements must merge");
763 assert_eq!(
764 report.failures[0].needed_by,
765 vec!["publish:homebrew".to_string(), "publish:scoop".to_string()]
766 );
767 }
768
769 #[test]
770 fn env_any_of_passes_when_one_var_present() {
771 let reqs = vec![req(
772 "publish:release",
773 EnvRequirement::EnvAnyOf {
774 vars: vec!["ANODIZER_GITHUB_TOKEN".into(), "GITHUB_TOKEN".into()],
775 },
776 )];
777 let env = |k: &str| (k == "GITHUB_TOKEN").then(|| "tok".to_string());
778 let report = evaluate(&reqs, &env, &all_pass_probes());
779 assert!(report.ok(), "{report}");
780 }
781
782 #[test]
783 fn empty_env_value_counts_as_missing() {
784 let reqs = vec![req(
785 "s",
786 EnvRequirement::EnvAllOf {
787 vars: vec!["EMPTY_VAR".into()],
788 },
789 )];
790 let env = |_: &str| Some(String::new());
791 let report = evaluate(&reqs, &env, &all_pass_probes());
792 assert_eq!(report.failures.len(), 1);
793 assert_eq!(report.failures[0].kind, FailureKind::MissingEnv);
794 }
795
796 #[test]
797 fn report_never_echoes_secret_values() {
798 const SECRET: &str = "hunter2-super-secret-value";
799 let reqs = vec![
800 req(
801 "publish:aur",
802 EnvRequirement::KeyEnv {
803 kind: KeyKind::SshPrivate,
804 var: "AUR_SSH_KEY".into(),
805 },
806 ),
807 req(
808 "stage:sign",
809 EnvRequirement::KeyEnv {
810 kind: KeyKind::Cosign,
811 var: "COSIGN_KEY".into(),
812 },
813 ),
814 ];
815 let env = |_: &str| Some(SECRET.to_string());
818 let report = evaluate(&reqs, &env, &all_pass_probes());
819 assert_eq!(report.failures.len(), 2);
820 let rendered = report.to_string();
821 assert!(
822 !rendered.contains(SECRET),
823 "report leaked a secret value: {rendered}"
824 );
825 let json = serde_json::to_string(&report).unwrap();
826 assert!(!json.contains(SECRET), "json report leaked a secret value");
827 }
828
829 #[test]
830 fn ssh_key_valid_openssh_block_passes() {
831 let key = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaA==\n-----END OPENSSH PRIVATE KEY-----\n";
832 assert!(validate_key_material(KeyKind::SshPrivate, key).is_ok());
833 }
834
835 #[test]
836 fn ssh_key_missing_trailing_newline_passes() {
837 let key =
838 "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaA==\n-----END OPENSSH PRIVATE KEY-----";
839 assert!(validate_key_material(KeyKind::SshPrivate, key).is_ok());
840 }
841
842 #[test]
843 fn ssh_key_garbage_is_rejected_without_echo() {
844 let err = validate_key_material(KeyKind::SshPrivate, "not-a-key-material").unwrap_err();
845 assert!(!err.contains("not-a-key-material"));
846 }
847
848 #[test]
849 fn pgp_armored_and_binary_pass_garbage_fails() {
850 let armored =
851 "-----BEGIN PGP PRIVATE KEY BLOCK-----\nxx\n-----END PGP PRIVATE KEY BLOCK-----\n";
852 assert!(validate_key_material(KeyKind::PgpPrivate, armored).is_ok());
853 let binary = "\u{95}binarystuff";
855 assert!(validate_key_material(KeyKind::PgpPrivate, binary).is_ok());
856 assert!(validate_key_material(KeyKind::PgpPrivate, "plain text").is_err());
857 }
858
859 #[test]
860 fn cosign_sigstore_header_passes() {
861 let key = "-----BEGIN ENCRYPTED SIGSTORE PRIVATE KEY-----\nxx\n-----END ENCRYPTED SIGSTORE PRIVATE KEY-----\n";
862 assert!(validate_key_material(KeyKind::Cosign, key).is_ok());
863 assert!(validate_key_material(KeyKind::Cosign, "ghp_token").is_err());
864 }
865
866 #[test]
867 fn key_env_missing_var_classifies_as_missing_env() {
868 let reqs = vec![req(
869 "publish:aur",
870 EnvRequirement::KeyEnv {
871 kind: KeyKind::SshPrivate,
872 var: "AUR_SSH_KEY".into(),
873 },
874 )];
875 let report = evaluate(&reqs, &no_env, &all_pass_probes());
876 assert_eq!(report.failures[0].kind, FailureKind::MissingEnv);
877 }
878
879 #[test]
880 fn key_file_missing_and_invalid_are_flagged() {
881 let dir = tempfile::tempdir().unwrap();
882 let missing = dir.path().join("absent.key");
883 let invalid = dir.path().join("invalid.key");
884 std::fs::write(&invalid, "not a pgp key").unwrap();
885 let reqs = vec![
886 req(
887 "stage:nfpm",
888 EnvRequirement::KeyFile {
889 kind: KeyKind::PgpPrivate,
890 path: missing.display().to_string(),
891 },
892 ),
893 req(
894 "stage:nfpm",
895 EnvRequirement::KeyFile {
896 kind: KeyKind::PgpPrivate,
897 path: invalid.display().to_string(),
898 },
899 ),
900 ];
901 let report = evaluate(&reqs, &no_env, &all_pass_probes());
902 assert_eq!(report.failures.len(), 2);
903 assert!(
904 report
905 .failures
906 .iter()
907 .all(|f| f.kind == FailureKind::BadKeyMaterial)
908 );
909 }
910
911 #[test]
912 fn template_env_refs_handles_both_styles_and_default_filter() {
913 assert_eq!(
914 template_env_refs("{{ .Env.AUR_SSH_KEY }}"),
915 vec!["AUR_SSH_KEY"]
916 );
917 assert_eq!(
918 template_env_refs("{{ Env.MINIO_ENDPOINT }}/bucket"),
919 vec!["MINIO_ENDPOINT"]
920 );
921 assert_eq!(
922 template_env_refs("{{ Env.A }}-{{ .Env.B }}"),
923 vec!["A", "B"]
924 );
925 assert!(
926 template_env_refs(r#"{{ Env.OPTIONAL | default(value="x") }}"#).is_empty(),
927 "default()-filtered refs are satisfiable without the var"
928 );
929 assert!(template_env_refs("no refs here").is_empty());
930 assert!(
931 template_env_refs("{{ MyEnv.NOT_AN_ENV }}").is_empty(),
932 "identifiers ending in 'Env.' must not match"
933 );
934 }
935
936 #[test]
937 fn sole_env_ref_only_matches_whole_single_expressions() {
938 assert_eq!(
939 sole_env_ref("{{ .Env.AUR_SSH_KEY }}"),
940 Some("AUR_SSH_KEY".to_string())
941 );
942 assert_eq!(
943 sole_env_ref("{{ Env.AUR_SSH_KEY }}"),
944 Some("AUR_SSH_KEY".to_string())
945 );
946 assert_eq!(sole_env_ref("prefix {{ .Env.X }}"), None);
947 assert_eq!(sole_env_ref("{{ .Env.X }}{{ .Env.Y }}"), None);
948 assert_eq!(sole_env_ref("/path/to/key"), None);
949 }
950
951 #[test]
952 fn env_scheme_refs_extracts_cosign_style() {
953 assert_eq!(
954 env_scheme_refs("--key=env://COSIGN_KEY"),
955 vec!["COSIGN_KEY"]
956 );
957 assert!(env_scheme_refs("--key=cosign.key").is_empty());
958 }
959
960 #[test]
961 fn endpoint_failure_includes_url_and_reason() {
962 let reqs = vec![req(
963 "stage:blob",
964 EnvRequirement::Endpoint {
965 url: "http://minio.local:9000".into(),
966 },
967 )];
968 let probes = EnvProbes {
969 tool: &|_| true,
970 endpoint: &|_| Err("timed out".into()),
971 docker: &|| true,
972 };
973 let report = evaluate(&reqs, &no_env, &probes);
974 assert!(
975 report.failures[0]
976 .message
977 .contains("http://minio.local:9000")
978 );
979 assert!(report.failures[0].message.contains("timed out"));
980 }
981
982 #[test]
983 fn configured_build_targets_mirror_build_resolution() {
984 use crate::config::{BuildConfig, Config, CrateConfig, StringOrBool};
985 use crate::context::{Context, ContextOptions};
986
987 let krate = |name: &str, builds: Option<Vec<BuildConfig>>| CrateConfig {
988 name: name.to_string(),
989 builds,
990 ..Default::default()
991 };
992
993 let config = Config {
995 crates: vec![krate("app", None)],
996 ..Default::default()
997 };
998 let ctx = Context::new(config, ContextOptions::default());
999 assert_eq!(
1000 configured_build_targets(&ctx),
1001 crate::target::DEFAULT_TARGETS
1002 .iter()
1003 .map(|s| (*s).to_string())
1004 .collect::<Vec<_>>()
1005 );
1006
1007 let config = Config {
1011 crates: vec![
1012 krate(
1013 "app",
1014 Some(vec![
1015 BuildConfig {
1016 targets: Some(vec!["x86_64-unknown-linux-gnu".to_string()]),
1017 ..Default::default()
1018 },
1019 BuildConfig {
1020 targets: Some(vec!["x86_64-pc-windows-msvc".to_string()]),
1021 skip: Some(StringOrBool::Bool(true)),
1022 ..Default::default()
1023 },
1024 ]),
1025 ),
1026 krate(
1027 "helper",
1028 Some(vec![BuildConfig {
1029 targets: Some(vec![
1030 "aarch64-apple-darwin".to_string(),
1031 "x86_64-unknown-linux-gnu".to_string(),
1032 ]),
1033 ..Default::default()
1034 }]),
1035 ),
1036 ],
1037 ..Default::default()
1038 };
1039 let ctx = Context::new(config.clone(), ContextOptions::default());
1040 assert_eq!(
1041 configured_build_targets(&ctx),
1042 vec![
1043 "x86_64-unknown-linux-gnu".to_string(),
1044 "aarch64-apple-darwin".to_string(),
1045 ]
1046 );
1047
1048 let ctx = Context::new(
1050 config,
1051 ContextOptions {
1052 single_target: Some("aarch64-apple-darwin".to_string()),
1053 ..Default::default()
1054 },
1055 );
1056 assert_eq!(
1057 configured_build_targets(&ctx),
1058 vec!["aarch64-apple-darwin".to_string()]
1059 );
1060 }
1061}