Skip to main content

anodizer_core/
determinism_runner.rs

1//! Subprocess runner for the determinism harness.
2//!
3//! Allow-listed entry-point for `Command::new` in core. The determinism
4//! harness in `crates/cli/src/determinism_harness.rs` is forbidden
5//! from spawning processes directly per the module-boundary rule, so
6//! this module owns the `anodize release --snapshot --skip=...`
7//! invocation that drives each from-clean rebuild.
8//!
9//! Why a separate module: `Command::new` is an authorization boundary
10//! (write-to-disk, network, env exfiltration); concentrating the
11//! harness's one call site here keeps the security-relevant surface
12//! reviewable.
13
14use anyhow::{Context, Result};
15use std::collections::HashMap;
16use std::path::{Path, PathBuf};
17use std::process::{Command, Stdio};
18use std::time::Duration;
19
20/// Stage names the determinism harness must NOT run.
21///
22/// Single source of truth for the `--skip=...` list passed to the child
23/// `anodize release --snapshot` invocation: every stage in
24/// [`crate::stages::UPSTREAM_STAGES`] (uploads, API calls, push, announce)
25/// plus the harness-only extras below — stages that don't reach upstream
26/// but still have no place in a hermetic byte-reproducibility rebuild.
27/// Deriving from the shared classification means a future upstream stage
28/// added there is skipped here automatically, instead of the two lists
29/// drifting apart by hand.
30pub static SIDE_EFFECT_STAGES: std::sync::LazyLock<Vec<&'static str>> =
31    std::sync::LazyLock::new(|| {
32        // Harness-only extras: notarization is an Apple-server round-trip
33        // (`notarytool` submit + an Apple-issued ticket stapled to the
34        // artifact) — the ticket is server-generated so the output can never
35        // be byte-reproduced in a hermetic rebuild, and the submit is
36        // credential-gated (App Store Connect API key). `--prepare`
37        // legitimately produces notarized artifacts, so it lives here rather
38        // than in the shared upstream classification.
39        let mut stages = vec!["notarize"];
40        stages.extend_from_slice(crate::stages::UPSTREAM_STAGES);
41        stages
42    });
43
44/// Comma-join [`SIDE_EFFECT_STAGES`] plus an `extra` list for use as
45/// the `--skip=<list>` CLI argument value. Order-preserving and
46/// duplicate-free: every entry from [`SIDE_EFFECT_STAGES`] comes first
47/// (in declared order), then each `extra` entry that hasn't already been
48/// seen. Kept as a function (not a const) because Rust can't
49/// const-evaluate `[&str]::join`.
50///
51/// The `extra` argument is the harness's "complement set": every stage
52/// the operator did NOT request via `--stages=` AND that doesn't belong
53/// to the preamble preserve set (`validate` / `before` / `changelog` /
54/// `templatefiles`). Skipping them in the child release subprocess
55/// matches the spec's promise that `anodize check determinism
56/// --stages=<list>` only exercises (and validates) the named stages —
57/// previously the child still ran the full pipeline, attempting nfpm /
58/// nsis / dmg / etc. on shards that have no business running them.
59pub fn compute_skip_arg(extra: &[&str]) -> String {
60    let mut merged: Vec<&str> = Vec::with_capacity(SIDE_EFFECT_STAGES.len() + extra.len());
61    for &name in SIDE_EFFECT_STAGES.iter() {
62        if !merged.contains(&name) {
63            merged.push(name);
64        }
65    }
66    for &name in extra {
67        if !merged.contains(&name) {
68            merged.push(name);
69        }
70    }
71    format!("--skip={}", merged.join(","))
72}
73
74/// Invoke the running `anodize` binary against `worktree_path` with the
75/// supplied isolated env.
76///
77/// Pinning args:
78/// - `release` — drives the full build-side pipeline.
79/// - `--snapshot` (when `snapshot` is `true`) — disables tag-cutting and
80///   tells stages to use the pre-resolved SDE. The release workflow
81///   passes `false` on tag-push runs so produce-stages emit artifacts
82///   named with the actual release version (no `-SNAPSHOT-<sha>` suffix)
83///   that the publish-only path can ship directly.
84/// - `--skip=<SIDE_EFFECT_STAGES + extra_skip>` — strips every
85///   side-effect-producing stage AND every non-requested produce-stage
86///   (the harness's complement set). Doubling N is safe in any env
87///   because of this skip list.
88/// - `--no-env-preflight` — always. The replica runs in a deliberately
89///   credential-less env; see `build_subprocess_command`.
90/// - `--targets=<csv>` (when `targets` is `Some`) — restricts the
91///   rebuild to a subset of configured triples. The sharded
92///   `release.yml` matrix passes this so each runner only validates
93///   the targets it can natively build (cross-compile to Apple /
94///   Windows from a Linux runner would otherwise fail at link time).
95///
96/// The `extra_skip` slice carries the harness's complement set: stages
97/// the operator did NOT name via `--stages=` (minus the preamble
98/// preserve set). Merged with [`SIDE_EFFECT_STAGES`] via
99/// [`compute_skip_arg`]; the harness in
100/// `crates/cli/src/determinism_harness.rs` is the canonical caller and
101/// computes the set from `anodizer_core::context::VALID_RELEASE_SKIPS`.
102/// Pass `&[]` to keep the legacy "side-effect stages only" behavior.
103///
104/// The child env is fully replaced (`env_clear` then re-populate) so
105/// host env vars cannot leak through and perturb the build. Caller
106/// (the harness) constructs the env map.
107pub fn run_build_pipeline_subprocess(spec: &ChildInvocation<'_>) -> Result<()> {
108    let mut cmd = build_subprocess_command(spec);
109    tracing::debug!(
110        args = ?cmd.get_args(),
111        worktree = %spec.worktree_path.display(),
112        "spawning anodize release child for determinism harness",
113    );
114    let status = cmd
115        .status()
116        .context("spawning anodize release for determinism harness")?;
117    anyhow::ensure!(
118        status.success(),
119        "harness build pipeline failed in worktree {} (exit {:?})",
120        spec.worktree_path.display(),
121        status.code()
122    );
123    Ok(())
124}
125
126/// Invocation knobs for the child `anodize release` subprocess, grouped
127/// so the spawn surface takes one spec instead of a positional argument
128/// list that grows with every new knob.
129pub struct ChildInvocation<'a> {
130    /// Path to the running `anodize` binary (see
131    /// [`current_anodize_binary`]).
132    pub anodize_binary: &'a Path,
133    /// Hermetic worktree the child builds in (`current_dir`).
134    pub worktree_path: &'a Path,
135    /// Fully-replacing child env map (`env_clear` + re-populate);
136    /// constructed by the harness.
137    pub env: &'a HashMap<String, String>,
138    /// `--targets=<csv>` restriction; `None` validates every configured
139    /// target.
140    pub targets: Option<&'a [String]>,
141    /// The harness's complement skip set, merged with
142    /// [`SIDE_EFFECT_STAGES`] via [`compute_skip_arg`]. Pass `&[]` for
143    /// the legacy "side-effect stages only" behavior.
144    pub extra_skip: &'a [String],
145    /// Whether the child gets `--snapshot`. The release workflow passes
146    /// `false` on tag-push runs so artifacts carry the real version.
147    pub snapshot: bool,
148    /// `--crate=<name>` scoping for per-crate shards; `None` builds the
149    /// workspace default.
150    pub crate_name: Option<&'a str>,
151    /// Operator verbosity, forwarded as `--quiet` / `--verbose` /
152    /// `--debug` so the child's inherited stderr honors the same
153    /// contract as the harness's own logger.
154    pub verbosity: crate::log::Verbosity,
155}
156
157/// Build the [`Command`] the harness will spawn. Split out from
158/// [`run_build_pipeline_subprocess`] so unit tests can inspect the
159/// constructed argv (`cmd.get_args()`) without shelling out — the
160/// alternative is to ship a real `anodize` binary into the test harness.
161fn build_subprocess_command(spec: &ChildInvocation<'_>) -> Command {
162    let ChildInvocation {
163        anodize_binary,
164        worktree_path,
165        env,
166        targets,
167        extra_skip,
168        snapshot,
169        crate_name,
170        verbosity,
171    } = *spec;
172    let mut cmd = Command::new(anodize_binary);
173    let extra_refs: Vec<&str> = extra_skip.iter().map(String::as_str).collect();
174    cmd.arg("release");
175    if snapshot {
176        cmd.arg("--snapshot");
177    }
178    // The child's stderr is inherited into the harness's own stream, so
179    // the operator's verbosity choice must extend to the child — a
180    // `check determinism -q` whose children still print every section
181    // would make the flag meaningless.
182    match verbosity {
183        crate::log::Verbosity::Quiet => {
184            cmd.arg("--quiet");
185        }
186        crate::log::Verbosity::Verbose => {
187            cmd.arg("--verbose");
188        }
189        crate::log::Verbosity::Debug => {
190            cmd.arg("--debug");
191        }
192        crate::log::Verbosity::Normal => {}
193    }
194    cmd.arg(compute_skip_arg(&extra_refs));
195    // The replica pipeline runs in a deliberately credential-less hermetic
196    // env (env_clear + identity-only re-population): its run paths skip
197    // gracefully when keys/tools are absent, nothing publishes (see the
198    // skip list above), and signature outputs are excluded from
199    // byte-comparison. The config-derived env preflight would therefore
200    // reject exactly the environment the harness is designed to run in —
201    // disable it for the child by construction. Real release entrypoints
202    // are unaffected; preflight guards them as before.
203    cmd.arg("--no-env-preflight");
204    if let Some(list) = targets
205        && !list.is_empty()
206    {
207        cmd.arg(format!("--targets={}", list.join(",")));
208    }
209    // Scope the child build to the same crate the harness is preserving for.
210    // Without it a workspace build defaults to its primary crate, so a
211    // per-crate shard would rebuild (and preserve) the wrong member's
212    // artifacts — e.g. a library's source archive in place of a binary
213    // crate's compiled binaries, leaving publish-only with no binary to
214    // ship or stage into a docker context.
215    if let Some(name) = crate_name {
216        cmd.arg(format!("--crate={name}"));
217    }
218    cmd.current_dir(worktree_path);
219    cmd.env_clear();
220    for (k, v) in env {
221        cmd.env(k, v);
222    }
223    // anodizer's stdout is a machine-readable data channel (GHA step
224    // outputs, JSON payloads); the harness consumes none of the child's —
225    // it reads artifacts from disk and gates on the exit status alone. Null
226    // the child's stdout so its data channel never pollutes the harness's
227    // own stdout. The child's *stderr* (its logger's status/verbose lines)
228    // stays inherited so the operator's verbosity choice, forwarded above,
229    // still surfaces the inner run's progress.
230    cmd.stdout(Stdio::null());
231    // The child's stderr is inherited, so its lines interleave straight
232    // into the harness's stream. Export the parent's nesting depth (+2)
233    // so the child's section headers render beneath the harness's
234    // `• run N of M` bullet instead of flush-left: the bullet itself
235    // sits one section level plus a body indent under the harness
236    // header, and the child's sections belong one further level in.
237    // Set AFTER the hermetic env map so the map cannot clobber it.
238    cmd.env(
239        crate::log::LOG_DEPTH_ENV,
240        (crate::log::current_depth() + 2).to_string(),
241    );
242    cmd
243}
244
245/// Resolve the path of the currently-running `anodize` binary. Thin
246/// wrapper over [`std::env::current_exe`] kept here so the harness side
247/// doesn't have to touch `std::env` for binary resolution.
248pub fn current_anodize_binary() -> Result<PathBuf> {
249    std::env::current_exe().context("locating the currently-running anodize binary")
250}
251
252/// Number of `cargo fetch` attempts [`prefetch_deps`] makes before failing.
253/// The prefetch is the harness's single network operation; transient
254/// registry/DNS failures (`Could not resolve host: index.crates.io`) are
255/// precisely what a retry kills — so the retry lives here, on the one online
256/// op, and NOT on the offline-sealed rebuilds (which cannot reach the network
257/// to flake on it in the first place).
258const PREFETCH_ATTEMPTS: u32 = 3;
259
260/// Fixed backoff between [`prefetch_deps`] attempts.
261const PREFETCH_BACKOFF: Duration = Duration::from_secs(3);
262
263/// Warm `cargo_home`'s registry cache by fetching every `Cargo.lock`-pinned
264/// dependency of the workspace at `manifest_dir` exactly ONCE, before the
265/// harness's rebuild loop seals all child builds offline
266/// (`CARGO_NET_OFFLINE=true`, set in the harness's child env).
267///
268/// This makes the reproducibility gate network-independent on every rebuild:
269/// the shared, lock-pinned cache is determinism-safe to reuse across runs
270/// (`.crate` tarballs + extracted sources are content-addressed → byte-
271/// identical regardless of which run downloaded them), and the offline seal
272/// then guarantees no rebuild can reach the network at all. The prefetch is
273/// therefore the only place a transient registry hiccup is survivable, which
274/// is why it — and only it — retries.
275///
276/// The prefetch passes NO `--target`: a plain `cargo fetch` resolves and
277/// downloads the dependency superset for EVERY platform in the manifest's cfg
278/// graph (host + all cross targets at once — empirically `windows-sys`,
279/// `core-foundation-sys`, `cpufeatures`, … all land in the cache even from a
280/// Linux host). That all-platform superset is exactly what a hermetic offline
281/// rebuild needs, on any shard: the explicit-target Windows shards, the
282/// multi-arch `targets:''` macOS/Ubuntu shards, and the host-side man-page
283/// `before:` hook (`cargo run --bin anodize`) alike. See
284/// `build_fetch_command` for why an explicit `--target` is the bug this
285/// guards against.
286///
287/// The child inherits the host env (operator cargo/proxy/registry config,
288/// `RUSTUP_HOME`, …) and only overrides `CARGO_HOME` + forces
289/// `CARGO_NET_OFFLINE=false` — the prefetch MUST stay online even if the host
290/// opted offline, unlike the sealed rebuild children.
291pub fn prefetch_deps(manifest_dir: &Path, cargo_home: &Path) -> Result<()> {
292    // A project that doesn't commit a `Cargo.lock` (library crate / minimal
293    // fixture) gets one WRITTEN into the worktree by `cargo fetch` as a side
294    // effect of resolution. Left in place it dirties the worktree and trips the
295    // release pipeline's clean-state guard on non-snapshot rebuilds (`git is in
296    // a dirty state; … Use --snapshot to force`). The thing we actually wanted —
297    // a warm registry cache — lives in CARGO_HOME, not the worktree, so drop the
298    // stray lock afterward to leave the checkout byte-for-byte as `git worktree
299    // add` produced it. The offline rebuild regenerates an identical lock from
300    // the warm cache during its OWN build (after the clean-state check passes),
301    // so determinism is unaffected. Projects that DO commit a lock take the
302    // `--locked` path, which never mutates the lock, so nothing is removed.
303    let lock = manifest_dir.join("Cargo.lock");
304    let lock_committed = lock.is_file();
305    let res = prefetch_deps_with(
306        manifest_dir,
307        cargo_home,
308        PREFETCH_ATTEMPTS,
309        PREFETCH_BACKOFF,
310    );
311    if !lock_committed && lock.is_file() {
312        let _ = std::fs::remove_file(&lock);
313    }
314    res
315}
316
317/// Build the `cargo fetch` [`Command`] that warms the shared cache. Split out
318/// so unit tests can inspect the argv/env (`cmd.get_args()` / `cmd.get_envs()`)
319/// without a live network fetch.
320///
321/// Passes NO `--target` deliberately: a plain `cargo fetch` resolves and
322/// downloads the dependency superset for EVERY platform in the manifest's cfg
323/// graph (host + all cross targets in one shot). Passing an explicit
324/// `--target X` is the bug this guards against — it NARROWS the fetch to X's
325/// resolve graph, dropping host-target deps the offline rebuild still needs
326/// for host-side work (the man-page `before:` hook's `cargo run --bin anodize`
327/// host build, proc-macros, build scripts). On a cross shard (host x86_64 ≠
328/// target aarch64) that surfaces as `failed to download <crate>: --offline was
329/// specified` the instant the offline seal bites.
330fn build_fetch_command(manifest_dir: &Path, cargo_home: &Path) -> Command {
331    let mut cmd = Command::new("cargo");
332    cmd.arg("fetch");
333    // `--locked` ONLY when the project actually commits a `Cargo.lock` — the
334    // determinism contract for binary releases. cargo's `--locked` hard-errors
335    // when the lock is absent or stale, which would break two legitimate cases:
336    // library crates (Cargo.lock is conventionally gitignored) and the minimal
337    // test-fixture workspaces. When no lock is committed, plain `cargo fetch`
338    // resolves the manifest and writes a lock the offline rebuild then reuses;
339    // determinism still holds because every rebuild resolves the SAME manifest
340    // against the SAME warm, content-addressed registry cache.
341    if manifest_dir.join("Cargo.lock").is_file() {
342        cmd.arg("--locked");
343    }
344    cmd.arg("--manifest-path")
345        .arg(manifest_dir.join("Cargo.toml"));
346    cmd.current_dir(manifest_dir);
347    cmd.env("CARGO_HOME", cargo_home);
348    // The harness seals the *rebuilds* offline; the prefetch is the one
349    // op that must reach crates.io, so force it online even if the host
350    // exported CARGO_NET_OFFLINE=true (otherwise the warm-the-cache step
351    // would itself fail offline and defeat the whole prefetch).
352    cmd.env("CARGO_NET_OFFLINE", "false");
353    // cargo writes progress to stderr (inherited so the operator sees the
354    // one-time download); stdout carries nothing the harness consumes.
355    cmd.stdout(Stdio::null());
356    cmd
357}
358
359/// Retry-wrapped core of [`prefetch_deps`], parameterized on attempt count +
360/// backoff so tests can drive the retry path without real sleeps. One
361/// all-platform `cargo fetch`, retried, so a transient registry/DNS hiccup is
362/// survivable on the harness's single network operation.
363fn prefetch_deps_with(
364    manifest_dir: &Path,
365    cargo_home: &Path,
366    attempts: u32,
367    backoff: Duration,
368) -> Result<()> {
369    let attempts = attempts.max(1);
370    let mut last: Option<String> = None;
371    for attempt in 1..=attempts {
372        let mut cmd = build_fetch_command(manifest_dir, cargo_home);
373        match cmd.status() {
374            Ok(status) if status.success() => return Ok(()),
375            Ok(status) => last = Some(format!("cargo fetch exited {:?}", status.code())),
376            Err(e) => last = Some(format!("spawning cargo fetch: {e}")),
377        }
378        if attempt < attempts {
379            tracing::warn!(
380                attempt,
381                attempts,
382                "determinism prefetch `cargo fetch` failed; retrying"
383            );
384            std::thread::sleep(backoff);
385        }
386    }
387    anyhow::bail!(
388        "determinism prefetch `cargo fetch` failed after {attempts} attempt(s) in {}: {}",
389        manifest_dir.display(),
390        last.unwrap_or_default()
391    )
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    #[test]
399    fn current_binary_resolves_to_a_real_file() {
400        // In test context, `current_exe` returns the test runner; the
401        // path is just expected to be readable.
402        let p = current_anodize_binary().unwrap();
403        assert!(p.exists(), "current_exe should point at a real file");
404    }
405
406    #[test]
407    fn run_build_pipeline_subprocess_fails_when_binary_missing() {
408        let env = HashMap::new();
409        let worktree = std::env::temp_dir();
410        let bogus = PathBuf::from("/nonexistent/anodize-binary-for-tests");
411        let res = run_build_pipeline_subprocess(&ChildInvocation {
412            anodize_binary: &bogus,
413            worktree_path: &worktree,
414            env: &env,
415            targets: None,
416            extra_skip: &[],
417            snapshot: true,
418            crate_name: None,
419            verbosity: crate::log::Verbosity::Normal,
420        });
421        assert!(
422            res.is_err(),
423            "missing binary should surface as an error, not a panic"
424        );
425    }
426
427    /// Argv shape sanity: no `--targets` flag when the harness passes
428    /// `None` (legacy single-runner path validates every configured
429    /// target).
430    #[test]
431    fn subprocess_command_omits_targets_when_none() {
432        let env = HashMap::new();
433        let cmd = build_subprocess_command(&ChildInvocation {
434            anodize_binary: &PathBuf::from("/usr/bin/anodize"),
435            worktree_path: &std::env::temp_dir(),
436            env: &env,
437            targets: None,
438            extra_skip: &[],
439            snapshot: true,
440            crate_name: None,
441            verbosity: crate::log::Verbosity::Normal,
442        });
443        let args: Vec<&str> = cmd.get_args().map(|s| s.to_str().expect("ascii")).collect();
444        assert!(
445            args.iter().all(|a| !a.starts_with("--targets")),
446            "expected no --targets argument; got {args:?}"
447        );
448        // Sanity: --snapshot + --skip=... still present.
449        assert!(
450            args.contains(&"--snapshot"),
451            "argv missing --snapshot: {args:?}"
452        );
453        assert!(
454            args.iter().any(|a| a.starts_with("--skip=")),
455            "argv missing --skip=...: {args:?}"
456        );
457    }
458
459    /// When the harness restricts targets, the child subprocess gets
460    /// the same restriction as a single `--targets=<csv>` argument.
461    /// Sharded release.yml depends on this — each OS shard must only
462    /// rebuild its own native targets.
463    #[test]
464    fn subprocess_command_propagates_targets_csv() {
465        let env = HashMap::new();
466        let triples = vec![
467            "x86_64-apple-darwin".to_string(),
468            "aarch64-apple-darwin".to_string(),
469        ];
470        let cmd = build_subprocess_command(&ChildInvocation {
471            anodize_binary: &PathBuf::from("/usr/bin/anodize"),
472            worktree_path: &std::env::temp_dir(),
473            env: &env,
474            targets: Some(&triples),
475            extra_skip: &[],
476            snapshot: true,
477            crate_name: None,
478            verbosity: crate::log::Verbosity::Normal,
479        });
480        let args: Vec<String> = cmd
481            .get_args()
482            .map(|s| s.to_str().expect("ascii").to_string())
483            .collect();
484        assert!(
485            args.iter()
486                .any(|a| a == "--targets=x86_64-apple-darwin,aarch64-apple-darwin"),
487            "expected joined --targets= argument; got {args:?}"
488        );
489    }
490
491    /// Empty-slice short-circuit: an explicit `Some(&[])` should NOT
492    /// produce `--targets=` (which would parse to "all-empty CSV" and
493    /// fail downstream). The harness is expected to pass `None` when it
494    /// has nothing to filter on; this guards against a future caller
495    /// passing an empty `Vec` by accident.
496    #[test]
497    fn subprocess_command_drops_targets_when_list_is_empty() {
498        let env = HashMap::new();
499        let empty: Vec<String> = Vec::new();
500        let cmd = build_subprocess_command(&ChildInvocation {
501            anodize_binary: &PathBuf::from("/usr/bin/anodize"),
502            worktree_path: &std::env::temp_dir(),
503            env: &env,
504            targets: Some(&empty),
505            extra_skip: &[],
506            snapshot: true,
507            crate_name: None,
508            verbosity: crate::log::Verbosity::Normal,
509        });
510        let args: Vec<String> = cmd
511            .get_args()
512            .map(|s| s.to_str().expect("ascii").to_string())
513            .collect();
514        assert!(
515            args.iter().all(|a| !a.starts_with("--targets")),
516            "empty target slice should omit --targets entirely; got {args:?}"
517        );
518    }
519
520    /// The child release subprocess MUST carry `--no-env-preflight` in every
521    /// mode — snapshot children skip the env preflight via the snapshot
522    /// gate anyway, but non-snapshot children (tag-push determinism runs,
523    /// where the workflow passes `--no-snapshot` so artifacts carry the
524    /// real version) would otherwise run the config-derived env preflight
525    /// inside the credential-less worktree and abort the replica build on
526    /// missing secrets/tools the run paths handle gracefully.
527    #[test]
528    fn subprocess_command_always_disables_env_preflight() {
529        let env = HashMap::new();
530        for snapshot in [true, false] {
531            let cmd = build_subprocess_command(&ChildInvocation {
532                anodize_binary: &PathBuf::from("/usr/bin/anodize"),
533                worktree_path: &std::env::temp_dir(),
534                env: &env,
535                targets: None,
536                extra_skip: &[],
537                snapshot,
538                crate_name: None,
539                verbosity: crate::log::Verbosity::Normal,
540            });
541            let args: Vec<&str> = cmd.get_args().map(|s| s.to_str().expect("ascii")).collect();
542            assert!(
543                args.contains(&"--no-env-preflight"),
544                "child argv (snapshot={snapshot}) must always carry --no-env-preflight; got {args:?}"
545            );
546        }
547    }
548
549    /// The child argv must not carry a flag `anodizer release` no longer
550    /// accepts. Automatic rollback is gone, and with it `--rollback` and
551    /// `--no-failure-policy`: a harness that still passed either would die
552    /// on an unknown-flag clap error before building anything, turning
553    /// every determinism check into a parse failure.
554    #[test]
555    fn subprocess_command_passes_no_removed_rollback_flags() {
556        let env = HashMap::new();
557        for snapshot in [true, false] {
558            let cmd = build_subprocess_command(&ChildInvocation {
559                anodize_binary: &PathBuf::from("/usr/bin/anodize"),
560                worktree_path: &std::env::temp_dir(),
561                env: &env,
562                targets: None,
563                extra_skip: &[],
564                snapshot,
565                crate_name: None,
566                verbosity: crate::log::Verbosity::Normal,
567            });
568            let args: Vec<&str> = cmd.get_args().map(|s| s.to_str().expect("ascii")).collect();
569            for removed in ["--rollback", "--rollback-only", "--no-failure-policy"] {
570                assert!(
571                    !args.contains(&removed),
572                    "child argv (snapshot={snapshot}) must not carry the removed \
573                     {removed} flag; got {args:?}"
574                );
575            }
576        }
577    }
578
579    /// The child env must ALWAYS carry the log-depth var so the child's
580    /// interleaved stderr nests beneath the harness's `• run N of M`
581    /// bullet — and it must survive the hermetic `env_clear` +
582    /// re-population (it is set after the map is applied, so the map
583    /// cannot clobber it).
584    #[test]
585    fn subprocess_command_always_exports_log_depth() {
586        // A hermetic map that tries to clobber the var: the explicit
587        // post-map set must win.
588        let mut env = HashMap::new();
589        env.insert(crate::log::LOG_DEPTH_ENV.to_string(), "99".to_string());
590        for snapshot in [true, false] {
591            let cmd = build_subprocess_command(&ChildInvocation {
592                anodize_binary: &PathBuf::from("/usr/bin/anodize"),
593                worktree_path: &std::env::temp_dir(),
594                env: &env,
595                targets: None,
596                extra_skip: &[],
597                snapshot,
598                crate_name: None,
599                verbosity: crate::log::Verbosity::Normal,
600            });
601            let depth = cmd
602                .get_envs()
603                .find(|(k, _)| *k == std::ffi::OsStr::new(crate::log::LOG_DEPTH_ENV))
604                .and_then(|(_, v)| v)
605                .and_then(|v| v.to_str())
606                .map(str::to_string);
607            // Pin presence + the `+2` floor rather than an exact value:
608            // SECTION_DEPTH is process-global and sibling tests open
609            // sections concurrently, so the exact depth at build time is
610            // not stable under a parallel test runner.
611            let parsed: usize = depth
612                .as_deref()
613                .unwrap_or_else(|| {
614                    panic!(
615                        "child env (snapshot={snapshot}) must carry {}",
616                        crate::log::LOG_DEPTH_ENV
617                    )
618                })
619                .parse()
620                .expect("depth var must be numeric");
621            assert!(
622                parsed >= 2,
623                "depth must be parent depth + 2 (>= 2); got {parsed}"
624            );
625        }
626    }
627
628    /// `snapshot=false` MUST drop `--snapshot` from the argv so the
629    /// child release subprocess uses the real release version instead
630    /// of a `-SNAPSHOT-<sha>` suffix. The release workflow relies on
631    /// this for tag-push runs.
632    #[test]
633    fn subprocess_command_drops_snapshot_when_disabled() {
634        let env = HashMap::new();
635        let cmd = build_subprocess_command(&ChildInvocation {
636            anodize_binary: &PathBuf::from("/usr/bin/anodize"),
637            worktree_path: &std::env::temp_dir(),
638            env: &env,
639            targets: None,
640            extra_skip: &[],
641            snapshot: false,
642            crate_name: None,
643            verbosity: crate::log::Verbosity::Normal,
644        });
645        let args: Vec<&str> = cmd.get_args().map(|s| s.to_str().expect("ascii")).collect();
646        assert!(
647            !args.contains(&"--snapshot"),
648            "snapshot=false should drop --snapshot; got {args:?}"
649        );
650        assert!(
651            args.iter().any(|a| a.starts_with("--skip=")),
652            "argv still needs --skip=...: {args:?}"
653        );
654        assert_eq!(args[0], "release", "argv must lead with `release`");
655    }
656
657    /// A per-crate determinism shard MUST scope the child build to its
658    /// crate, else a workspace build defaults to the primary member and
659    /// the shard preserves the wrong crate's artifacts (a library's source
660    /// archive in place of a binary crate's binaries), starving publish-only
661    /// of the binaries docker and the binary publishers need.
662    #[test]
663    fn subprocess_command_scopes_to_crate_when_named() {
664        let env = HashMap::new();
665        let cmd = build_subprocess_command(&ChildInvocation {
666            anodize_binary: &PathBuf::from("/usr/bin/anodize"),
667            worktree_path: &std::env::temp_dir(),
668            env: &env,
669            targets: None,
670            extra_skip: &[],
671            snapshot: true,
672            crate_name: Some("cfgd"),
673            verbosity: crate::log::Verbosity::Normal,
674        });
675        let args: Vec<String> = cmd
676            .get_args()
677            .map(|s| s.to_str().expect("ascii").to_string())
678            .collect();
679        assert!(
680            args.iter().any(|a| a == "--crate=cfgd"),
681            "expected --crate=cfgd to scope the child build; got {args:?}"
682        );
683    }
684
685    /// `None` (a single-crate / non-workspace project) must NOT emit
686    /// `--crate`, so the default whole-project build is preserved.
687    #[test]
688    fn subprocess_command_omits_crate_when_none() {
689        let env = HashMap::new();
690        let cmd = build_subprocess_command(&ChildInvocation {
691            anodize_binary: &PathBuf::from("/usr/bin/anodize"),
692            worktree_path: &std::env::temp_dir(),
693            env: &env,
694            targets: None,
695            extra_skip: &[],
696            snapshot: true,
697            crate_name: None,
698            verbosity: crate::log::Verbosity::Normal,
699        });
700        let args: Vec<String> = cmd
701            .get_args()
702            .map(|s| s.to_str().expect("ascii").to_string())
703            .collect();
704        assert!(
705            args.iter().all(|a| !a.starts_with("--crate")),
706            "no crate named: --crate must be omitted; got {args:?}"
707        );
708    }
709
710    /// Operator verbosity must reach the child argv: each non-Normal
711    /// [`crate::log::Verbosity`] maps to exactly one flag, and Normal
712    /// maps to none (the child's own default). The child's stderr is
713    /// inherited into the parent stream, so a dropped flag would leave
714    /// `-q` runs loud and `--debug` runs mute inside the harness.
715    #[test]
716    fn subprocess_command_forwards_verbosity_flag() {
717        let env = HashMap::new();
718        let argv_for = |verbosity: crate::log::Verbosity| -> Vec<String> {
719            let cmd = build_subprocess_command(&ChildInvocation {
720                anodize_binary: &PathBuf::from("/usr/bin/anodize"),
721                worktree_path: &std::env::temp_dir(),
722                env: &env,
723                targets: None,
724                extra_skip: &[],
725                snapshot: true,
726                crate_name: None,
727                verbosity,
728            });
729            cmd.get_args()
730                .map(|s| s.to_str().expect("ascii").to_string())
731                .collect()
732        };
733        let verbosity_flags = ["--quiet", "--verbose", "--debug"];
734        for (verbosity, expected) in [
735            (crate::log::Verbosity::Quiet, Some("--quiet")),
736            (crate::log::Verbosity::Verbose, Some("--verbose")),
737            (crate::log::Verbosity::Debug, Some("--debug")),
738            (crate::log::Verbosity::Normal, None),
739        ] {
740            let args = argv_for(verbosity);
741            let present: Vec<&String> = args
742                .iter()
743                .filter(|a| verbosity_flags.contains(&a.as_str()))
744                .collect();
745            match expected {
746                Some(flag) => assert_eq!(
747                    present,
748                    vec![flag],
749                    "{verbosity:?} must forward exactly {flag}; got {args:?}"
750                ),
751                None => assert!(
752                    present.is_empty(),
753                    "Normal must forward no verbosity flag; got {args:?}"
754                ),
755            }
756        }
757    }
758
759    /// The prefetch argv must carry `fetch --manifest-path <dir>/Cargo.toml`,
760    /// set `CARGO_HOME` to the shared cache, force `CARGO_NET_OFFLINE=false`
761    /// (the one online op), and — because a `Cargo.lock` is committed here —
762    /// the strict `--locked`.
763    #[test]
764    fn fetch_command_pins_locked_home_and_manifest() {
765        let dir = tempfile::tempdir().unwrap();
766        // A committed lock → `--locked` (the binary-release determinism path).
767        std::fs::write(dir.path().join("Cargo.lock"), "# lock").unwrap();
768        let home = PathBuf::from("/cache/cargo");
769        let cmd = build_fetch_command(dir.path(), &home);
770
771        let args: Vec<String> = cmd
772            .get_args()
773            .map(|s| s.to_str().expect("ascii").to_string())
774            .collect();
775        assert_eq!(
776            args[0], "fetch",
777            "argv must lead with `fetch`; got {args:?}"
778        );
779        assert!(
780            args.contains(&"--locked".to_string()),
781            "a committed Cargo.lock must yield --locked: {args:?}"
782        );
783        let mp = args
784            .iter()
785            .position(|a| a == "--manifest-path")
786            .expect("--manifest-path present");
787        assert_eq!(
788            args.get(mp + 1).map(String::as_str),
789            Some(dir.path().join("Cargo.toml").to_str().unwrap()),
790            "manifest path must point at the worktree's Cargo.toml; got {args:?}"
791        );
792
793        let env: HashMap<String, String> = cmd
794            .get_envs()
795            .filter_map(|(k, v)| Some((k.to_str()?.to_string(), v?.to_str()?.to_string())))
796            .collect();
797        assert_eq!(
798            env.get("CARGO_HOME").map(String::as_str),
799            Some("/cache/cargo")
800        );
801        assert_eq!(
802            env.get("CARGO_NET_OFFLINE").map(String::as_str),
803            Some("false"),
804            "prefetch must stay online; the rebuilds are what get sealed offline"
805        );
806    }
807
808    /// No `Cargo.lock` committed (library crate / minimal fixture) → omit
809    /// `--locked`, which would otherwise hard-error before cargo can resolve
810    /// + write a lock. Plain `cargo fetch` is correct there.
811    #[test]
812    fn fetch_command_omits_locked_without_committed_lock() {
813        let dir = tempfile::tempdir().unwrap();
814        // No Cargo.lock written.
815        let cmd = build_fetch_command(dir.path(), &PathBuf::from("/c"));
816        let args: Vec<String> = cmd
817            .get_args()
818            .map(|s| s.to_str().expect("ascii").to_string())
819            .collect();
820        assert!(
821            args.iter().all(|a| a != "--locked"),
822            "no committed lock → --locked must be omitted; got {args:?}"
823        );
824    }
825
826    /// Regression guard for the cross-shard offline failure: the prefetch must
827    /// NEVER pass `--target`. A plain `cargo fetch` downloads the all-platform
828    /// dependency superset (host + every cross target); `cargo fetch --target X`
829    /// NARROWS to X's `CompileKind` and drops the host-target deps the offline
830    /// rebuild still needs for host-side work (the man-page `before:` hook's
831    /// `cargo run --bin anodize` host build) — which then dies with `failed to
832    /// download <crate>: --offline was specified` on a cross shard.
833    #[test]
834    fn fetch_command_never_narrows_to_a_target() {
835        let dir = tempfile::tempdir().unwrap();
836        std::fs::write(dir.path().join("Cargo.lock"), "# lock").unwrap();
837        let cmd = build_fetch_command(dir.path(), &PathBuf::from("/c"));
838        let args: Vec<String> = cmd
839            .get_args()
840            .map(|s| s.to_str().expect("ascii").to_string())
841            .collect();
842        assert!(
843            args.iter().all(|a| a != "--target"),
844            "prefetch must fetch the all-platform superset (no --target); got {args:?}"
845        );
846    }
847
848    /// The prefetch retries then bails with a context-bearing error when
849    /// the fetch can't succeed — driven here with a bogus manifest dir so
850    /// every attempt fails fast (attempts=1, zero backoff keeps the test
851    /// instant).
852    #[test]
853    fn prefetch_fails_after_exhausting_attempts() {
854        let res = prefetch_deps_with(
855            &PathBuf::from("/nonexistent/anodize-prefetch-test-dir"),
856            &PathBuf::from("/nonexistent/cargo-home"),
857            1,
858            Duration::ZERO,
859        );
860        let err = res.expect_err("fetch against a missing manifest must error");
861        assert!(
862            err.to_string().contains("determinism prefetch"),
863            "error must identify the prefetch step; got {err:#}"
864        );
865    }
866
867    #[test]
868    fn side_effect_stages_covers_every_known_publish_side_effect() {
869        // Regression guard: if a future pipeline edit adds a side-effect
870        // stage and forgets to register it here, this test surfaces the
871        // omission. Add the new stage to SIDE_EFFECT_STAGES (and update
872        // this list) once the new entry is confirmed to belong in the
873        // skip set.
874        let expected = [
875            "release",
876            "docker",
877            "docker-sign",
878            "publish",
879            "blob",
880            "snapcraft-publish",
881            "announce",
882            "verify-release",
883        ];
884        for name in expected {
885            assert!(
886                SIDE_EFFECT_STAGES.contains(&name),
887                "SIDE_EFFECT_STAGES missing known publish-side stage `{name}`"
888            );
889        }
890    }
891
892    /// The harness's skip set must be a SUPERSET of the upstream-touching
893    /// classification `release --prepare` skips: everything that reaches
894    /// upstream is also a hermetic-rebuild side effect (the harness merely
895    /// adds host-state extras like notarize on top). Holds by construction
896    /// since the derivation, but pins the relation against a future rewrite
897    /// of either set.
898    #[test]
899    fn side_effect_stages_superset_of_upstream_stages() {
900        for &name in crate::stages::UPSTREAM_STAGES {
901            assert!(
902                SIDE_EFFECT_STAGES.contains(&name),
903                "SIDE_EFFECT_STAGES must contain every UPSTREAM_STAGES entry; missing `{name}`"
904            );
905        }
906        assert!(
907            SIDE_EFFECT_STAGES.contains(&"notarize"),
908            "notarize is a harness-only extra and must stay in the skip set"
909        );
910    }
911
912    #[test]
913    fn compute_skip_arg_starts_with_skip_flag() {
914        // I8 fix shape: harness still uses --skip=<list> (the conservative
915        // path; --only=<list> would require a new CLI flag). Guard against
916        // a future refactor accidentally flipping to a different prefix.
917        let arg = compute_skip_arg(&[]);
918        assert!(
919            arg.starts_with("--skip="),
920            "expected --skip= prefix, got `{arg}`"
921        );
922        // And the joined list is non-empty.
923        assert!(arg.len() > "--skip=".len(), "skip list must not be empty");
924    }
925
926    #[test]
927    fn compute_skip_arg_round_trips_through_comma_join() {
928        let arg = compute_skip_arg(&[]);
929        let list = arg
930            .trim_start_matches("--skip=")
931            .split(',')
932            .collect::<Vec<_>>();
933        assert_eq!(list.len(), SIDE_EFFECT_STAGES.len());
934        for (a, b) in list.iter().zip(SIDE_EFFECT_STAGES.iter()) {
935            assert_eq!(a, b);
936        }
937    }
938
939    /// `compute_skip_arg` MUST merge `SIDE_EFFECT_STAGES` with the
940    /// harness's complement set — otherwise the child release subprocess
941    /// runs produce-stages like `nfpm` / `nsis` / `dmg` on shards that
942    /// have no business running them, and the run dies with `No such
943    /// file or directory`. The harness fix in
944    /// `crates/cli/src/determinism_harness.rs` relies on this merge.
945    #[test]
946    fn compute_skip_arg_includes_side_effects_and_extra() {
947        let extra = ["nfpm".to_string(), "msi".to_string(), "dmg".to_string()];
948        let extra_refs: Vec<&str> = extra.iter().map(String::as_str).collect();
949        let arg = compute_skip_arg(&extra_refs);
950        let list: Vec<&str> = arg.trim_start_matches("--skip=").split(',').collect();
951        for &name in SIDE_EFFECT_STAGES.iter() {
952            assert!(
953                list.contains(&name),
954                "merged skip list missing side-effect stage `{name}`: {list:?}"
955            );
956        }
957        for name in ["nfpm", "msi", "dmg"] {
958            assert!(
959                list.contains(&name),
960                "merged skip list missing extra stage `{name}`: {list:?}"
961            );
962        }
963    }
964
965    /// Overlap is a real scenario — the harness's complement set is
966    /// computed against `VALID_RELEASE_SKIPS`, which contains the same
967    /// `release` / `publish` / `announce` names as `SIDE_EFFECT_STAGES`.
968    /// `compute_skip_arg` MUST de-dupe so the final argv isn't bloated
969    /// and CLI validation doesn't choke on a repeated token.
970    #[test]
971    fn compute_skip_arg_dedupes_overlap() {
972        // Pass a SIDE_EFFECT_STAGES member through `extra` and confirm it
973        // appears exactly once in the merged list.
974        let extra = ["release".to_string(), "nfpm".to_string()];
975        let extra_refs: Vec<&str> = extra.iter().map(String::as_str).collect();
976        let arg = compute_skip_arg(&extra_refs);
977        let list: Vec<&str> = arg.trim_start_matches("--skip=").split(',').collect();
978        let release_count = list.iter().filter(|&&s| s == "release").count();
979        assert_eq!(
980            release_count, 1,
981            "expected `release` exactly once in merged skip list, got {release_count} in {list:?}"
982        );
983        // And nfpm did come through.
984        assert!(
985            list.contains(&"nfpm"),
986            "merged list missing extra entry `nfpm`: {list:?}"
987        );
988    }
989
990    /// Every name the harness shovels into `--skip=...` MUST be accepted
991    /// by the release CLI's skip validator. Surfaced by the
992    /// drift-injection integration test when `docker-sign`
993    /// was present in [`SIDE_EFFECT_STAGES`] but missing from
994    /// [`crate::context::VALID_RELEASE_SKIPS`] — the harness's child
995    /// subprocess bombed with `invalid --skip value(s): docker-sign`. This
996    /// pure-cross-check unit test catches the drift in milliseconds so a
997    /// future addition to either list flags the gap immediately.
998    #[test]
999    fn side_effect_stages_are_all_valid_release_skip_values() {
1000        use crate::context::VALID_RELEASE_SKIPS;
1001        for &name in SIDE_EFFECT_STAGES.iter() {
1002            assert!(
1003                VALID_RELEASE_SKIPS.contains(&name),
1004                "SIDE_EFFECT_STAGES contains `{name}` but VALID_RELEASE_SKIPS does not — \
1005                 the harness would fail at `anodize release --skip=<list>` invocation. \
1006                 Add `{name}` to VALID_RELEASE_SKIPS in crates/core/src/context.rs."
1007            );
1008        }
1009    }
1010}