Skip to main content

anodizer_core/
env_preflight.rs

1//! Config-derived environment preflight: requirement vocabulary + check engine.
2//!
3//! Before any release stage runs, every enabled publisher and stage declares
4//! the environment it needs — CLI tools on PATH, env vars/secrets, endpoint
5//! reachability, loadable key material — derived from the **same resolved
6//! config its run path reads** (publishers via
7//! [`crate::Publisher::requirements`], stages via their crate's
8//! `env_requirements` function). The engine checks every requirement in one
9//! pass and reports **all** failures together, so one run shows the complete
10//! environment state instead of failing on the first missing secret.
11//!
12//! Failure messages never include env-var *values* — only names, tool names,
13//! configured URLs, and structural descriptions of key material.
14
15use serde::Serialize;
16
17// ---------------------------------------------------------------------------
18// Requirement vocabulary
19// ---------------------------------------------------------------------------
20
21/// Key-material families the preflight can structurally validate.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
23#[serde(rename_all = "snake_case")]
24pub enum KeyKind {
25    /// OpenSSH / PEM private key (AUR pushes over ssh).
26    SshPrivate,
27    /// PGP private key — ASCII-armored block or binary keyring packet
28    /// (gpg signing, nfpm deb/rpm signatures).
29    PgpPrivate,
30    /// Cosign / sigstore private key (`COSIGN_KEY`, `env://` refs).
31    Cosign,
32}
33
34impl KeyKind {
35    /// Human label used in failure messages.
36    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/// One environment prerequisite a publisher or stage derives from its
46/// resolved config.
47#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
48#[serde(tag = "type", rename_all = "snake_case")]
49pub enum EnvRequirement {
50    /// A CLI tool that must be resolvable on PATH.
51    Tool { name: String },
52    /// At least one of the listed CLI tools must be resolvable on PATH
53    /// (stages with a detection ladder, e.g. dmg's
54    /// hdiutil → genisoimage → mkisofs).
55    ToolAnyOf { names: Vec<String> },
56    /// Every listed env var must be present and non-empty.
57    EnvAllOf { vars: Vec<String> },
58    /// At least one of the listed env vars must be present and non-empty.
59    EnvAnyOf { vars: Vec<String> },
60    /// An HTTP(S) endpoint from config that must be reachable.
61    Endpoint { url: String },
62    /// A reachable docker daemon (`docker info` must succeed).
63    DockerDaemon,
64    /// An env var that must hold parseable key material of `kind`.
65    KeyEnv { kind: KeyKind, var: String },
66    /// A file path (already template-rendered) that must hold parseable
67    /// key material of `kind`.
68    KeyFile { kind: KeyKind, path: String },
69}
70
71/// A requirement tagged with the publisher/stage that declared it.
72#[derive(Debug, Clone)]
73pub struct SourcedRequirement {
74    /// Declaring surface, e.g. `"publish:aur"` or `"stage:nfpm"`.
75    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// ---------------------------------------------------------------------------
89// Report
90// ---------------------------------------------------------------------------
91
92/// Classification of a preflight failure.
93#[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/// One failed check, with every publisher/stage that needs it.
104#[derive(Debug, Clone, Serialize)]
105pub struct EnvCheckFailure {
106    pub kind: FailureKind,
107    /// Human-readable description. Never contains secret values.
108    pub message: String,
109    /// Sources (publishers/stages) whose declared requirement failed.
110    pub needed_by: Vec<String>,
111}
112
113/// Aggregate result of one preflight pass.
114#[derive(Debug, Clone, Serialize)]
115pub struct EnvPreflightReport {
116    /// Distinct checks evaluated (after de-duplication across sources).
117    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
150// ---------------------------------------------------------------------------
151// Probes — injectable so the engine stays pure and unit-testable
152// ---------------------------------------------------------------------------
153
154/// Side-effecting probes injected into [`evaluate`]. Production callers wire
155/// `tool` to [`crate::util::find_binary`] (a pure PATH lookup mirroring how
156/// stages spawn commands), `endpoint` to [`crate::http`], and `docker` to
157/// [`crate::tool_detect`]; tests inject closures.
158pub struct EnvProbes<'a> {
159    /// `true` when the tool resolves on PATH.
160    pub tool: &'a dyn Fn(&str) -> bool,
161    /// `Ok(())` when the endpoint answers HTTP; `Err(reason)` otherwise.
162    pub endpoint: &'a dyn Fn(&str) -> Result<(), String>,
163    /// `true` when a docker daemon answers `docker info`.
164    pub docker: &'a dyn Fn() -> bool,
165}
166
167// ---------------------------------------------------------------------------
168// Engine
169// ---------------------------------------------------------------------------
170
171/// Evaluate every requirement, de-duplicating identical requirements across
172/// sources, and return a collect-all report.
173///
174/// `env` resolves an env-var name to its value (callers typically merge the
175/// template `Env` map — which includes `env_files` entries — with the
176/// process environment). Values are only tested for presence/shape; they
177/// never appear in the report.
178pub fn evaluate(
179    requirements: &[SourcedRequirement],
180    env: &dyn Fn(&str) -> Option<String>,
181    probes: &EnvProbes<'_>,
182) -> EnvPreflightReport {
183    // De-duplicate while preserving first-seen order; N is small enough
184    // that linear scan beats pulling in an ordered-map dependency.
185    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                // Binary (non-UTF-8) content is accepted for PGP keyring
300                // exports; the armored validators only apply to text.
301                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
325// ---------------------------------------------------------------------------
326// Key-material validation (structural parse — never echoes content)
327// ---------------------------------------------------------------------------
328
329/// Structurally validate key material. Returns `Err(reason)` with a
330/// description that never includes the key content.
331pub 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    // A missing trailing newline after the END marker is NOT an error:
356    // the key writer normalizes the content to exactly one trailing
357    // newline before ssh ever reads it, so a secret stored via a tool
358    // that strips the final newline still works at run time. Failing
359    // here would reject input the pipeline provably tolerates.
360    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    // Binary OpenPGP packet: first byte has the packet-tag high bit set.
372    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
398// ---------------------------------------------------------------------------
399// Template env-reference extraction
400// ---------------------------------------------------------------------------
401
402/// Extract env-var names referenced as `{{ .Env.NAME }}` / `{{ Env.NAME }}`
403/// from a templated config string.
404///
405/// References inside a template expression that applies a `default(`
406/// filter are skipped — a defaulted lookup is satisfiable without the var.
407pub 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
424/// The full crate universe of a resolved config: top-level `crates` plus
425/// every workspace's crates (first-seen name wins, matching the publish
426/// path's `all_crates`). Requirement derivation must union across ALL
427/// publishable crates so per-crate workspace mode preflights the same
428/// surface the per-crate pipeline will run.
429pub 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
441/// When the entire string is a single `{{ [.]Env.NAME }}` expression,
442/// return `NAME`. Used to decide whether a templated secret field maps to
443/// validatable key material from one env var (vs. a composite template,
444/// where only presence of the referenced vars can be required).
445pub 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
459/// Extract env-var names referenced via cosign's `env://NAME` key scheme.
460pub 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        // Accept both `.Env.NAME` (Go-template style the preprocessor
481        // translates) and bare `Env.NAME` (native Tera object lookup), but
482        // not identifiers that merely end in `Env.` (e.g. `MyEnv.`).
483        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
499/// Convenience: map every env reference in a templated config string
500/// (both `{{ .Env.X }}` and `env://X` forms) to an [`EnvRequirement`],
501/// tagged with `source`.
502pub 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
520/// True when a config entry is statically inactive for this run: its
521/// `skip:` / `skip_upload:` evaluates truthy, or its `if:` condition
522/// renders falsy. Mirrors the run-path gating for requirement derivation —
523/// a `skip: true` entry must not demand tools or credentials from
524/// preflight. Anything unrenderable is treated as ACTIVE so preflight
525/// over-collects rather than silently under-collecting.
526pub 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
548/// Requirement for a templated secret-bearing config value with an env-var
549/// fallback: a set value declares its `{{ .Env.X }}` references (a literal
550/// declares nothing — the credential is inline); an unset value declares
551/// the fallback env var the run path reads instead.
552pub 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
567/// The union of build targets this run would compile, mirroring the build
568/// stage's resolution: per-build `targets:` (an explicitly empty list means
569/// "skip this build"), else `defaults.targets`, else the built-in default
570/// matrix; a skipped build (`skip:` truthy) contributes nothing; an
571/// unrenderable `skip:` counts as active (over-collect). `--single-target`
572/// narrows the union to the requested triple (exact match first, then the
573/// same OS/arch alias fallback the build stage applies), so a host-only
574/// release never demands cross-platform bundler tools.
575pub 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            // No builds configured: the build stage synthesizes a default
608            // binary build over the default target matrix.
609            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        // Both vars hold the secret but are structurally invalid keys, so
816        // both checks fail — the failure text must never leak the value.
817        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        // 0x95 = binary OpenPGP secret-key packet tag byte.
854        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        // No builds anywhere: the default matrix applies.
994        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        // Explicit per-build targets win; a skipped build and an
1008        // explicitly-empty target list contribute nothing; the union spans
1009        // crates.
1010        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        // --single-target narrows the union to the requested triple.
1049        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}