From 562a07fee23c788c06f8d1cf4edcce753460262c Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 3 Oct 2026 11:56:03 -0700 Subject: [PATCH 1/2] refactor(ape): delegate fbuild loader to running-process --- Cargo.lock | 98 +- Cargo.toml | 6 +- README.md | 8 +- crates/fbuild-build-arm/Cargo.toml | 2 +- crates/fbuild-build-engine/Cargo.toml | 2 +- .../src/zccache_embedded.rs | 9 +- crates/fbuild-build-esp/Cargo.toml | 2 +- crates/fbuild-build-mcu/Cargo.toml | 2 +- crates/fbuild-build/Cargo.toml | 4 +- crates/fbuild-build/tests/it/ape_toolchain.rs | 2 +- crates/fbuild-core/Cargo.toml | 3 - crates/fbuild-core/src/platform/README.md | 25 +- crates/fbuild-core/src/platform/ape/README.md | 28 +- crates/fbuild-core/src/platform/ape/mod.rs | 1113 ++++------------- crates/fbuild-core/src/platform/fs.rs | 12 - crates/fbuild-core/src/platform/linux/fs.rs | 28 - crates/fbuild-core/src/platform/linux/mod.rs | 1 - crates/fbuild-core/src/platform/macos/fs.rs | 28 - crates/fbuild-core/src/platform/macos/mod.rs | 1 - crates/fbuild-core/src/platform/process.rs | 42 +- crates/fbuild-core/src/platform/windows/fs.rs | 9 - .../fbuild-core/src/platform/windows/mod.rs | 1 - 22 files changed, 321 insertions(+), 1105 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 241bf819f..657190498 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -879,7 +879,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -918,7 +918,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1226,7 +1226,6 @@ name = "fbuild-core" version = "2.5.33" dependencies = [ "async-trait", - "flate2", "fs2", "interprocess", "libc", @@ -2241,7 +2240,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2299,9 +2298,9 @@ dependencies = [ [[package]] name = "kernal-api" -version = "0.1.23" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54844116729c2a5c64d5b313d79f839323c044cc01867f7e2bf0f192ac9e6903" +checksum = "f18d3cbb9bd8f6a590cf9b5a75297c542786fcef93bb3ff7245eb8f8b1b09561" dependencies = [ "blake3", "crash-handler", @@ -2687,7 +2686,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3430,9 +3429,9 @@ dependencies = [ [[package]] name = "running-process" -version = "4.10.14" +version = "4.10.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02f0ff903e7b61396a074648fc6301668492de90aea4b5d09cc914eee9389e9b" +checksum = "c040ca81d60235a6027ef3115629049829464758e1011500902c8a2355727812" dependencies = [ "anyhow", "blake3", @@ -3461,14 +3460,15 @@ dependencies = [ [[package]] name = "running-process-platform-internal" -version = "4.10.14" +version = "4.10.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e5db04a966dfead09ff660b704c2461a05e8477249b8f10532b41a22219348" +checksum = "0ef39418f8c71399ea811c5daba36f2325516cc02c3b2169473400411941ed5f" dependencies = [ "blake3", "dirs", "interprocess", "libc", + "miniz_oxide", "sysinfo", "thiserror 2.0.20", "tokio", @@ -3479,9 +3479,9 @@ dependencies = [ [[package]] name = "running-process-probe" -version = "4.10.14" +version = "4.10.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42a44810a8ff3d44214a02b2de688a8004cd35fe66c585cde273770d49860a68" +checksum = "68d60cba7103c84759e85cd98778c44090d01627b1eebbf79c72270c32f772b7" dependencies = [ "crash-handler", "framehop", @@ -3492,6 +3492,7 @@ dependencies = [ "prost", "prost-build", "protox", + "running-process-platform-internal", "thiserror 2.0.20", "winapi", "windows-sys 0.61.2", @@ -3499,9 +3500,9 @@ dependencies = [ [[package]] name = "running-process-probe-daemon" -version = "4.10.14" +version = "4.10.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6276bcbbdb64c0db39e087c2f24fcd02916038550f362bef8a1798506048500d" +checksum = "1de6766e359cc87de9d22acfee67defd759172c8dfddd6b40c9257e5e3d359b8" dependencies = [ "axum 0.8.9", "blake3", @@ -3518,6 +3519,7 @@ dependencies = [ "protox", "regex", "running-process", + "running-process-platform-internal", "running-process-probe", "rusqlite", "serde", @@ -3534,9 +3536,9 @@ dependencies = [ [[package]] name = "running-process-protocol" -version = "4.10.14" +version = "4.10.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c32daa00db0c79106d3181f1e306efe92a59bd1b0429440bdc5e6debf3cc4345" +checksum = "c51894dfa970ac24213066f37b33656fd92c7053ef3e94f184b70000d8cf48f7" dependencies = [ "prost", "prost-build", @@ -3583,7 +3585,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4018,7 +4020,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4778,7 +4780,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5090,8 +5092,8 @@ dependencies = [ [[package]] name = "zccache" -version = "1.14.14" -source = "git+https://github.com/zackees/zccache?rev=b385a7bfb308c2d1456613622ad361eb66d31cdd#b385a7bfb308c2d1456613622ad361eb66d31cdd" +version = "1.15.1" +source = "git+https://github.com/zackees/zccache?rev=5e917593984ba77124080987abae702adfe47f0c#5e917593984ba77124080987abae702adfe47f0c" dependencies = [ "arc-swap", "bincode", @@ -5131,8 +5133,8 @@ dependencies = [ [[package]] name = "zccache-artifact" -version = "1.14.14" -source = "git+https://github.com/zackees/zccache?rev=b385a7bfb308c2d1456613622ad361eb66d31cdd#b385a7bfb308c2d1456613622ad361eb66d31cdd" +version = "1.15.1" +source = "git+https://github.com/zackees/zccache?rev=5e917593984ba77124080987abae702adfe47f0c#5e917593984ba77124080987abae702adfe47f0c" dependencies = [ "bincode", "dashmap", @@ -5150,8 +5152,8 @@ dependencies = [ [[package]] name = "zccache-audit" -version = "1.14.14" -source = "git+https://github.com/zackees/zccache?rev=b385a7bfb308c2d1456613622ad361eb66d31cdd#b385a7bfb308c2d1456613622ad361eb66d31cdd" +version = "1.15.1" +source = "git+https://github.com/zackees/zccache?rev=5e917593984ba77124080987abae702adfe47f0c#5e917593984ba77124080987abae702adfe47f0c" dependencies = [ "serde", "serde_json", @@ -5160,16 +5162,16 @@ dependencies = [ [[package]] name = "zccache-compile-trace" -version = "1.14.14" -source = "git+https://github.com/zackees/zccache?rev=b385a7bfb308c2d1456613622ad361eb66d31cdd#b385a7bfb308c2d1456613622ad361eb66d31cdd" +version = "1.15.1" +source = "git+https://github.com/zackees/zccache?rev=5e917593984ba77124080987abae702adfe47f0c#5e917593984ba77124080987abae702adfe47f0c" dependencies = [ "zccache-audit", ] [[package]] name = "zccache-compiler" -version = "1.14.14" -source = "git+https://github.com/zackees/zccache?rev=b385a7bfb308c2d1456613622ad361eb66d31cdd#b385a7bfb308c2d1456613622ad361eb66d31cdd" +version = "1.15.1" +source = "git+https://github.com/zackees/zccache?rev=5e917593984ba77124080987abae702adfe47f0c#5e917593984ba77124080987abae702adfe47f0c" dependencies = [ "clang", "kernal-api", @@ -5182,8 +5184,8 @@ dependencies = [ [[package]] name = "zccache-core" -version = "1.14.14" -source = "git+https://github.com/zackees/zccache?rev=b385a7bfb308c2d1456613622ad361eb66d31cdd#b385a7bfb308c2d1456613622ad361eb66d31cdd" +version = "1.15.1" +source = "git+https://github.com/zackees/zccache?rev=5e917593984ba77124080987abae702adfe47f0c#5e917593984ba77124080987abae702adfe47f0c" dependencies = [ "kernal-api", "serde", @@ -5194,8 +5196,8 @@ dependencies = [ [[package]] name = "zccache-daemon-core" -version = "1.14.14" -source = "git+https://github.com/zackees/zccache?rev=b385a7bfb308c2d1456613622ad361eb66d31cdd#b385a7bfb308c2d1456613622ad361eb66d31cdd" +version = "1.15.1" +source = "git+https://github.com/zackees/zccache?rev=5e917593984ba77124080987abae702adfe47f0c#5e917593984ba77124080987abae702adfe47f0c" dependencies = [ "arc-swap", "bincode", @@ -5230,8 +5232,8 @@ dependencies = [ [[package]] name = "zccache-depgraph" -version = "1.14.14" -source = "git+https://github.com/zackees/zccache?rev=b385a7bfb308c2d1456613622ad361eb66d31cdd#b385a7bfb308c2d1456613622ad361eb66d31cdd" +version = "1.15.1" +source = "git+https://github.com/zackees/zccache?rev=5e917593984ba77124080987abae702adfe47f0c#5e917593984ba77124080987abae702adfe47f0c" dependencies = [ "bincode", "dashmap", @@ -5250,8 +5252,8 @@ dependencies = [ [[package]] name = "zccache-fingerprint" -version = "1.14.14" -source = "git+https://github.com/zackees/zccache?rev=b385a7bfb308c2d1456613622ad361eb66d31cdd#b385a7bfb308c2d1456613622ad361eb66d31cdd" +version = "1.15.1" +source = "git+https://github.com/zackees/zccache?rev=5e917593984ba77124080987abae702adfe47f0c#5e917593984ba77124080987abae702adfe47f0c" dependencies = [ "kernal-api", "rayon", @@ -5265,8 +5267,8 @@ dependencies = [ [[package]] name = "zccache-fscache" -version = "1.14.14" -source = "git+https://github.com/zackees/zccache?rev=b385a7bfb308c2d1456613622ad361eb66d31cdd#b385a7bfb308c2d1456613622ad361eb66d31cdd" +version = "1.15.1" +source = "git+https://github.com/zackees/zccache?rev=5e917593984ba77124080987abae702adfe47f0c#5e917593984ba77124080987abae702adfe47f0c" dependencies = [ "bincode", "dashmap", @@ -5280,8 +5282,8 @@ dependencies = [ [[package]] name = "zccache-hash" -version = "1.14.14" -source = "git+https://github.com/zackees/zccache?rev=b385a7bfb308c2d1456613622ad361eb66d31cdd#b385a7bfb308c2d1456613622ad361eb66d31cdd" +version = "1.15.1" +source = "git+https://github.com/zackees/zccache?rev=5e917593984ba77124080987abae702adfe47f0c#5e917593984ba77124080987abae702adfe47f0c" dependencies = [ "kernal-api", "serde", @@ -5289,8 +5291,8 @@ dependencies = [ [[package]] name = "zccache-ipc" -version = "1.14.14" -source = "git+https://github.com/zackees/zccache?rev=b385a7bfb308c2d1456613622ad361eb66d31cdd#b385a7bfb308c2d1456613622ad361eb66d31cdd" +version = "1.15.1" +source = "git+https://github.com/zackees/zccache?rev=5e917593984ba77124080987abae702adfe47f0c#5e917593984ba77124080987abae702adfe47f0c" dependencies = [ "bytes", "dashmap", @@ -5307,8 +5309,8 @@ dependencies = [ [[package]] name = "zccache-protocol" -version = "1.14.14" -source = "git+https://github.com/zackees/zccache?rev=b385a7bfb308c2d1456613622ad361eb66d31cdd#b385a7bfb308c2d1456613622ad361eb66d31cdd" +version = "1.15.1" +source = "git+https://github.com/zackees/zccache?rev=5e917593984ba77124080987abae702adfe47f0c#5e917593984ba77124080987abae702adfe47f0c" dependencies = [ "bytes", "kernal-api", @@ -5323,8 +5325,8 @@ dependencies = [ [[package]] name = "zccache-watcher" -version = "1.14.14" -source = "git+https://github.com/zackees/zccache?rev=b385a7bfb308c2d1456613622ad361eb66d31cdd#b385a7bfb308c2d1456613622ad361eb66d31cdd" +version = "1.15.1" +source = "git+https://github.com/zackees/zccache?rev=5e917593984ba77124080987abae702adfe47f0c#5e917593984ba77124080987abae702adfe47f0c" dependencies = [ "kernal-api", "rayon", diff --git a/Cargo.toml b/Cargo.toml index 1a60a7b76..ef43132e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -156,7 +156,7 @@ mimalloc-pprof = "0.9" # something the shipped daemon links. The version MUST equal the # `running-process` rev pinned above, for the same `rp_*_public` # symbol-duplication reason documented there. -running-process-probe-daemon = "=4.10.14" +running-process-probe-daemon = "=4.10.16" object = { version = "0.36", default-features = false, features = ["read", "std", "elf", "write"] } rusqlite = { version = "0.32", features = ["bundled"] } shell-words = "1" @@ -196,13 +196,13 @@ owo-colors = "4" # loopback-HTTP path (see crates/fbuild-daemon/src/broker). # # Source must match zccache's `running-process` pin exactly (same source and -# version, not just the same semver; zccache takes =4.10.14 from crates.io). `running-process` exports unmangled `rp_*_public` C +# version, not just the same semver; zccache takes =4.10.16 from crates.io). `running-process` exports unmangled `rp_*_public` C # symbols; Cargo treats a registry package and a git package as two distinct # packages, so a registry pin here plus zccache's git pin puts BOTH copies in # the link and every one of those symbols collides (`rust-lld: error: # duplicate symbol: rp_native_process_start_public`, …). Keep this pin in # lockstep with zccache's resolved `running-process` when repinning zccache. -running-process = { version = "=4.10.14", default-features = false, features = ["client-async"] } +running-process = { version = "=4.10.16", default-features = false, features = ["client-async", "ape-loader"] } [profile.dev] debug = "line-tables-only" diff --git a/README.md b/README.md index 77ceee37d..9192a5b89 100644 --- a/README.md +++ b/README.md @@ -343,14 +343,14 @@ through a loader: | Host | How an APE tool runs | |---|---| | Windows | Natively; an APE file is a valid PE. | -| Linux | fbuild extracts the loader embedded in the tool itself, checks it is a 64-bit ELF for the host CPU, and caches it in an owner-only, exec-capable directory. If no such directory exists (read-only `HOME`, `noexec` `/tmp`), it runs the loader from a sealed in-memory file instead. The tool's environment needs no `PATH`, `sh`, coreutils, `HOME` or `TMPDIR`. | -| macOS | x86_64: fbuild extracts the tool's embedded loader and turns it into a Mach-O (the prologue's header move). Apple Silicon: fbuild compiles the tool's embedded `ape-m1.c` once with `/usr/bin/cc` (Xcode Command Line Tools). Both are cached like on Linux. | +| Linux | The shared running-process implementation extracts the loader embedded in the tool itself, checks it is a 64-bit ELF for the host CPU, and caches it in an owner-only, exec-capable directory. If no such directory exists (read-only `HOME`, `noexec` `/tmp`), it runs the loader from a sealed in-memory file instead. The tool's environment needs no `PATH`, `sh`, coreutils, `HOME` or `TMPDIR`. | +| macOS | x86_64: the shared loader extracts the tool's embedded loader and turns it into a Mach-O (the prologue's header move). Apple Silicon: the shared loader compiles the tool's embedded `ape-m1.c` once with `/usr/bin/cc` (Xcode Command Line Tools). Both are cached like on Linux. | Loader precedence is `FBUILD_APE_LOADER` (explicit override), then the loader embedded in the tool, then `ape` on `PATH`, `/usr/bin/ape` and `/usr/local/bin/ape`, then `/bin/sh`. Set `FBUILD_APE_CACHE_DIR` to choose -where extracted loaders are cached. Parallel builds and deploys are safe: fbuild -holds a fork lock while it writes a loader, so a concurrent first spawn can't +where extracted loaders are cached. Parallel builds and deploys are safe: running-process +holds a shared fork lock while it writes a loader, so a concurrent first spawn can't fail with `ETXTBSY` ("Text file busy"). ### Adding an APE tool diff --git a/crates/fbuild-build-arm/Cargo.toml b/crates/fbuild-build-arm/Cargo.toml index 80dbd0523..91c1760ca 100644 --- a/crates/fbuild-build-arm/Cargo.toml +++ b/crates/fbuild-build-arm/Cargo.toml @@ -34,7 +34,7 @@ tar = { workspace = true } zip = { workspace = true } tree-sitter = { workspace = true } tree-sitter-cpp = { workspace = true } -zccache = { git = "https://github.com/zackees/zccache", rev = "b385a7bfb308c2d1456613622ad361eb66d31cdd" } +zccache = { git = "https://github.com/zackees/zccache", rev = "5e917593984ba77124080987abae702adfe47f0c" } [dev-dependencies] filetime = { workspace = true } diff --git a/crates/fbuild-build-engine/Cargo.toml b/crates/fbuild-build-engine/Cargo.toml index 2658fa2cd..aeef61b36 100644 --- a/crates/fbuild-build-engine/Cargo.toml +++ b/crates/fbuild-build-engine/Cargo.toml @@ -35,7 +35,7 @@ tar = { workspace = true } zip = { workspace = true } tree-sitter = { workspace = true } tree-sitter-cpp = { workspace = true } -zccache = { git = "https://github.com/zackees/zccache", rev = "b385a7bfb308c2d1456613622ad361eb66d31cdd" } +zccache = { git = "https://github.com/zackees/zccache", rev = "5e917593984ba77124080987abae702adfe47f0c" } [dev-dependencies] filetime = { workspace = true } diff --git a/crates/fbuild-build-engine/src/zccache_embedded.rs b/crates/fbuild-build-engine/src/zccache_embedded.rs index 7eb32b8fc..08e23a543 100644 --- a/crates/fbuild-build-engine/src/zccache_embedded.rs +++ b/crates/fbuild-build-engine/src/zccache_embedded.rs @@ -296,14 +296,9 @@ impl FbuildZccacheService { compiler: &Path, args: Vec, cwd: PathBuf, - env: Vec<(String, String)>, + mut env: Vec<(String, String)>, ) -> Result { - // zccache spawns the compiler itself, so an APE (cosmocc) compiler - // can't be routed through fbuild's loader; hand it a host-native - // stand-in with the same file name instead. - let compiler = fbuild_core::platform::ape::native_executable(compiler) - .map(fbuild_core::path::NormalizedPath::into_path_buf) - .unwrap_or_else(|| compiler.to_path_buf()); + fbuild_core::platform::ape::add_environment_overrides(&mut env); let req = ZccacheCompileRequest { audit: default_audit_context(), compiler: compiler.into(), diff --git a/crates/fbuild-build-esp/Cargo.toml b/crates/fbuild-build-esp/Cargo.toml index 1b84bb59c..ab496f16e 100644 --- a/crates/fbuild-build-esp/Cargo.toml +++ b/crates/fbuild-build-esp/Cargo.toml @@ -35,7 +35,7 @@ tar = { workspace = true } zip = { workspace = true } tree-sitter = { workspace = true } tree-sitter-cpp = { workspace = true } -zccache = { git = "https://github.com/zackees/zccache", rev = "b385a7bfb308c2d1456613622ad361eb66d31cdd" } +zccache = { git = "https://github.com/zackees/zccache", rev = "5e917593984ba77124080987abae702adfe47f0c" } [dev-dependencies] filetime = { workspace = true } diff --git a/crates/fbuild-build-mcu/Cargo.toml b/crates/fbuild-build-mcu/Cargo.toml index 31d51013f..602247e63 100644 --- a/crates/fbuild-build-mcu/Cargo.toml +++ b/crates/fbuild-build-mcu/Cargo.toml @@ -34,7 +34,7 @@ tar = { workspace = true } zip = { workspace = true } tree-sitter = { workspace = true } tree-sitter-cpp = { workspace = true } -zccache = { git = "https://github.com/zackees/zccache", rev = "b385a7bfb308c2d1456613622ad361eb66d31cdd" } +zccache = { git = "https://github.com/zackees/zccache", rev = "5e917593984ba77124080987abae702adfe47f0c" } [dev-dependencies] filetime = { workspace = true } diff --git a/crates/fbuild-build/Cargo.toml b/crates/fbuild-build/Cargo.toml index 4ddd5d1cb..32e9495a3 100644 --- a/crates/fbuild-build/Cargo.toml +++ b/crates/fbuild-build/Cargo.toml @@ -48,7 +48,7 @@ tree-sitter-cpp = { workspace = true } # child processes) was deleted in the same PR; there is no # `--no-default-features` escape hatch. # -# Pinned at zccache main `b385a7b` (1.14.14 plus its post-release perf +# Pinned at the APE-capable zccache revision (including its post-release perf # work: #1768's heartbeat-free compile requests, #1770's mtime-floor skip on # C/C++ hits, #1761/#1763's dropped durability syncs, #1775 unchanged-state # shutdown skip), which takes @@ -58,7 +58,7 @@ tree-sitter-cpp = { workspace = true } # `running-process` in the workspace root must be pinned to the SAME source # and version zccache uses, or its unmangled `rp_*_public` C symbols get # linked twice (guarded by crates/fbuild-core/tests/dep_identity.rs). -zccache = { git = "https://github.com/zackees/zccache", rev = "b385a7bfb308c2d1456613622ad361eb66d31cdd" } +zccache = { git = "https://github.com/zackees/zccache", rev = "5e917593984ba77124080987abae702adfe47f0c" } [dev-dependencies] filetime = { workspace = true } diff --git a/crates/fbuild-build/tests/it/ape_toolchain.rs b/crates/fbuild-build/tests/it/ape_toolchain.rs index a9db8897f..7cb4b2db4 100644 --- a/crates/fbuild-build/tests/it/ape_toolchain.rs +++ b/crates/fbuild-build/tests/it/ape_toolchain.rs @@ -5,7 +5,7 @@ //! cosmocc's gcc, cc1, as and ld are all APE images, and gcc spawns cc1/as //! itself, so this exercises nested APE spawns too. Both paths give the //! compiler a hostile environment (no usable PATH/TMPDIR/HOME; zccache passes -//! an empty env), so only fbuild's own loader support can make it work. +//! an empty env), so the shared running-process loader must make it work through both paths. //! //! Opt-in: point `FBUILD_TEST_COSMOCC` at an unpacked //! (the directory holding `bin/`). diff --git a/crates/fbuild-core/Cargo.toml b/crates/fbuild-core/Cargo.toml index a2da3976a..69f248749 100644 --- a/crates/fbuild-core/Cargo.toml +++ b/crates/fbuild-core/Cargo.toml @@ -18,9 +18,6 @@ sha2 = { workspace = true } semver = { workspace = true } # Runtime decompression for the downloaded FastLED/boards cache. zstd = { workspace = true } -# Inflates the gzip'd loader embedded in APE (cosmocc) images so -# `platform::ape` can run them without a host APE loader. -flate2 = { workspace = true } prost = { workspace = true } # Cross-platform containment and detached-spawn implementation delegated by # `platform::process`. Its source and revision intentionally match zccache's diff --git a/crates/fbuild-core/src/platform/README.md b/crates/fbuild-core/src/platform/README.md index b1338e4f0..c47c532e2 100644 --- a/crates/fbuild-core/src/platform/README.md +++ b/crates/fbuild-core/src/platform/README.md @@ -35,19 +35,12 @@ self-extracting prologue). Windows runs APE natively. Every `subprocess::run_command*` spawn applies this; direct spawns build their command with `process::command` / `process::tokio_command` instead of `Command::new`. -The embedded loader is located from the prologue's `dd skip=N count=M | gzip -dc` -line for the host CPU, inflated in-process, validated (64-bit ELF or Mach-O for -that CPU), and installed content-addressed into the first owner-only (0700, not -group/world-writable, not a symlink), exec-capable directory among -`FBUILD_APE_CACHE_DIR`, fbuild's cache root (registered by the daemon and CLI via -`ape::set_default_cache_root`), `$XDG_CACHE_HOME`/`~/.cache` `fbuild/ape` (macOS: -`~/Library/Caches/fbuild/ape`) and `$XDG_RUNTIME_DIR/fbuild/ape`; on Linux, failing -all of those, it lives in a sealed `memfd` exec'd via `/proc/self/fd/N`. The child -needs no PATH, `sh`, coreutils, `gzip`, `HOME` or `TMPDIR`, and the loader is put -first on its PATH as `ape` so APE programs it spawns itself resolve too. Installs -are atomic (temp file + `rename`), tampered or truncated copies are rewritten, -and the writes hold `process::exclusive_fork_guard` — a Go-style fork lock every -spawn helper (and the few allowlisted direct spawns, via -`process::shared_fork_guard`) holds shared across fork→exec — so no concurrently -forked child can inherit the writable descriptor. Spawners fbuild doesn't -control (zccache) can still race it, so APE launches retry `ETXTBSY` briefly. +APE extraction, validation and installation are owned by `running-process`, +including the shared fork lock and transient `ETXTBSY` retry. fbuild's adapter +preserves `FBUILD_APE_LOADER` and selects loader storage from +`FBUILD_APE_CACHE_DIR`, `FBCACHE_DIR/ape`, `FBUILD_CACHE_DIR/ape`, the cache root +registered by the CLI/daemon, then the shared loader's host defaults. Embedded +zccache receives the corresponding canonical settings and launches the original +compiler directly through kernal-api 0.1.26; no fbuild native-path shim is needed. + +See [`ape/README.md`](ape/README.md) for adapter and test locations. diff --git a/crates/fbuild-core/src/platform/ape/README.md b/crates/fbuild-core/src/platform/ape/README.md index 1ff876ae2..4ce5a7c9b 100644 --- a/crates/fbuild-core/src/platform/ape/README.md +++ b/crates/fbuild-core/src/platform/ape/README.md @@ -1,18 +1,16 @@ -# `platform::ape` — APE (cosmocc) host-tool support +# `platform::ape` — fbuild APE loader adapter -Runs Actually Portable Executable tools on every host fbuild supports without -any host APE support installed. +`mod.rs` delegates detection and launch planning to the published +`running_process::ape` implementation. That implementation owns extraction, +validation, private atomic installation, nested APE launches, and the fork lock. -- `mod.rs` — APE detection, launch planning (` `), and - per-host loader extraction: Linux ELF loader, macOS x86_64 Mach-O loader, - macOS arm64 loader compiled from the image's `ape-m1.c`. -- `prologue.rs` — parses the image's shell prologue (per-CPU loader blobs, - the macOS x86_64 header-patched blob, the Apple Silicon loader source). -- `install.rs` — owner-only, exec-capable, atomic, content-addressed installs - under the fork lock (no `ETXTBSY`). -- `native.rs` — a `/bin/sh` shim on every Unix host for spawners fbuild - doesn't control (zccache): same file name, the image's identity baked in, - `ape` first on PATH, `exec `. +The adapter preserves `FBUILD_APE_LOADER`, `FBUILD_APE_CACHE_DIR`, `FBCACHE_DIR`, +`FBUILD_CACHE_DIR` and the cache root registered by fbuild's CLI and daemon. +Embedded zccache receives the equivalent canonical environment overrides; +its kernal-api dependency plans compiler launches directly with the original +compiler image, without a generated shim. -Host specifics (cache dirs, Linux `memfd` fallback) live in -`../{linux,macos,windows}/ape.rs`. +Tests here cover fbuild's settings, real contained/subprocess launches, and +48 concurrent first launches. Loader format and host-specific tests live in +running-process's concrete platform trees. The real compiler cold-miss/warm-hit +regression lives in `fbuild-build/tests/it/ape_toolchain.rs`. diff --git a/crates/fbuild-core/src/platform/ape/mod.rs b/crates/fbuild-core/src/platform/ape/mod.rs index e975fcacf..d367d0ed5 100644 --- a/crates/fbuild-core/src/platform/ape/mod.rs +++ b/crates/fbuild-core/src/platform/ape/mod.rs @@ -1,756 +1,243 @@ -//! Actually Portable Executable (APE / cosmocc) launch support. +//! fbuild policy for the canonical running-process APE loader. //! -//! An APE image is simultaneously a Windows PE, a Bourne-shell script, and -//! (via its embedded loader) an ELF/Mach-O program. Windows runs it natively. -//! Unix kernels do not: `execve` returns `ENOEXEC` unless the host registered -//! an APE `binfmt_misc` handler, and `posix_spawn` — what Rust's `Command` -//! uses — never retries through `/bin/sh` the way an interactive shell does. -//! NixOS ships no APE handler, so a bare spawn fails with "Exec format error". -//! -//! fbuild therefore detects the APE magic and launches the image through a -//! loader explicitly: ` `. fbuild provides the loader -//! itself from the image (see [`prologue`]): -//! -//! * Linux x86_64/aarch64: the gzip'd static ELF loader for the host CPU. -//! * macOS x86_64: the same blob with its embedded Mach-O header moved to -//! offset 0. -//! * macOS arm64: the image's `ape-m1.c`, compiled once with the host `cc`. -//! -//! Each is validated and installed content-addressed into an owner-only, -//! exec-capable cache directory ([`install`]); on Linux a sealed `memfd` is the -//! last resort. None of this needs `sh`, coreutils, `gzip`, PATH, or a -//! writable temp dir in the child environment. -//! -//! Spawners fbuild does not control (zccache runs the compiler itself) get a -//! host-native path instead, from [`native_executable`]. -//! -//! Loader precedence: `FBUILD_APE_LOADER` → loader embedded in the image → -//! `ape` on PATH → well-known `ape` install locations → `/bin/sh` → `sh` on -//! PATH. +//! Extraction, validation, installation and nested launches belong to +//! running-process. This adapter preserves fbuild's environment overrides. -use std::collections::HashMap; use std::ffi::{OsStr, OsString}; -use std::io::Read; use std::path::Path; +use std::sync::OnceLock; use crate::path::NormalizedPath; -use std::sync::Mutex; -use std::time::SystemTime; - -use sha2::{Digest, Sha256}; - -use super::host::{self, HostArch, HostPlatform}; -pub(crate) mod install; -mod native; -mod prologue; - -pub use native::native_executable; - -/// Environment variable naming an explicit APE loader (an `ape` binary or a -/// POSIX shell). Read from the child env overlay first, then the process env. +/// fbuild override for the shared APE loader. pub const LOADER_ENV: &str = "FBUILD_APE_LOADER"; - -/// Environment variable naming the preferred directory for loaders extracted -/// from APE images. Consulted before the host defaults; it must be owned by -/// the current user, not group/world-writable, and on an exec-capable mount. +/// fbuild override for extracted loader storage. pub const CACHE_DIR_ENV: &str = "FBUILD_APE_CACHE_DIR"; -/// Bytes of an image scanned for the shell prologue. Real prologues end -/// within the first ~8 KiB; the cap bounds work on hostile input. -const PROLOGUE_SCAN_BYTES: u64 = 64 * 1024; - -/// Upper bound on a compressed or inflated embedded loader (real ones are -/// ~4–5 KiB compressed, ~10 KiB inflated). -const MAX_LOADER_BYTES: u64 = 4 * 1024 * 1024; - -/// Leading bytes that identify an APE image. `MZqFpD='` is the standard -/// header (also a valid DOS/PE `MZ` stub), `jartsr='` is the non-Windows -/// variant, and `APEDBG='` is the debug build variant. -const APE_MAGICS: [&[u8; 8]; 3] = [b"MZqFpD='", b"jartsr='", b"APEDBG='"]; - -/// Well-known install locations for the Cosmopolitan loader, consulted when -/// `ape` is not on PATH (e.g. NixOS service environments with a minimal PATH). -const WELL_KNOWN_LOADERS: [&str; 2] = ["/usr/bin/ape", "/usr/local/bin/ape"]; +static DEFAULT_CACHE_ROOT: OnceLock = OnceLock::new(); -/// Whether `head` starts with an APE magic. -pub fn is_ape_header(head: &[u8]) -> bool { - APE_MAGICS.iter().any(|magic| head.starts_with(*magic)) +/// Register the fbuild cache selected by the application at startup. +/// The first registration wins, as with the original fbuild loader. +pub fn set_default_cache_root(dir: impl AsRef) { + let _ = DEFAULT_CACHE_ROOT.set(NormalizedPath::new(dir)); } -/// Whether the file at `path` is an APE image. Unreadable files are not APE. +/// Whether a file is an APE image. pub fn is_ape_file(path: &Path) -> bool { - let mut head = [0u8; 8]; - std::fs::File::open(path) - .and_then(|mut file| file.read_exact(&mut head)) - .is_ok_and(|()| is_ape_header(&head)) + running_process::ape::is_ape_file(path) +} + +/// Whether a header starts with an APE magic. +pub fn is_ape_header(header: &[u8]) -> bool { + running_process::ape::is_ape_header(header) } -/// How to launch an APE image: run `loader` with `image` as its first argument, -/// followed by the caller's original arguments. -#[derive(Clone, Debug, PartialEq, Eq)] +/// Prepared loader and image for an external tool. pub struct ApeLaunch { pub loader: NormalizedPath, pub image: NormalizedPath, - /// A private directory holding this loader as `ape`. Spawners prepend it - /// to the child's `PATH` so APE programs the tool itself spawns (gcc → - /// `cc1`) find a loader via the prologue's `type ape` — without it those - /// nested spawns need `sh` + coreutils + a writable temp dir. - pub ape_path_dir: Option, + inner: running_process::ape::ApeLaunch, } impl ApeLaunch { - /// The child's `PATH` with [`Self::ape_path_dir`] prepended, given the - /// `PATH` it would otherwise get. `None` when there is nothing to add. + /// Make the loader available to tools launched by the child. pub fn child_path(&self, inherited: Option<&OsStr>) -> Option { - let dir = self.ape_path_dir.as_ref()?; - let rest = inherited.filter(|p| !p.is_empty()); - std::env::join_paths( - std::iter::once(dir.as_path().to_path_buf()) - .chain(rest.into_iter().flat_map(std::env::split_paths)), - ) - .ok() - } -} - -/// Plan an APE launch for `program` on the current host. -/// -/// `cwd` is the child's working directory (used to resolve a relative -/// program path) and `env_overlay` the child's env overlay (consulted for -/// `PATH`, [`LOADER_ENV`] and [`CACHE_DIR_ENV`] before the process -/// environment). Returns `None` when `program` is not an APE image or the -/// host runs APE natively. -pub fn plan_launch( - program: &OsStr, - cwd: Option<&Path>, - env_overlay: Option<&[(&str, &str)]>, -) -> Option { - let host = host::current(); - if host.is_windows() { - return None; - } - let overlay_var = |key: &str| { - env_overlay - .and_then(|vars| vars.iter().rev().find(|(k, _)| *k == key)) - .map(|(_, v)| OsString::from(*v)) - .or_else(|| std::env::var_os(key)) - .filter(|v| !v.is_empty()) - }; - let path_var = overlay_var("PATH"); - let loader_override = overlay_var(LOADER_ENV); - let cache_dirs = cache_dirs(overlay_var(CACHE_DIR_ENV)); - plan_launch_for( - host, - program, - cwd, - path_var.as_deref(), - loader_override.as_deref(), - &cache_dirs, - ) -} - -static DEFAULT_CACHE_ROOT: std::sync::OnceLock = std::sync::OnceLock::new(); - -/// Register fbuild's own cache directory for APE-derived executables -/// (loaders, `ape` PATH entries, shims). Binaries call this at startup with a -/// directory under `fbuild_paths::get_cache_root()`, which `fbuild-core` can't -/// depend on. The first registration wins. -pub fn set_default_cache_root(dir: impl AsRef) { - let _ = DEFAULT_CACHE_ROOT.set(NormalizedPath::new(dir)); -} - -/// Candidate cache directories, most preferred first: the explicit -/// [`CACHE_DIR_ENV`] value, the registered fbuild cache root, then host -/// defaults (XDG/`~/.cache`, runtime dir; `~/Library/Caches` on macOS). -fn cache_dirs(explicit: Option) -> Vec { - explicit - .map(NormalizedPath::new) - .into_iter() - .chain(DEFAULT_CACHE_ROOT.get().cloned()) - .chain(super::selected::ape::default_loader_dirs()) - .collect() -} - -/// [`plan_launch`] with every host input explicit, for deterministic tests. -/// `cache_dirs` are the candidate directories for an extracted loader. -pub fn plan_launch_for( - host: HostPlatform, - program: &OsStr, - cwd: Option<&Path>, - path_var: Option<&OsStr>, - loader_override: Option<&OsStr>, - cache_dirs: &[NormalizedPath], -) -> Option { - if host.is_windows() { - return None; - } - let image = resolve_program(program, cwd, path_var)?; - if !is_ape_file(&image) { - return None; - } - let loader = match loader_override.filter(|v| !v.is_empty()) { - Some(explicit) => resolve_explicit_loader(explicit, path_var)?, - None => { - embedded_loader(&image, host, cache_dirs).or_else(|| resolve_host_loader(path_var))? - } - }; - let ape_path_dir = ape_path_dir(&loader, cache_dirs); - Some(ApeLaunch { - loader, - image, - ape_path_dir, - }) -} - -/// Resolve `program` to the file the kernel would execute: a path with a -/// separator is taken as-is (relative to `cwd` when given), a bare name is -/// searched on `path_var`. -fn resolve_program( - program: &OsStr, - cwd: Option<&Path>, - path_var: Option<&OsStr>, -) -> Option { - let path = Path::new(program); - if path.components().count() > 1 || path.is_absolute() { - let resolved = match cwd { - Some(dir) if path.is_relative() => dir.join(path), - _ => path.to_path_buf(), - }; - return std::path::absolute(resolved).ok().map(NormalizedPath::new); + self.inner.child_path(inherited) } - find_executable_on_path(program, path_var) } -fn resolve_explicit_loader(explicit: &OsStr, path_var: Option<&OsStr>) -> Option { - let explicit = Path::new(explicit); - if explicit.components().count() > 1 || explicit.is_absolute() { - return Some(NormalizedPath::new(explicit)); - } - find_executable_on_path(explicit.as_os_str(), path_var) -} - -fn resolve_host_loader(path_var: Option<&OsStr>) -> Option { - find_executable_on_path(OsStr::new("ape"), path_var) - .or_else(|| first_executable(WELL_KNOWN_LOADERS.iter().map(NormalizedPath::new))) - .or_else(|| first_executable([NormalizedPath::from("/bin/sh")])) - .or_else(|| find_executable_on_path(OsStr::new("sh"), path_var)) -} - -fn find_executable_on_path(name: &OsStr, path_var: Option<&OsStr>) -> Option { - let dirs = std::env::split_paths(path_var?).filter(|dir| !dir.as_os_str().is_empty()); - first_executable(dirs.map(|dir| NormalizedPath::new(dir.join(name)))) -} - -fn first_executable( - candidates: impl IntoIterator, -) -> Option { - candidates - .into_iter() - .find(|candidate| is_executable_file(candidate)) -} - -fn is_executable_file(path: &Path) -> bool { - std::fs::metadata(path).is_ok_and(|meta| meta.is_file() && super::fs::is_executable(&meta)) -} - -/// Identity of an image (and where its derived files may live) for the -/// memos: a rebuilt image at the same path changes length or mtime and is -/// re-derived. -type ImageKey = ( - NormalizedPath, - u64, - Option, - HostPlatform, - Vec, -); - -fn image_key(image: &Path, host: HostPlatform, dirs: &[NormalizedPath]) -> Option { - let meta = std::fs::metadata(image).ok()?; - Some(( - NormalizedPath::new(image), - meta.len(), - meta.modified().ok(), - host, - dirs.to_vec(), - )) -} - -/// Memo of derived executables (loaders, native copies) per image. -struct Memo(Mutex>>); - -impl Memo { - const fn new() -> Self { - Self(Mutex::new(None)) - } - - fn get(&self, key: &ImageKey) -> Option { - let guard = self.0.lock().unwrap_or_else(|e| e.into_inner()); - let hit = guard.as_ref()?.get(key)?.clone(); - // A cache cleaner may have removed it since; re-derive then. - hit.exists().then_some(hit) - } - - fn put(&self, key: ImageKey, path: NormalizedPath) { - let mut guard = self.0.lock().unwrap_or_else(|e| e.into_inner()); - guard.get_or_insert_with(HashMap::new).insert(key, path); - } +fn options(overlay: Option<&[(&str, &str)]>) -> running_process::ape::ApeOptions { + options_with_cache(overlay, DEFAULT_CACHE_ROOT.get().map(AsRef::as_ref)) } -static LOADER_MEMO: Memo = Memo::new(); - -/// The host-runnable loader embedded in `image`, installed into the first -/// usable `cache_dirs` entry (or, on Linux, a sealed memfd). `None` when the -/// image carries no valid loader for the host or nothing could be -/// materialized. -fn embedded_loader( - image: &Path, - host: HostPlatform, - cache_dirs: &[NormalizedPath], -) -> Option { - let key = image_key(image, host, cache_dirs)?; - if let Some(hit) = LOADER_MEMO.get(&key) { - return Some(hit); - } - let arch = arch_name(host.arch())?; - let loader = if host.is_linux() { - let bytes = extract_loader(image, host.arch())?; - materialize( - &bytes, - &format!("ape-loader-linux-{arch}-{}", short_hash(&bytes)), - cache_dirs, - )? - } else if host.is_macos() && host.arch() == HostArch::X86_64 { - let bytes = extract_macos_x86_64_loader(image)?; - materialize( - &bytes, - &format!("ape-loader-macos-{arch}-{}", short_hash(&bytes)), - cache_dirs, - )? - } else if host.is_macos() && host.arch() == HostArch::Aarch64 { - compile_macos_aarch64_loader(image, cache_dirs)? - } else { - return None; +fn options_with_cache( + overlay: Option<&[(&str, &str)]>, + default_cache: Option<&Path>, +) -> running_process::ape::ApeOptions { + let vars = overlay.unwrap_or_default(); + let value = |key: &str| { + vars.iter() + .rev() + .find(|(k, _)| *k == key) + .map(|(_, v)| OsString::from(v)) + .or_else(|| std::env::var_os(key)) }; - LOADER_MEMO.put(key, loader.clone()); - Some(loader) -} - -/// A private directory containing `loader` under the name `ape`, for -/// [`ApeLaunch::ape_path_dir`]. Shells are never exposed as `ape`. -fn ape_path_dir(loader: &Path, cache_dirs: &[NormalizedPath]) -> Option { - if loader.file_name().is_some_and(|n| n == "sh") || super::selected::ape::is_anonymous(loader) { - return None; - } - if loader.file_name().is_some_and(|n| n == "ape") && !super::selected::ape::is_anonymous(loader) - { - return loader.parent().map(NormalizedPath::new); - } - let bytes = std::fs::read(loader).ok()?; - if bytes.len() as u64 > MAX_LOADER_BYTES { - return None; + let mut options = running_process::ape::ApeOptions::with_overrides( + false, + vars.iter() + .map(|(k, v)| (OsStr::new(k), Some(OsStr::new(v)))), + ); + if let Some(loader) = value(LOADER_ENV).filter(|v| !v.is_empty()) { + options.loader = Some(loader); } - let sub = format!("bin-{}", short_hash(&bytes)); - install::install(cache_dirs, Some(&sub), "ape", &bytes)? - .parent() + let cache = value(CACHE_DIR_ENV) + .filter(|v| !v.is_empty()) .map(NormalizedPath::new) -} - -/// Install `bytes` as an executable named `name` in the first usable cache -/// dir, else as a host anonymous executable (Linux memfd). -pub(crate) fn materialize( - bytes: &[u8], - name: &str, - dirs: &[NormalizedPath], -) -> Option { - install::install(dirs, None, name, bytes) - .or_else(|| super::selected::ape::anonymous_executable(bytes, name)) -} - -fn short_hash(bytes: &[u8]) -> String { - Sha256::digest(bytes)[..8] - .iter() - .map(|b| format!("{b:02x}")) - .collect() -} - -fn arch_name(arch: HostArch) -> Option<&'static str> { - match arch { - HostArch::X86_64 => Some("x86_64"), - HostArch::Aarch64 => Some("aarch64"), - _ => None, + .or_else(|| { + value("FBCACHE_DIR") + .filter(|v| !v.is_empty()) + .map(|root| NormalizedPath::new(root).join("ape")) + }) + .or_else(|| { + value("FBUILD_CACHE_DIR") + .filter(|v| !v.is_empty()) + .map(|root| NormalizedPath::new(root).join("ape")) + }); + // Keep the registered fbuild root as a fallback even when the caller + // supplies an override that the shared loader cannot execute from. + if let Some(default_cache) = default_cache { + options + .cache_dirs + .insert(0, NormalizedPath::new(default_cache).into_path_buf()); } -} - -/// ELF `e_machine` for `arch`. -fn elf_machine(arch: HostArch) -> Option { - match arch { - HostArch::X86_64 => Some(62), // EM_X86_64 - HostArch::Aarch64 => Some(183), // EM_AARCH64 - _ => None, + if let Some(cache) = cache { + options.cache_dirs.insert(0, cache.into_path_buf()); } + options } -/// An opened image with its parsed prologue. -struct Image { - file: std::fs::File, - len: u64, - prologue: prologue::Prologue, -} - -fn open_image(image: &Path) -> Option { - let mut file = std::fs::File::open(image).ok()?; - let len = file.metadata().ok()?.len(); - let mut head = Vec::new(); - (&mut file) - .take(PROLOGUE_SCAN_BYTES) - .read_to_end(&mut head) - .ok()?; - if !is_ape_header(&head) { - return None; - } - Some(Image { - file, - len, - prologue: prologue::parse(&head), +/// Plan a tool launch with the child's cwd and environment overlay. +pub fn plan_launch( + program: &OsStr, + cwd: Option<&Path>, + overlay: Option<&[(&str, &str)]>, +) -> Option { + let inner = running_process::ape::plan_launch(program, cwd, &options(overlay))?; + Some(ApeLaunch { + loader: inner.loader.clone().into(), + image: inner.image.clone().into(), + inner, }) } -impl Image { - /// Read `range`, bounded by the file length and [`MAX_LOADER_BYTES`]. - fn read(&mut self, (skip, count): prologue::Range) -> Option> { - use std::io::{Seek, SeekFrom}; - let end = skip.checked_add(count)?; - if count == 0 || count > MAX_LOADER_BYTES || end > self.len { - return None; +/// Translate fbuild's APE settings for a compiler spawned by embedded zccache. +/// The compiler path and argv remain unchanged; kernal-api plans the launch. +pub fn add_environment_overrides(env: &mut Vec<(String, String)>) { + let overlay: Vec<_> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); + let options = options(Some(&overlay)); + let settings = [ + (running_process::ape::LOADER_ENV, options.loader), + ( + running_process::ape::CACHE_DIR_ENV, + options + .cache_dirs + .first() + .map(|path| path.as_os_str().to_owned()), + ), + ]; + for (key, value) in settings { + if let Some(value) = value.and_then(|value| value.into_string().ok()) { + env.retain(|(k, _)| k != key); + env.push((key.to_string(), value)); } - self.file.seek(SeekFrom::Start(skip)).ok()?; - let mut out = Vec::with_capacity(count as usize); - (&mut self.file).take(count).read_to_end(&mut out).ok()?; - (out.len() as u64 == count).then_some(out) } - - fn inflate(&mut self, range: prologue::Range) -> Option> { - let gz = self.read(range)?; - let mut out = Vec::new(); - flate2::read::GzDecoder::new(gz.as_slice()) - .take(MAX_LOADER_BYTES + 1) - .read_to_end(&mut out) - .ok()?; - (out.len() as u64 <= MAX_LOADER_BYTES).then_some(out) - } -} - -/// Inflate and validate the Linux ELF loader `image` embeds for `arch`. -pub fn extract_loader(image: &Path, arch: HostArch) -> Option> { - let machine = elf_machine(arch)?; - let mut img = open_image(image)?; - let range = img.prologue.linux_loader(arch)?; - let elf = img.inflate(range)?; - is_static_elf_for(&elf, machine).then_some(elf) -} - -/// The macOS x86_64 loader: the same gzip'd blob as Linux, with the Mach-O -/// header it carries at offset 320 (8 × 64 bytes) moved to offset 0 — what the -/// prologue's `dd if="$t.$$" of="$t.$$" skip=5 count=8 bs=64` does. -pub fn extract_macos_x86_64_loader(image: &Path) -> Option> { - let mut img = open_image(image)?; - let range = img.prologue.macos_loader_x86_64?; - let mut bin = img.inflate(range)?; - if bin.len() < 320 + 512 { - return None; - } - bin.copy_within(320..320 + 512, 0); - is_macho_x86_64(&bin).then_some(bin) -} - -/// The Apple Silicon loader source (`ape-m1.c`) embedded in `image`. -pub fn extract_macos_aarch64_loader_source(image: &Path) -> Option> { - let mut img = open_image(image)?; - let range = img.prologue.macos_loader_source_aarch64?; - let src = img.inflate(range)?; - (std::str::from_utf8(&src).is_ok() && src.windows(4).any(|w| w == b"main")).then_some(src) -} - -/// Compile the image's `ape-m1.c` with the host C compiler (Xcode Command -/// Line Tools) into a content-addressed cache entry, as the prologue would, -/// but without depending on `sh`, `dd`, `gzip` or `$TMPDIR`. -fn compile_macos_aarch64_loader(image: &Path, dirs: &[NormalizedPath]) -> Option { - let src = extract_macos_aarch64_loader_source(image)?; - let name = format!("ape-loader-macos-aarch64-{}", short_hash(&src)); - let dir = dirs - .iter() - .find(|dir| super::fs::ensure_private_dir(dir) && super::fs::mount_allows_exec(dir))?; - let target = dir.join(&name); - if is_executable_file(&target) { - return Some(target); - } - // One compile at a time in this process: concurrent first spawns would - // otherwise all run `cc` (seconds each). Re-check once we hold the lock. - static COMPILE: Mutex<()> = Mutex::new(()); - let _compiling = COMPILE.lock().unwrap_or_else(|e| e.into_inner()); - if is_executable_file(&target) { - return Some(target); - } - // Unique per attempt, so racing processes never share temp files. - static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - let tag = format!( - "{}.{}", - std::process::id(), - SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) - ); - let src_path = dir.join(format!(".{name}.{tag}.c")); - let out_path = dir.join(format!(".{name}.{tag}")); - std::fs::write(&src_path, &src).ok()?; - let cc = first_executable([NormalizedPath::from("/usr/bin/cc")]) - .unwrap_or_else(|| NormalizedPath::from("cc")); - let compiled = crate::subprocess::run_command_blocking( - &[ - cc.to_str()?, - "-w", - "-O", - "-o", - out_path.to_str()?, - src_path.to_str()?, - ], - None, - None, - Some(std::time::Duration::from_secs(300)), - ); - let _ = std::fs::remove_file(&src_path); - let ok = compiled.as_ref().is_ok_and(|out| out.success()) - && std::fs::rename(&out_path, &target).is_ok(); - if !ok { - let _ = std::fs::remove_file(&out_path); - if let Ok(out) = compiled { - tracing::warn!( - "could not build the Apple Silicon APE loader with {}: {}", - cc.display(), - out.stderr.trim() - ); - } - return None; - } - Some(target) -} - -/// A 64-bit little-endian ELF executable for `machine`. -fn is_static_elf_for(elf: &[u8], machine: u16) -> bool { - elf.len() >= 64 - && elf.len() as u64 <= MAX_LOADER_BYTES - && elf.starts_with(b"\x7fELF") - && elf[4] == 2 // ELFCLASS64 - && elf[5] == 1 // ELFDATA2LSB - // ET_EXEC (x86_64 loader) or ET_DYN (position-independent aarch64 loader) - && matches!(u16::from_le_bytes([elf[16], elf[17]]), 2 | 3) - && u16::from_le_bytes([elf[18], elf[19]]) == machine -} - -/// A 64-bit Mach-O for x86_64 (`MH_MAGIC_64`, `CPU_TYPE_X86_64`). -fn is_macho_x86_64(bin: &[u8]) -> bool { - bin.len() >= 32 && bin.starts_with(&[0xcf, 0xfa, 0xed, 0xfe]) && bin[4..8] == [7, 0, 0, 1] } #[cfg(test)] mod tests { use super::*; - use crate::platform::host::{HostArch, HostOs}; - - const LINUX: HostPlatform = HostPlatform::new(HostOs::Linux, HostArch::X86_64); - const WINDOWS: HostPlatform = HostPlatform::new(HostOs::Windows, HostArch::X86_64); - - /// Minimal APE-shaped script: the `MZqFpD='...'` header is a shell - /// assignment, exactly like a real cosmocc image's prologue. - const FAKE_APE: &str = "MZqFpD='\n'\necho fake-ape \"$@\"\n"; - - fn write_exe(dir: &Path, name: &str, body: &str) -> NormalizedPath { - let path = dir.join(name); - std::fs::write(&path, body).unwrap(); - crate::platform::fs::set_executable(&path).unwrap(); - NormalizedPath::new(path) - } #[test] - fn recognizes_every_ape_magic_and_rejects_other_formats() { - assert!(is_ape_header(b"MZqFpD='\n\n\0")); - assert!(is_ape_header(b"jartsr='\n")); - assert!(is_ape_header(b"APEDBG='\n")); - assert!(!is_ape_header(b"MZ\x90\0\x03\0\0\0")); // plain PE - assert!(!is_ape_header(b"\x7fELF\x02\x01\x01\0")); // ELF - assert!(!is_ape_header(b"#!/bin/sh\n")); - assert!(!is_ape_header(b"MZqF")); - } - - #[test] - fn windows_runs_ape_natively() { - let dir = tempfile::tempdir().unwrap(); - let ape = write_exe(dir.path(), "tool", FAKE_APE); - let plan = plan_launch_for( - WINDOWS, - ape.as_os_str(), - None, - None, - Some(OsStr::new("/bin/sh")), - &[], + fn child_overrides_select_loader_and_cache() { + let opts = options(Some(&[ + (LOADER_ENV, "first"), + (LOADER_ENV, "chosen-loader"), + (CACHE_DIR_ENV, "chosen-cache"), + ])); + assert_eq!(opts.loader, Some(OsString::from("chosen-loader"))); + assert_eq!( + opts.cache_dirs[0], + NormalizedPath::new("chosen-cache").into_path_buf() ); - assert_eq!(plan, None); } #[test] - fn non_ape_program_is_left_alone() { - let dir = tempfile::tempdir().unwrap(); - let script = write_exe(dir.path(), "tool", "#!/bin/sh\necho hi\n"); - let plan = plan_launch_for( - LINUX, - script.as_os_str(), - None, - None, - Some(OsStr::new("/bin/sh")), - &[], + fn fbcache_root_is_preserved() { + let opts = options(Some(&[ + ("FBCACHE_DIR", "session-cache"), + (CACHE_DIR_ENV, ""), + ])); + assert_eq!( + opts.cache_dirs[0], + NormalizedPath::new("session-cache") + .join("ape") + .into_path_buf() ); - assert_eq!(plan, None); } #[test] - fn explicit_loader_override_wins() { - let dir = tempfile::tempdir().unwrap(); - let ape = write_exe(dir.path(), "tool", FAKE_APE); - let plan = plan_launch_for( - LINUX, - ape.as_os_str(), - None, - None, - Some(OsStr::new("/opt/cosmo/ape")), - &[], - ) - .expect("APE must be planned"); - assert_eq!(plan.loader, NormalizedPath::from("/opt/cosmo/ape")); - assert_eq!(plan.image, ape); - } - - #[test] - fn bare_name_resolves_on_path_and_prefers_ape_loader_on_path() { - let bin = tempfile::tempdir().unwrap(); - let ape = write_exe(bin.path(), "tool", FAKE_APE); - let loader = write_exe(bin.path(), "ape", "#!/bin/sh\n"); - let plan = plan_launch_for( - LINUX, - OsStr::new("tool"), - None, - Some(bin.path().as_os_str()), - None, - &[], - ) - .expect("APE on PATH must be planned"); - assert_eq!(plan.image, ape); - assert_eq!(plan.loader, loader); + fn registered_cache_root_is_preserved() { + let opts = options_with_cache( + Some(&[ + (CACHE_DIR_ENV, ""), + ("FBCACHE_DIR", ""), + ("FBUILD_CACHE_DIR", ""), + ]), + Some(Path::new("registered-cache/ape")), + ); + assert_eq!( + opts.cache_dirs[0], + NormalizedPath::new("registered-cache/ape").into_path_buf() + ); } #[test] - fn relative_program_resolves_against_child_cwd() { - let dir = tempfile::tempdir().unwrap(); - std::fs::create_dir(dir.path().join("bin")).unwrap(); - let ape = write_exe(&dir.path().join("bin"), "tool", FAKE_APE); - let plan = plan_launch_for( - LINUX, - OsStr::new("bin/tool"), - Some(dir.path()), - None, - Some(OsStr::new("/bin/sh")), - &[], - ) - .expect("relative APE must be planned"); - assert_eq!(plan.image, ape); + fn explicit_cache_keeps_registered_root_as_a_fallback() { + let opts = options_with_cache( + Some(&[(CACHE_DIR_ENV, "preferred-cache")]), + Some(Path::new("registered-cache/ape")), + ); + assert_eq!( + opts.cache_dirs[0], + NormalizedPath::new("preferred-cache").into_path_buf() + ); + assert_eq!( + opts.cache_dirs[1], + NormalizedPath::new("registered-cache/ape").into_path_buf() + ); } - /// End-to-end through the real spawn path: without loader routing a Unix - /// `posix_spawn` of an APE fails with ENOEXEC ("Exec format error"). - /// Windows runs an APE image directly (it is a valid PE) under every - /// spelling fbuild accepts for a package tool. #[test] - fn windows_spawns_real_ape_under_exe_and_com_names() { - if !host::current().is_windows() { - return; - } - let dir = tempfile::tempdir().unwrap(); - for name in ["tool.exe", "tool.com"] { - let image = copy_fixture(dir.path(), name); - assert_eq!(plan_launch(image.as_os_str(), None, None), None); - let out = crate::subprocess::run_command_blocking( - &[image.to_str().unwrap(), "on", "windows"], - None, - None, - None, - ) - .expect("APE must spawn natively on Windows"); - assert!(out.success(), "{name} stderr: {}", out.stderr); - assert_eq!(out.stdout, "hello world on windows\n", "{name}"); - } + fn embedded_compiler_receives_fbuild_overrides() { + let mut env = vec![ + (LOADER_ENV.to_string(), "chosen-loader".to_string()), + (CACHE_DIR_ENV.to_string(), "chosen-cache".to_string()), + ]; + add_environment_overrides(&mut env); + assert!(env.contains(&( + running_process::ape::LOADER_ENV.to_string(), + "chosen-loader".to_string() + ))); + assert!(env.contains(&( + running_process::ape::CACHE_DIR_ENV.to_string(), + "chosen-cache".to_string() + ))); } #[test] - fn run_command_launches_ape_through_loader() { - if host::current().is_windows() { - return; - } - let dir = tempfile::tempdir().unwrap(); - let ape = write_exe(dir.path(), "tool", FAKE_APE); - let program = ape.to_str().unwrap(); - let out = crate::subprocess::run_command_blocking( - &[program, "a", "b c"], - None, - Some(&[(LOADER_ENV, "/bin/sh")]), - None, - ) - .expect("APE spawn must succeed through the loader"); - assert!(out.success(), "stderr: {}", out.stderr); - assert_eq!(out.stdout, "fake-ape a b c\n"); - } - - /// The checked-in cosmocc hello-world (`data/ape-hello`). - fn hello_fixture() -> NormalizedPath { - NormalizedPath::new(Path::new(env!("CARGO_MANIFEST_DIR")).join("data/ape-hello/hello.com")) + fn missing_program_has_no_launch_plan() { + assert!(plan_launch(OsStr::new("fbuild-missing-ape-fixture"), None, None).is_none()); } - #[test] - fn hello_fixture_is_detected_as_ape() { - assert!(is_ape_file(&hello_fixture())); + fn fixture() -> NormalizedPath { + NormalizedPath::new(env!("CARGO_MANIFEST_DIR")).join("data/ape-hello/hello.com") } - /// A real cosmocc image through the default loader chain (no override). - /// Every Unix host: Linux uses the embedded ELF loader, macOS x86_64 the - /// embedded Mach-O loader, Apple Silicon the image's `ape-m1.c` built with `cc`. #[test] - fn real_ape_hello_runs_via_run_command() { - if host::current().is_windows() { - return; - } - let ape = hello_fixture(); - let out = crate::subprocess::run_command_blocking( - &[ape.to_str().unwrap(), "from", "fbuild"], - None, - None, - None, - ) - .expect("real APE must spawn"); - assert!(out.success(), "stderr: {}", out.stderr); - assert_eq!(out.stdout, "hello world from fbuild\n"); - } - - #[test] - fn real_ape_hello_runs_via_process_command() { - if host::current().is_windows() { - return; - } - let mut command = crate::platform::process::command(hello_fixture()); - command.arg("std"); - let mut child = crate::platform::process::spawn_contained( + fn real_ape_runs_through_the_contained_std_path() { + use crate::platform::process::{self, ContainedStdio, StdioSource}; + use std::io::Read; + let mut command = process::command(fixture()); + command.arg("std path"); + let mut child = process::spawn_contained( &mut command, - crate::platform::process::ContainedStdio { - stdout: crate::platform::process::StdioSource::Pipe, - ..Default::default() + ContainedStdio { + stdout: StdioSource::Pipe, + stderr: StdioSource::Pipe, + ..ContainedStdio::default() }, ) - .expect("real APE must spawn"); + .expect("real APE spawns"); let mut stdout = String::new(); child .take_stdout() @@ -758,226 +245,72 @@ mod tests { .read_to_string(&mut stdout) .unwrap(); assert_eq!(child.wait().unwrap(), 0); - assert_eq!(stdout, "hello world std\n"); - } - - fn copy_fixture(dir: &Path, name: &str) -> NormalizedPath { - let path = dir.join(name); - std::fs::copy(hello_fixture(), &path).unwrap(); - NormalizedPath::new(path) + assert_eq!(stdout, "hello world std path\n"); } - /// Hostile/corrupt images never panic and never yield a loader. - #[test] - fn corrupt_images_yield_no_embedded_loader() { - let dir = tempfile::tempdir().unwrap(); - let real = std::fs::read(hello_fixture()).unwrap(); - let arch = HostArch::X86_64; - - let truncated = dir.path().join("truncated"); - std::fs::write(&truncated, &real[..20_000]).unwrap(); - assert_eq!(extract_loader(&truncated, arch), None); - - let mut garbage = real.clone(); - garbage[275392..275392 + 4180].fill(0xA5); - let garbled = dir.path().join("garbled"); - std::fs::write(&garbled, &garbage).unwrap(); - assert_eq!(extract_loader(&garbled, arch), None); - - let huge = dir.path().join("huge-range"); - std::fs::write( - &huge, - "MZqFpD='\n'\nif [ \"$m\" = x86_64 ]; then\ndd if=\"$o\" skip=18446744073709551615 count=99 bs=1 2>/dev/null | gzip -dc >\"$t.$$\" ||exit\nfi\n", - ) - .unwrap(); - assert_eq!(extract_loader(&huge, arch), None); - - // A valid gzip of a non-ELF payload is rejected too. - let mut fake = b"MZqFpD='\n'\nif [ \"$m\" = x86_64 ]; then\ndd if=\"$o\" skip=200 count=COUNT bs=1 2>/dev/null | gzip -dc >\"$t.$$\" ||exit\nfi\n".to_vec(); - let mut gz = Vec::new(); - { - use std::io::Write; - let mut enc = flate2::write::GzEncoder::new(&mut gz, flate2::Compression::fast()); - enc.write_all(b"#!/bin/sh\necho pwned\n").unwrap(); - enc.finish().unwrap(); - } - let text = String::from_utf8(fake) - .unwrap() - .replace("COUNT", &gz.len().to_string()); - fake = text.into_bytes(); - fake.resize(200, b'\n'); - fake.extend_from_slice(&gz); - let not_elf = dir.path().join("not-elf"); - std::fs::write(¬_elf, &fake).unwrap(); - assert_eq!(extract_loader(¬_elf, arch), None); - } - - /// Spawn the fixture in a child environment with no PATH, TMPDIR or HOME, - /// so neither `ape`, `sh` utilities, nor the prologue's `$TMPDIR` cache can - /// help: only fbuild's own extracted loader can make this work. - fn run_hostile(program: &str, cache: &Path, args: &[&str]) -> crate::subprocess::ToolOutput { - let mut argv = vec![program]; - argv.extend_from_slice(args); - crate::subprocess::run_command_blocking( - &argv, - None, + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn real_ape_runs_with_child_cwd_and_hostile_environment() { + let tmp = tempfile::tempdir().unwrap(); + let image = NormalizedPath::new(tmp.path()).join("tool.com"); + std::fs::copy(fixture(), &image).unwrap(); + crate::platform::fs::set_executable(&image).unwrap(); + let cache = NormalizedPath::new(tmp.path()).join("cache"); + let cache_str = cache.to_str().unwrap(); + let out = crate::subprocess::run_command( + &["./tool.com", "argument with spaces"], + Some(tmp.path()), Some(&[ ("PATH", "/nonexistent"), ("TMPDIR", "/nonexistent"), ("HOME", "/nonexistent"), - (CACHE_DIR_ENV, cache.to_str().unwrap()), - ]), - None, - ) - .expect("APE must spawn") - } - - #[test] - fn real_ape_runs_with_hostile_child_env_using_own_loader() { - if host::current().is_windows() { - return; - } - let cache = tempfile::tempdir().unwrap(); - let out = run_hostile( - hello_fixture().to_str().unwrap(), - cache.path(), - &["hostile"], - ); - assert!(out.success(), "stderr: {}", out.stderr); - assert_eq!(out.stdout, "hello world hostile\n"); - let mut installed: Vec<_> = std::fs::read_dir(cache.path()) - .unwrap() - .map(|e| e.unwrap().file_name().into_string().unwrap()) - .collect(); - installed.sort(); - // The loader, plus the same loader exposed as `bin-/ape` for - // the child's PATH (nested APE spawns). - assert_eq!(installed.len(), 2, "{installed:?}"); - assert!(installed[0].starts_with("ape-loader-"), "{installed:?}"); - assert!(installed[1].starts_with("bin-"), "{installed:?}"); - let exposed = cache.path().join(&installed[1]).join("ape"); - assert_eq!( - std::fs::read(exposed).unwrap(), - std::fs::read(cache.path().join(&installed[0])).unwrap() - ); - } - - #[test] - fn broken_ape_on_path_does_not_hijack_launch() { - if host::current().is_windows() { - return; - } - let bin = tempfile::tempdir().unwrap(); - write_exe(bin.path(), "ape", "#!/bin/sh\necho hijacked; exit 99\n"); - let image = copy_fixture(bin.path(), "hello"); - let cache = tempfile::tempdir().unwrap(); - let out = crate::subprocess::run_command_blocking( - &["hello", "ok"], - None, - Some(&[ - ("PATH", bin.path().to_str().unwrap()), - (CACHE_DIR_ENV, cache.path().to_str().unwrap()), + (CACHE_DIR_ENV, cache_str), ]), - None, + Some(std::time::Duration::from_secs(60)), ) - .expect("bare-name APE must spawn"); - assert_eq!(out.stdout, "hello world ok\n", "stderr: {}", out.stderr); - assert!(image.exists()); + .await + .expect("real APE subprocess spawns"); + assert!(out.success(), "{}", out.stderr); + assert_eq!(out.stdout, "hello world argument with spaces\n"); } #[test] - fn awkward_image_paths_and_args_survive() { - if host::current().is_windows() { - return; - } - let dir = tempfile::tempdir().unwrap(); - let sub = dir.path().join("dir with spaces – ünïcode"); - std::fs::create_dir(&sub).unwrap(); - let image = copy_fixture(&sub, "hello world.com"); - let link = dir.path().join("link-to-hello"); - crate::platform::fs::symlink_file(&image, &link).unwrap(); - let cache = tempfile::tempdir().unwrap(); - let link = NormalizedPath::new(link); - for program in [&image, &link] { - let out = run_hostile( - program.to_str().unwrap(), - cache.path(), - &["a b", "", "$HOME", "--assimilate"], - ); - assert!(out.success(), "stderr: {}", out.stderr); - assert_eq!(out.stdout, "hello world a b $HOME --assimilate\n"); - } - // `--assimilate` is just an argument: the image must be untouched. - assert_eq!( - std::fs::read(&image).unwrap(), - std::fs::read(hello_fixture()).unwrap() - ); - } - - #[test] - fn rebuilt_image_at_same_path_is_reextracted() { - if host::current().is_windows() { - return; - } - let dir = tempfile::tempdir().unwrap(); - let image = copy_fixture(dir.path(), "tool"); - let cache = tempfile::tempdir().unwrap(); - let program = image.to_str().unwrap(); - assert_eq!( - run_hostile(program, cache.path(), &["1"]).stdout, - "hello world 1\n" - ); - // Replace with a non-APE script: must now run natively, not via loader. - std::fs::write(&image, "#!/bin/sh\necho native \"$@\"\n").unwrap(); - crate::platform::fs::set_executable(&image).unwrap(); - // Just-written script: retry ETXTBSY from sibling tests forking (#1366). - let out = crate::subprocess::run_command_blocking_retrying_exec_busy( - &[program, "2"], - None, - None, - None, - ) - .unwrap(); - assert_eq!(out.stdout, "native 2\n"); - // Deleted cache between spawns is re-materialized. - std::fs::copy(hello_fixture(), &image).unwrap(); - for entry in std::fs::read_dir(cache.path()).unwrap() { - let path = entry.unwrap().path(); - if path.is_dir() { - std::fs::remove_dir_all(path).unwrap(); - } else { - std::fs::remove_file(path).unwrap(); - } - } - assert_eq!( - run_hostile(program, cache.path(), &["3"]).stdout, - "hello world 3\n" - ); - } - - #[test] - fn stress_many_concurrent_first_spawns() { - if host::current().is_windows() { - return; - } - let cache = tempfile::tempdir().unwrap(); - let cache_dir = cache.path().join("cold"); - let image = hello_fixture(); - let program = image.to_str().unwrap(); - std::thread::scope(|s| { - let handles: Vec<_> = (0..48) - .map(|i| { - let cache_dir = &cache_dir; - s.spawn(move || { - let arg = i.to_string(); - let out = run_hostile(program, cache_dir, &[&arg]); - assert!(out.success(), "spawn {i} stderr: {}", out.stderr); - assert_eq!(out.stdout, format!("hello world {i}\n")); - }) - }) - .collect(); - for h in handles { - h.join().unwrap(); + fn concurrent_first_launches_use_the_shared_loader() { + let tmp = tempfile::tempdir().unwrap(); + let cache = NormalizedPath::new(tmp.path()).join("cache"); + let image = fixture(); + let barrier = std::sync::Barrier::new(48); + std::thread::scope(|scope| { + for _ in 0..48 { + let barrier = &barrier; + let image = ℑ + let cache = &cache; + scope.spawn(move || { + barrier.wait(); + let launch = plan_launch( + image.as_os_str(), + None, + Some(&[(CACHE_DIR_ENV, cache.to_str().unwrap())]), + ); + // Windows runs the PE image natively. + let mut command = match launch { + Some(launch) => { + // allow-direct-spawn: construction only, spawned through the contained facade below. + let mut command = std::process::Command::new(&launch.loader); + command.arg(&launch.image); + command + } + None => crate::platform::process::command(image), + }; + let mut child = crate::platform::process::spawn_contained( + &mut command, + crate::platform::process::ContainedStdio { + stdout: crate::platform::process::StdioSource::Null, + ..crate::platform::process::ContainedStdio::default() + }, + ) + .expect("concurrent APE launch"); + assert_eq!(child.wait().unwrap(), 0); + }); } }); } diff --git a/crates/fbuild-core/src/platform/fs.rs b/crates/fbuild-core/src/platform/fs.rs index 0c1aaed28..c2279c866 100644 --- a/crates/fbuild-core/src/platform/fs.rs +++ b/crates/fbuild-core/src/platform/fs.rs @@ -79,18 +79,6 @@ pub fn is_executable(metadata: &std::fs::Metadata) -> bool { super::selected::fs::is_executable(metadata) } -/// Create `dir` (owner-only where the host has permissions) if needed and -/// report whether it is a real directory that only the current user can -/// modify — safe to hold files fbuild will later execute. -pub fn ensure_private_dir(dir: &Path) -> bool { - super::selected::fs::ensure_private_dir(dir) -} - -/// Whether files under `dir` may be executed (false on `noexec` mounts). -pub fn mount_allows_exec(dir: &Path) -> bool { - super::selected::fs::mount_allows_exec(dir) -} - /// Ensure an extracted tool is executable without changing an already-runnable file. pub fn ensure_executable(path: &Path) -> std::io::Result<()> { super::selected::fs::ensure_executable(path) diff --git a/crates/fbuild-core/src/platform/linux/fs.rs b/crates/fbuild-core/src/platform/linux/fs.rs index 61cd782fc..9a03a84d3 100644 --- a/crates/fbuild-core/src/platform/linux/fs.rs +++ b/crates/fbuild-core/src/platform/linux/fs.rs @@ -45,34 +45,6 @@ pub(crate) fn set_executable(path: &Path) -> std::io::Result<()> { std::fs::set_permissions(path, permissions) } -pub(crate) fn ensure_private_dir(dir: &Path) -> bool { - use std::os::unix::fs::{DirBuilderExt, MetadataExt}; - let _ = std::fs::DirBuilder::new() - .recursive(true) - .mode(0o700) - .create(dir); - // SAFETY: geteuid has no preconditions and cannot fail. - let uid = unsafe { libc::geteuid() }; - std::fs::symlink_metadata(dir).is_ok_and(|meta| { - meta.file_type().is_dir() && meta.uid() == uid && meta.mode() & 0o022 == 0 - }) -} - -pub(crate) fn mount_allows_exec(dir: &Path) -> bool { - use std::os::unix::ffi::OsStrExt; - let Ok(path) = std::ffi::CString::new(dir.as_os_str().as_bytes()) else { - return false; - }; - let mut stats = std::mem::MaybeUninit::::uninit(); - // SAFETY: `path` is NUL-terminated and `stats` points to writable storage. - if unsafe { libc::statvfs(path.as_ptr(), stats.as_mut_ptr()) } != 0 { - return false; - } - // SAFETY: successful statvfs initialized the complete output structure. - let stats = unsafe { stats.assume_init() }; - stats.f_flag & libc::ST_NOEXEC == 0 -} - pub(crate) fn is_executable(metadata: &std::fs::Metadata) -> bool { metadata.permissions().mode() & 0o111 != 0 } diff --git a/crates/fbuild-core/src/platform/linux/mod.rs b/crates/fbuild-core/src/platform/linux/mod.rs index 69496313e..a45bca1f5 100644 --- a/crates/fbuild-core/src/platform/linux/mod.rs +++ b/crates/fbuild-core/src/platform/linux/mod.rs @@ -1,6 +1,5 @@ use super::host::{HostArch, HostOs}; -pub(super) mod ape; pub(super) mod device; pub(super) mod fs; pub(super) mod host; diff --git a/crates/fbuild-core/src/platform/macos/fs.rs b/crates/fbuild-core/src/platform/macos/fs.rs index b9922d1fa..473a93706 100644 --- a/crates/fbuild-core/src/platform/macos/fs.rs +++ b/crates/fbuild-core/src/platform/macos/fs.rs @@ -45,34 +45,6 @@ pub(crate) fn set_executable(path: &Path) -> std::io::Result<()> { std::fs::set_permissions(path, permissions) } -pub(crate) fn ensure_private_dir(dir: &Path) -> bool { - use std::os::unix::fs::{DirBuilderExt, MetadataExt}; - let _ = std::fs::DirBuilder::new() - .recursive(true) - .mode(0o700) - .create(dir); - // SAFETY: geteuid has no preconditions and cannot fail. - let uid = unsafe { libc::geteuid() }; - std::fs::symlink_metadata(dir).is_ok_and(|meta| { - meta.file_type().is_dir() && meta.uid() == uid && meta.mode() & 0o022 == 0 - }) -} - -pub(crate) fn mount_allows_exec(dir: &Path) -> bool { - use std::os::unix::ffi::OsStrExt; - let Ok(path) = std::ffi::CString::new(dir.as_os_str().as_bytes()) else { - return false; - }; - let mut stats = std::mem::MaybeUninit::::uninit(); - // SAFETY: `path` is NUL-terminated and `stats` points to writable storage. - if unsafe { libc::statfs(path.as_ptr(), stats.as_mut_ptr()) } != 0 { - return false; - } - // SAFETY: successful statfs initialized the complete output structure. - let stats = unsafe { stats.assume_init() }; - stats.f_flags & (libc::MNT_NOEXEC as u32) == 0 -} - pub(crate) fn is_executable(metadata: &std::fs::Metadata) -> bool { metadata.permissions().mode() & 0o111 != 0 } diff --git a/crates/fbuild-core/src/platform/macos/mod.rs b/crates/fbuild-core/src/platform/macos/mod.rs index de4eaed90..c86cf5e6e 100644 --- a/crates/fbuild-core/src/platform/macos/mod.rs +++ b/crates/fbuild-core/src/platform/macos/mod.rs @@ -1,6 +1,5 @@ use super::host::{HostArch, HostOs}; -pub(super) mod ape; pub(super) mod device; pub(super) mod fs; pub(super) mod host; diff --git a/crates/fbuild-core/src/platform/process.rs b/crates/fbuild-core/src/platform/process.rs index 0ca65d0eb..1df9a6c46 100644 --- a/crates/fbuild-core/src/platform/process.rs +++ b/crates/fbuild-core/src/platform/process.rs @@ -3,7 +3,7 @@ use std::ffi::OsStr; use std::fs::File; use std::process::{ChildStderr, ChildStdin, ChildStdout, Command, ExitStatus}; -use std::sync::{OnceLock, RwLock, RwLockReadGuard, RwLockWriteGuard}; +use std::sync::{OnceLock, RwLockReadGuard}; use std::time::{Duration, Instant}; use crate::path::NormalizedPath; @@ -103,27 +103,11 @@ pub fn containment_is_initialized() -> bool { CONTAINMENT.get().is_some() } -/// Process-wide fork lock, as Go's `syscall.ForkLock`. -/// -/// A file this process writes and then executes fails with `ETXTBSY` if a -/// sibling thread forks while the writable descriptor is open: the forked -/// child holds it until its own `exec`. Every spawn helper here holds the -/// lock shared across fork→exec (`spawn` returns only after the child has -/// exec'd); writers of to-be-executed files hold it exclusively while their -/// descriptor is open, so no child can inherit it. -static FORK_LOCK: RwLock<()> = RwLock::new(()); - -/// Hold across a fork→exec that bypasses this module's spawn helpers (the -/// few allowlisted direct `Command` spawns), so the child can't inherit a -/// loader that [`exclusive_fork_guard`] is writing. Release it before -/// waiting on the child. +/// Hold the shared APE fork lock across a raw fork/exec that bypasses +/// running-process. Build the command before taking this guard, and release +/// it before waiting. The running-process spawn helpers acquire it themselves. pub fn shared_fork_guard() -> RwLockReadGuard<'static, ()> { - FORK_LOCK.read().unwrap_or_else(|e| e.into_inner()) -} - -/// Hold while a file that will be executed is open for writing. -pub fn exclusive_fork_guard() -> RwLockWriteGuard<'static, ()> { - FORK_LOCK.write().unwrap_or_else(|e| e.into_inner()) + running_process::ape::fork_guard() } /// Construct a [`Command`] for `program`, routing an APE (cosmocc) image @@ -185,12 +169,9 @@ pub fn spawn_contained( stderr: into_running_stdio(stdio.stderr), ..running_process::SpawnStdio::default() }; - let inner = { - let _fork = shared_fork_guard(); - match CONTAINMENT.get() { - Some(group) => group.spawn(command, stdio)?, - None => running_process::spawn(command, stdio)?, - } + let inner = match CONTAINMENT.get() { + Some(group) => group.spawn(command, stdio)?, + None => running_process::spawn(command, stdio)?, }; Ok(ContainedChild { inner }) } @@ -213,7 +194,6 @@ pub fn spawn_detached( stderr: Option<&File>, environment: DetachedEnvironment, ) -> std::io::Result { - let _fork = shared_fork_guard(); super::selected::process::spawn_detached(command, stderr, environment) } @@ -242,10 +222,8 @@ fn spawn_tokio_contained_here( } super::selected::process::configure_tokio_owner_death(command)?; } - let child = { - let _fork = shared_fork_guard(); - running_process::spawn_tokio(command, running_process::TokioSpawnOptions::default())? - }; + let child = + running_process::spawn_tokio(command, running_process::TokioSpawnOptions::default())?; if CONTAINMENT.get().is_some() { super::selected::process::after_tokio_spawn(&child)?; } diff --git a/crates/fbuild-core/src/platform/windows/fs.rs b/crates/fbuild-core/src/platform/windows/fs.rs index 973b52129..a120fe8b7 100644 --- a/crates/fbuild-core/src/platform/windows/fs.rs +++ b/crates/fbuild-core/src/platform/windows/fs.rs @@ -52,15 +52,6 @@ pub(crate) fn set_executable(_path: &Path) -> std::io::Result<()> { Ok(()) } -pub(crate) fn ensure_private_dir(dir: &Path) -> bool { - std::fs::create_dir_all(dir).is_ok() - && std::fs::symlink_metadata(dir).is_ok_and(|meta| meta.file_type().is_dir()) -} - -pub(crate) fn mount_allows_exec(_dir: &Path) -> bool { - true -} - pub(crate) fn is_executable(_metadata: &std::fs::Metadata) -> bool { true } diff --git a/crates/fbuild-core/src/platform/windows/mod.rs b/crates/fbuild-core/src/platform/windows/mod.rs index 612a832f5..bbd714bcf 100644 --- a/crates/fbuild-core/src/platform/windows/mod.rs +++ b/crates/fbuild-core/src/platform/windows/mod.rs @@ -1,6 +1,5 @@ use super::host::{HostArch, HostOs}; -pub(super) mod ape; pub(super) mod device; pub(super) mod fs; pub(super) mod host; From 6ff4f9074b441d3d7d922b126213a841119ffb86 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 3 Oct 2026 12:29:13 -0700 Subject: [PATCH 2/2] fix(process): resolve relative tools against child working directory --- crates/fbuild-core/src/platform/linux/fs.rs | 19 +++++++++++ crates/fbuild-core/src/subprocess.rs | 38 ++++++++++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/crates/fbuild-core/src/platform/linux/fs.rs b/crates/fbuild-core/src/platform/linux/fs.rs index 9a03a84d3..e2a753434 100644 --- a/crates/fbuild-core/src/platform/linux/fs.rs +++ b/crates/fbuild-core/src/platform/linux/fs.rs @@ -135,6 +135,25 @@ fn byte_count(blocks: u128, fragment_size: u128) -> u64 { mod tests { use super::*; + #[test] + fn relative_executable_preserves_symlink_parent_traversal() { + let tmp = tempfile::tempdir().unwrap(); + let root = crate::path::NormalizedPath::new(tmp.path()); + let tools = root.join("tools"); + std::fs::create_dir_all(tools.join("bin")).unwrap(); + std::os::unix::fs::symlink(tools.join("bin"), root.join("link")).unwrap(); + std::os::unix::fs::symlink("/bin/echo", tools.join("echo")).unwrap(); + let out = crate::subprocess::run_command_blocking( + &["./link/../echo", "symlink traversal"], + Some(tmp.path()), + None, + Some(std::time::Duration::from_secs(10)), + ) + .unwrap(); + assert!(out.success(), "{}", out.stderr); + assert_eq!(out.stdout, "symlink traversal\n"); + } + #[test] fn executable_permission_preserves_private_read_access() { let temp = tempfile::NamedTempFile::new().unwrap(); diff --git a/crates/fbuild-core/src/subprocess.rs b/crates/fbuild-core/src/subprocess.rs index e3eebee7e..51a54b0d3 100644 --- a/crates/fbuild-core/src/subprocess.rs +++ b/crates/fbuild-core/src/subprocess.rs @@ -629,6 +629,23 @@ fn build_command( capture: bool, stdin_piped: bool, ) -> Result { + // Explicit relative paths are relative to the requested child cwd. + // Resolve them before spawning: Windows otherwise uses the parent's cwd, + // while Unix applies the child's cwd during exec. Bare names retain PATH + // lookup, and absolute paths retain their original spelling. + let program = Path::new(args[0]); + let mut components = program.components(); + let bare = matches!( + (components.next(), components.next()), + (Some(std::path::Component::Normal(_)), None) + ); + let resolved = match cwd { + // Preserve `..` components: the OS must resolve symlinks before them. + Some(dir) if !program.is_absolute() && !bare => { + Some(std::env::current_dir()?.join(dir).join(program)) + } + _ => None, + }; // APE (cosmocc) images can't be exec'd directly on hosts without an APE // binfmt handler (e.g. NixOS); route them through a loader. let launch = process::ape_launch(args[0], cwd, env); @@ -638,7 +655,7 @@ fn build_command( cmd.arg(&launch.image); cmd } - None => TokioCommand::new(args[0]), + None => TokioCommand::new(resolved.as_ref().map_or(program, |path| path.as_path())), }; // Expose fbuild's loader as `ape` on the child's PATH so APE programs the // tool spawns itself (gcc -> cc1) resolve a loader too. @@ -782,6 +799,25 @@ fn compute_env(program: &str, overlay: Option<&[(&str, &str)]>) -> Option