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    let mut out = std::collections::BTreeSet::new();
546
547    // Per-crate producer blocks (unioned across the full crate universe so
548    // per-crate workspace mode sees the same surface the pipeline runs).
549    if crates
550        .iter()
551        .any(|c| c.nfpms.iter().flatten().next().is_some())
552    {
553        out.insert("nfpm");
554    }
555    if crates
556        .iter()
557        .any(|c| c.snapcrafts.iter().flatten().next().is_some())
558    {
559        out.insert("snapcraft");
560    }
561    if crates
562        .iter()
563        .any(|c| c.dmgs.iter().flatten().next().is_some())
564    {
565        out.insert("dmg");
566    }
567    if crates
568        .iter()
569        .any(|c| c.msis.iter().flatten().next().is_some())
570    {
571        out.insert("msi");
572    }
573    if crates
574        .iter()
575        .any(|c| c.pkgs.iter().flatten().next().is_some())
576    {
577        out.insert("pkg");
578    }
579    if crates
580        .iter()
581        .any(|c| c.nsis.iter().flatten().next().is_some())
582    {
583        out.insert("nsis");
584    }
585    if crates
586        .iter()
587        .any(|c| c.app_bundles.iter().flatten().next().is_some())
588    {
589        out.insert("appbundle");
590    }
591    if crates
592        .iter()
593        .any(|c| c.flatpaks.iter().flatten().next().is_some())
594    {
595        out.insert("flatpak");
596    }
597    if crates
598        .iter()
599        .any(|c| c.dockers_v2.iter().flatten().next().is_some())
600    {
601        out.insert("docker");
602    }
603
604    // Top-level producer blocks.
605    if !config.appimages.is_empty() {
606        out.insert("appimage");
607    }
608    if !config.makeselfs.is_empty() {
609        out.insert("makeself");
610    }
611    if config.srpms.is_some() {
612        out.insert("srpm");
613    }
614
615    out
616}
617
618/// When the entire string is a single `{{ [.]Env.NAME }}` expression,
619/// return `NAME`. Used to decide whether a templated secret field maps to
620/// validatable key material from one env var (vs. a composite template,
621/// where only presence of the referenced vars can be required).
622pub fn sole_env_ref(s: &str) -> Option<String> {
623    let t = s.trim();
624    if !(t.starts_with("{{") && t.ends_with("}}")) || t[2..].contains("{{") {
625        return None;
626    }
627    let refs = template_env_refs(t);
628    let inner = t[2..t.len() - 2].trim();
629    let bare = inner.strip_prefix('.').unwrap_or(inner);
630    match refs.as_slice() {
631        [only] if bare == format!("Env.{only}") => Some(only.clone()),
632        _ => None,
633    }
634}
635
636/// Extract env-var names referenced via cosign's `env://NAME` key scheme.
637pub fn env_scheme_refs(s: &str) -> Vec<String> {
638    let mut out = Vec::new();
639    let mut rest = s;
640    while let Some(pos) = rest.find("env://") {
641        let tail = &rest[pos + 6..];
642        let name: String = tail
643            .chars()
644            .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
645            .collect();
646        if !name.is_empty() && !out.contains(&name) {
647            out.push(name.clone());
648        }
649        rest = &tail[name.len()..];
650    }
651    out
652}
653
654fn collect_env_names(expr: &str, out: &mut Vec<String>) {
655    let mut rest = expr;
656    while let Some(pos) = rest.find("Env.") {
657        // Accept both `.Env.NAME` (Go-template style the preprocessor
658        // translates) and bare `Env.NAME` (native Tera object lookup), but
659        // not identifiers that merely end in `Env.` (e.g. `MyEnv.`).
660        let preceded_ok = match rest[..pos].chars().next_back() {
661            None => true,
662            Some(c) => !(c.is_ascii_alphanumeric() || c == '_'),
663        };
664        let tail = &rest[pos + 4..];
665        let name: String = tail
666            .chars()
667            .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
668            .collect();
669        if preceded_ok && !name.is_empty() && !out.contains(&name) {
670            out.push(name.clone());
671        }
672        rest = &tail[name.len().min(tail.len())..];
673    }
674}
675
676/// Convenience: map every env reference in a templated config string
677/// (both `{{ .Env.X }}` and `env://X` forms) to an [`EnvRequirement`],
678/// tagged with `source`.
679pub fn env_ref_requirements(source: &str, value: &str) -> Vec<SourcedRequirement> {
680    let mut out = Vec::new();
681    let refs = template_env_refs(value);
682    if !refs.is_empty() {
683        out.push(SourcedRequirement::new(
684            source,
685            EnvRequirement::EnvAllOf { vars: refs },
686        ));
687    }
688    for var in env_scheme_refs(value) {
689        out.push(SourcedRequirement::new(
690            source,
691            EnvRequirement::EnvAllOf { vars: vec![var] },
692        ));
693    }
694    out
695}
696
697/// True when a config entry is statically inactive for this run: its
698/// `skip:` / `skip_upload:` evaluates truthy, or its `if:` condition
699/// renders falsy. Mirrors the run-path gating for requirement derivation —
700/// a `skip: true` entry must not demand tools or credentials from
701/// preflight. Anything unrenderable is treated as ACTIVE so preflight
702/// over-collects rather than silently under-collecting.
703pub fn entry_inactive(
704    ctx: &crate::context::Context,
705    skip: Option<&crate::config::StringOrBool>,
706    skip_upload: Option<&crate::config::StringOrBool>,
707    if_condition: Option<&str>,
708) -> bool {
709    let truthy = |v: &crate::config::StringOrBool| {
710        v.try_evaluates_to_true(|t| ctx.render_template(t))
711            .unwrap_or(false)
712    };
713    if skip.is_some_and(truthy) || skip_upload.is_some_and(truthy) {
714        return true;
715    }
716    if_condition.is_some_and(|cond| {
717        matches!(
718            crate::config::evaluate_if_condition(Some(cond), "preflight", |t| ctx
719                .render_template(t)),
720            Ok(false)
721        )
722    })
723}
724
725/// Requirement for a templated secret-bearing config value with an env-var
726/// fallback: a set value declares its `{{ .Env.X }}` references (a literal
727/// declares nothing — the credential is inline); an unset value declares
728/// the fallback env var the run path reads instead.
729pub fn secret_requirement(
730    config_value: Option<&str>,
731    fallback_env: &str,
732) -> Option<EnvRequirement> {
733    match config_value.filter(|v| !v.is_empty()) {
734        Some(v) => {
735            let refs = template_env_refs(v);
736            (!refs.is_empty()).then_some(EnvRequirement::EnvAllOf { vars: refs })
737        }
738        None => Some(EnvRequirement::EnvAllOf {
739            vars: vec![fallback_env.to_string()],
740        }),
741    }
742}
743
744/// The union of build targets this run would compile, routed through the
745/// build-synthesis SSOT ([`crate::build_plan::planned_builds`] +
746/// [`crate::build_plan::build_produces`]): per-build `targets:` (an explicitly
747/// empty list means "skip this build"), else `defaults.targets`, else the
748/// canonical default matrix (via [`crate::config::Config::effective_default_targets`]).
749/// A build the planner compiles nothing for — a library crate's materialized
750/// `binary: None` build with no matching `--bin` — contributes nothing; a
751/// skipped build (`skip:` truthy) contributes nothing; an unrenderable `skip:`
752/// counts as active (over-collect). `--single-target` narrows the union to the
753/// requested triple (exact match first, then the same OS/arch alias fallback the
754/// build stage applies), so a host-only release never demands cross-platform
755/// bundler tools.
756pub fn configured_build_targets(ctx: &crate::context::Context) -> Vec<String> {
757    let default_targets: Vec<String> = ctx.config.effective_default_targets();
758    let mut out: Vec<String> = Vec::new();
759    let mut push = |t: &str| {
760        if !out.iter().any(|x| x == t) {
761            out.push(t.to_string());
762        }
763    };
764    for krate in crate_universe(&ctx.config) {
765        // A library crate with no default binary yields no builds, so it
766        // contributes nothing — the planner's compile/artifact gate.
767        let Some(builds) = crate::build_plan::planned_builds(krate) else {
768            continue;
769        };
770        for build in &builds {
771            if entry_inactive(ctx, build.skip.as_ref(), None, None) {
772                continue;
773            }
774            if !crate::build_plan::build_produces(krate, build) {
775                continue;
776            }
777            match build.targets.as_ref() {
778                Some(targets) => targets.iter().for_each(|t| push(t)),
779                None => default_targets.iter().for_each(|t| push(t)),
780            }
781        }
782    }
783    if let Some(single) = ctx.options.single_target.as_deref() {
784        if out.iter().any(|t| t == single) {
785            out.retain(|t| t == single);
786        } else {
787            out = crate::partial::find_runtime_target(single, &out)
788                .into_iter()
789                .collect();
790        }
791    }
792    out
793}
794
795#[cfg(test)]
796mod tests {
797    use super::*;
798
799    #[test]
800    fn configured_producer_stages_reports_only_present_blocks() {
801        use crate::config::{Config, CrateConfig, FlatpakConfig, NfpmConfig, SrpmConfig};
802
803        // Empty config configures no producers.
804        let empty = Config {
805            crates: vec![CrateConfig {
806                name: "x".to_string(),
807                path: ".".to_string(),
808                ..Default::default()
809            }],
810            ..Default::default()
811        };
812        assert!(configured_producer_stages(&empty).is_empty());
813
814        // A per-crate block (nfpm/flatpak) and a top-level block (srpm) each
815        // surface their token; nothing else does.
816        let configured = Config {
817            crates: vec![CrateConfig {
818                name: "x".to_string(),
819                path: ".".to_string(),
820                nfpms: Some(vec![NfpmConfig::default()]),
821                flatpaks: Some(vec![FlatpakConfig::default()]),
822                ..Default::default()
823            }],
824            srpms: Some(SrpmConfig::default()),
825            ..Default::default()
826        };
827        let got = configured_producer_stages(&configured);
828        assert!(got.contains("nfpm"));
829        assert!(got.contains("flatpak"));
830        assert!(got.contains("srpm"));
831        assert!(!got.contains("docker"));
832        assert!(!got.contains("appimage"));
833        // Always-on base stages are never enumerated here.
834        assert!(!got.contains("build"));
835        assert!(!got.contains("archive"));
836
837        // A `Some(empty vec)` block is NOT "configured".
838        let empty_vec = Config {
839            crates: vec![CrateConfig {
840                name: "x".to_string(),
841                path: ".".to_string(),
842                nfpms: Some(vec![]),
843                ..Default::default()
844            }],
845            ..Default::default()
846        };
847        assert!(!configured_producer_stages(&empty_vec).contains("nfpm"));
848    }
849
850    fn no_env(_: &str) -> Option<String> {
851        None
852    }
853
854    fn all_pass_probes() -> EnvProbes<'static> {
855        EnvProbes {
856            tool: &|_| true,
857            endpoint: &|_| Ok(()),
858            docker: &|| true,
859        }
860    }
861
862    fn req(source: &str, r: EnvRequirement) -> SourcedRequirement {
863        SourcedRequirement::new(source, r)
864    }
865
866    #[test]
867    fn empty_requirements_pass() {
868        let report = evaluate(&[], &no_env, &all_pass_probes());
869        assert!(report.ok());
870        assert_eq!(report.checks, 0);
871    }
872
873    #[test]
874    fn advisory_tool_failure_warns_and_does_not_fail_the_gate() {
875        // A missing ADVISORY tool (the cross-compile toolchain the build stage
876        // falls back from) must surface as a warning, never a gate failure —
877        // the regression that made `anodizer release` abort on any box without
878        // zig/cargo-zigbuild.
879        let none = EnvProbes {
880            tool: &|_| false,
881            endpoint: &|_| Ok(()),
882            docker: &|| true,
883        };
884        let reqs = vec![SourcedRequirement::new_advisory(
885            "stage:build",
886            EnvRequirement::Tool { name: "zig".into() },
887        )];
888        let report = evaluate(&reqs, &no_env, &none);
889        assert!(report.ok(), "advisory failure must not flip ok()");
890        assert!(report.failures.is_empty());
891        assert_eq!(report.warnings.len(), 1);
892        assert!(report.warnings[0].message.contains("zig"));
893        assert_eq!(report.checks, 1);
894    }
895
896    #[test]
897    fn hard_source_overrides_advisory_for_the_same_tool() {
898        // A tool needed by BOTH a hard and an advisory source is a gate failure
899        // when absent: one hard need is enough. (Guards the dedup's advisory AND.)
900        let none = EnvProbes {
901            tool: &|_| false,
902            endpoint: &|_| Ok(()),
903            docker: &|| true,
904        };
905        let reqs = vec![
906            SourcedRequirement::new_advisory(
907                "stage:build",
908                EnvRequirement::Tool { name: "cc".into() },
909            ),
910            SourcedRequirement::new("stage:other", EnvRequirement::Tool { name: "cc".into() }),
911        ];
912        let report = evaluate(&reqs, &no_env, &none);
913        assert!(
914            !report.ok(),
915            "a hard source makes the merged check blocking"
916        );
917        assert_eq!(report.failures.len(), 1);
918        assert!(report.warnings.is_empty());
919    }
920
921    #[test]
922    fn advisory_merge_is_order_independent_and_holds_for_many_advisory_sources() {
923        // The dedup `advisory = advisory && sr.advisory` must be commutative:
924        // hard-THEN-advisory blocks just like advisory-then-hard, and a tool
925        // needed only by several advisory sources stays a warning. `cc` is
926        // hard-first then advisory; `zig` is advisory from two sources.
927        let none = EnvProbes {
928            tool: &|_| false,
929            endpoint: &|_| Ok(()),
930            docker: &|| true,
931        };
932        let reqs = vec![
933            SourcedRequirement::new("stage:other", EnvRequirement::Tool { name: "cc".into() }),
934            SourcedRequirement::new_advisory(
935                "stage:build",
936                EnvRequirement::Tool { name: "cc".into() },
937            ),
938            SourcedRequirement::new_advisory(
939                "stage:build",
940                EnvRequirement::Tool { name: "zig".into() },
941            ),
942            SourcedRequirement::new_advisory(
943                "stage:archive",
944                EnvRequirement::Tool { name: "zig".into() },
945            ),
946        ];
947        let report = evaluate(&reqs, &no_env, &none);
948        assert_eq!(report.checks, 2, "cc and zig dedup to two checks");
949        assert!(
950            !report.ok(),
951            "hard-first cc still blocks regardless of order"
952        );
953        assert_eq!(report.failures.len(), 1);
954        assert!(report.failures[0].message.contains("cc"));
955        assert_eq!(
956            report.warnings.len(),
957            1,
958            "zig stays a single advisory warning"
959        );
960        assert!(report.warnings[0].message.contains("zig"));
961        // Both advisory sources are credited as needing zig.
962        assert_eq!(report.warnings[0].needed_by.len(), 2);
963    }
964
965    #[test]
966    fn tool_any_of_passes_when_one_is_present() {
967        let ladder = EnvRequirement::ToolAnyOf {
968            names: vec!["hdiutil".into(), "genisoimage".into(), "mkisofs".into()],
969        };
970        let probes = EnvProbes {
971            tool: &|name| name == "mkisofs",
972            endpoint: &|_| Ok(()),
973            docker: &|| true,
974        };
975        let report = evaluate(&[req("stage:dmg", ladder.clone())], &no_env, &probes);
976        assert!(report.ok(), "one available tool must satisfy the ladder");
977
978        let none_available = EnvProbes {
979            tool: &|_| false,
980            endpoint: &|_| Ok(()),
981            docker: &|| true,
982        };
983        let report = evaluate(&[req("stage:dmg", ladder)], &no_env, &none_available);
984        assert_eq!(report.failures.len(), 1);
985        assert_eq!(report.failures[0].kind, FailureKind::MissingTool);
986        assert!(
987            report.failures[0].message.contains("hdiutil")
988                && report.failures[0].message.contains("mkisofs"),
989            "message must list the whole ladder: {}",
990            report.failures[0].message
991        );
992    }
993
994    #[test]
995    fn collects_all_failures_in_one_pass() {
996        let reqs = vec![
997            req(
998                "stage:nfpm",
999                EnvRequirement::Tool {
1000                    name: "nfpm".into(),
1001                },
1002            ),
1003            req(
1004                "publish:cargo",
1005                EnvRequirement::EnvAllOf {
1006                    vars: vec!["CARGO_REGISTRY_TOKEN".into()],
1007                },
1008            ),
1009            req("stage:docker", EnvRequirement::DockerDaemon),
1010            req(
1011                "stage:blob",
1012                EnvRequirement::Endpoint {
1013                    url: "http://minio.example".into(),
1014                },
1015            ),
1016        ];
1017        let probes = EnvProbes {
1018            tool: &|_| false,
1019            endpoint: &|_| Err("connection refused".into()),
1020            docker: &|| false,
1021        };
1022        let report = evaluate(&reqs, &no_env, &probes);
1023        assert_eq!(report.checks, 4);
1024        assert_eq!(
1025            report.failures.len(),
1026            4,
1027            "every failure must be reported in one pass: {report}"
1028        );
1029    }
1030
1031    #[test]
1032    fn classifies_tool_vs_env_failures() {
1033        let reqs = vec![
1034            req(
1035                "a",
1036                EnvRequirement::Tool {
1037                    name: "syft".into(),
1038                },
1039            ),
1040            req(
1041                "b",
1042                EnvRequirement::EnvAllOf {
1043                    vars: vec!["NPM_TOKEN".into()],
1044                },
1045            ),
1046        ];
1047        let probes = EnvProbes {
1048            tool: &|_| false,
1049            endpoint: &|_| Ok(()),
1050            docker: &|| true,
1051        };
1052        let report = evaluate(&reqs, &no_env, &probes);
1053        assert_eq!(report.failures[0].kind, FailureKind::MissingTool);
1054        assert_eq!(report.failures[1].kind, FailureKind::MissingEnv);
1055    }
1056
1057    #[test]
1058    fn dedup_merges_sources_for_identical_requirements() {
1059        let reqs = vec![
1060            req(
1061                "publish:homebrew",
1062                EnvRequirement::Tool { name: "git".into() },
1063            ),
1064            req("publish:scoop", EnvRequirement::Tool { name: "git".into() }),
1065            req(
1066                "publish:homebrew",
1067                EnvRequirement::Tool { name: "git".into() },
1068            ),
1069        ];
1070        let probes = EnvProbes {
1071            tool: &|_| false,
1072            endpoint: &|_| Ok(()),
1073            docker: &|| true,
1074        };
1075        let report = evaluate(&reqs, &no_env, &probes);
1076        assert_eq!(report.checks, 1, "identical requirements must merge");
1077        assert_eq!(
1078            report.failures[0].needed_by,
1079            vec!["publish:homebrew".to_string(), "publish:scoop".to_string()]
1080        );
1081    }
1082
1083    #[test]
1084    fn env_any_of_passes_when_one_var_present() {
1085        let reqs = vec![req(
1086            "publish:release",
1087            EnvRequirement::EnvAnyOf {
1088                vars: vec!["ANODIZER_GITHUB_TOKEN".into(), "GITHUB_TOKEN".into()],
1089            },
1090        )];
1091        let env = |k: &str| (k == "GITHUB_TOKEN").then(|| "tok".to_string());
1092        let report = evaluate(&reqs, &env, &all_pass_probes());
1093        assert!(report.ok(), "{report}");
1094    }
1095
1096    #[test]
1097    fn empty_env_value_counts_as_missing() {
1098        let reqs = vec![req(
1099            "s",
1100            EnvRequirement::EnvAllOf {
1101                vars: vec!["EMPTY_VAR".into()],
1102            },
1103        )];
1104        let env = |_: &str| Some(String::new());
1105        let report = evaluate(&reqs, &env, &all_pass_probes());
1106        assert_eq!(report.failures.len(), 1);
1107        assert_eq!(report.failures[0].kind, FailureKind::MissingEnv);
1108    }
1109
1110    #[test]
1111    fn report_never_echoes_secret_values() {
1112        const SECRET: &str = "hunter2-super-secret-value";
1113        let reqs = vec![
1114            req(
1115                "publish:aur",
1116                EnvRequirement::KeyEnv {
1117                    kind: KeyKind::SshPrivate,
1118                    var: "AUR_SSH_KEY".into(),
1119                },
1120            ),
1121            req(
1122                "stage:sign",
1123                EnvRequirement::KeyEnv {
1124                    kind: KeyKind::Cosign,
1125                    var: "COSIGN_KEY".into(),
1126                },
1127            ),
1128        ];
1129        // Both vars hold the secret but are structurally invalid keys, so
1130        // both checks fail — the failure text must never leak the value.
1131        let env = |_: &str| Some(SECRET.to_string());
1132        let report = evaluate(&reqs, &env, &all_pass_probes());
1133        assert_eq!(report.failures.len(), 2);
1134        let rendered = report.to_string();
1135        assert!(
1136            !rendered.contains(SECRET),
1137            "report leaked a secret value: {rendered}"
1138        );
1139        let json = serde_json::to_string(&report).unwrap();
1140        assert!(!json.contains(SECRET), "json report leaked a secret value");
1141    }
1142
1143    #[test]
1144    fn ssh_key_valid_openssh_block_passes() {
1145        let key = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaA==\n-----END OPENSSH PRIVATE KEY-----\n";
1146        assert!(validate_key_material(KeyKind::SshPrivate, key).is_ok());
1147    }
1148
1149    #[test]
1150    fn ssh_key_missing_trailing_newline_passes() {
1151        let key =
1152            "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaA==\n-----END OPENSSH PRIVATE KEY-----";
1153        assert!(validate_key_material(KeyKind::SshPrivate, key).is_ok());
1154    }
1155
1156    #[test]
1157    fn ssh_key_garbage_is_rejected_without_echo() {
1158        let err = validate_key_material(KeyKind::SshPrivate, "not-a-key-material").unwrap_err();
1159        assert!(!err.contains("not-a-key-material"));
1160    }
1161
1162    #[test]
1163    fn pgp_armored_and_binary_pass_garbage_fails() {
1164        let armored =
1165            "-----BEGIN PGP PRIVATE KEY BLOCK-----\nxx\n-----END PGP PRIVATE KEY BLOCK-----\n";
1166        assert!(validate_key_material(KeyKind::PgpPrivate, armored).is_ok());
1167        // 0x95 = binary OpenPGP secret-key packet tag byte.
1168        let binary = "\u{95}binarystuff";
1169        assert!(validate_key_material(KeyKind::PgpPrivate, binary).is_ok());
1170        assert!(validate_key_material(KeyKind::PgpPrivate, "plain text").is_err());
1171    }
1172
1173    #[test]
1174    fn cosign_sigstore_header_passes() {
1175        let key = "-----BEGIN ENCRYPTED SIGSTORE PRIVATE KEY-----\nxx\n-----END ENCRYPTED SIGSTORE PRIVATE KEY-----\n";
1176        assert!(validate_key_material(KeyKind::Cosign, key).is_ok());
1177        assert!(validate_key_material(KeyKind::Cosign, "ghp_token").is_err());
1178    }
1179
1180    #[test]
1181    fn key_env_missing_var_classifies_as_missing_env() {
1182        let reqs = vec![req(
1183            "publish:aur",
1184            EnvRequirement::KeyEnv {
1185                kind: KeyKind::SshPrivate,
1186                var: "AUR_SSH_KEY".into(),
1187            },
1188        )];
1189        let report = evaluate(&reqs, &no_env, &all_pass_probes());
1190        assert_eq!(report.failures[0].kind, FailureKind::MissingEnv);
1191    }
1192
1193    #[test]
1194    fn key_file_missing_and_invalid_are_flagged() {
1195        let dir = tempfile::tempdir().unwrap();
1196        let missing = dir.path().join("absent.key");
1197        let invalid = dir.path().join("invalid.key");
1198        std::fs::write(&invalid, "not a pgp key").unwrap();
1199        let reqs = vec![
1200            req(
1201                "stage:nfpm",
1202                EnvRequirement::KeyFile {
1203                    kind: KeyKind::PgpPrivate,
1204                    path: missing.display().to_string(),
1205                },
1206            ),
1207            req(
1208                "stage:nfpm",
1209                EnvRequirement::KeyFile {
1210                    kind: KeyKind::PgpPrivate,
1211                    path: invalid.display().to_string(),
1212                },
1213            ),
1214        ];
1215        let report = evaluate(&reqs, &no_env, &all_pass_probes());
1216        assert_eq!(report.failures.len(), 2);
1217        assert!(
1218            report
1219                .failures
1220                .iter()
1221                .all(|f| f.kind == FailureKind::BadKeyMaterial)
1222        );
1223    }
1224
1225    #[test]
1226    fn template_env_refs_handles_both_styles_and_default_filter() {
1227        assert_eq!(
1228            template_env_refs("{{ .Env.AUR_SSH_KEY }}"),
1229            vec!["AUR_SSH_KEY"]
1230        );
1231        assert_eq!(
1232            template_env_refs("{{ Env.MINIO_ENDPOINT }}/bucket"),
1233            vec!["MINIO_ENDPOINT"]
1234        );
1235        assert_eq!(
1236            template_env_refs("{{ Env.A }}-{{ .Env.B }}"),
1237            vec!["A", "B"]
1238        );
1239        assert!(
1240            template_env_refs(r#"{{ Env.OPTIONAL | default(value="x") }}"#).is_empty(),
1241            "default()-filtered refs are satisfiable without the var"
1242        );
1243        assert!(template_env_refs("no refs here").is_empty());
1244        assert!(
1245            template_env_refs("{{ MyEnv.NOT_AN_ENV }}").is_empty(),
1246            "identifiers ending in 'Env.' must not match"
1247        );
1248    }
1249
1250    #[test]
1251    fn sole_env_ref_only_matches_whole_single_expressions() {
1252        assert_eq!(
1253            sole_env_ref("{{ .Env.AUR_SSH_KEY }}"),
1254            Some("AUR_SSH_KEY".to_string())
1255        );
1256        assert_eq!(
1257            sole_env_ref("{{ Env.AUR_SSH_KEY }}"),
1258            Some("AUR_SSH_KEY".to_string())
1259        );
1260        assert_eq!(sole_env_ref("prefix {{ .Env.X }}"), None);
1261        assert_eq!(sole_env_ref("{{ .Env.X }}{{ .Env.Y }}"), None);
1262        assert_eq!(sole_env_ref("/path/to/key"), None);
1263    }
1264
1265    #[test]
1266    fn env_scheme_refs_extracts_cosign_style() {
1267        assert_eq!(
1268            env_scheme_refs("--key=env://COSIGN_KEY"),
1269            vec!["COSIGN_KEY"]
1270        );
1271        assert!(env_scheme_refs("--key=cosign.key").is_empty());
1272    }
1273
1274    #[test]
1275    fn endpoint_failure_includes_url_and_reason() {
1276        let reqs = vec![req(
1277            "stage:blob",
1278            EnvRequirement::Endpoint {
1279                url: "http://minio.local:9000".into(),
1280            },
1281        )];
1282        let probes = EnvProbes {
1283            tool: &|_| true,
1284            endpoint: &|_| Err("timed out".into()),
1285            docker: &|| true,
1286        };
1287        let report = evaluate(&reqs, &no_env, &probes);
1288        assert!(
1289            report.failures[0]
1290                .message
1291                .contains("http://minio.local:9000")
1292        );
1293        assert!(report.failures[0].message.contains("timed out"));
1294    }
1295
1296    #[test]
1297    fn configured_build_targets_mirror_build_resolution() {
1298        use crate::config::{BuildConfig, Config, CrateConfig, StringOrBool};
1299        use crate::context::{Context, ContextOptions};
1300
1301        let krate = |name: &str, builds: Option<Vec<BuildConfig>>| CrateConfig {
1302            name: name.to_string(),
1303            builds,
1304            ..Default::default()
1305        };
1306
1307        // A producing build with targets unset inherits the default matrix.
1308        // (A `binary` clears the planner's compile/artifact gate; a `binary:
1309        // None` build on a crate with no `--bin` would compile nothing.)
1310        let config = Config {
1311            crates: vec![krate(
1312                "app",
1313                Some(vec![BuildConfig {
1314                    binary: Some("app".to_string()),
1315                    ..Default::default()
1316                }]),
1317            )],
1318            ..Default::default()
1319        };
1320        let ctx = Context::new(config, ContextOptions::default());
1321        assert_eq!(
1322            configured_build_targets(&ctx),
1323            crate::target::DEFAULT_TARGETS
1324                .iter()
1325                .map(|s| (*s).to_string())
1326                .collect::<Vec<_>>()
1327        );
1328
1329        // Explicit per-build targets win; a skipped build and an
1330        // explicitly-empty target list contribute nothing; the union spans
1331        // crates. Each producing build carries a `binary` so it clears the
1332        // compile/artifact gate.
1333        let config = Config {
1334            crates: vec![
1335                krate(
1336                    "app",
1337                    Some(vec![
1338                        BuildConfig {
1339                            binary: Some("app".to_string()),
1340                            targets: Some(vec!["x86_64-unknown-linux-gnu".to_string()]),
1341                            ..Default::default()
1342                        },
1343                        BuildConfig {
1344                            binary: Some("app".to_string()),
1345                            targets: Some(vec!["x86_64-pc-windows-msvc".to_string()]),
1346                            skip: Some(StringOrBool::Bool(true)),
1347                            ..Default::default()
1348                        },
1349                    ]),
1350                ),
1351                krate(
1352                    "helper",
1353                    Some(vec![BuildConfig {
1354                        binary: Some("helper".to_string()),
1355                        targets: Some(vec![
1356                            "aarch64-apple-darwin".to_string(),
1357                            "x86_64-unknown-linux-gnu".to_string(),
1358                        ]),
1359                        ..Default::default()
1360                    }]),
1361                ),
1362            ],
1363            ..Default::default()
1364        };
1365        let ctx = Context::new(config.clone(), ContextOptions::default());
1366        assert_eq!(
1367            configured_build_targets(&ctx),
1368            vec![
1369                "x86_64-unknown-linux-gnu".to_string(),
1370                "aarch64-apple-darwin".to_string(),
1371            ]
1372        );
1373
1374        // --single-target narrows the union to the requested triple.
1375        let ctx = Context::new(
1376            config,
1377            ContextOptions {
1378                single_target: Some("aarch64-apple-darwin".to_string()),
1379                ..Default::default()
1380            },
1381        );
1382        assert_eq!(
1383            configured_build_targets(&ctx),
1384            vec!["aarch64-apple-darwin".to_string()]
1385        );
1386    }
1387
1388    #[test]
1389    fn configured_build_targets_no_bin_crate_with_defaults_builds_template_contributes_nothing() {
1390        use crate::config::{BuildConfig, Config, CrateConfig};
1391        use crate::context::{Context, ContextOptions};
1392
1393        // A library crate (no src/main.rs, no [[bin]]) that inherited a
1394        // `defaults.builds` template carries a build with `binary: None`. The
1395        // planner's compile/artifact gate compiles nothing for it, so the
1396        // preflight target union must be empty — no cross bundler tools demanded.
1397        let dir = tempfile::tempdir().unwrap();
1398        std::fs::write(
1399            dir.path().join("Cargo.toml"),
1400            "[package]\nname = \"lib\"\nversion = \"0.0.0\"\n",
1401        )
1402        .unwrap();
1403        let config = Config {
1404            crates: vec![CrateConfig {
1405                name: "lib".to_string(),
1406                path: dir.path().to_string_lossy().into_owned(),
1407                builds: Some(vec![BuildConfig {
1408                    // binary: None — the materialized template default.
1409                    targets: Some(vec!["aarch64-unknown-linux-gnu".to_string()]),
1410                    ..Default::default()
1411                }]),
1412                ..Default::default()
1413            }],
1414            ..Default::default()
1415        };
1416        let ctx = Context::new(config, ContextOptions::default());
1417        assert!(
1418            configured_build_targets(&ctx).is_empty(),
1419            "library crate with a binary:None template build compiles nothing",
1420        );
1421    }
1422}