zlayer-toolchain 0.14.3

Runtime toolchain provisioning (macOS Homebrew bottle resolver/installer) for ZLayer
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
//! Brew-emulate fallback: build a homebrew-core formula with **real Homebrew
//! installed at the toolchain prefix**, for the macOS long tail the generic
//! [`crate::source_build`] recipe runner cannot reproduce.
//!
//! # When this runs
//!
//! [`crate::source_build::ensure_from_source`] handles the homebrew-core C-tool
//! population with a generic autotools/CMake/Makefile runner. Some formulae have
//! custom `.rb` `install do` logic (compile a single file by hand, run a
//! bespoke `install.sh`, `cargo install` / `go build`, apply `patches`, …) that
//! a generic build-system detector cannot reproduce — for those the generic
//! runner either fails detection (no `configure`/`CMakeLists.txt`/`Makefile`) or
//! errors mid-build. This module is the fallback: it runs the formula's *actual*
//! Homebrew install recipe, so whatever custom logic the formula carries is
//! executed faithfully.
//!
//! # Why install Homebrew AT the toolchain prefix
//!
//! The toolchain must be **self-contained and relocation-free** — no `@@HOMEBREW@@`
//! placeholders in any binary's load commands (those abort under a darwin
//! Seatbelt container, see [`crate::source_build`] module docs). Two properties
//! get us there:
//!
//! 1. **A non-default prefix forces build-from-source.** We additionally pass
//!    `--build-from-source`, so Homebrew compiles the named formula instead of
//!    pouring a relocatable bottle (a poured bottle is exactly where the
//!    `@@HOMEBREW@@` install-name placeholders come from). A compiled binary
//!    bakes the *absolute* prefix path into its `LC_LOAD_DYLIB` / rpath load
//!    commands.
//! 2. **The prefix lives inside the toolchain, permanently.** We clone Homebrew into
//!    `<toolchain>/brew` and point `HOMEBREW_PREFIX`/`HOMEBREW_CELLAR`/
//!    `HOMEBREW_REPOSITORY` there, so those baked-in absolute paths
//!    (`<toolchain>/brew/opt/<dep>/lib/...`) remain valid for the life of the toolchain —
//!    no post-install relocation, ever.
//!
//! The resulting toolchain has the standard layout the resolver expects (a `bin` of
//! the installed formula's executables, reached via the Homebrew prefix's
//! `opt/<formula>/bin` + `bin` symlink dirs) plus a [`ToolchainManifest`].

use std::collections::HashMap;
use std::path::{Path, PathBuf};

use tracing::{debug, info, warn};

use crate::error::{Result, ToolchainError};
use crate::executor::{ContainerBuildExecutor, ContainerBuildRequest, NetPolicy};
use crate::manifest::{ToolchainManifest, ToolchainSource};
use crate::recipe::{InstallPlan, InstallStep};
use crate::source_build::SourceSpec;

/// Upstream Homebrew git repository (cloned shallow into the toolchain prefix).
const HOMEBREW_REPO_URL: &str = "https://github.com/Homebrew/brew";

/// Host architecture token used in cache keys (`arm64` / `x86_64`).
fn arch_token() -> &'static str {
    match std::env::consts::ARCH {
        "aarch64" => "arm64",
        other => other,
    }
}

/// Build `formula` into a self-contained toolchain by running its *real* Homebrew
/// install recipe at a toolchain-rooted prefix, returning the toolchain path.
///
/// The toolchain lives at `<cache_dir>/<formula>-<version>-<arch>/` (identical to the
/// source-build layout, so the cache key, `.ready` marker, and
/// [`crate::probe_ready_toolchain`] all behave the same). The Homebrew checkout
/// lands at `<toolchain>/brew` and is reused as the prefix; the formula is installed
/// with `--build-from-source` so no `@@HOMEBREW@@` placeholder ever reaches a
/// binary's load commands.
///
/// The `brew install` itself runs inside a throwaway runtime container (via the
/// registered [`crate::executor::ContainerBuildExecutor`]) — NEVER as a host
/// subprocess. Only provision-time file fetches (the Homebrew checkout) touch
/// the host.
///
/// # Errors
///
/// Returns [`ToolchainError::ExecutorUnavailable`] when no runtime container
/// executor is registered, [`ToolchainError::RegistryError`] if Homebrew cannot
/// be provisioned at the prefix or the `brew install` fails, or an I/O error on
/// a filesystem failure.
pub async fn ensure_via_brew(
    formula: &str,
    spec: &SourceSpec,
    cache_dir: &Path,
) -> Result<PathBuf> {
    let toolchain = cache_dir.join(format!("{formula}-{}-{}", spec.version, arch_token()));
    let ready_marker = toolchain.join(".ready");
    if tokio::fs::try_exists(&ready_marker).await.unwrap_or(false) {
        return Ok(toolchain);
    }

    // Fresh build — clear any partial toolchain from a crashed prior attempt.
    let _ = tokio::fs::remove_dir_all(&toolchain).await;
    tokio::fs::create_dir_all(&toolchain).await?;

    let brew_prefix = toolchain.join("brew");
    provision_brew_at_prefix(&brew_prefix).await?;

    // Shared download cache so a repeated fallback reuses fetched bottles/source.
    let brew_cache = cache_dir.join(".brew-cache");
    tokio::fs::create_dir_all(&brew_cache).await?;

    // The install runs inside a throwaway runtime container — resolve the
    // process-global executor here; the absence check (ExecutorUnavailable,
    // NEVER a host fallback) lives in `brew_install_in_container`.
    let executor = crate::executor::container_executor();
    brew_install_in_container(formula, &toolchain, &brew_cache, executor.as_deref()).await?;

    // The formula's executables are reached through the prefix's `opt/<formula>`
    // symlink (canonical). Prefer ONLY that per-formula bin: the shared prefix
    // `bin` aggregates every dependency's shims PLUS a `brew` wrapper, and since
    // the slim step below deletes `Library/` (Homebrew's Ruby) that `brew` shim
    // is DEAD -- yet prepending prefix `bin` to PATH would shadow the host's real
    // `/opt/homebrew/bin/brew`, so a job's `brew install <x>` hits the dead shim
    // and dies `brew.sh: No such file or directory` (exit 127). Expose only
    // `opt/<formula>/bin`; fall back to the prefix `bin` ONLY when the formula has
    // no opt bin (rare), so no toolchain is ever left with an empty PATH.
    let mut path_dirs = Vec::new();
    let opt_bin = brew_prefix.join("opt").join(formula).join("bin");
    if tokio::fs::try_exists(&opt_bin).await.unwrap_or(false) {
        path_dirs.push(opt_bin.display().to_string());
    } else {
        let prefix_bin = brew_prefix.join("bin");
        if tokio::fs::try_exists(&prefix_bin).await.unwrap_or(false) {
            path_dirs.push(prefix_bin.display().to_string());
        }
    }
    if path_dirs.is_empty() {
        return Err(ToolchainError::RegistryError {
            message: format!(
                "brew-emulate install of {formula} produced no bin dir under {}",
                brew_prefix.display()
            ),
        });
    }

    // Defensive guard: the whole point is no `@@HOMEBREW@@` placeholder survives.
    // `--build-from-source` guarantees this for the named formula; assert it so a
    // regression (e.g. brew silently pouring a bottle) is caught at provision
    // time rather than as an Abort trap inside the sandbox.
    if let Some(offending) = scan_for_homebrew_placeholder(&opt_bin).await {
        return Err(ToolchainError::RegistryError {
            message: format!(
                "brew-emulate {formula}: binary {} still carries an @@HOMEBREW@@ load \
                 command (bottle poured instead of built from source?)",
                offending.display()
            ),
        });
    }

    // Slim the toolchain: the compiled binaries never reference Homebrew's own Ruby
    // code or git history at runtime, so drop them. The Cellar + opt + bin +
    // lib trees (which the load commands DO reference) are left intact.
    for slim in [".git", "Library", "docs", "completions", "manpages"] {
        let _ = tokio::fs::remove_dir_all(brew_prefix.join(slim)).await;
    }
    // The `brew` CLI shim in the prefix `bin` is dead once `Library/` (its Ruby)
    // is gone; drop it so it can never shadow the host `brew` if the prefix `bin`
    // is ever surfaced.
    let _ = tokio::fs::remove_file(brew_prefix.join("bin").join("brew")).await;

    let manifest = ToolchainManifest {
        tool: formula.to_string(),
        version: spec.version.clone(),
        arch: arch_token().to_string(),
        platform: "macos".to_string(),
        path_dirs,
        env: std::collections::HashMap::new(),
        source: ToolchainSource::SourceBuild {
            url: spec.tarball_url.clone(),
            sha256: spec.sha256.clone(),
        },
        build_deps: spec.build_dependencies.clone(),
        provisioned_at: chrono::Utc::now().to_rfc3339(),
    };
    manifest.write_to_toolchain(&toolchain).await?;
    tokio::fs::write(&ready_marker, b"").await?;

    info!(formula, toolchain = %toolchain.display(), "brew-emulate fallback produced a self-contained toolchain");
    Ok(toolchain)
}

/// Provision a throwaway Homebrew checkout at `brew_prefix`.
///
/// Prefers a shallow `git clone` (the host CLT git, unsandboxed — this is
/// provision time, not the sandboxed runtime); falls back to the GitHub source
/// tarball when git is unavailable. A real git checkout is preferred because
/// `brew` is happier inside a git repository.
async fn provision_brew_at_prefix(brew_prefix: &Path) -> Result<()> {
    if tokio::fs::try_exists(brew_prefix.join("bin/brew"))
        .await
        .unwrap_or(false)
    {
        return Ok(());
    }
    if let Some(parent) = brew_prefix.parent() {
        tokio::fs::create_dir_all(parent).await?;
    }

    // Try a shallow clone first.
    let clone = tokio::process::Command::new("git")
        .arg("clone")
        .arg("--depth=1")
        .arg(HOMEBREW_REPO_URL)
        .arg(brew_prefix)
        .output()
        .await;
    if let Ok(out) = clone {
        if out.status.success()
            && tokio::fs::try_exists(brew_prefix.join("bin/brew"))
                .await
                .unwrap_or(false)
        {
            return Ok(());
        }
        warn!(
            "git clone of Homebrew failed ({}); falling back to source tarball",
            String::from_utf8_lossy(&out.stderr).trim()
        );
    }

    // Fallback: download + extract the master tarball.
    let tarball = "https://github.com/Homebrew/brew/archive/refs/heads/master.tar.gz";
    let bytes = reqwest::get(tarball)
        .await
        .map_err(|e| ToolchainError::RegistryError {
            message: format!("failed to download Homebrew tarball: {e}"),
        })?
        .bytes()
        .await
        .map_err(|e| ToolchainError::RegistryError {
            message: format!("failed to read Homebrew tarball bytes: {e}"),
        })?;
    let tmp = brew_prefix.with_extension("tar.gz");
    tokio::fs::write(&tmp, &bytes).await?;
    tokio::fs::create_dir_all(brew_prefix).await?;
    let untar = tokio::process::Command::new("tar")
        .arg("xf")
        .arg(&tmp)
        .args(["--strip-components", "1", "-C"])
        .arg(brew_prefix)
        .output()
        .await?;
    let _ = tokio::fs::remove_file(&tmp).await;
    if !untar.status.success() {
        return Err(ToolchainError::RegistryError {
            message: format!(
                "failed to extract Homebrew tarball: {}",
                String::from_utf8_lossy(&untar.stderr)
            ),
        });
    }
    if !tokio::fs::try_exists(brew_prefix.join("bin/brew"))
        .await
        .unwrap_or(false)
    {
        return Err(ToolchainError::RegistryError {
            message: format!(
                "Homebrew checkout at {} has no bin/brew",
                brew_prefix.display()
            ),
        });
    }
    Ok(())
}

/// Run `brew install --build-from-source <formula>` inside a throwaway runtime
/// container, with the prefix env pointed at the toolchain-rooted Homebrew.
///
/// The executor is a parameter (never resolved from the global slot here) so
/// tests can inject a mock `&dyn` exactly like `source_build`'s do; the public
/// [`ensure_via_brew`] resolves the process-global slot and passes it down.
/// `None` is a hard [`ToolchainError::ExecutorUnavailable`] — a containerized
/// build step NEVER falls back to a host subprocess.
async fn brew_install_in_container(
    formula: &str,
    toolchain: &Path,
    brew_cache: &Path,
    executor: Option<&dyn ContainerBuildExecutor>,
) -> Result<()> {
    let Some(executor) = executor else {
        return Err(ToolchainError::ExecutorUnavailable {
            tool: formula.to_string(),
        });
    };

    // brew needs no source tree, so the scratch dir doubles as `src_dir`.
    // Nothing has created it at this point (the toolchain root and the brew
    // checkout exist; `.build` does not) — create it, plus the container HOME
    // under it.
    let scratch = toolchain.join(".build");
    tokio::fs::create_dir_all(scratch.join("home")).await?;

    let req = assemble_brew_container_request(formula, toolchain, brew_cache);

    warn!(
        target: "net_fallback",
        tool = %formula,
        "NET-FALLBACK: building via brew inside a throwaway container (recipe not natively executable)"
    );
    let report = executor.execute(&req).await?;
    debug!(tool = %formula, log_tail = %report.log_tail, "brew-in-container install succeeded");
    warn!(
        target: "net_fallback",
        tool = %formula,
        "NET-FALLBACK: brew-in-container build completed"
    );
    Ok(())
}

/// Assemble the one-step [`ContainerBuildRequest`] that runs
/// `<toolchain>/brew/bin/brew install --build-from-source <formula>` in a
/// runtime container.
///
/// Everything brew writes lands under the request's `prefix` (the toolchain
/// root): the Cellar/opt/bin trees under `<prefix>/brew`, AND brew's
/// portable-ruby bootstrap, which it vendors into
/// `<prefix>/brew/Library/Homebrew/vendor` — the container's prefix write
/// grant covers both. Network is inherent to this path (brew fetches the
/// formula's source, taps, and portable-ruby itself), hence
/// [`NetPolicy::AllowLoud`] rather than the recipe route's `Deny`.
fn assemble_brew_container_request(
    formula: &str,
    toolchain: &Path,
    brew_cache: &Path,
) -> ContainerBuildRequest {
    let brew_prefix = toolchain.join("brew");
    let scratch = toolchain.join(".build");
    let env = brew_env(&brew_prefix, brew_cache, &scratch);
    ContainerBuildRequest {
        tool: formula.to_string(),
        platform: crate::ToolPlatform::MacOS,
        plan: InstallPlan {
            steps: vec![InstallStep::System {
                argv: vec![
                    brew_prefix.join("bin/brew").display().to_string(),
                    "install".to_string(),
                    "--build-from-source".to_string(),
                    formula.to_string(),
                ],
            }],
            resources: vec![],
            patches: vec![],
            env: env.clone(),
            deparallelize: false,
        },
        src_dir: scratch.clone(),
        prefix: toolchain.to_path_buf(),
        scratch_dir: scratch,
        dep_toolchains: vec![],
        resources_dir: None,
        env,
        path_prefix: vec![],
        net: NetPolicy::AllowLoud,
    }
}

/// The environment for the containerized `brew install`: exactly the variables
/// the old host subprocess set (same values, same derivations — Homebrew prefix
/// family + download cache + the NO_* quieting family + PATH with the prefix
/// bin first, then the host PATH, then the system fallback), plus a writable
/// `HOME` under the scratch dir (the container has no real home).
fn brew_env(brew_prefix: &Path, brew_cache: &Path, scratch: &Path) -> HashMap<String, String> {
    let prefix_str = brew_prefix.display().to_string();

    // PATH: prefix bin first, then host PATH, then the system fallback.
    let host_path = std::env::var("PATH").unwrap_or_default();
    let mut path_parts = vec![brew_prefix.join("bin").display().to_string()];
    if !host_path.is_empty() {
        path_parts.push(host_path);
    }
    path_parts.push("/usr/bin:/bin:/usr/sbin:/sbin".to_string());

    let mut env = HashMap::new();
    env.insert("PATH".to_string(), path_parts.join(":"));
    env.insert("HOMEBREW_PREFIX".to_string(), prefix_str.clone());
    env.insert("HOMEBREW_REPOSITORY".to_string(), prefix_str);
    env.insert(
        "HOMEBREW_CELLAR".to_string(),
        brew_prefix.join("Cellar").display().to_string(),
    );
    env.insert(
        "HOMEBREW_CACHE".to_string(),
        brew_cache.display().to_string(),
    );
    env.insert("HOMEBREW_NO_AUTO_UPDATE".to_string(), "1".to_string());
    env.insert("HOMEBREW_NO_ANALYTICS".to_string(), "1".to_string());
    env.insert("HOMEBREW_NO_ENV_HINTS".to_string(), "1".to_string());
    env.insert("HOMEBREW_NO_INSTALL_CLEANUP".to_string(), "1".to_string());
    env.insert(
        "HOME".to_string(),
        scratch.join("home").display().to_string(),
    );
    env
}

/// Scan every regular file under `dir` for the `@@HOMEBREW@@` byte sequence in a
/// binary's load commands. Returns the first offending path, or `None` when the
/// tree is clean. Best-effort and shallow-recursive; a missing dir is clean.
async fn scan_for_homebrew_placeholder(dir: &Path) -> Option<PathBuf> {
    let mut stack = vec![dir.to_path_buf()];
    while let Some(d) = stack.pop() {
        let mut entries = tokio::fs::read_dir(&d).await.ok()?;
        while let Ok(Some(entry)) = entries.next_entry().await {
            let path = entry.path();
            let ft = entry.file_type().await.ok()?;
            if ft.is_dir() {
                stack.push(path);
            } else if ft.is_file() {
                if let Ok(bytes) = tokio::fs::read(&path).await {
                    if contains_subslice(&bytes, b"@@HOMEBREW") {
                        return Some(path);
                    }
                }
            }
        }
    }
    None
}

/// Tiny byte-substring helper for the placeholder scan.
fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
    if needle.is_empty() || haystack.len() < needle.len() {
        return false;
    }
    haystack.windows(needle.len()).any(|w| w == needle)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn placeholder_scan_helper_matches() {
        assert!(contains_subslice(b"abc@@HOMEBREW@@/lib", b"@@HOMEBREW"));
        assert!(!contains_subslice(
            b"/usr/lib/libSystem.dylib",
            b"@@HOMEBREW"
        ));
        assert!(!contains_subslice(b"", b"@@HOMEBREW"));
    }

    #[tokio::test]
    async fn placeholder_scan_finds_offender_and_clean_is_none() {
        let tmp = tempfile::tempdir().unwrap();
        let sub = tmp.path().join("bin");
        tokio::fs::create_dir_all(&sub).await.unwrap();
        tokio::fs::write(sub.join("clean"), b"/usr/lib/libSystem.B.dylib")
            .await
            .unwrap();
        assert!(scan_for_homebrew_placeholder(tmp.path()).await.is_none());

        tokio::fs::write(
            sub.join("dirty"),
            b"@@HOMEBREW_PREFIX@@/opt/x/lib/libx.dylib",
        )
        .await
        .unwrap();
        let hit = scan_for_homebrew_placeholder(tmp.path()).await;
        assert!(hit.is_some());
        assert!(hit.unwrap().ends_with("dirty"));
    }

    #[tokio::test]
    async fn ensure_via_brew_short_circuits_on_ready_toolchain() {
        let tmp = tempfile::tempdir().unwrap();
        let spec = SourceSpec {
            version: "1.2.3".to_string(),
            tarball_url: "https://example/x.tar.gz".to_string(),
            sha256: String::new(),
            dependencies: vec![],
            build_dependencies: vec![],
            macos_provided: vec![],
        };
        let toolchain = tmp.path().join(format!("demo-1.2.3-{}", arch_token()));
        tokio::fs::create_dir_all(&toolchain).await.unwrap();
        tokio::fs::write(toolchain.join(".ready"), b"")
            .await
            .unwrap();

        let got = ensure_via_brew("demo", &spec, tmp.path()).await.unwrap();
        assert_eq!(
            got, toolchain,
            "a ready toolchain short-circuits without invoking brew"
        );
    }

    /// No registered executor is a hard `ExecutorUnavailable` — NEVER a host
    /// subprocess fallback. Exercised through the injected `Option<&dyn>`
    /// parameter, never through the process-global slot (that slot belongs to
    /// `executor.rs`'s single sequential test).
    #[tokio::test]
    async fn brew_install_without_executor_is_executor_unavailable() {
        let tmp = tempfile::tempdir().unwrap();
        let err =
            brew_install_in_container("demo", tmp.path(), &tmp.path().join(".brew-cache"), None)
                .await
                .expect_err("executor-less brew install must be a hard error");
        assert!(
            matches!(err, ToolchainError::ExecutorUnavailable { ref tool } if tool == "demo"),
            "expected ExecutorUnavailable for 'demo', got: {err}"
        );
    }

    /// The assembled request is a one-step plan carrying the exact brew argv,
    /// the exact HOMEBREW_* env the old host subprocess set (plus HOME under
    /// scratch), and `AllowLoud` networking, rooted at the toolchain prefix.
    #[test]
    fn brew_container_request_carries_brew_argv_env_and_allowloud() {
        let toolchain = Path::new("/tc/demo-1.2.3-arm64");
        let brew_cache = Path::new("/tc/.brew-cache");
        let req = assemble_brew_container_request("demo", toolchain, brew_cache);

        assert_eq!(req.tool, "demo");
        assert_eq!(req.platform, crate::ToolPlatform::MacOS);
        assert_eq!(
            req.net,
            NetPolicy::AllowLoud,
            "network is inherent to the brew path and must be loud"
        );
        assert_eq!(req.prefix, toolchain);
        assert_eq!(req.scratch_dir, toolchain.join(".build"));
        assert_eq!(
            req.src_dir,
            toolchain.join(".build"),
            "brew needs no source tree; scratch doubles as src_dir"
        );
        assert!(req.dep_toolchains.is_empty());
        assert!(req.resources_dir.is_none());
        assert!(
            req.path_prefix.is_empty(),
            "PATH travels in env, not path_prefix"
        );

        // One-step plan: exactly the brew invocation, nothing else.
        assert_eq!(
            req.plan.steps,
            vec![InstallStep::System {
                argv: vec![
                    "/tc/demo-1.2.3-arm64/brew/bin/brew".to_string(),
                    "install".to_string(),
                    "--build-from-source".to_string(),
                    "demo".to_string(),
                ],
            }]
        );
        assert!(req.plan.resources.is_empty());
        assert!(req.plan.patches.is_empty());
        assert!(!req.plan.deparallelize);
        assert_eq!(
            req.plan.env, req.env,
            "steps run with the same env the request carries"
        );

        let env = &req.env;
        assert_eq!(
            env.get("HOMEBREW_PREFIX").map(String::as_str),
            Some("/tc/demo-1.2.3-arm64/brew")
        );
        assert_eq!(
            env.get("HOMEBREW_REPOSITORY").map(String::as_str),
            Some("/tc/demo-1.2.3-arm64/brew")
        );
        assert_eq!(
            env.get("HOMEBREW_CELLAR").map(String::as_str),
            Some("/tc/demo-1.2.3-arm64/brew/Cellar")
        );
        assert_eq!(
            env.get("HOMEBREW_CACHE").map(String::as_str),
            Some("/tc/.brew-cache")
        );
        for quiet in [
            "HOMEBREW_NO_AUTO_UPDATE",
            "HOMEBREW_NO_ANALYTICS",
            "HOMEBREW_NO_ENV_HINTS",
            "HOMEBREW_NO_INSTALL_CLEANUP",
        ] {
            assert_eq!(
                env.get(quiet).map(String::as_str),
                Some("1"),
                "{quiet} must be set to 1"
            );
        }
        assert_eq!(
            env.get("HOME").map(String::as_str),
            Some("/tc/demo-1.2.3-arm64/.build/home"),
            "container HOME lives under scratch"
        );
        // PATH: prefix bin first, system fallback last (host PATH in between
        // when present — same derivation as the old host subprocess).
        let path = env.get("PATH").expect("PATH must be set");
        assert!(
            path.starts_with("/tc/demo-1.2.3-arm64/brew/bin:"),
            "prefix bin must lead PATH, got: {path}"
        );
        assert!(
            path.ends_with("/usr/bin:/bin:/usr/sbin:/sbin"),
            "system fallback must end PATH, got: {path}"
        );
    }

    /// Local mock executor passed DIRECTLY as `&dyn` (never through the
    /// process-global slot). Captures the request it served.
    struct CapturingExecutor {
        seen: std::sync::Mutex<Option<ContainerBuildRequest>>,
    }

    impl ContainerBuildExecutor for CapturingExecutor {
        fn execute<'a>(
            &'a self,
            req: &'a ContainerBuildRequest,
        ) -> std::pin::Pin<
            Box<
                dyn std::future::Future<Output = Result<crate::executor::ContainerBuildReport>>
                    + Send
                    + 'a,
            >,
        > {
            Box::pin(async move {
                *self.seen.lock().unwrap() = Some(req.clone());
                Ok(crate::executor::ContainerBuildReport {
                    log_tail: String::new(),
                })
            })
        }
    }

    /// The routed install invokes the injected executor with the assembled
    /// request and creates the scratch (+ HOME) dir it advertises.
    #[tokio::test]
    async fn brew_install_routes_request_through_injected_executor() {
        let tmp = tempfile::tempdir().unwrap();
        let toolchain = tmp.path().join(format!("demo-1.2.3-{}", arch_token()));
        tokio::fs::create_dir_all(&toolchain).await.unwrap();
        let brew_cache = tmp.path().join(".brew-cache");

        let exec = CapturingExecutor {
            seen: std::sync::Mutex::new(None),
        };
        brew_install_in_container("demo", &toolchain, &brew_cache, Some(&exec))
            .await
            .expect("mock container build should succeed");

        let seen = exec
            .seen
            .lock()
            .unwrap()
            .take()
            .expect("executor must have been invoked with the assembled request");
        assert_eq!(seen.tool, "demo");
        assert_eq!(seen.net, NetPolicy::AllowLoud);
        assert_eq!(seen.prefix, toolchain);
        assert!(
            toolchain.join(".build").join("home").is_dir(),
            "scratch HOME dir created before the container runs"
        );
    }
}