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    /// When `true`, a failed probe is a non-fatal WARNING rather than a gate
78    /// failure: the declaring surface degrades gracefully without it. The
79    /// canonical case is the build stage's cross-compilation toolchain, which
80    /// falls back `zigbuild → cargo → system gcc` per target — so a missing
81    /// `zig`/`cargo-zigbuild` must surface as "recommended", never block a
82    /// release. `anodizer tools` still reports advisory requirements; only the
83    /// pass/fail gate treats them as non-blocking.
84    pub advisory: bool,
85}
86
87impl SourcedRequirement {
88    /// A hard requirement: a failed probe fails the preflight gate.
89    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    /// An advisory requirement: a failed probe WARNS instead of failing the
98    /// gate, because the declaring surface degrades gracefully without it (see
99    /// [`SourcedRequirement::advisory`]).
100    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// ---------------------------------------------------------------------------
110// Report
111// ---------------------------------------------------------------------------
112
113/// Classification of a preflight failure.
114#[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/// One failed check, with every publisher/stage that needs it.
125#[derive(Debug, Clone, Serialize)]
126pub struct EnvCheckFailure {
127    pub kind: FailureKind,
128    /// Human-readable description. Never contains secret values.
129    pub message: String,
130    /// Sources (publishers/stages) whose declared requirement failed.
131    pub needed_by: Vec<String>,
132}
133
134/// Aggregate result of one preflight pass.
135#[derive(Debug, Clone, Serialize)]
136pub struct EnvPreflightReport {
137    /// Distinct checks evaluated (after de-duplication across sources).
138    pub checks: usize,
139    pub failures: Vec<EnvCheckFailure>,
140    /// Advisory checks that failed: the environment lacks a tool the run would
141    /// PREFER but does not require, because the declaring surface degrades
142    /// gracefully (e.g. a missing cross-compile toolchain that the build stage
143    /// falls back from). Surfaced as warnings; never flips [`ok`](Self::ok).
144    #[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    /// Record a failure discovered outside the requirement-evaluation engine
154    /// (e.g. the offline cosign key-load verification, which needs to spawn a
155    /// tool and so cannot live in this pure module). Counts toward `checks` and
156    /// flips `ok()` to `false`, so a single non-ok report still drives the
157    /// caller's abort/exit-non-zero decision.
158    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        // `checks` counts every evaluated requirement; a failed advisory check
171        // is neither a pass nor a hard failure, so it must not inflate the
172        // "passed" count. Detail for each warning is emitted separately by the
173        // caller's warning logger — here we only summarise the count.
174        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
205// ---------------------------------------------------------------------------
206// Probes — injectable so the engine stays pure and unit-testable
207// ---------------------------------------------------------------------------
208
209/// Side-effecting probes injected into [`evaluate`]. Production callers wire
210/// `tool` to [`crate::util::find_binary`] (a pure PATH lookup mirroring how
211/// stages spawn commands), `endpoint` to [`crate::http`], and `docker` to
212/// [`crate::tool_detect`]; tests inject closures.
213pub struct EnvProbes<'a> {
214    /// `true` when the tool resolves on PATH.
215    pub tool: &'a dyn Fn(&str) -> bool,
216    /// `Ok(())` when the endpoint answers HTTP; `Err(reason)` otherwise.
217    pub endpoint: &'a dyn Fn(&str) -> Result<(), String>,
218    /// `true` when a docker daemon answers `docker info`.
219    pub docker: &'a dyn Fn() -> bool,
220}
221
222// ---------------------------------------------------------------------------
223// Engine
224// ---------------------------------------------------------------------------
225
226/// Evaluate every requirement, de-duplicating identical requirements across
227/// sources, and return a collect-all report.
228///
229/// `env` resolves an env-var name to its value (callers typically merge the
230/// template `Env` map — which includes `env_files` entries — with the
231/// process environment). Values are only tested for presence/shape; they
232/// never appear in the report.
233pub fn evaluate(
234    requirements: &[SourcedRequirement],
235    env: &dyn Fn(&str) -> Option<String>,
236    probes: &EnvProbes<'_>,
237) -> EnvPreflightReport {
238    // De-duplicate while preserving first-seen order; N is small enough
239    // that linear scan beats pulling in an ordered-map dependency. A merged
240    // requirement is advisory only when EVERY source that needs it is advisory —
241    // one hard source makes a failed probe a gate failure.
242    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            // Advisory failures warn; the surface degrades gracefully without
266            // the tool, so they must not flip `ok()` or block the release gate.
267            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        // No "required" in the message: the same text serves hard failures and
292        // advisory warnings. Required-ness is conveyed by the surrounding frame
293        // (a hard `✗ … [needed by]` vs an advisory `Warning … [recommended by]`),
294        // so "required tool 'zig' … [recommended by]" would self-contradict.
295        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                // Binary (non-UTF-8) content is accepted for PGP keyring
374                // exports; the armored validators only apply to text.
375                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
399// ---------------------------------------------------------------------------
400// Key-material validation (structural parse — never echoes content)
401// ---------------------------------------------------------------------------
402
403/// Structurally validate key material. Returns `Err(reason)` with a
404/// description that never includes the key content.
405pub 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    // A missing trailing newline after the END marker is NOT an error:
430    // the key writer normalizes the content to exactly one trailing
431    // newline before ssh ever reads it, so a secret stored via a tool
432    // that strips the final newline still works at run time. Failing
433    // here would reject input the pipeline provably tolerates.
434    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    // Binary OpenPGP packet: first byte has the packet-tag high bit set.
446    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
472// ---------------------------------------------------------------------------
473// Template env-reference extraction
474// ---------------------------------------------------------------------------
475
476/// Extract env-var names referenced as `{{ .Env.NAME }}` / `{{ Env.NAME }}`
477/// from a templated config string.
478///
479/// References inside a template expression that applies a `default(`
480/// filter are skipped — a defaulted lookup is satisfiable without the var.
481pub 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
498/// The full crate universe of a resolved config: top-level `crates` plus
499/// every workspace's crates (first-seen name wins, matching the publish
500/// path's `all_crates`). Requirement derivation must union across ALL
501/// publishable crates so per-crate workspace mode preflights the same
502/// surface the per-crate pipeline will run.
503pub fn crate_universe(config: &crate::config::Config) -> Vec<&crate::config::CrateConfig> {
504    let mut out: Vec<&crate::config::CrateConfig> = config.crates.iter().collect();
505    for ws in config.workspaces.iter().flatten() {
506        for c in &ws.crates {
507            if !out.iter().any(|e| e.name == c.name) {
508                out.push(c);
509            }
510        }
511    }
512    out
513}
514
515/// The artifact-producing stage tokens a resolved config actually
516/// configures — i.e. carries a non-empty config block for. Tokens match the
517/// determinism harness's stage names (`nfpm`, `flatpak`, `dmg`, …) and the
518/// `--skip=`/preflight vocabulary.
519///
520/// Each token gates on the SAME `Config` / `CrateConfig` field the matching
521/// stage crate's `env_requirements` reads (per-crate blocks unioned across
522/// [`crate_universe`]; top-level blocks read directly), so a consumer of this
523/// set cannot drift from which producers the pipeline will actually run. The
524/// determinism harness intersects its OS-native default partition with this
525/// set so a `--stages`-absent run only byte-verifies — and, under
526/// `--require-tools`, only requires the tools for — producers the project
527/// configures.
528///
529/// Pass a config that has already had [`crate::defaults_merge::apply_defaults`]
530/// run on it: `defaults:` materializes producer blocks onto crates, and the
531/// stage gates read the post-merge config, so a raw (pre-merge) config would
532/// miss any producer declared only under `defaults:`.
533///
534/// Block PRESENCE (≥1 entry) is the predicate; per-entry `skip:` conditions
535/// are not evaluated (a declared block is intent-to-produce — over-covering
536/// the determinism set is safe, silently under-covering is the bug this
537/// guards). The always-on stages (`build` / `source` / `archive` / `checksum`
538/// / `sbom` / `sign` / `upx`) are deliberately NOT enumerated: they carry no
539/// installer tool and emit nothing when unconfigured, so the determinism
540/// default keeps them unconditionally rather than gating on config.
541pub fn configured_producer_stages(
542    config: &crate::config::Config,
543) -> std::collections::BTreeSet<&'static str> {
544    let crates = crate_universe(config);
545    CONFIGURED_PRODUCERS
546        .iter()
547        .filter(|p| (p.configured)(config, &crates))
548        .map(|p| p.token)
549        .collect()
550}
551
552/// Stage tokens of every config-gated producer whose payload binary `host_os`
553/// natively builds. `host_os` is a `target`-style OS token
554/// (`"linux"` / `"macos"` / `"windows"`); an unrecognized value yields an
555/// empty list. Order follows [`CONFIGURED_PRODUCERS`] within the OS group.
556///
557/// The determinism harness's `--stages`-absent default unions its always-on
558/// base with this set so each OS-native installer runs on the shard that
559/// builds what it packages — routing one to a shard that lacks its payload
560/// binary is how a format silently ships in NO release.
561pub fn os_native_producer_tokens(host_os: &str) -> Vec<&'static str> {
562    CONFIGURED_PRODUCERS
563        .iter()
564        .filter(|p| p.native_os == host_os)
565        .map(|p| p.token)
566        .collect()
567}
568
569/// One artifact-producing stage the determinism harness gates on config and
570/// routes to the host OS that natively builds its payload binary.
571///
572/// Single source for both the config-configured producer set
573/// ([`configured_producer_stages`]) and the per-host OS-native producer set
574/// ([`os_native_producer_tokens`]): adding a producer to [`CONFIGURED_PRODUCERS`]
575/// extends both derivations at once, so the determinism default and the
576/// preflight set cannot list a producer the other misses.
577struct ConfiguredProducer {
578    /// Canonical stage token — matches the determinism harness's `StageId`
579    /// token vocabulary and the `--skip=` / `--stages=` names.
580    token: &'static str,
581    /// `target`-style host-OS token (`"linux"` / `"macos"` / `"windows"`)
582    /// whose native toolchain builds this producer's payload binary.
583    native_os: &'static str,
584    /// Fires when the resolved config carries ≥1 block for this producer
585    /// (per-crate blocks unioned across the supplied [`crate_universe`];
586    /// top-level blocks read off `Config` directly).
587    configured: fn(&crate::config::Config, &[&crate::config::CrateConfig]) -> bool,
588}
589
590/// Every config-gated artifact producer the determinism harness can verify,
591/// with the host OS that natively builds it and the config predicate that
592/// decides whether the project configures it.
593///
594/// Order within each OS group is load-bearing for [`os_native_producer_tokens`]:
595/// on macOS `appbundle` precedes `dmg`/`pkg` so their `use: appbundle` finds a
596/// source `.app`. The always-on base stages (`build` / `source` / `archive` /
597/// `checksum` / `sbom` / `sign` / `upx`) are deliberately absent — they carry
598/// no installer tool and emit nothing when unconfigured, so the harness keeps
599/// them unconditionally rather than gating on config or OS.
600const CONFIGURED_PRODUCERS: &[ConfiguredProducer] = &[
601    ConfiguredProducer {
602        token: "nfpm",
603        native_os: "linux",
604        configured: |_, cs| cs.iter().any(|c| c.nfpms.iter().flatten().next().is_some()),
605    },
606    ConfiguredProducer {
607        token: "makeself",
608        native_os: "linux",
609        configured: |cfg, _| !cfg.makeselfs.is_empty(),
610    },
611    ConfiguredProducer {
612        token: "snapcraft",
613        native_os: "linux",
614        configured: |_, cs| {
615            cs.iter()
616                .any(|c| c.snapcrafts.iter().flatten().next().is_some())
617        },
618    },
619    ConfiguredProducer {
620        token: "srpm",
621        native_os: "linux",
622        configured: |cfg, _| cfg.srpms.is_some(),
623    },
624    ConfiguredProducer {
625        token: "docker",
626        native_os: "linux",
627        configured: |_, cs| {
628            cs.iter()
629                .any(|c| c.dockers_v2.iter().flatten().next().is_some())
630        },
631    },
632    ConfiguredProducer {
633        token: "appimage",
634        native_os: "linux",
635        configured: |cfg, _| !cfg.appimages.is_empty(),
636    },
637    ConfiguredProducer {
638        token: "flatpak",
639        native_os: "linux",
640        configured: |_, cs| {
641            cs.iter()
642                .any(|c| c.flatpaks.iter().flatten().next().is_some())
643        },
644    },
645    ConfiguredProducer {
646        token: "appbundle",
647        native_os: "macos",
648        configured: |_, cs| {
649            cs.iter()
650                .any(|c| c.app_bundles.iter().flatten().next().is_some())
651        },
652    },
653    ConfiguredProducer {
654        token: "dmg",
655        native_os: "macos",
656        configured: |_, cs| cs.iter().any(|c| c.dmgs.iter().flatten().next().is_some()),
657    },
658    ConfiguredProducer {
659        token: "pkg",
660        native_os: "macos",
661        configured: |_, cs| cs.iter().any(|c| c.pkgs.iter().flatten().next().is_some()),
662    },
663    ConfiguredProducer {
664        token: "msi",
665        native_os: "windows",
666        configured: |_, cs| cs.iter().any(|c| c.msis.iter().flatten().next().is_some()),
667    },
668    ConfiguredProducer {
669        token: "nsis",
670        native_os: "windows",
671        configured: |_, cs| cs.iter().any(|c| c.nsis.iter().flatten().next().is_some()),
672    },
673];
674
675/// When the entire string is a single `{{ [.]Env.NAME }}` expression,
676/// return `NAME`. Used to decide whether a templated secret field maps to
677/// validatable key material from one env var (vs. a composite template,
678/// where only presence of the referenced vars can be required).
679pub fn sole_env_ref(s: &str) -> Option<String> {
680    let t = s.trim();
681    if !(t.starts_with("{{") && t.ends_with("}}")) || t[2..].contains("{{") {
682        return None;
683    }
684    let refs = template_env_refs(t);
685    let inner = t[2..t.len() - 2].trim();
686    let bare = inner.strip_prefix('.').unwrap_or(inner);
687    match refs.as_slice() {
688        [only] if bare == format!("Env.{only}") => Some(only.clone()),
689        _ => None,
690    }
691}
692
693/// Extract env-var names referenced via cosign's `env://NAME` key scheme.
694pub fn env_scheme_refs(s: &str) -> Vec<String> {
695    let mut out = Vec::new();
696    let mut rest = s;
697    while let Some(pos) = rest.find("env://") {
698        let tail = &rest[pos + 6..];
699        let name: String = tail
700            .chars()
701            .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
702            .collect();
703        if !name.is_empty() && !out.contains(&name) {
704            out.push(name.clone());
705        }
706        rest = &tail[name.len()..];
707    }
708    out
709}
710
711fn collect_env_names(expr: &str, out: &mut Vec<String>) {
712    let mut rest = expr;
713    while let Some(pos) = rest.find("Env.") {
714        // Accept both `.Env.NAME` (Go-template style the preprocessor
715        // translates) and bare `Env.NAME` (native Tera object lookup), but
716        // not identifiers that merely end in `Env.` (e.g. `MyEnv.`).
717        let preceded_ok = match rest[..pos].chars().next_back() {
718            None => true,
719            Some(c) => !(c.is_ascii_alphanumeric() || c == '_'),
720        };
721        let tail = &rest[pos + 4..];
722        let name: String = tail
723            .chars()
724            .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
725            .collect();
726        if preceded_ok && !name.is_empty() && !out.contains(&name) {
727            out.push(name.clone());
728        }
729        rest = &tail[name.len().min(tail.len())..];
730    }
731}
732
733/// Convenience: map every env reference in a templated config string
734/// (both `{{ .Env.X }}` and `env://X` forms) to an [`EnvRequirement`],
735/// tagged with `source`.
736pub fn env_ref_requirements(source: &str, value: &str) -> Vec<SourcedRequirement> {
737    let mut out = Vec::new();
738    let refs = template_env_refs(value);
739    if !refs.is_empty() {
740        out.push(SourcedRequirement::new(
741            source,
742            EnvRequirement::EnvAllOf { vars: refs },
743        ));
744    }
745    for var in env_scheme_refs(value) {
746        out.push(SourcedRequirement::new(
747            source,
748            EnvRequirement::EnvAllOf { vars: vec![var] },
749        ));
750    }
751    out
752}
753
754/// True when a config entry is statically inactive for this run: its
755/// `skip:` / `skip_upload:` evaluates truthy, or its `if:` condition
756/// renders falsy. Mirrors the run-path gating for requirement derivation —
757/// a `skip: true` entry must not demand tools or credentials from
758/// preflight. Anything unrenderable is treated as ACTIVE so preflight
759/// over-collects rather than silently under-collecting.
760pub fn entry_inactive(
761    ctx: &crate::context::Context,
762    skip: Option<&crate::config::StringOrBool>,
763    skip_upload: Option<&crate::config::StringOrBool>,
764    if_condition: Option<&str>,
765) -> bool {
766    let truthy = |v: &crate::config::StringOrBool| {
767        v.try_evaluates_to_true(|t| ctx.render_template(t))
768            .unwrap_or(false)
769    };
770    if skip.is_some_and(truthy) || skip_upload.is_some_and(truthy) {
771        return true;
772    }
773    if_condition.is_some_and(|cond| {
774        matches!(
775            crate::config::evaluate_if_condition(Some(cond), "preflight", |t| ctx
776                .render_template(t)),
777            Ok(false)
778        )
779    })
780}
781
782/// Requirement for a templated secret-bearing config value with an env-var
783/// fallback: a set value declares its `{{ .Env.X }}` references (a literal
784/// declares nothing — the credential is inline); an unset value declares
785/// the fallback env var the run path reads instead.
786pub fn secret_requirement(
787    config_value: Option<&str>,
788    fallback_env: &str,
789) -> Option<EnvRequirement> {
790    match config_value.filter(|v| !v.is_empty()) {
791        Some(v) => {
792            let refs = template_env_refs(v);
793            (!refs.is_empty()).then_some(EnvRequirement::EnvAllOf { vars: refs })
794        }
795        None => Some(EnvRequirement::EnvAllOf {
796            vars: vec![fallback_env.to_string()],
797        }),
798    }
799}
800
801/// The union of build targets this run would compile, routed through the
802/// build-synthesis SSOT ([`crate::build_plan::planned_builds`] +
803/// [`crate::build_plan::build_produces`]): per-build `targets:` (an explicitly
804/// empty list means "skip this build"), else `defaults.targets`, else the
805/// canonical default matrix (via [`crate::config::Config::effective_default_targets`]).
806/// A build the planner compiles nothing for — a library crate's materialized
807/// `binary: None` build with no matching `--bin` — contributes nothing; a
808/// skipped build (`skip:` truthy) contributes nothing; an unrenderable `skip:`
809/// counts as active (over-collect). `--single-target` narrows the union to the
810/// requested triple (exact match first, then the same OS/arch alias fallback the
811/// build stage applies), so a host-only release never demands cross-platform
812/// bundler tools.
813pub fn configured_build_targets(ctx: &crate::context::Context) -> Vec<String> {
814    let default_targets: Vec<String> = ctx.config.effective_default_targets();
815    let mut out: Vec<String> = Vec::new();
816    let mut push = |t: &str| {
817        if !out.iter().any(|x| x == t) {
818            out.push(t.to_string());
819        }
820    };
821    for krate in crate_universe(&ctx.config) {
822        // A library crate with no default binary yields no builds, so it
823        // contributes nothing — the planner's compile/artifact gate.
824        let Some(builds) = crate::build_plan::planned_builds(krate) else {
825            continue;
826        };
827        for build in &builds {
828            if entry_inactive(ctx, build.skip.as_ref(), None, None) {
829                continue;
830            }
831            if !crate::build_plan::build_produces(krate, build) {
832                continue;
833            }
834            match build.targets.as_ref() {
835                Some(targets) => targets.iter().for_each(|t| push(t)),
836                None => default_targets.iter().for_each(|t| push(t)),
837            }
838        }
839    }
840    if let Some(single) = ctx.options.single_target.as_deref() {
841        if out.iter().any(|t| t == single) {
842            out.retain(|t| t == single);
843        } else {
844            out = crate::partial::find_runtime_target(single, &out)
845                .into_iter()
846                .collect();
847        }
848    }
849    out
850}
851
852#[cfg(test)]
853mod tests {
854    use super::*;
855
856    #[test]
857    fn configured_producer_stages_reports_only_present_blocks() {
858        use crate::config::{Config, CrateConfig, FlatpakConfig, NfpmConfig, SrpmConfig};
859
860        // Empty config configures no producers.
861        let empty = Config {
862            crates: vec![CrateConfig {
863                name: "x".to_string(),
864                path: ".".to_string(),
865                ..Default::default()
866            }],
867            ..Default::default()
868        };
869        assert!(configured_producer_stages(&empty).is_empty());
870
871        // A per-crate block (nfpm/flatpak) and a top-level block (srpm) each
872        // surface their token; nothing else does.
873        let configured = Config {
874            crates: vec![CrateConfig {
875                name: "x".to_string(),
876                path: ".".to_string(),
877                nfpms: Some(vec![NfpmConfig::default()]),
878                flatpaks: Some(vec![FlatpakConfig::default()]),
879                ..Default::default()
880            }],
881            srpms: Some(SrpmConfig::default()),
882            ..Default::default()
883        };
884        let got = configured_producer_stages(&configured);
885        assert!(got.contains("nfpm"));
886        assert!(got.contains("flatpak"));
887        assert!(got.contains("srpm"));
888        assert!(!got.contains("docker"));
889        assert!(!got.contains("appimage"));
890        // Always-on base stages are never enumerated here.
891        assert!(!got.contains("build"));
892        assert!(!got.contains("archive"));
893
894        // A `Some(empty vec)` block is NOT "configured".
895        let empty_vec = Config {
896            crates: vec![CrateConfig {
897                name: "x".to_string(),
898                path: ".".to_string(),
899                nfpms: Some(vec![]),
900                ..Default::default()
901            }],
902            ..Default::default()
903        };
904        assert!(!configured_producer_stages(&empty_vec).contains("nfpm"));
905    }
906
907    fn no_env(_: &str) -> Option<String> {
908        None
909    }
910
911    fn all_pass_probes() -> EnvProbes<'static> {
912        EnvProbes {
913            tool: &|_| true,
914            endpoint: &|_| Ok(()),
915            docker: &|| true,
916        }
917    }
918
919    fn req(source: &str, r: EnvRequirement) -> SourcedRequirement {
920        SourcedRequirement::new(source, r)
921    }
922
923    #[test]
924    fn empty_requirements_pass() {
925        let report = evaluate(&[], &no_env, &all_pass_probes());
926        assert!(report.ok());
927        assert_eq!(report.checks, 0);
928    }
929
930    #[test]
931    fn advisory_tool_failure_warns_and_does_not_fail_the_gate() {
932        // A missing ADVISORY tool (the cross-compile toolchain the build stage
933        // falls back from) must surface as a warning, never a gate failure —
934        // the regression that made `anodizer release` abort on any box without
935        // zig/cargo-zigbuild.
936        let none = EnvProbes {
937            tool: &|_| false,
938            endpoint: &|_| Ok(()),
939            docker: &|| true,
940        };
941        let reqs = vec![SourcedRequirement::new_advisory(
942            "stage:build",
943            EnvRequirement::Tool { name: "zig".into() },
944        )];
945        let report = evaluate(&reqs, &no_env, &none);
946        assert!(report.ok(), "advisory failure must not flip ok()");
947        assert!(report.failures.is_empty());
948        assert_eq!(report.warnings.len(), 1);
949        assert!(report.warnings[0].message.contains("zig"));
950        assert_eq!(report.checks, 1);
951    }
952
953    #[test]
954    fn hard_source_overrides_advisory_for_the_same_tool() {
955        // A tool needed by BOTH a hard and an advisory source is a gate failure
956        // when absent: one hard need is enough. (Guards the dedup's advisory AND.)
957        let none = EnvProbes {
958            tool: &|_| false,
959            endpoint: &|_| Ok(()),
960            docker: &|| true,
961        };
962        let reqs = vec![
963            SourcedRequirement::new_advisory(
964                "stage:build",
965                EnvRequirement::Tool { name: "cc".into() },
966            ),
967            SourcedRequirement::new("stage:other", EnvRequirement::Tool { name: "cc".into() }),
968        ];
969        let report = evaluate(&reqs, &no_env, &none);
970        assert!(
971            !report.ok(),
972            "a hard source makes the merged check blocking"
973        );
974        assert_eq!(report.failures.len(), 1);
975        assert!(report.warnings.is_empty());
976    }
977
978    #[test]
979    fn advisory_merge_is_order_independent_and_holds_for_many_advisory_sources() {
980        // The dedup `advisory = advisory && sr.advisory` must be commutative:
981        // hard-THEN-advisory blocks just like advisory-then-hard, and a tool
982        // needed only by several advisory sources stays a warning. `cc` is
983        // hard-first then advisory; `zig` is advisory from two sources.
984        let none = EnvProbes {
985            tool: &|_| false,
986            endpoint: &|_| Ok(()),
987            docker: &|| true,
988        };
989        let reqs = vec![
990            SourcedRequirement::new("stage:other", EnvRequirement::Tool { name: "cc".into() }),
991            SourcedRequirement::new_advisory(
992                "stage:build",
993                EnvRequirement::Tool { name: "cc".into() },
994            ),
995            SourcedRequirement::new_advisory(
996                "stage:build",
997                EnvRequirement::Tool { name: "zig".into() },
998            ),
999            SourcedRequirement::new_advisory(
1000                "stage:archive",
1001                EnvRequirement::Tool { name: "zig".into() },
1002            ),
1003        ];
1004        let report = evaluate(&reqs, &no_env, &none);
1005        assert_eq!(report.checks, 2, "cc and zig dedup to two checks");
1006        assert!(
1007            !report.ok(),
1008            "hard-first cc still blocks regardless of order"
1009        );
1010        assert_eq!(report.failures.len(), 1);
1011        assert!(report.failures[0].message.contains("cc"));
1012        assert_eq!(
1013            report.warnings.len(),
1014            1,
1015            "zig stays a single advisory warning"
1016        );
1017        assert!(report.warnings[0].message.contains("zig"));
1018        // Both advisory sources are credited as needing zig.
1019        assert_eq!(report.warnings[0].needed_by.len(), 2);
1020    }
1021
1022    #[test]
1023    fn tool_any_of_passes_when_one_is_present() {
1024        let ladder = EnvRequirement::ToolAnyOf {
1025            names: vec!["hdiutil".into(), "genisoimage".into(), "mkisofs".into()],
1026        };
1027        let probes = EnvProbes {
1028            tool: &|name| name == "mkisofs",
1029            endpoint: &|_| Ok(()),
1030            docker: &|| true,
1031        };
1032        let report = evaluate(&[req("stage:dmg", ladder.clone())], &no_env, &probes);
1033        assert!(report.ok(), "one available tool must satisfy the ladder");
1034
1035        let none_available = EnvProbes {
1036            tool: &|_| false,
1037            endpoint: &|_| Ok(()),
1038            docker: &|| true,
1039        };
1040        let report = evaluate(&[req("stage:dmg", ladder)], &no_env, &none_available);
1041        assert_eq!(report.failures.len(), 1);
1042        assert_eq!(report.failures[0].kind, FailureKind::MissingTool);
1043        assert!(
1044            report.failures[0].message.contains("hdiutil")
1045                && report.failures[0].message.contains("mkisofs"),
1046            "message must list the whole ladder: {}",
1047            report.failures[0].message
1048        );
1049    }
1050
1051    #[test]
1052    fn collects_all_failures_in_one_pass() {
1053        let reqs = vec![
1054            req(
1055                "stage:nfpm",
1056                EnvRequirement::Tool {
1057                    name: "nfpm".into(),
1058                },
1059            ),
1060            req(
1061                "publish:cargo",
1062                EnvRequirement::EnvAllOf {
1063                    vars: vec!["CARGO_REGISTRY_TOKEN".into()],
1064                },
1065            ),
1066            req("stage:docker", EnvRequirement::DockerDaemon),
1067            req(
1068                "stage:blob",
1069                EnvRequirement::Endpoint {
1070                    url: "http://minio.example".into(),
1071                },
1072            ),
1073        ];
1074        let probes = EnvProbes {
1075            tool: &|_| false,
1076            endpoint: &|_| Err("connection refused".into()),
1077            docker: &|| false,
1078        };
1079        let report = evaluate(&reqs, &no_env, &probes);
1080        assert_eq!(report.checks, 4);
1081        assert_eq!(
1082            report.failures.len(),
1083            4,
1084            "every failure must be reported in one pass: {report}"
1085        );
1086    }
1087
1088    #[test]
1089    fn classifies_tool_vs_env_failures() {
1090        let reqs = vec![
1091            req(
1092                "a",
1093                EnvRequirement::Tool {
1094                    name: "syft".into(),
1095                },
1096            ),
1097            req(
1098                "b",
1099                EnvRequirement::EnvAllOf {
1100                    vars: vec!["NPM_TOKEN".into()],
1101                },
1102            ),
1103        ];
1104        let probes = EnvProbes {
1105            tool: &|_| false,
1106            endpoint: &|_| Ok(()),
1107            docker: &|| true,
1108        };
1109        let report = evaluate(&reqs, &no_env, &probes);
1110        assert_eq!(report.failures[0].kind, FailureKind::MissingTool);
1111        assert_eq!(report.failures[1].kind, FailureKind::MissingEnv);
1112    }
1113
1114    #[test]
1115    fn dedup_merges_sources_for_identical_requirements() {
1116        let reqs = vec![
1117            req(
1118                "publish:homebrew",
1119                EnvRequirement::Tool { name: "git".into() },
1120            ),
1121            req("publish:scoop", EnvRequirement::Tool { name: "git".into() }),
1122            req(
1123                "publish:homebrew",
1124                EnvRequirement::Tool { name: "git".into() },
1125            ),
1126        ];
1127        let probes = EnvProbes {
1128            tool: &|_| false,
1129            endpoint: &|_| Ok(()),
1130            docker: &|| true,
1131        };
1132        let report = evaluate(&reqs, &no_env, &probes);
1133        assert_eq!(report.checks, 1, "identical requirements must merge");
1134        assert_eq!(
1135            report.failures[0].needed_by,
1136            vec!["publish:homebrew".to_string(), "publish:scoop".to_string()]
1137        );
1138    }
1139
1140    #[test]
1141    fn env_any_of_passes_when_one_var_present() {
1142        let reqs = vec![req(
1143            "publish:release",
1144            EnvRequirement::EnvAnyOf {
1145                vars: vec!["ANODIZER_GITHUB_TOKEN".into(), "GITHUB_TOKEN".into()],
1146            },
1147        )];
1148        let env = |k: &str| (k == "GITHUB_TOKEN").then(|| "tok".to_string());
1149        let report = evaluate(&reqs, &env, &all_pass_probes());
1150        assert!(report.ok(), "{report}");
1151    }
1152
1153    #[test]
1154    fn empty_env_value_counts_as_missing() {
1155        let reqs = vec![req(
1156            "s",
1157            EnvRequirement::EnvAllOf {
1158                vars: vec!["EMPTY_VAR".into()],
1159            },
1160        )];
1161        let env = |_: &str| Some(String::new());
1162        let report = evaluate(&reqs, &env, &all_pass_probes());
1163        assert_eq!(report.failures.len(), 1);
1164        assert_eq!(report.failures[0].kind, FailureKind::MissingEnv);
1165    }
1166
1167    #[test]
1168    fn report_never_echoes_secret_values() {
1169        const SECRET: &str = "hunter2-super-secret-value";
1170        let reqs = vec![
1171            req(
1172                "publish:aur",
1173                EnvRequirement::KeyEnv {
1174                    kind: KeyKind::SshPrivate,
1175                    var: "AUR_SSH_KEY".into(),
1176                },
1177            ),
1178            req(
1179                "stage:sign",
1180                EnvRequirement::KeyEnv {
1181                    kind: KeyKind::Cosign,
1182                    var: "COSIGN_KEY".into(),
1183                },
1184            ),
1185        ];
1186        // Both vars hold the secret but are structurally invalid keys, so
1187        // both checks fail — the failure text must never leak the value.
1188        let env = |_: &str| Some(SECRET.to_string());
1189        let report = evaluate(&reqs, &env, &all_pass_probes());
1190        assert_eq!(report.failures.len(), 2);
1191        let rendered = report.to_string();
1192        assert!(
1193            !rendered.contains(SECRET),
1194            "report leaked a secret value: {rendered}"
1195        );
1196        let json = serde_json::to_string(&report).unwrap();
1197        assert!(!json.contains(SECRET), "json report leaked a secret value");
1198    }
1199
1200    #[test]
1201    fn ssh_key_valid_openssh_block_passes() {
1202        let key = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaA==\n-----END OPENSSH PRIVATE KEY-----\n";
1203        assert!(validate_key_material(KeyKind::SshPrivate, key).is_ok());
1204    }
1205
1206    #[test]
1207    fn ssh_key_missing_trailing_newline_passes() {
1208        let key =
1209            "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaA==\n-----END OPENSSH PRIVATE KEY-----";
1210        assert!(validate_key_material(KeyKind::SshPrivate, key).is_ok());
1211    }
1212
1213    #[test]
1214    fn ssh_key_garbage_is_rejected_without_echo() {
1215        let err = validate_key_material(KeyKind::SshPrivate, "not-a-key-material").unwrap_err();
1216        assert!(!err.contains("not-a-key-material"));
1217    }
1218
1219    #[test]
1220    fn pgp_armored_and_binary_pass_garbage_fails() {
1221        let armored =
1222            "-----BEGIN PGP PRIVATE KEY BLOCK-----\nxx\n-----END PGP PRIVATE KEY BLOCK-----\n";
1223        assert!(validate_key_material(KeyKind::PgpPrivate, armored).is_ok());
1224        // 0x95 = binary OpenPGP secret-key packet tag byte.
1225        let binary = "\u{95}binarystuff";
1226        assert!(validate_key_material(KeyKind::PgpPrivate, binary).is_ok());
1227        assert!(validate_key_material(KeyKind::PgpPrivate, "plain text").is_err());
1228    }
1229
1230    #[test]
1231    fn cosign_sigstore_header_passes() {
1232        let key = "-----BEGIN ENCRYPTED SIGSTORE PRIVATE KEY-----\nxx\n-----END ENCRYPTED SIGSTORE PRIVATE KEY-----\n";
1233        assert!(validate_key_material(KeyKind::Cosign, key).is_ok());
1234        assert!(validate_key_material(KeyKind::Cosign, "ghp_token").is_err());
1235    }
1236
1237    #[test]
1238    fn key_env_missing_var_classifies_as_missing_env() {
1239        let reqs = vec![req(
1240            "publish:aur",
1241            EnvRequirement::KeyEnv {
1242                kind: KeyKind::SshPrivate,
1243                var: "AUR_SSH_KEY".into(),
1244            },
1245        )];
1246        let report = evaluate(&reqs, &no_env, &all_pass_probes());
1247        assert_eq!(report.failures[0].kind, FailureKind::MissingEnv);
1248    }
1249
1250    #[test]
1251    fn key_file_missing_and_invalid_are_flagged() {
1252        let dir = tempfile::tempdir().unwrap();
1253        let missing = dir.path().join("absent.key");
1254        let invalid = dir.path().join("invalid.key");
1255        std::fs::write(&invalid, "not a pgp key").unwrap();
1256        let reqs = vec![
1257            req(
1258                "stage:nfpm",
1259                EnvRequirement::KeyFile {
1260                    kind: KeyKind::PgpPrivate,
1261                    path: missing.display().to_string(),
1262                },
1263            ),
1264            req(
1265                "stage:nfpm",
1266                EnvRequirement::KeyFile {
1267                    kind: KeyKind::PgpPrivate,
1268                    path: invalid.display().to_string(),
1269                },
1270            ),
1271        ];
1272        let report = evaluate(&reqs, &no_env, &all_pass_probes());
1273        assert_eq!(report.failures.len(), 2);
1274        assert!(
1275            report
1276                .failures
1277                .iter()
1278                .all(|f| f.kind == FailureKind::BadKeyMaterial)
1279        );
1280    }
1281
1282    #[test]
1283    fn template_env_refs_handles_both_styles_and_default_filter() {
1284        assert_eq!(
1285            template_env_refs("{{ .Env.AUR_SSH_KEY }}"),
1286            vec!["AUR_SSH_KEY"]
1287        );
1288        assert_eq!(
1289            template_env_refs("{{ Env.MINIO_ENDPOINT }}/bucket"),
1290            vec!["MINIO_ENDPOINT"]
1291        );
1292        assert_eq!(
1293            template_env_refs("{{ Env.A }}-{{ .Env.B }}"),
1294            vec!["A", "B"]
1295        );
1296        assert!(
1297            template_env_refs(r#"{{ Env.OPTIONAL | default(value="x") }}"#).is_empty(),
1298            "default()-filtered refs are satisfiable without the var"
1299        );
1300        assert!(template_env_refs("no refs here").is_empty());
1301        assert!(
1302            template_env_refs("{{ MyEnv.NOT_AN_ENV }}").is_empty(),
1303            "identifiers ending in 'Env.' must not match"
1304        );
1305    }
1306
1307    #[test]
1308    fn sole_env_ref_only_matches_whole_single_expressions() {
1309        assert_eq!(
1310            sole_env_ref("{{ .Env.AUR_SSH_KEY }}"),
1311            Some("AUR_SSH_KEY".to_string())
1312        );
1313        assert_eq!(
1314            sole_env_ref("{{ Env.AUR_SSH_KEY }}"),
1315            Some("AUR_SSH_KEY".to_string())
1316        );
1317        assert_eq!(sole_env_ref("prefix {{ .Env.X }}"), None);
1318        assert_eq!(sole_env_ref("{{ .Env.X }}{{ .Env.Y }}"), None);
1319        assert_eq!(sole_env_ref("/path/to/key"), None);
1320    }
1321
1322    #[test]
1323    fn env_scheme_refs_extracts_cosign_style() {
1324        assert_eq!(
1325            env_scheme_refs("--key=env://COSIGN_KEY"),
1326            vec!["COSIGN_KEY"]
1327        );
1328        assert!(env_scheme_refs("--key=cosign.key").is_empty());
1329    }
1330
1331    #[test]
1332    fn endpoint_failure_includes_url_and_reason() {
1333        let reqs = vec![req(
1334            "stage:blob",
1335            EnvRequirement::Endpoint {
1336                url: "http://minio.local:9000".into(),
1337            },
1338        )];
1339        let probes = EnvProbes {
1340            tool: &|_| true,
1341            endpoint: &|_| Err("timed out".into()),
1342            docker: &|| true,
1343        };
1344        let report = evaluate(&reqs, &no_env, &probes);
1345        assert!(
1346            report.failures[0]
1347                .message
1348                .contains("http://minio.local:9000")
1349        );
1350        assert!(report.failures[0].message.contains("timed out"));
1351    }
1352
1353    #[test]
1354    fn configured_build_targets_mirror_build_resolution() {
1355        use crate::config::{BuildConfig, Config, CrateConfig, StringOrBool};
1356        use crate::context::{Context, ContextOptions};
1357
1358        let krate = |name: &str, builds: Option<Vec<BuildConfig>>| CrateConfig {
1359            name: name.to_string(),
1360            builds,
1361            ..Default::default()
1362        };
1363
1364        // A producing build with targets unset inherits the default matrix.
1365        // (A `binary` clears the planner's compile/artifact gate; a `binary:
1366        // None` build on a crate with no `--bin` would compile nothing.)
1367        let config = Config {
1368            crates: vec![krate(
1369                "app",
1370                Some(vec![BuildConfig {
1371                    binary: Some("app".to_string()),
1372                    ..Default::default()
1373                }]),
1374            )],
1375            ..Default::default()
1376        };
1377        let ctx = Context::new(config, ContextOptions::default());
1378        assert_eq!(
1379            configured_build_targets(&ctx),
1380            crate::target::DEFAULT_TARGETS
1381                .iter()
1382                .map(|s| (*s).to_string())
1383                .collect::<Vec<_>>()
1384        );
1385
1386        // Explicit per-build targets win; a skipped build and an
1387        // explicitly-empty target list contribute nothing; the union spans
1388        // crates. Each producing build carries a `binary` so it clears the
1389        // compile/artifact gate.
1390        let config = Config {
1391            crates: vec![
1392                krate(
1393                    "app",
1394                    Some(vec![
1395                        BuildConfig {
1396                            binary: Some("app".to_string()),
1397                            targets: Some(vec!["x86_64-unknown-linux-gnu".to_string()]),
1398                            ..Default::default()
1399                        },
1400                        BuildConfig {
1401                            binary: Some("app".to_string()),
1402                            targets: Some(vec!["x86_64-pc-windows-msvc".to_string()]),
1403                            skip: Some(StringOrBool::Bool(true)),
1404                            ..Default::default()
1405                        },
1406                    ]),
1407                ),
1408                krate(
1409                    "helper",
1410                    Some(vec![BuildConfig {
1411                        binary: Some("helper".to_string()),
1412                        targets: Some(vec![
1413                            "aarch64-apple-darwin".to_string(),
1414                            "x86_64-unknown-linux-gnu".to_string(),
1415                        ]),
1416                        ..Default::default()
1417                    }]),
1418                ),
1419            ],
1420            ..Default::default()
1421        };
1422        let ctx = Context::new(config.clone(), ContextOptions::default());
1423        assert_eq!(
1424            configured_build_targets(&ctx),
1425            vec![
1426                "x86_64-unknown-linux-gnu".to_string(),
1427                "aarch64-apple-darwin".to_string(),
1428            ]
1429        );
1430
1431        // --single-target narrows the union to the requested triple.
1432        let ctx = Context::new(
1433            config,
1434            ContextOptions {
1435                single_target: Some("aarch64-apple-darwin".to_string()),
1436                ..Default::default()
1437            },
1438        );
1439        assert_eq!(
1440            configured_build_targets(&ctx),
1441            vec!["aarch64-apple-darwin".to_string()]
1442        );
1443    }
1444
1445    #[test]
1446    fn configured_build_targets_no_bin_crate_with_defaults_builds_template_contributes_nothing() {
1447        use crate::config::{BuildConfig, Config, CrateConfig};
1448        use crate::context::{Context, ContextOptions};
1449
1450        // A library crate (no src/main.rs, no [[bin]]) that inherited a
1451        // `defaults.builds` template carries a build with `binary: None`. The
1452        // planner's compile/artifact gate compiles nothing for it, so the
1453        // preflight target union must be empty — no cross bundler tools demanded.
1454        let dir = tempfile::tempdir().unwrap();
1455        std::fs::write(
1456            dir.path().join("Cargo.toml"),
1457            "[package]\nname = \"lib\"\nversion = \"0.0.0\"\n",
1458        )
1459        .unwrap();
1460        let config = Config {
1461            crates: vec![CrateConfig {
1462                name: "lib".to_string(),
1463                path: dir.path().to_string_lossy().into_owned(),
1464                builds: Some(vec![BuildConfig {
1465                    // binary: None — the materialized template default.
1466                    targets: Some(vec!["aarch64-unknown-linux-gnu".to_string()]),
1467                    ..Default::default()
1468                }]),
1469                ..Default::default()
1470            }],
1471            ..Default::default()
1472        };
1473        let ctx = Context::new(config, ContextOptions::default());
1474        assert!(
1475            configured_build_targets(&ctx).is_empty(),
1476            "library crate with a binary:None template build compiles nothing",
1477        );
1478    }
1479}