From 0eda585ef49cd718ef3d44c71401eb7ea1a9da2e Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Fri, 2 Oct 2026 18:15:36 -0700 Subject: [PATCH 01/12] feat(core): run cosmocc APE host tools on every host fbuild supports fbuild host tools are moving to cosmocc Actually Portable Executables: one binary per tool for Windows, macOS and Linux on x86_64 and aarch64. Unix kernels can't exec an APE without a binfmt handler (NixOS ships none), so every spawn failed with "Exec format error". - platform::ape detects the APE magic and launches ` args`. fbuild provides the loader from the image itself: the Linux ELF loader, the macOS x86_64 Mach-O loader, or ape-m1.c compiled once with cc on Apple Silicon. Loaders are validated and installed content-addressed into an owner-only, exec-capable cache, with a sealed memfd as the last resort on Linux (raw syscall, so the glibc 2.17 floor holds). - The loader is also exposed as `ape` first on the child's PATH, so APE programs a tool spawns itself (gcc -> cc1/as) resolve too. - A process-wide fork lock keeps freshly written loaders from failing with ETXTBSY under parallel spawns. - zccache spawns compilers itself, so FbuildZccacheService hands it a `#!/bin/sh` shim that keeps the compiler's name and execs the loader on the original image. - Tool lookup accepts tool, tool.exe and tool.com on every host (non-native spellings only when they are APE). Zip extraction restores the APE exec bit. Registry files without `system`, and tools.json "any", count as universal. - Direct Command::new tool spawns move to process::command/tokio_command. Tests: the hello-world cosmocc fixture via run_command and process::command, hostile envs, tampered caches, symlinked and world-writable cache dirs, 48-way concurrent first spawns, and an opt-in all-APE cosmocc gcc toolchain through run_command and embedded zccache (cold miss then warm hit). Validated on Ubuntu, Debian, Fedora, Alpine, Arch (x64, root and user), aarch64 under qemu-user, and Windows tool-resolution logic under wine. Refs FastLED/fbuild#1629 --- Cargo.lock | 1 + README.md | 73 ++ crates/fbuild-build-engine/src/build_info.rs | 9 + .../src/compile_database/cache_wrapper.rs | 6 +- .../src/compile_database/clang.rs | 5 +- crates/fbuild-build-engine/src/linker.rs | 7 + .../src/symbol_analyzer/tests.rs | 9 + .../src/zccache_embedded.rs | 7 +- crates/fbuild-build/tests/it/ape_toolchain.rs | 182 ++++ crates/fbuild-build/tests/it/main.rs | 1 + crates/fbuild-cli/src/cli/clang_tools.rs | 4 +- crates/fbuild-cli/src/cli/debug.rs | 26 +- crates/fbuild-cli/src/cli/ide.rs | 2 +- crates/fbuild-cli/src/cli/symbols_cmd.rs | 9 +- crates/fbuild-cli/src/daemon_client.rs | 2 +- crates/fbuild-core/Cargo.toml | 3 + crates/fbuild-core/data/README.md | 1 + crates/fbuild-core/data/ape-hello/README.md | 17 + crates/fbuild-core/data/ape-hello/build.sh | 8 + crates/fbuild-core/data/ape-hello/hello.c | 8 + crates/fbuild-core/data/ape-hello/hello.com | Bin 0 -> 295090 bytes crates/fbuild-core/src/platform/README.md | 23 + crates/fbuild-core/src/platform/ape/README.md | 17 + .../fbuild-core/src/platform/ape/install.rs | 86 ++ crates/fbuild-core/src/platform/ape/mod.rs | 945 ++++++++++++++++++ crates/fbuild-core/src/platform/ape/native.rs | 188 ++++ .../fbuild-core/src/platform/ape/prologue.rs | 101 ++ crates/fbuild-core/src/platform/executable.rs | 221 +++- crates/fbuild-core/src/platform/fs.rs | 17 + crates/fbuild-core/src/platform/linux/ape.rs | 204 ++++ crates/fbuild-core/src/platform/linux/fs.rs | 32 + crates/fbuild-core/src/platform/linux/mod.rs | 1 + crates/fbuild-core/src/platform/macos/ape.rs | 29 + crates/fbuild-core/src/platform/macos/fs.rs | 32 + crates/fbuild-core/src/platform/macos/mod.rs | 1 + crates/fbuild-core/src/platform/mod.rs | 1 + crates/fbuild-core/src/platform/process.rs | 82 +- .../fbuild-core/src/platform/windows/ape.rs | 12 + crates/fbuild-core/src/platform/windows/fs.rs | 13 + .../fbuild-core/src/platform/windows/mod.rs | 1 + crates/fbuild-core/src/platformio_package.rs | 3 + crates/fbuild-core/src/subprocess.rs | 33 +- .../tests/it/platformio_package_resolution.rs | 53 + .../src/handlers/emulator/avr8js_headless.rs | 2 +- .../src/handlers/emulator/runners.rs | 17 +- .../src/handlers/emulator/shared.rs | 2 +- .../src/handlers/operations/deploy.rs | 36 +- crates/fbuild-deploy/src/lpc.rs | 8 +- .../fbuild-deploy/src/lpc_debugger_reflash.rs | 7 +- crates/fbuild-deploy/src/probe_rs.rs | 46 +- crates/fbuild-deploy/src/wchisp.rs | 23 +- crates/fbuild-deploy/src/wlink.rs | 23 +- crates/fbuild-library/src/library/esptool.rs | 16 +- crates/fbuild-packages-fetch/src/extractor.rs | 82 ++ crates/fbuild-packages-fetch/src/lib.rs | 70 ++ crates/fbuild-python/src/daemon.rs | 4 +- crates/fbuild-serial/src/crash_decoder.rs | 51 +- crates/fbuild-toolchain/src/toolchain/arm.rs | 47 +- .../src/toolchain/arm_gcc8.rs | 11 +- crates/fbuild-toolchain/src/toolchain/avr.rs | 20 +- .../fbuild-toolchain/src/toolchain/clang.rs | 43 +- .../fbuild-toolchain/src/toolchain/esp32.rs | 22 +- .../src/toolchain/esp32_metadata.rs | 57 +- .../fbuild-toolchain/src/toolchain/esp8266.rs | 20 +- .../src/toolchain/esp_qemu.rs | 34 +- .../fbuild-toolchain/src/toolchain/riscv.rs | 60 +- .../src/toolchain/rp2040_picotool.rs | 22 +- .../src/toolchain/rp2040_pqt.rs | 20 +- .../src/toolchain/teensy_arm.rs | 20 +- 69 files changed, 2878 insertions(+), 360 deletions(-) create mode 100644 crates/fbuild-build/tests/it/ape_toolchain.rs create mode 100644 crates/fbuild-core/data/ape-hello/README.md create mode 100755 crates/fbuild-core/data/ape-hello/build.sh create mode 100644 crates/fbuild-core/data/ape-hello/hello.c create mode 100755 crates/fbuild-core/data/ape-hello/hello.com create mode 100644 crates/fbuild-core/src/platform/ape/README.md create mode 100644 crates/fbuild-core/src/platform/ape/install.rs create mode 100644 crates/fbuild-core/src/platform/ape/mod.rs create mode 100644 crates/fbuild-core/src/platform/ape/native.rs create mode 100644 crates/fbuild-core/src/platform/ape/prologue.rs create mode 100644 crates/fbuild-core/src/platform/linux/ape.rs create mode 100644 crates/fbuild-core/src/platform/macos/ape.rs create mode 100644 crates/fbuild-core/src/platform/windows/ape.rs diff --git a/Cargo.lock b/Cargo.lock index 14fdd92b1..0da0462b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1226,6 +1226,7 @@ name = "fbuild-core" version = "2.5.31" dependencies = [ "async-trait", + "flate2", "fs2", "interprocess", "libc", diff --git a/README.md b/README.md index 0be4acdea..50a7320b9 100644 --- a/README.md +++ b/README.md @@ -286,12 +286,85 @@ points: - URL-based package and library management, including GitHub `lib_deps` - Build, deploy, serial monitor, and emulator test workflows from one CLI - Cross-platform support on Windows, macOS, and Linux +- Host tools as Rust-built APE native executables: one binary per tool that + runs everywhere fbuild does, even on NixOS + ([details](#host-tools-ape-native-executables)) - Transparent architecture with Rust workspace internals documented under [`docs/architecture/`](docs/architecture/README.md) See [`docs/WHY.md`](docs/WHY.md) for the full rationale, benefits, and performance notes. +## Host Tools: APE Native Executables + +The tools fbuild runs on the host for each platform are moving to FastLED-built +Rust executables shipped in a single format: the cosmocc +[Actually Portable Executable](https://justine.lol/ape.html) (APE). That covers +upload/flash utilities, deployers and image/format helpers that would otherwise +come as Python scripts, per-OS vendor binaries, or packages that behave +differently on each host. Each tool is built once and runs as a native +executable on every host fbuild supports: Linux, macOS and Windows, x86_64 and +aarch64. There is one artifact per tool instead of one per OS and CPU, and one +behavior to test instead of a matrix. + +The goal per platform is to replace its third-party host tools with Rust tools +whose bugs FastLED fixes once and then ships as APE, so the fix reaches every +host at the same time. + +**Reference platform: NXP LPC8xx.** Its flasher is FastLED's fork of the Rust +[`probe-rs`](https://probe.rs). The fork carries the fixes that make the +LPC845-BRK's LPC-Link2 CMSIS-DAP firmware work: spec-default fallbacks when +the firmware doesn't answer `DAP_Info`, an explicit `DAP_Connect(Swd)` with +retry, and a CMSIS-DAP v1 HID transport over `nusb` (FastLED/fbuild#935, +FastLED/fbuild#936). The source is on the `tools` branch of +[`FastLED/framework-arduino-lpc8xx`](https://github.com/FastLED/framework-arduino-lpc8xx), +and `fastled-release-cross.yml` cross-builds it today as **six** per-host +release assets: Windows, Linux and macOS, each on x86_64 and aarch64. fbuild +pins and checksums all six in `crates/fbuild-deploy/src/probe_rs.rs`. The APE +target is one asset, built and pinned once, that runs on all of those hosts. + +**Next: WCH CH32V.** Its flashers, [`wlink`](https://github.com/ch32-rs/wlink) +(WCH-LinkE) and [`wchisp`](https://github.com/ch32-rs/wchisp) (USB ISP), are +already Rust. fbuild pins them per host in +`crates/fbuild-deploy/src/wlink.rs` and `wchisp.rs`, and `wlink` has assets for +only three hosts. Every other host needs `FBUILD_WLINK_PATH`. An APE build +removes that gap. + +### Guaranteed launch, nothing to install + +An APE tool launches anywhere fbuild does. Nothing has to be installed on the +host: no `ape` loader, no `binfmt_misc` registration, no shell support. That +guarantee is needed because most Unix hosts can't exec an APE file directly. +`posix_spawn` returns `ENOEXEC` ("Exec format error") unless an APE handler is +registered, and some distros, NixOS among them, ship none. Every fbuild spawn +detects the APE magic (`MZqFpD='`, `jartsr='`, `APEDBG='`) and runs the tool +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 | Best effort: an installed `ape`, otherwise the tool's own `/bin/sh` prologue, which may compile its loader with `cc`. | + +Loader precedence is `FBUILD_APE_LOADER` (explicit override), then the loader +embedded in the tool (Linux), 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 +fail with `ETXTBSY` ("Text file busy"). + +### Adding an APE tool + +Build the Rust tool with cosmocc so the output is an APE, provision it like any +other package, and spawn it through `fbuild_core::subprocess::run_command*`, or +build the command with `fbuild_core::platform::process::command` / +`tokio_command` instead of `Command::new`. The tool then works like any other +executable, with no per-host special casing. A hello-world fixture and its build +script live in +[`crates/fbuild-core/data/ape-hello/`](crates/fbuild-core/data/ape-hello/README.md). +Implementation notes are in +[`crates/fbuild-core/src/platform/README.md`](crates/fbuild-core/src/platform/README.md). + ## CLI Usage The user-facing command reference is diff --git a/crates/fbuild-build-engine/src/build_info.rs b/crates/fbuild-build-engine/src/build_info.rs index 5f219ee02..efab0615a 100644 --- a/crates/fbuild-build-engine/src/build_info.rs +++ b/crates/fbuild-build-engine/src/build_info.rs @@ -707,6 +707,15 @@ mod tests { ); } + #[test] + fn derive_gcc_tool_path_preserves_ape_com_suffix() { + let size = PathBuf::from("/toolchain/bin/xtensa-esp32s3-elf-size.com"); + assert_eq!( + derive_gcc_tool_path(&size, "nm"), + PathBuf::from("/toolchain/bin").join("xtensa-esp32s3-elf-nm.com") + ); + } + #[test] fn derive_gcc_tool_path_bare_size_falls_back_to_bare_target() { // PATH-resolved `size` with no prefix. diff --git a/crates/fbuild-build-engine/src/compile_database/cache_wrapper.rs b/crates/fbuild-build-engine/src/compile_database/cache_wrapper.rs index 3cf0db777..1a7ef3a69 100644 --- a/crates/fbuild-build-engine/src/compile_database/cache_wrapper.rs +++ b/crates/fbuild-build-engine/src/compile_database/cache_wrapper.rs @@ -14,10 +14,8 @@ pub fn strip_cache_wrapper(args: &[String]) -> Vec { // `Path::file_stem` only splits on the platform's native separator, so // `C:\...\sccache.exe` is treated as one component on Linux/macOS. let filename = args[0].rsplit(['/', '\\']).next().unwrap_or(&args[0]); - let stem = filename - .strip_suffix(".exe") - .or_else(|| filename.strip_suffix(".EXE")) - .unwrap_or(filename) + let stem = fbuild_core::platform::executable::split_tool_suffix(filename) + .0 .to_lowercase(); if stem == "sccache" || stem == "ccache" || stem == "zccache" { diff --git a/crates/fbuild-build-engine/src/compile_database/clang.rs b/crates/fbuild-build-engine/src/compile_database/clang.rs index 8de0eff84..0c0a2b60d 100644 --- a/crates/fbuild-build-engine/src/compile_database/clang.rs +++ b/crates/fbuild-build-engine/src/compile_database/clang.rs @@ -55,7 +55,10 @@ pub fn translate_flags_for_clang(args: &[String], arch: TargetArchitecture) -> V let compiler_path = fbuild_core::path::NormalizedPath::from(args[0].as_str()) .display_slash() .to_lowercase(); - let clang_name = if compiler_path.ends_with("g++") || compiler_path.ends_with("g++.exe") { + let clang_name = if compiler_path.ends_with("g++") + || compiler_path.ends_with("g++.exe") + || compiler_path.ends_with("g++.com") + { "clang++" } else { "clang" diff --git a/crates/fbuild-build-engine/src/linker.rs b/crates/fbuild-build-engine/src/linker.rs index 71d5c1115..efff80985 100644 --- a/crates/fbuild-build-engine/src/linker.rs +++ b/crates/fbuild-build-engine/src/linker.rs @@ -932,4 +932,11 @@ mod tests { let nm = LinkerBase::nm_path_from_size_path(&size); assert_eq!(nm, PathBuf::from("C:/toolchain/bin/arm-none-eabi-nm.exe")); } + + #[test] + fn test_nm_path_from_size_path_with_ape_com() { + let size = PathBuf::from("/toolchain/bin/arm-none-eabi-size.com"); + let nm = LinkerBase::nm_path_from_size_path(&size); + assert_eq!(nm, PathBuf::from("/toolchain/bin/arm-none-eabi-nm.com")); + } } diff --git a/crates/fbuild-build-engine/src/symbol_analyzer/tests.rs b/crates/fbuild-build-engine/src/symbol_analyzer/tests.rs index ec47e1bed..2ca12c5a7 100644 --- a/crates/fbuild-build-engine/src/symbol_analyzer/tests.rs +++ b/crates/fbuild-build-engine/src/symbol_analyzer/tests.rs @@ -17,6 +17,15 @@ fn derive_cppfilt_prefix() { ); } +#[test] +fn derive_cppfilt_preserves_ape_com_suffix() { + let nm = PathBuf::from("/tools/xtensa-esp32s3-elf-nm.com"); + assert_eq!( + derive_cppfilt_path(&nm), + PathBuf::from("/tools/xtensa-esp32s3-elf-c++filt.com") + ); +} + #[test] fn derive_cppfilt_no_prefix() { let nm = PathBuf::from("/usr/bin/nm"); diff --git a/crates/fbuild-build-engine/src/zccache_embedded.rs b/crates/fbuild-build-engine/src/zccache_embedded.rs index 073051b7b..6a3b49f28 100644 --- a/crates/fbuild-build-engine/src/zccache_embedded.rs +++ b/crates/fbuild-build-engine/src/zccache_embedded.rs @@ -298,9 +298,14 @@ impl FbuildZccacheService { cwd: PathBuf, 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) + .unwrap_or_else(|| compiler.to_path_buf()); let req = ZccacheCompileRequest { audit: default_audit_context(), - compiler: compiler.to_path_buf().into(), + compiler: compiler.into(), args, cwd: cwd.into(), env, diff --git a/crates/fbuild-build/tests/it/ape_toolchain.rs b/crates/fbuild-build/tests/it/ape_toolchain.rs new file mode 100644 index 000000000..260f32bea --- /dev/null +++ b/crates/fbuild-build/tests/it/ape_toolchain.rs @@ -0,0 +1,182 @@ +//! A toolchain made entirely of APE (cosmocc) executables, driven the two +//! ways fbuild runs compilers: `run_command` (fbuild's own spawn path) and the +//! embedded zccache service (which spawns the compiler itself). +//! +//! 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. +//! +//! Opt-in: point `FBUILD_TEST_COSMOCC` at an unpacked +//! (the directory holding `bin/`). + +use std::path::{Path, PathBuf}; + +use fbuild_build::zccache_embedded::FbuildZccacheService; +use zccache::embedded::ShutdownMode; + +fn cosmo_gcc() -> PathBuf { + let root = std::env::var_os("FBUILD_TEST_COSMOCC").expect("FBUILD_TEST_COSMOCC"); + let arch = fbuild_core::platform::host::arch_name(); + let gcc = Path::new(&root) + .join("bin") + .join(format!("{arch}-linux-cosmo-gcc")); + assert!( + fbuild_core::platform::ape::is_ape_file(&gcc), + "{} must be an APE image", + gcc.display() + ); + gcc +} + +/// A packaged-toolchain layout of the APE gcc. Raw cosmocc gcc has no +/// built-in system include dirs (the `cosmocc` driver script passes them), and +/// zccache bypasses its cache when include discovery finds none. A platform +/// package ships its gcc configured, so mirror that: a tree of symlinks into +/// the unpacked cosmocc with the gcc APE image copied in and a GCC `specs` file +/// adding the toolchain's include root. +fn packaged_cosmo_gcc(into: &Path) -> PathBuf { + let src = cosmo_gcc(); + let root = src.parent().unwrap().parent().unwrap(); + let triple = format!("{}-linux-cosmo", fbuild_core::platform::host::arch_name()); + for entry in std::fs::read_dir(root).unwrap() { + let name = entry.unwrap().file_name(); + if name != "bin" && name != "lib" { + link(&root.join(&name), &into.join(&name)).unwrap(); + } + } + std::fs::create_dir_all(into.join("bin")).unwrap(); + let gcc = into.join("bin").join(src.file_name().unwrap()); + std::fs::copy(&src, &gcc).unwrap(); + // GCC reads `specs` from lib/gcc/// under its prefix. + let version = std::fs::read_dir(root.join("libexec/gcc").join(&triple)) + .unwrap() + .next() + .unwrap() + .unwrap() + .file_name(); + let dest = into.join("lib/gcc").join(&triple).join(&version); + std::fs::create_dir_all(&dest).unwrap(); + for entry in std::fs::read_dir(root.join("lib/gcc")).unwrap() { + let entry = entry.unwrap(); + if entry.file_type().unwrap().is_file() { + link(&entry.path(), &into.join("lib/gcc").join(entry.file_name())).unwrap(); + } + } + std::fs::write( + dest.join("specs"), + format!("*cpp:\n+ -isystem {}\n\n", root.join("include").display()), + ) + .unwrap(); + gcc +} + +fn link(original: &Path, link: &Path) -> std::io::Result<()> { + if original.is_dir() { + fbuild_core::platform::fs::symlink_dir(original, link) + } else { + fbuild_core::platform::fs::symlink_file(original, link) + } +} + +fn source(dir: &Path) -> PathBuf { + let src = dir.join("add.c"); + std::fs::write(&src, "int add(int a, int b) { return a + b; }\n").unwrap(); + src +} + +fn compile_args(src: &Path, obj: &Path) -> Vec { + vec![ + "-nostdinc".into(), + "-c".into(), + src.display().to_string(), + "-o".into(), + obj.display().to_string(), + ] +} + +fn zccache_args(src: &Path, obj: &Path) -> Vec { + vec![ + "-c".into(), + src.display().to_string(), + "-o".into(), + obj.display().to_string(), + ] +} + +#[test] +#[ignore = "requires the cosmocc toolchain (~440 MB) via FBUILD_TEST_COSMOCC"] +fn ape_gcc_compiles_through_run_command_in_hostile_env() { + let gcc = cosmo_gcc(); + let dir = tempfile::tempdir().unwrap(); + let src = source(dir.path()); + let obj = dir.path().join("add.o"); + let mut argv = vec![gcc.display().to_string()]; + argv.extend(compile_args(&src, &obj)); + let argv: Vec<&str> = argv.iter().map(String::as_str).collect(); + let out = fbuild_core::subprocess::run_command_blocking( + &argv, + Some(dir.path()), + Some(&[ + ("PATH", "/nonexistent"), + ("TMPDIR", "/nonexistent"), + ("HOME", "/nonexistent"), + ]), + None, + ) + .expect("APE gcc must spawn"); + assert!( + out.success(), + "stdout: {}\nstderr: {}", + out.stdout, + out.stderr + ); + assert!(std::fs::read(&obj).unwrap().starts_with(b"\x7fELF")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires the cosmocc toolchain (~440 MB) via FBUILD_TEST_COSMOCC"] +async fn ape_gcc_compiles_through_embedded_zccache_and_hits_cache() { + let tmp = tempfile::tempdir().unwrap(); + let toolchain = tmp.path().join("toolchain"); + std::fs::create_dir(&toolchain).unwrap(); + let gcc = packaged_cosmo_gcc(&toolchain); + let svc = FbuildZccacheService::start_in(tmp.path().join("zccache")) + .await + .expect("zccache starts"); + let work = tmp.path().join("work"); + std::fs::create_dir(&work).unwrap(); + let src = source(&work); + let obj = work.join("add.o"); + // The same hermetic env production compiles get (no inherited PATH). + let mut env = fbuild_core::subprocess::compile_env_for_build(&work).unwrap(); + // A daemon started with a minimal PATH (systemd service, launchd): no + // coreutils for the APE prologue's own fallback to lean on. + env.retain(|(k, _)| k != "PATH"); + env.push(("PATH".to_string(), "/nonexistent".to_string())); + env.push(( + "ZCCACHE_WORKTREE_ROOT".to_string(), + work.display().to_string(), + )); + for round in 0..2 { + let _ = std::fs::remove_file(&obj); + let out = svc + .compile(&gcc, zccache_args(&src, &obj), work.clone(), env.clone()) + .await + .expect("zccache compile"); + assert_eq!( + out.exit_code, + 0, + "round {round} stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(std::fs::read(&obj).unwrap().starts_with(b"\x7fELF")); + assert_eq!( + out.cached, + round == 1, + "round {round}: cold miss, then warm hit" + ); + svc.flush().await.expect("flush into the cache"); + } + svc.shutdown(ShutdownMode::Graceful).await.unwrap(); +} diff --git a/crates/fbuild-build/tests/it/main.rs b/crates/fbuild-build/tests/it/main.rs index 1d5cea594..738d7eade 100644 --- a/crates/fbuild-build/tests/it/main.rs +++ b/crates/fbuild-build/tests/it/main.rs @@ -2,6 +2,7 @@ //! each top-level `tests/*.rs` file links the crate's whole dependency //! graph separately, so these modules share one link instead. +mod ape_toolchain; mod cache_survives_tar_extract; mod clangd_check_parity; mod compile_many_stage2_perf; diff --git a/crates/fbuild-cli/src/cli/clang_tools.rs b/crates/fbuild-cli/src/cli/clang_tools.rs index 67c9c6b16..b6d420c93 100644 --- a/crates/fbuild-cli/src/cli/clang_tools.rs +++ b/crates/fbuild-cli/src/cli/clang_tools.rs @@ -287,7 +287,7 @@ pub async fn run_iwyu( // Cache miss — run IWYU. Parallel async fan-out inside the CLI binary // (no daemon containment group in this process). // allow-direct-spawn: parallel async fan-out in CLI; no containment group here. - let mut cmd = tokio::process::Command::new(tool.as_ref()); + let mut cmd = fbuild_core::platform::process::tokio_command(tool.as_ref()); cmd.arg("-p").arg(p_dir.as_ref()); cmd.arg("-Xiwyu").arg("--no_comments"); cmd.arg("-Xiwyu").arg("--quoted_includes_first"); @@ -573,7 +573,7 @@ pub async fn run_clang_tool( .await .expect("fbuild-cli: clang-tool semaphore is never closed before all tasks finish"); // allow-direct-spawn: parallel async fan-out (clang-tidy) in CLI binary. - let mut cmd = tokio::process::Command::new(tool.as_ref()); + let mut cmd = fbuild_core::platform::process::tokio_command(tool.as_ref()); cmd.arg("-p").arg(pd.as_ref()); for arg in &extra { cmd.arg(arg); diff --git a/crates/fbuild-cli/src/cli/debug.rs b/crates/fbuild-cli/src/cli/debug.rs index 91d3305f6..89d044d52 100644 --- a/crates/fbuild-cli/src/cli/debug.rs +++ b/crates/fbuild-cli/src/cli/debug.rs @@ -81,7 +81,6 @@ //! `-ex "set serial baud " -ex "target remote " `. use std::path::Path; -use std::process::Command; use fbuild_build::build_info::{find_build_info_near, load_build_info}; use fbuild_build::symbol_analyzer::discover_elf_in_project; @@ -246,27 +245,26 @@ pub(crate) fn gdb_candidate_names(toolchain_prefix: &str) -> Vec { names } +#[cfg(test)] fn exe_name(name: &str) -> String { fbuild_core::platform::executable::native_name(name) } /// Search a list of directories (in order) for the first existing -/// candidate binary name. Pure function of its inputs — I/O-free beyond -/// `Path::exists`, so tests can point it at a `tempdir` fixture instead of -/// the real `PATH`/toolchain cache. +/// candidate binary (native spelling, or a `.com`/`.exe` APE; see +/// `find_tool_in`). Pure function of its inputs — file I/O only, so tests +/// can point it at a `tempdir` fixture instead of the real +/// `PATH`/toolchain cache. pub(crate) fn find_gdb_in_dirs( dirs: &[NormalizedPath], candidate_names: &[String], ) -> Option { - for dir in dirs { - for name in candidate_names { - let candidate = dir.join(exe_name(name)); - if candidate.exists() { - return Some(candidate); - } - } - } - None + dirs.iter().find_map(|dir| { + candidate_names + .iter() + .find_map(|name| fbuild_core::platform::executable::find_tool_in(dir, name)) + .map(NormalizedPath::from) + }) } fn path_dirs() -> Vec { @@ -543,7 +541,7 @@ pub async fn run_debug( // interactively in the user's terminal exactly as if they'd typed the // command themselves. // allow-direct-spawn: interactive gdb must inherit the user's terminal stdio; the capturing subprocess helpers would break the session. - let status = Command::new(&gdb_path) + let status = fbuild_core::platform::process::command(&gdb_path) .args(&argv) .status() .map_err(|e| FbuildError::Other(format!("failed to launch {}: {e}", gdb_path.display())))?; diff --git a/crates/fbuild-cli/src/cli/ide.rs b/crates/fbuild-cli/src/cli/ide.rs index a898a79fc..f2b3014d8 100644 --- a/crates/fbuild-cli/src/cli/ide.rs +++ b/crates/fbuild-cli/src/cli/ide.rs @@ -315,7 +315,7 @@ fn print_zed_install_guidance() { /// wait on the editor process). fn launch_zed(zed_path: &Path, project_dir: &str) -> fbuild_core::Result<()> { // allow-direct-spawn: editor is launched detached and must outlive the CLI; deliberately not in a containment group. - let mut cmd = std::process::Command::new(zed_path); + let mut cmd = fbuild_core::platform::process::command(zed_path); cmd.arg(project_dir); fbuild_core::platform::process::spawn_detached( &mut cmd, diff --git a/crates/fbuild-cli/src/cli/symbols_cmd.rs b/crates/fbuild-cli/src/cli/symbols_cmd.rs index 2f85e78e8..2d57fd282 100644 --- a/crates/fbuild-cli/src/cli/symbols_cmd.rs +++ b/crates/fbuild-cli/src/cli/symbols_cmd.rs @@ -223,11 +223,10 @@ fn resolve_elf(input: &Path) -> Result { fn find_nm_on_path() -> Result { let exe_name = fbuild_core::platform::executable::native_name("nm"); let path = std::env::var_os("PATH").ok_or_else(|| FbuildError::Other("PATH not set".into()))?; - for dir in std::env::split_paths(&path) { - let candidate = dir.join(&exe_name); - if candidate.exists() { - return Ok(candidate); - } + if let Some(found) = + fbuild_core::platform::executable::find_tool_on_paths(std::env::split_paths(&path), "nm") + { + return Ok(found); } Err(FbuildError::BuildFailed(format!( "{exe_name} not found on PATH; pass --nm to point at a cross toolchain nm" diff --git a/crates/fbuild-cli/src/daemon_client.rs b/crates/fbuild-cli/src/daemon_client.rs index 33a54c60e..ac9e538bf 100644 --- a/crates/fbuild-cli/src/daemon_client.rs +++ b/crates/fbuild-cli/src/daemon_client.rs @@ -1254,7 +1254,7 @@ async fn spawn_daemon_process() -> fbuild_core::Result { // name (and thus PATH) when no sibling is found. let daemon_exe = daemon_executable_hint(); // allow-direct-spawn: daemon must outlive the CLI; see INTENTIONALLY DETACHED comment below. - let mut cmd = std::process::Command::new(&daemon_exe); + let mut cmd = fbuild_core::platform::process::command(&daemon_exe); let baseline = running_process::environment::user_baseline_environment().map_err(|error| { fbuild_core::FbuildError::DaemonError(format!( "failed to construct daemon user environment: {error}" diff --git a/crates/fbuild-core/Cargo.toml b/crates/fbuild-core/Cargo.toml index 69f248749..a2da3976a 100644 --- a/crates/fbuild-core/Cargo.toml +++ b/crates/fbuild-core/Cargo.toml @@ -18,6 +18,9 @@ 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/data/README.md b/crates/fbuild-core/data/README.md index b507cad16..fe55b443b 100644 --- a/crates/fbuild-core/data/README.md +++ b/crates/fbuild-core/data/README.md @@ -8,6 +8,7 @@ the published FastLED/boards artifacts during the build/cache phase. | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `usb-vendors.tar.zst` | Frozen test-only USB Vendor-ID fixture used by `crate::usb::embedded` under `cfg(test)`. It is never a production fallback. | | `usb-vids.proto.zstd` | Test fixture for the compact VID:PID overlay produced by **FastLED/boards**. It must never be used as a production built-in catalogue. Production ingestion fetches/consumes the published boards artifact. | +| `ape-hello/` | Test-only cosmocc hello-world APE (with source and build script) used by `crate::platform::ape` tests. See `ape-hello/README.md`. | ## How to refresh the VID:PID overlay (`usb-vids.proto.zstd`) diff --git a/crates/fbuild-core/data/ape-hello/README.md b/crates/fbuild-core/data/ape-hello/README.md new file mode 100644 index 000000000..b812f8abb --- /dev/null +++ b/crates/fbuild-core/data/ape-hello/README.md @@ -0,0 +1,17 @@ +# ape-hello — APE test fixture + +A minimal [Actually Portable Executable](https://justine.lol/ape.html) +(cosmocc) used by `crate::platform::ape` tests to prove fbuild can spawn APE +binaries on hosts with no APE `binfmt_misc` handler (e.g. NixOS), where a direct +`posix_spawn` fails with `ENOEXEC` ("Exec format error"). + +| File | Purpose | +| ---------- | ------- | +| `hello.c` | Source: prints `hello world` followed by its arguments. | +| `hello.com`| Fat x86_64 + aarch64 APE built from `hello.c` (`-Os -mtiny -s`, ~295 KB). Test-only. | +| `build.sh` | Rebuilds `hello.com`; set `COSMOCC` to the `cosmocc` driver. | + +```console +$ ./hello.com a b # works from bash (it retries ENOEXEC via sh) +hello world a b +``` diff --git a/crates/fbuild-core/data/ape-hello/build.sh b/crates/fbuild-core/data/ape-hello/build.sh new file mode 100755 index 000000000..6e36021ce --- /dev/null +++ b/crates/fbuild-core/data/ape-hello/build.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Rebuild hello.com with cosmocc (https://cosmo.zip/pub/cosmocc/cosmocc.zip). +# Usage: COSMOCC=/path/to/cosmocc/bin/cosmocc ./build.sh +set -euo pipefail +cd "$(dirname "$0")" +"${COSMOCC:-cosmocc}" -Os -mtiny -s -o hello.com hello.c +# cosmocc also leaves per-arch debug images next to the output. +rm -f hello.com.dbg hello.aarch64.elf diff --git a/crates/fbuild-core/data/ape-hello/hello.c b/crates/fbuild-core/data/ape-hello/hello.c new file mode 100644 index 000000000..fa59efea0 --- /dev/null +++ b/crates/fbuild-core/data/ape-hello/hello.c @@ -0,0 +1,8 @@ +#include + +int main(int argc, char **argv) { + printf("hello world"); + for (int i = 1; i < argc; ++i) printf(" %s", argv[i]); + printf("\n"); + return 0; +} diff --git a/crates/fbuild-core/data/ape-hello/hello.com b/crates/fbuild-core/data/ape-hello/hello.com new file mode 100755 index 0000000000000000000000000000000000000000..47bff35a1733cdef52cb9d3fd6d7f84811099a69 GIT binary patch literal 295090 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�_Z$Duym+lPC)DzIXMo+Y_J5MzF zy2p8sP_MB|Y2P>%%T}?U-KUZs-D?~cqoLFGjq454CFoB6vktrO(vWq7s90}5-}>lr zUX#6IeN=>d4YW<#SJr%WcT{Z_0DHb#{Nz3pwY(QOQHau$Td z(Y;X&MXe;ppGO+;s&<{{)i8v4eJb+)@*EIi+1L zHR$R*@86YLI9;0Wxa5}h(2k$KJ8s?IY(aNMOUdv;d61)Urrc+4Az!nvFiYy=z$hX0 zpt&w}Pw2~RLvVH=#_&_k>X9=LmLwQ@T6Nczt;)z;`7{=~plVJWnvppN58|MV%xSoT zd->I&;AC+vFwgi~4fR6j=cH4PXcx@_|6Wkq)h_UVy{06j2J7L}$duLj;jLV=f0{vl z)Ud`e z`5CI6O!CT&n76iId8QMs7jKepFT(P2l)OM3nQ$BR=h_@WUa+hRli{S0@mX=4VAvyM zoaUbnE~*c^f)GG+1^H1k>F5?%XjCr@g%2%O4)p^EFe)$M26=$jWEi`f1Y$ny64>tR zk_VLsADNDJG)jZznDWgVu;Xixdx2St*@QksRh)7I<|gJqI+kzg09TOh9$?l4PXgJK z9)Y@Dh?}14#=2=4Nu)ktZB+(utnwH5XV}nGj3&k{Tpi2f?>30npx^tv{i!BpH@NSj zFj)|3s)6S95e8w3HVCAlH0CPnP%}CTBV=YA<&-{J)Rs@J(FicSOeT~I z+_IHrg=>NbbdZ=Wue2V>lGm9W4doa{hz1}l4~h^cX!5>aU<`Rj;Pxwsa+eF{3C%Vn z#C_iG;@_NT1yM$aX_6?Z0_;QxJaaV}Wl5dHsNI2wV$>Ev_7jdS2}Q{}D5zXVtXXQH zLBry+Ny#x2V?dt|NeyJdH?n~gTV&&(pm1bCRUtJ6y9OhTEr{UDrlJ7!z_w4)=%OtJ z5i~kvFjXK=rJX~uz!w5Lt8kOvPQ(?Kfw!%=E6)P}xt!jk0i$8KIRFfmCs3ypj^)cH zpqa2wqB5zRGG8$va+Bq^wZa28;f)Y@E}3rF4Y(cU&=$>c;47E2m(ng_WWrDs1HO^} z9A(4$qtA1UmbGW}=}Z88N}l)`aSHE1IY7x6>8Z^T-Y_ryC;0=Z{{d<~dO8ugwhc$s z=eKITlgd*OCA0QR<>@R;O{HP`#03VRVHBSH&k<5sK~WyHCEWax#${-mTu!)&Mt@UO zC>pyU5_?A7VR#v8N}o@u*OU^=6dD7}Um6nl0|?Uo%M|%Up4B6#wLA#}@oZr3!g9<5 zNk!W4rYJ4?r%zXc% z^tkW%{>ua*@E1}%r3xyZe|Pp&zW-vJD5-c4k2rBaMOU}dtNxz#eE*6H7WA>Uy)54` zQL4;E?(qC{=U>t+S&T}tXq+kEw3A3ddhYM=H-dcyoX8ezYi7k>$d)c*bKKh}3!W!) zzGtlMIM`$QOnKr?Vt|f-+uFnAcz*y@V|mfT=wLJNLaCq|u$rAL2rF+~BaSl#DTxk7*rV^?*$9v{owTJZ2yX!Pvlv-koU;!iu;6e&OuHWtL7qT zVxyljQe)2INh^RaKaMbKK<7R|o{9WFS@U~HMX;IR>Y;qeoC%L=-Be0ZDig5`80FU{ z5G?B;He$+d!Z?L-(vo8?aIlv)mZ##{8;${rxhDe{NB?kQB_6SDXBqhAT}aBBm`M_~ z*aX)xi-Rj3#bz_+GK3FM(?&!21V)Vp)>CXvA%9kR+BpfFBHk99F2yjy03%f-$a~-y zpC!*Ma!1cx7K(MtHJ zB|D`e?7(U?p=EJMcC#yB?{Z3uO)kmPB{Z_WL`}4VF_%|SweLmcB**LSp z0B@v7r!K;ufT$@Rt!_&t&ID!vQolkM)tCY^*GvJKfIEBuu1!-NyKDSd(Q1~{`LPdM zEm{0nY|7y3w%T#c@!_(l5|@I_xU8ze#T�?v<5iNTSq{tr98)mXxZm3kACJ>$2pb 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

X*KKO7qg!k3??&=8xL#-)&&+Xs^QF9Odnv;|LoI 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 z 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

3d&Ot_^Tka!%`d-jq21l1Cg1Y< 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≠Z#NR@&; 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%0D$PnS4r<`;|cZs1d*x43}xN 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 Q8=V*aUm;ZLwWE;$0Ao)#Hvj+t literal 0 HcmV?d00001 diff --git a/crates/fbuild-core/src/platform/README.md b/crates/fbuild-core/src/platform/README.md index c96314132..b9eb10779 100644 --- a/crates/fbuild-core/src/platform/README.md +++ b/crates/fbuild-core/src/platform/README.md @@ -23,3 +23,26 @@ Unix endpoint creation, TCP listener socket policy, and endpoint readiness probing. Broker framing/routing, daemon retry/yield policy, and HTTP/protobuf compatibility remain with the daemon. Additional native shutdown notifications route through `process` into the daemon's neutral shutdown channel. + +`ape` detects Actually Portable Executable (cosmocc) images by their magic +(`MZqFpD='`, `jartsr='`, `APEDBG='`) and, on Unix hosts, launches them as +` `, because `posix_spawn` cannot exec an APE without a +`binfmt_misc` handler (NixOS ships none). Loader order: `FBUILD_APE_LOADER`, then +(Linux) the loader embedded in the image itself, then `ape` on PATH, then +`/usr/bin/ape` / `/usr/local/bin/ape`, then `/bin/sh` (which runs the image's +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, checked to be a 64-bit ELF 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`, `$XDG_CACHE_HOME`/`~/.cache` `fbuild/ape`, +`$XDG_RUNTIME_DIR/fbuild/ape`, and `$TMPDIR/fbuild-ape-`; 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`. 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 holds shared across fork→exec — so no concurrently forked child can +inherit the writable descriptor and make the exec fail with `ETXTBSY`. diff --git a/crates/fbuild-core/src/platform/ape/README.md b/crates/fbuild-core/src/platform/ape/README.md new file mode 100644 index 000000000..a65d705b6 --- /dev/null +++ b/crates/fbuild-core/src/platform/ape/README.md @@ -0,0 +1,17 @@ +# `platform::ape` — APE (cosmocc) host-tool support + +Runs Actually Portable Executable tools on every host fbuild supports without +any host APE support installed. + +- `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 (loader blobs, loader + source, `--assimilate` headers). +- `install.rs` — owner-only, exec-capable, atomic, content-addressed installs + under the fork lock (no `ETXTBSY`). +- `native.rs` — host-native stand-ins (assimilated copy, or a `/bin/sh` shim + on macOS arm64) for spawners fbuild doesn't control, such as zccache. + +Host specifics (cache dirs, Linux `memfd` fallback) live in +`../{linux,macos,windows}/ape.rs`. diff --git a/crates/fbuild-core/src/platform/ape/install.rs b/crates/fbuild-core/src/platform/ape/install.rs new file mode 100644 index 000000000..e7f5bed1f --- /dev/null +++ b/crates/fbuild-core/src/platform/ape/install.rs @@ -0,0 +1,86 @@ +//! Owner-only, atomic installation of executables fbuild derives from APE +//! images (extracted loaders, `ape` PATH entries, launch shims). +//! +//! A candidate directory is used only when it is a real directory owned by +//! the effective user with no group/other write access (so no other account +//! can plant or swap a file in it) and sits on a mount that allows exec. + +use std::fs::OpenOptions; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use super::super::{fs, process}; + +/// Install `bytes` as executable `

//` in the first usable +/// candidate `dir`, reusing an identical existing file. `None` when no +/// candidate is usable. +pub(crate) fn install( + dirs: &[PathBuf], + subdir: Option<&str>, + name: &str, + bytes: &[u8], +) -> Option { + dirs.iter().find_map(|dir| { + let dir = match subdir { + Some(sub) => { + // The parent must pass the same checks before we nest in it. + if !usable_dir(dir) { + return None; + } + dir.join(sub) + } + None => dir.clone(), + }; + install_in(&dir, bytes, name) + }) +} + +fn usable_dir(dir: &Path) -> bool { + fs::ensure_private_dir(dir) && fs::mount_allows_exec(dir) +} + +pub(crate) fn install_in(dir: &Path, bytes: &[u8], name: &str) -> Option { + if !usable_dir(dir) { + return None; + } + let target = dir.join(name); + if is_installed(&target, bytes) { + return Some(target); + } + static SEQ: AtomicU64 = AtomicU64::new(0); + let tmp = dir.join(format!( + ".{name}.{}.{}", + std::process::id(), + SEQ.fetch_add(1, Ordering::Relaxed) + )); + // No child may inherit the writable fd, or exec of the file would hit + // ETXTBSY until that child execs (see `process::exclusive_fork_guard`). + let written = { + let _fork = process::exclusive_fork_guard(); + OpenOptions::new() + .write(true) + .create_new(true) + .open(&tmp) + .and_then(|mut file| { + file.write_all(bytes)?; + file.sync_all() + }) + } + .and_then(|()| fs::set_executable(&tmp)) + // rename(2) is atomic: concurrent installers race benignly to identical + // content, and readers never see a partial file. + .and_then(|()| std::fs::rename(&tmp, &target)); + if written.is_err() { + let _ = std::fs::remove_file(&tmp); + return None; + } + is_installed(&target, bytes).then_some(target) +} + +/// A regular (not symlinked), executable file whose content is exactly `bytes`. +pub(crate) fn is_installed(path: &Path, bytes: &[u8]) -> bool { + std::fs::symlink_metadata(path).is_ok_and(|meta| { + meta.file_type().is_file() && fs::is_executable(&meta) && meta.len() == bytes.len() as u64 + }) && std::fs::read(path).is_ok_and(|content| content == bytes) +} diff --git a/crates/fbuild-core/src/platform/ape/mod.rs b/crates/fbuild-core/src/platform/ape/mod.rs new file mode 100644 index 000000000..bacd9509c --- /dev/null +++ b/crates/fbuild-core/src/platform/ape/mod.rs @@ -0,0 +1,945 @@ +//! Actually Portable Executable (APE / cosmocc) launch support. +//! +//! 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. + +use std::collections::HashMap; +use std::ffi::{OsStr, OsString}; +use std::io::Read; +use std::path::{Path, PathBuf}; +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. +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. +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"]; + +/// Whether `head` starts with an APE magic. +pub fn is_ape_header(head: &[u8]) -> bool { + APE_MAGICS.iter().any(|magic| head.starts_with(*magic)) +} + +/// Whether the file at `path` is an APE image. Unreadable files are not APE. +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)) +} + +/// 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)] +pub struct ApeLaunch { + pub loader: PathBuf, + pub image: PathBuf, + /// 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, +} + +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. + 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.clone()).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 mut cache_dirs: Vec = overlay_var(CACHE_DIR_ENV) + .map(PathBuf::from) + .into_iter() + .collect(); + cache_dirs.extend(super::selected::ape::default_loader_dirs()); + plan_launch_for( + host, + program, + cwd, + path_var.as_deref(), + loader_override.as_deref(), + &cache_dirs, + ) +} + +/// [`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: &[PathBuf], +) -> 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(); + } + 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(explicit.to_path_buf()); + } + 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(PathBuf::from))) + .or_else(|| first_executable([PathBuf::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| 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 = (PathBuf, u64, Option, HostPlatform, Vec); + +fn image_key(image: &Path, host: HostPlatform, dirs: &[PathBuf]) -> Option { + let meta = std::fs::metadata(image).ok()?; + Some(( + image.to_path_buf(), + 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: PathBuf) { + let mut guard = self.0.lock().unwrap_or_else(|e| e.into_inner()); + guard.get_or_insert_with(HashMap::new).insert(key, path); + } +} + +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: &[PathBuf]) -> 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; + }; + 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: &[PathBuf]) -> Option { + if loader.file_name().is_some_and(|n| n == "sh") { + return None; + } + if loader.file_name().is_some_and(|n| n == "ape") && !loader.starts_with("/proc/self") { + return loader.parent().map(Path::to_path_buf); + } + let bytes = std::fs::read(loader).ok()?; + if bytes.len() as u64 > MAX_LOADER_BYTES { + return None; + } + let sub = format!("bin-{}", short_hash(&bytes)); + install::install(cache_dirs, Some(&sub), "ape", &bytes)? + .parent() + .map(Path::to_path_buf) +} + +/// 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: &[PathBuf]) -> 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, + } +} + +/// 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, + } +} + +/// 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), + }) +} + +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; + } + 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: &[PathBuf]) -> 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([PathBuf::from("/usr/bin/cc")]).unwrap_or_else(|| PathBuf::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) -> PathBuf { + let path = dir.join(name); + std::fs::write(&path, body).unwrap(); + crate::platform::fs::set_executable(&path).unwrap(); + 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")), + &[], + ); + 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")), + &[], + ); + 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, PathBuf::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); + } + + #[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); + } + + /// 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}"); + } + } + + #[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() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("data/ape-hello/hello.com") + } + + #[test] + fn hello_fixture_is_detected_as_ape() { + assert!(is_ape_file(&hello_fixture())); + } + + /// 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( + &mut command, + crate::platform::process::ContainedStdio { + stdout: crate::platform::process::StdioSource::Pipe, + ..Default::default() + }, + ) + .expect("real APE must spawn"); + let mut stdout = String::new(); + child + .take_stdout() + .unwrap() + .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) -> PathBuf { + let path = dir.join(name); + std::fs::copy(hello_fixture(), &path).unwrap(); + path + } + + /// 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, + 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()), + ]), + None, + ) + .expect("bare-name APE must spawn"); + assert_eq!(out.stdout, "hello world ok\n", "stderr: {}", out.stderr); + assert!(image.exists()); + } + + #[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(); + 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(); + } + }); + } +} diff --git a/crates/fbuild-core/src/platform/ape/native.rs b/crates/fbuild-core/src/platform/ape/native.rs new file mode 100644 index 000000000..26beae9f1 --- /dev/null +++ b/crates/fbuild-core/src/platform/ape/native.rs @@ -0,0 +1,188 @@ +//! Host-native stand-ins for APE images, for spawners fbuild does not control. +//! +//! zccache runs the compiler itself (through `kernal-api`), so it can't be +//! routed through ` `. For those callers fbuild hands over a +//! `#!/bin/sh` shim the kernel executes directly. It keeps the image's file +//! name (zccache detects the compiler family from it) and `exec`s the loader +//! on the *original* image path, so tools that locate their own subprograms +//! relative to themselves (gcc → `../libexec/.../cc1`) still find them. The +//! shim also prepends fbuild's `ape` directory to `PATH`, so APE programs the +//! tool spawns in turn (gcc → `cc1`, `as`) resolve a loader too. +//! +//! Relocating an assimilated (native-header) copy instead was tried and +//! rejected: gcc then fails with "cannot execute 'cc1'". +//! +//! Shims live in content-addressed subdirectories of the APE cache +//! (`shim-/`). + +use std::path::{Path, PathBuf}; + +use super::super::host::{self, HostPlatform}; +use super::{ + CACHE_DIR_ENV, LOADER_ENV, Memo, image_key, install, is_ape_file, plan_launch_for, short_hash, +}; + +static NATIVE_MEMO: Memo = Memo::new(); + +/// A path the kernel can execute directly that behaves like `program`, when +/// `program` is an APE image on a host that can't exec it natively. `None` +/// for non-APE programs, on Windows (APE runs natively), or when nothing +/// could be materialized — callers then use `program` unchanged. +pub fn native_executable(program: &Path) -> Option { + let host = host::current(); + if host.is_windows() || !is_ape_file(program) { + return None; + } + let env = |key: &str| std::env::var_os(key).filter(|v| !v.is_empty()); + let mut dirs: Vec = env(CACHE_DIR_ENV).map(PathBuf::from).into_iter().collect(); + dirs.extend(super::super::selected::ape::default_loader_dirs()); + native_executable_for( + host, + program, + &dirs, + env(LOADER_ENV).as_deref(), + env("PATH").as_deref(), + ) +} + +/// [`native_executable`] with every host input explicit, for tests. +pub fn native_executable_for( + host: HostPlatform, + program: &Path, + dirs: &[PathBuf], + loader_override: Option<&std::ffi::OsStr>, + path_var: Option<&std::ffi::OsStr>, +) -> Option { + if host.is_windows() || !is_ape_file(program) { + return None; + } + let image = std::path::absolute(program).ok()?; + let key = image_key(&image, host, dirs)?; + if let Some(hit) = NATIVE_MEMO.get(&key) { + return Some(hit); + } + let name = image.file_name()?.to_str()?.to_owned(); + let launch = plan_launch_for( + host, + image.as_os_str(), + None, + path_var, + loader_override, + dirs, + )?; + // A memfd path is only valid in this process's direct children; the + // shim's exec runs one level further down. + if launch.loader.starts_with("/proc/self") { + return None; + } + let mut shim = String::from("#!/bin/sh\n"); + if let Some(dir) = &launch.ape_path_dir { + shim.push_str(&format!( + "PATH={}\"${{PATH:+:$PATH}}\"; export PATH\n", + sh_quote(dir)? + )); + } + shim.push_str(&format!( + "exec {} {} \"$@\"\n", + sh_quote(&launch.loader)?, + sh_quote(&launch.image)? + )); + let sub = format!("shim-{}", short_hash(shim.as_bytes())); + let native = install::install(dirs, Some(&sub), &name, shim.as_bytes())?; + NATIVE_MEMO.put(key, native.clone()); + Some(native) +} + +/// Single-quote `path` for `/bin/sh`. +fn sh_quote(path: &Path) -> Option { + let s = path.to_str()?; + Some(format!("'{}'", s.replace('\'', r"'\''"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::platform::host::{HostArch, HostOs}; + + const LINUX_X64: HostPlatform = HostPlatform::new(HostOs::Linux, HostArch::X86_64); + const WINDOWS: HostPlatform = HostPlatform::new(HostOs::Windows, HostArch::X86_64); + + fn hello() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("data/ape-hello/hello.com") + } + + #[test] + fn shim_keeps_name_runs_original_image_and_exposes_ape_on_path() { + if host::current().is_windows() { + return; + } + let cache = tempfile::tempdir().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let image = dir.path().join("it's x86_64-linux-cosmo-gcc"); + std::fs::copy(hello(), &image).unwrap(); + let shim = native_executable_for( + host::current(), + &image, + &[cache.path().to_path_buf()], + None, + None, + ) + .expect("shim"); + assert_eq!(shim.file_name(), image.file_name()); + assert!(shim.starts_with(cache.path())); + assert!(!is_ape_file(&shim), "the shim itself is a plain script"); + let text = std::fs::read_to_string(&shim).unwrap(); + assert!(text.starts_with("#!/bin/sh\nPATH="), "{text}"); + // Spawned directly — no fbuild loader routing involved. + let out = crate::subprocess::run_command_blocking_retrying_exec_busy( + &[shim.to_str().unwrap(), "via", "shim"], + None, + Some(&[("PATH", "/nonexistent")]), + None, + ) + .unwrap(); + assert_eq!( + out.stdout, "hello world via shim\n", + "stderr: {}", + out.stderr + ); + // Memoized and stable. + assert_eq!( + native_executable_for( + host::current(), + &image, + &[cache.path().to_path_buf()], + None, + None + ), + Some(shim) + ); + } + + #[test] + fn windows_non_ape_and_unusable_cache_yield_none() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!( + native_executable_for(WINDOWS, &hello(), &[dir.path().to_path_buf()], None, None), + None + ); + let script = dir.path().join("tool"); + std::fs::write(&script, "#!/bin/sh\n").unwrap(); + assert_eq!( + native_executable_for(LINUX_X64, &script, &[dir.path().to_path_buf()], None, None), + None + ); + let file_parent = dir.path().join("f"); + std::fs::write(&file_parent, "").unwrap(); + assert_eq!( + native_executable_for( + LINUX_X64, + &hello(), + &[file_parent.join("cache")], + None, + None + ), + None + ); + } +} diff --git a/crates/fbuild-core/src/platform/ape/prologue.rs b/crates/fbuild-core/src/platform/ape/prologue.rs new file mode 100644 index 000000000..cf23ed924 --- /dev/null +++ b/crates/fbuild-core/src/platform/ape/prologue.rs @@ -0,0 +1,101 @@ +//! Parsing of the APE shell prologue: where an image keeps its per-CPU +//! loaders and the macOS loader source. +//! +//! cosmocc emits the same line shapes for every image; only the numbers +//! change. Recognized lines, keyed by the enclosing `if [ "$m" = ]`: +//! +//! * `dd if="$o" skip=N count=M … | gzip -dc >"$t.$$"` — a gzip'd loader. +//! When the next line patches it (`dd if="$t.$$" of="$t.$$" skip=5 count=8 +//! bs=64`) it is the macOS x86_64 loader (ELF blob turned Mach-O), otherwise +//! the Linux loader for the CPU. +//! * `dd if="$o" skip=N count=M … | gzip -dc >"$t.c.$$"` — the gzip'd C +//! source of the Apple Silicon loader (`ape-m1.c`), compiled with `cc`. + +use super::super::host::HostArch; + +/// Byte range `(skip, count)` within the image. +pub(crate) type Range = (u64, u64); + +/// Everything fbuild needs from one image's prologue. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(crate) struct Prologue { + pub linux_loader_x86_64: Option, + pub linux_loader_aarch64: Option, + pub macos_loader_x86_64: Option, + pub macos_loader_source_aarch64: Option, +} + +impl Prologue { + pub(crate) fn linux_loader(&self, arch: HostArch) -> Option { + match arch { + HostArch::X86_64 => self.linux_loader_x86_64, + HostArch::Aarch64 => self.linux_loader_aarch64, + _ => None, + } + } +} + +/// Parse the prologue text at the start of an APE image. +pub(crate) fn parse(prologue: &[u8]) -> Prologue { + let text = String::from_utf8_lossy(prologue); + let mut out = Prologue::default(); + let mut cpu = None; + let mut lines = text.lines().map(str::trim).peekable(); + while let Some(line) = lines.next() { + if line.contains("\"$m\" = x86_64") { + cpu = Some(HostArch::X86_64); + } else if line.contains("\"$m\" = aarch64") { + cpu = Some(HostArch::Aarch64); + } + let Some(arch) = cpu else { continue }; + + if line.starts_with("dd if=\"$o\" ") && line.contains("| gzip -dc >\"$t.c.$$\"") { + if arch == HostArch::Aarch64 { + out.macos_loader_source_aarch64 = range(line); + } + } else if line.starts_with("dd if=\"$o\" ") && line.contains("| gzip -dc >\"$t.$$\"") { + let patched = lines + .peek() + .is_some_and(|next| next.starts_with("dd if=\"$t.$$\" of=\"$t.$$\"")); + match (arch, patched) { + (HostArch::X86_64, true) => out.macos_loader_x86_64 = range(line), + (HostArch::X86_64, false) => out.linux_loader_x86_64 = range(line), + (HostArch::Aarch64, false) => out.linux_loader_aarch64 = range(line), + _ => {} + } + } + } + out +} + +fn range(line: &str) -> Option { + let field = |key: &str| { + line.split_whitespace() + .find_map(|tok| tok.strip_prefix(key)) + .and_then(|v| v.parse::().ok()) + }; + Some((field("skip=")?, field("count=")?)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fixture_prologue_yields_every_branch() { + let image = std::fs::read( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("data/ape-hello/hello.com"), + ) + .unwrap(); + let p = parse(&image[..64 * 1024]); + assert_eq!(p.linux_loader_x86_64, Some((275392, 4180))); + assert_eq!(p.linux_loader_aarch64, Some((279572, 4928))); + assert_eq!(p.macos_loader_x86_64, Some((275392, 4180))); + assert_eq!(p.macos_loader_source_aarch64, Some((284500, 10590))); + } + + #[test] + fn garbage_parses_to_nothing() { + assert_eq!(parse(b"MZqFpD='\n\0\xff\xfe random\n"), Prologue::default()); + } +} diff --git a/crates/fbuild-core/src/platform/executable.rs b/crates/fbuild-core/src/platform/executable.rs index 6f39ce032..e9060e581 100644 --- a/crates/fbuild-core/src/platform/executable.rs +++ b/crates/fbuild-core/src/platform/executable.rs @@ -3,7 +3,7 @@ use super::host::{self, HostArch, HostPlatform}; use crate::path::NormalizedPath; use std::io; -use std::path::{Component, Path}; +use std::path::{Component, Path, PathBuf}; /// Select the spelling of an executable or command script for an explicit host. pub const fn name_for<'a>(host: HostPlatform, non_windows: &'a str, windows: &'a str) -> &'a str { @@ -51,6 +51,116 @@ pub fn path_candidate_names(stem: &str) -> Vec { path_candidate_names_for(host::current(), stem) } +/// Return the ordered file names probed for a package-provided host tool. +/// +/// The host-native spelling comes first, then the cosmocc default `.com` +/// suffix, then the other host's spelling. Non-native spellings are only +/// accepted by [`find_tool_in_for`] when the file is an APE image, which runs +/// on every host. +pub fn tool_candidate_names_for(host: HostPlatform, stem: &str) -> Vec { + if host.is_windows() { + vec![ + format!("{stem}.exe"), + format!("{stem}.com"), + stem.to_owned(), + ] + } else { + vec![ + stem.to_owned(), + format!("{stem}.com"), + format!("{stem}.exe"), + ] + } +} + +/// Return the ordered file names probed for a host tool on the current host. +pub fn tool_candidate_names(stem: &str) -> Vec { + tool_candidate_names_for(host::current(), stem) +} + +/// Find a package-provided tool named `stem` in `dir` for an explicit host. +/// +/// Returns the first candidate from [`tool_candidate_names_for`] that is a +/// regular file and runnable on `host`: the host-native spelling is always +/// accepted, any other spelling only when it is an APE image (so a +/// Windows-only PE `tool.exe` is not picked on Linux, and an extensionless +/// shell script is not picked on Windows). +pub fn find_tool_in_for(host: HostPlatform, dir: &Path, stem: &str) -> Option { + let native = native_name_for(host, stem); + tool_candidate_names_for(host, stem) + .into_iter() + .map(|name| { + let is_native = name == native; + (dir.join(name), is_native) + }) + .find(|(path, is_native)| path.is_file() && (*is_native || super::ape::is_ape_file(path))) + .map(|(path, _)| path) +} + +/// Find a package-provided tool named `stem` in `dir` on the current host. +pub fn find_tool_in(dir: &Path, stem: &str) -> Option { + find_tool_in_for(host::current(), dir, stem) +} + +/// Find a tool named `stem` anywhere below `root` (depth-first), applying +/// [`find_tool_in`]'s acceptance rules in each directory. Used for archives +/// whose binary sits under an unknown nesting of top-level folders. +pub fn find_tool_in_tree(root: &Path, stem: &str) -> Option { + if let Some(found) = find_tool_in(root, stem) { + return Some(found); + } + std::fs::read_dir(root) + .ok()? + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .find_map(|dir| find_tool_in_tree(&dir, stem)) +} + +/// Find a tool on `PATH` (or any list of search directories) by stem, using +/// [`find_tool_in`]'s per-directory candidate order and acceptance rules. +pub fn find_tool_on_paths(dirs: I, stem: &str) -> Option +where + I: IntoIterator, + I::Item: AsRef, +{ + dirs.into_iter() + .find_map(|dir| find_tool_in(dir.as_ref(), stem)) +} + +/// Resolve a package-provided tool in `dir` for an explicit host, falling back +/// to the host-native spelling when no acceptable candidate exists. +pub fn resolve_tool_in_for(host: HostPlatform, dir: &Path, stem: &str) -> PathBuf { + find_tool_in_for(host, dir, stem).unwrap_or_else(|| dir.join(native_name_for(host, stem))) +} + +/// Resolve a package-provided tool in `dir` on the current host. +/// +/// Returns [`find_tool_in`]'s hit, or `dir/` when nothing usable +/// exists so callers keep reporting the conventional missing path. +pub fn resolve_tool_in(dir: &Path, stem: &str) -> PathBuf { + resolve_tool_in_for(host::current(), dir, stem) +} + +/// Split a host-tool file name into its stem and executable suffix. +/// +/// Recognizes `.exe` and `.com` (case-insensitive); the returned suffix keeps +/// its leading dot and original case, or is empty when there is none. +pub fn split_tool_suffix(file_name: &str) -> (&str, &str) { + for suffix in [".exe", ".com"] { + let Some(split) = file_name.len().checked_sub(suffix.len()) else { + continue; + }; + if split > 0 + && file_name.is_char_boundary(split) + && file_name[split..].eq_ignore_ascii_case(suffix) + { + return (&file_name[..split], &file_name[split..]); + } + } + (file_name, "") +} + /// Discover the path of the currently running executable image. pub fn current_image() -> io::Result { std::env::current_exe().map(NormalizedPath::from) @@ -112,6 +222,115 @@ mod tests { assert_eq!(super::path_candidate_names_for(linux, "pio"), ["pio"]); } + const WINDOWS: HostPlatform = HostPlatform::new(HostOs::Windows, HostArch::X86_64); + const LINUX: HostPlatform = HostPlatform::new(HostOs::Linux, HostArch::X86_64); + const MACOS_ARM: HostPlatform = HostPlatform::new(HostOs::Macos, HostArch::Aarch64); + + fn write_ape(path: &std::path::Path) { + std::fs::write(path, b"MZqFpD='\n#!/bin/sh\n").expect("write fake APE"); + } + + fn write_plain(path: &std::path::Path, bytes: &[u8]) { + std::fs::write(path, bytes).expect("write plain file"); + } + + #[test] + fn tool_candidate_names_put_the_native_spelling_first_then_com() { + assert_eq!( + super::tool_candidate_names_for(WINDOWS, "gcc"), + ["gcc.exe", "gcc.com", "gcc"] + ); + assert_eq!( + super::tool_candidate_names_for(LINUX, "gcc"), + ["gcc", "gcc.com", "gcc.exe"] + ); + assert_eq!( + super::tool_candidate_names_for(MACOS_ARM, "gcc"), + ["gcc", "gcc.com", "gcc.exe"] + ); + } + + #[test] + fn find_tool_prefers_the_native_spelling_over_an_ape() { + let dir = tempfile::tempdir().unwrap(); + write_plain(&dir.path().join("tool"), b"\x7fELF"); + write_ape(&dir.path().join("tool.com")); + assert_eq!( + super::find_tool_in_for(LINUX, dir.path(), "tool"), + Some(dir.path().join("tool")) + ); + } + + #[test] + fn find_tool_accepts_ape_com_and_exe_on_unix_hosts() { + let dir = tempfile::tempdir().unwrap(); + write_ape(&dir.path().join("tool.com")); + assert_eq!( + super::find_tool_in_for(LINUX, dir.path(), "tool"), + Some(dir.path().join("tool.com")) + ); + + let dir = tempfile::tempdir().unwrap(); + write_ape(&dir.path().join("tool.exe")); + assert_eq!( + super::find_tool_in_for(MACOS_ARM, dir.path(), "tool"), + Some(dir.path().join("tool.exe")) + ); + } + + #[test] + fn find_tool_refuses_a_non_ape_windows_pe_on_unix_hosts() { + let dir = tempfile::tempdir().unwrap(); + write_plain(&dir.path().join("tool.exe"), b"MZ\x90\x00\x03\x00\x00\x00"); + assert_eq!(super::find_tool_in_for(LINUX, dir.path(), "tool"), None); + assert_eq!( + super::resolve_tool_in_for(LINUX, dir.path(), "tool"), + dir.path().join("tool") + ); + } + + #[test] + fn find_tool_on_windows_prefers_exe_then_ape_com_and_refuses_plain_scripts() { + let dir = tempfile::tempdir().unwrap(); + write_plain(&dir.path().join("tool"), b"#!/bin/sh\n"); + assert_eq!(super::find_tool_in_for(WINDOWS, dir.path(), "tool"), None); + assert_eq!( + super::resolve_tool_in_for(WINDOWS, dir.path(), "tool"), + dir.path().join("tool.exe") + ); + + write_ape(&dir.path().join("tool.com")); + assert_eq!( + super::find_tool_in_for(WINDOWS, dir.path(), "tool"), + Some(dir.path().join("tool.com")) + ); + + write_plain(&dir.path().join("tool.exe"), b"MZ\x90\x00"); + assert_eq!( + super::find_tool_in_for(WINDOWS, dir.path(), "tool"), + Some(dir.path().join("tool.exe")) + ); + } + + #[test] + fn find_tool_accepts_an_extensionless_ape_on_windows_and_skips_directories() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("tool.exe")).unwrap(); + write_ape(&dir.path().join("tool")); + assert_eq!( + super::find_tool_in_for(WINDOWS, dir.path(), "tool"), + Some(dir.path().join("tool")) + ); + } + + #[test] + fn split_tool_suffix_recognizes_exe_and_com() { + assert_eq!(super::split_tool_suffix("gcc.exe"), ("gcc", ".exe")); + assert_eq!(super::split_tool_suffix("gcc.COM"), ("gcc", ".COM")); + assert_eq!(super::split_tool_suffix("gcc"), ("gcc", "")); + assert_eq!(super::split_tool_suffix(".exe"), (".exe", "")); + } + #[test] fn current_image_and_sibling_discovery_share_the_same_parent() { let image = super::current_image().expect("current test image"); diff --git a/crates/fbuild-core/src/platform/fs.rs b/crates/fbuild-core/src/platform/fs.rs index 87d22e61d..0c1aaed28 100644 --- a/crates/fbuild-core/src/platform/fs.rs +++ b/crates/fbuild-core/src/platform/fs.rs @@ -74,6 +74,23 @@ pub fn set_executable(path: &Path) -> std::io::Result<()> { super::selected::fs::set_executable(path) } +/// Return whether metadata carries execute permission where the host represents it. +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/ape.rs b/crates/fbuild-core/src/platform/linux/ape.rs new file mode 100644 index 000000000..5bf7f96bf --- /dev/null +++ b/crates/fbuild-core/src/platform/linux/ape.rs @@ -0,0 +1,204 @@ +//! Linux pieces of APE support: default cache directories and the sealed +//! `memfd` fallback used when no cache directory is usable (read-only home, +//! `noexec` `/tmp`, no runtime dir). The memfd is held open for the life of +//! the process and exec'd via `/proc/self/fd/N`. + +use std::collections::HashMap; +use std::ffi::CString; +use std::fs::File; +use std::io::Write; +use std::os::fd::{AsRawFd, FromRawFd}; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::sync::Mutex; + +/// Default candidate directories, most durable first. +pub(crate) fn default_loader_dirs() -> Vec { + let env_dir = |key: &str| std::env::var_os(key).filter(|v| !v.is_empty()).map(PathBuf::from); + let mut dirs = Vec::new(); + if let Some(cache) = env_dir("XDG_CACHE_HOME").or_else(|| env_dir("HOME").map(|h| h.join(".cache"))) { + dirs.push(cache.join("fbuild").join("ape")); + } + if let Some(runtime) = env_dir("XDG_RUNTIME_DIR") { + dirs.push(runtime.join("fbuild").join("ape")); + } + // SAFETY: geteuid has no preconditions and cannot fail. + let uid = unsafe { libc::geteuid() }; + dirs.push(std::env::temp_dir().join(format!("fbuild-ape-{uid}"))); + dirs +} + +/// Last-resort executable with no filesystem home: a sealed memfd exec'd via +/// `/proc/self/fd/N`. Valid only for direct children of this process. +pub(crate) fn anonymous_executable(bytes: &[u8], name: &str) -> Option { + memfd_loader(bytes, name) +} + +/// Sealed memfds kept open for the process lifetime, keyed by loader name. +static MEMFDS: Mutex>> = Mutex::new(None); + +fn memfd_loader(bytes: &[u8], name: &str) -> Option { + let mut guard = MEMFDS.lock().unwrap_or_else(|e| e.into_inner()); + let fds = guard.get_or_insert_with(HashMap::new); + if let Some(file) = fds.get(name) { + return Some(fd_path(file)); + } + let file = { + // The memfd is writable until sealed; keep it out of forked children. + let _fork = crate::platform::process::exclusive_fork_guard(); + create_sealed_memfd(bytes, name)? + }; + let path = fd_path(&file); + // The child resolves this path before close-on-exec runs; without /proc + // it cannot, so refuse rather than hand out a dead path. + if !path.exists() { + return None; + } + fds.insert(name.to_owned(), file); + Some(path) +} + +fn create_sealed_memfd(bytes: &[u8], name: &str) -> Option { + let cname = CString::new(name).ok()?; + let base = libc::MFD_CLOEXEC | libc::MFD_ALLOW_SEALING; + // MFD_EXEC (Linux 6.3+) keeps the memfd executable under + // `vm.memfd_noexec=1`; older kernels reject the flag with EINVAL. + let fd = [base | libc::MFD_EXEC, base].into_iter().find_map(|flags| { + // Raw syscall, not the libc wrapper: release builds link glibc 2.17, + // which predates `memfd_create()` (added in glibc 2.27). + // SAFETY: `cname` is NUL-terminated; flags are valid memfd flags. + let fd = unsafe { libc::syscall(libc::SYS_memfd_create, cname.as_ptr(), flags) }; + (fd >= 0).then_some(fd as libc::c_int) + })?; + // SAFETY: `fd` is a freshly created descriptor owned by nobody else. + let mut file = unsafe { File::from_raw_fd(fd) }; + file.write_all(bytes).ok()?; + file.set_permissions(std::fs::Permissions::from_mode(0o500)) + .ok()?; + let seals = libc::F_SEAL_SHRINK | libc::F_SEAL_GROW | libc::F_SEAL_WRITE | libc::F_SEAL_SEAL; + // SAFETY: `file` owns a valid memfd created with MFD_ALLOW_SEALING. + if unsafe { libc::fcntl(file.as_raw_fd(), libc::F_ADD_SEALS, seals) } != 0 { + return None; + } + Some(file) +} + +fn fd_path(file: &File) -> PathBuf { + PathBuf::from(format!("/proc/self/fd/{}", file.as_raw_fd())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::platform::ape::install::install_in; + use crate::platform::host::HostArch; + use std::fs::OpenOptions; + use std::path::Path; + + fn hello() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("data/ape-hello/hello.com") + } + + fn host_loader() -> Vec { + let arch = crate::platform::host::current().arch(); + crate::platform::ape::extract_loader(&hello(), arch).expect("fixture embeds host loader") + } + + fn run(loader: &Path, args: &[&str]) -> crate::subprocess::ToolOutput { + let hello = hello(); + let mut argv = vec![loader.to_str().unwrap(), hello.to_str().unwrap()]; + argv.extend_from_slice(args); + // Hostile child env: nothing on PATH, no TMPDIR, no HOME. + crate::subprocess::run_command_blocking( + &argv, + None, + Some(&[("PATH", "/nonexistent"), ("TMPDIR", "/nonexistent"), ("HOME", "/nonexistent")]), + None, + ) + .expect("loader must spawn") + } + + #[test] + fn memfd_loader_runs_real_image() { + let loader = memfd_loader(&host_loader(), "ape-loader-test-memfd").expect("memfd"); + assert!(loader.starts_with("/proc/self/fd")); + let out = run(&loader, &["memfd"]); + assert!(out.success(), "stderr: {}", out.stderr); + assert_eq!(out.stdout, "hello world memfd\n"); + // Sealed: the running loader can't be rewritten underneath children. + assert!(OpenOptions::new().write(true).open(&loader).and_then(|mut f| f.write_all(b"x")).is_err()); + } + + #[test] + fn unusable_dirs_fall_back_to_memfd() { + let root = tempfile::tempdir().unwrap(); + // A regular file as parent: uncreatable even for root, unlike a 0500 + // dir (root bypasses DAC permissions). + let readonly = root.path().join("not-a-dir"); + std::fs::write(&readonly, b"").unwrap(); + let shared = root.path().join("shared"); + std::fs::create_dir(&shared).unwrap(); + std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o777)).unwrap(); + let dirs = [readonly.join("cache"), shared.clone(), PathBuf::from("/proc/fbuild-nope")]; + let loader = + crate::platform::ape::materialize(&host_loader(), "ape-loader-test-fallback", &dirs).unwrap(); + assert!(loader.starts_with("/proc/self/fd"), "got {}", loader.display()); + assert!(std::fs::read_dir(&shared).unwrap().next().is_none(), "nothing planted in shared dir"); + let out = run(&loader, &["fallback"]); + assert_eq!(out.stdout, "hello world fallback\n", "stderr: {}", out.stderr); + } + + #[test] + fn symlinked_cache_dir_is_rejected() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("real")).unwrap(); + std::os::unix::fs::symlink(root.path().join("real"), root.path().join("link")).unwrap(); + assert!(install_in(&root.path().join("link"), &host_loader(), "l").is_none()); + } + + #[test] + fn tampered_or_truncated_install_is_replaced() { + let dir = tempfile::tempdir().unwrap(); + let bytes = host_loader(); + let path = install_in(dir.path(), &bytes, "ape-loader-t").expect("install"); + for bad in [&b"#!/bin/sh\nexit 66\n"[..], &bytes[..bytes.len() / 2]] { + std::fs::write(&path, bad).unwrap(); + assert_eq!(install_in(dir.path(), &bytes, "ape-loader-t"), Some(path.clone())); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + let out = run(&path, &["repaired"]); + assert_eq!(out.stdout, "hello world repaired\n", "stderr: {}", out.stderr); + } + // Lost exec bit is also repaired. + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + install_in(dir.path(), &bytes, "ape-loader-t").unwrap(); + assert_ne!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o100, 0); + } + + #[test] + fn concurrent_installs_converge_without_partial_files() { + let dir = tempfile::tempdir().unwrap(); + let cache = dir.path().join("fresh/ape"); + let bytes = host_loader(); + let paths: Vec<_> = std::thread::scope(|s| { + let handles: Vec<_> = (0..32) + .map(|_| s.spawn(|| install_in(&cache, &bytes, "ape-loader-race"))) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + assert!(paths.iter().all(|p| p.as_deref() == Some(cache.join("ape-loader-race").as_path()))); + let entries: Vec<_> = std::fs::read_dir(&cache).unwrap().map(|e| e.unwrap().file_name()).collect(); + assert_eq!(entries, vec![std::ffi::OsString::from("ape-loader-race")], "no stray temp files"); + assert_eq!( + std::fs::metadata(&cache).unwrap().permissions().mode() & 0o777, + 0o700 + ); + } + + #[test] + fn foreign_arch_loader_is_valid_but_not_host_runnable_choice() { + // Both CPUs are present in the fat fixture; only the host one is used. + assert!(crate::platform::ape::extract_loader(&hello(), HostArch::X86_64).is_some()); + assert!(crate::platform::ape::extract_loader(&hello(), HostArch::Aarch64).is_some()); + assert!(crate::platform::ape::extract_loader(&hello(), HostArch::Arm).is_none()); + } +} diff --git a/crates/fbuild-core/src/platform/linux/fs.rs b/crates/fbuild-core/src/platform/linux/fs.rs index 912184b12..61cd782fc 100644 --- a/crates/fbuild-core/src/platform/linux/fs.rs +++ b/crates/fbuild-core/src/platform/linux/fs.rs @@ -45,6 +45,38 @@ 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 +} + pub(crate) fn ensure_executable(path: &Path) -> std::io::Result<()> { let permissions = std::fs::metadata(path)?.permissions(); if 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 4c8bef393..4910d2f2d 100644 --- a/crates/fbuild-core/src/platform/linux/mod.rs +++ b/crates/fbuild-core/src/platform/linux/mod.rs @@ -1,5 +1,6 @@ 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/ape.rs b/crates/fbuild-core/src/platform/macos/ape.rs new file mode 100644 index 000000000..35c1eccb3 --- /dev/null +++ b/crates/fbuild-core/src/platform/macos/ape.rs @@ -0,0 +1,29 @@ +//! macOS pieces of APE support: default cache directories. macOS has no +//! anonymous-executable fallback (no memfd), so a usable cache directory is +//! required; `$TMPDIR` is already per-user there. + +use std::path::PathBuf; + +/// Default candidate directories, most durable first. +pub(crate) fn default_loader_dirs() -> Vec { + let env_dir = |key: &str| { + std::env::var_os(key) + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + }; + let mut dirs = Vec::new(); + if let Some(cache) = env_dir("XDG_CACHE_HOME") { + dirs.push(cache.join("fbuild").join("ape")); + } + if let Some(home) = env_dir("HOME") { + dirs.push(home.join("Library").join("Caches").join("fbuild").join("ape")); + } + // SAFETY: geteuid has no preconditions and cannot fail. + let uid = unsafe { libc::geteuid() }; + dirs.push(std::env::temp_dir().join(format!("fbuild-ape-{uid}"))); + dirs +} + +pub(crate) fn anonymous_executable(_bytes: &[u8], _name: &str) -> Option { + None +} diff --git a/crates/fbuild-core/src/platform/macos/fs.rs b/crates/fbuild-core/src/platform/macos/fs.rs index 0f870ed41..b9922d1fa 100644 --- a/crates/fbuild-core/src/platform/macos/fs.rs +++ b/crates/fbuild-core/src/platform/macos/fs.rs @@ -45,6 +45,38 @@ 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 +} + pub(crate) fn ensure_executable(path: &Path) -> std::io::Result<()> { let permissions = std::fs::metadata(path)?.permissions(); if 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 c86cf5e6e..de4eaed90 100644 --- a/crates/fbuild-core/src/platform/macos/mod.rs +++ b/crates/fbuild-core/src/platform/macos/mod.rs @@ -1,5 +1,6 @@ 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/mod.rs b/crates/fbuild-core/src/platform/mod.rs index cc2f0b6a9..e1ec9e0e1 100644 --- a/crates/fbuild-core/src/platform/mod.rs +++ b/crates/fbuild-core/src/platform/mod.rs @@ -4,6 +4,7 @@ //! Callers use the capability modules and cannot name the private concrete //! implementation selected below. +pub mod ape; pub mod device; pub mod executable; pub mod fs; diff --git a/crates/fbuild-core/src/platform/process.rs b/crates/fbuild-core/src/platform/process.rs index 7396fff63..362480fb1 100644 --- a/crates/fbuild-core/src/platform/process.rs +++ b/crates/fbuild-core/src/platform/process.rs @@ -1,8 +1,9 @@ //! Neutral process, containment, and exit-interpretation APIs. +use std::ffi::OsStr; use std::fs::File; use std::process::{ChildStderr, ChildStdin, ChildStdout, Command, ExitStatus}; -use std::sync::OnceLock; +use std::sync::{OnceLock, RwLock, RwLockReadGuard, RwLockWriteGuard}; use std::time::{Duration, Instant}; use crate::path::NormalizedPath; @@ -102,6 +103,69 @@ 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(()); + +fn 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()) +} + +/// Construct a [`Command`] for `program`, routing an APE (cosmocc) image +/// through a loader on hosts that cannot exec APE natively (see +/// [`super::ape`]). Use in place of `Command::new` for any external tool. +/// +/// A relative `program` path is resolved against the parent's working +/// directory; pass an absolute path when the child's `current_dir` differs. +pub fn command(program: impl AsRef) -> Command { + match super::ape::plan_launch(program.as_ref(), None, None) { + Some(launch) => { + let mut command = Command::new(&launch.loader); + command.arg(&launch.image); + if let Some(path) = launch.child_path(std::env::var_os("PATH").as_deref()) { + command.env("PATH", path); + } + command + } + None => Command::new(program), + } +} + +/// Tokio counterpart of [`command`]. +pub fn tokio_command(program: impl AsRef) -> tokio::process::Command { + match super::ape::plan_launch(program.as_ref(), None, None) { + Some(launch) => { + let mut command = tokio::process::Command::new(&launch.loader); + command.arg(&launch.image); + if let Some(path) = launch.child_path(std::env::var_os("PATH").as_deref()) { + command.env("PATH", path); + } + command + } + None => tokio::process::Command::new(program), + } +} + +/// APE launch plan for a `run_command`-style spawn with its child cwd and env overlay. +pub(crate) fn ape_launch( + program: &str, + cwd: Option<&std::path::Path>, + overlay: Option<&[(&str, &str)]>, +) -> Option { + super::ape::plan_launch(OsStr::new(program), cwd, overlay) +} + /// Spawn a synchronous child using running-process's contained process tree. pub fn spawn_contained( command: &mut Command, @@ -113,9 +177,12 @@ pub fn spawn_contained( stderr: into_running_stdio(stdio.stderr), ..running_process::SpawnStdio::default() }; - let inner = match CONTAINMENT.get() { - Some(group) => group.spawn(command, stdio)?, - None => running_process::spawn(command, stdio)?, + let inner = { + let _fork = fork_guard(); + match CONTAINMENT.get() { + Some(group) => group.spawn(command, stdio)?, + None => running_process::spawn(command, stdio)?, + } }; Ok(ContainedChild { inner }) } @@ -138,6 +205,7 @@ pub fn spawn_detached( stderr: Option<&File>, environment: DetachedEnvironment, ) -> std::io::Result { + let _fork = fork_guard(); super::selected::process::spawn_detached(command, stderr, environment) } @@ -152,8 +220,10 @@ pub fn spawn_tokio_contained( } super::selected::process::configure_tokio_owner_death(command)?; } - let child = - running_process::spawn_tokio(command, running_process::TokioSpawnOptions::default())?; + let child = { + let _fork = fork_guard(); + 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/ape.rs b/crates/fbuild-core/src/platform/windows/ape.rs new file mode 100644 index 000000000..4cd1faf72 --- /dev/null +++ b/crates/fbuild-core/src/platform/windows/ape.rs @@ -0,0 +1,12 @@ +//! Windows runs APE images natively (an APE file is a valid PE), so there is +//! nothing to extract or cache. + +use std::path::PathBuf; + +pub(crate) fn default_loader_dirs() -> Vec { + Vec::new() +} + +pub(crate) fn anonymous_executable(_bytes: &[u8], _name: &str) -> Option { + None +} diff --git a/crates/fbuild-core/src/platform/windows/fs.rs b/crates/fbuild-core/src/platform/windows/fs.rs index 3fe10626d..973b52129 100644 --- a/crates/fbuild-core/src/platform/windows/fs.rs +++ b/crates/fbuild-core/src/platform/windows/fs.rs @@ -52,6 +52,19 @@ 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 +} + pub(crate) fn ensure_executable(_path: &Path) -> std::io::Result<()> { Ok(()) } diff --git a/crates/fbuild-core/src/platform/windows/mod.rs b/crates/fbuild-core/src/platform/windows/mod.rs index bbd714bcf..612a832f5 100644 --- a/crates/fbuild-core/src/platform/windows/mod.rs +++ b/crates/fbuild-core/src/platform/windows/mod.rs @@ -1,5 +1,6 @@ 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/platformio_package.rs b/crates/fbuild-core/src/platformio_package.rs index 9e9d776eb..a502c47a1 100644 --- a/crates/fbuild-core/src/platformio_package.rs +++ b/crates/fbuild-core/src/platformio_package.rs @@ -520,6 +520,8 @@ struct RegistryVersion { #[derive(Deserialize)] struct RegistryFile { + /// Host selector; a missing or `null` field means universal, like `"*"`. + #[serde(default)] system: serde_json::Value, download_url: String, checksum: Option, @@ -559,6 +561,7 @@ fn file_matches(file: &RegistryFile, system: &str) -> Option { match &file.system { serde_json::Value::String(s) if s == system => Some(2), serde_json::Value::String(s) if s == "*" => Some(1), + serde_json::Value::Null => Some(1), serde_json::Value::Array(systems) if systems.iter().any(|s| s.as_str() == Some(system)) => { Some(2) } diff --git a/crates/fbuild-core/src/subprocess.rs b/crates/fbuild-core/src/subprocess.rs index be7a7bdc9..4fec7155e 100644 --- a/crates/fbuild-core/src/subprocess.rs +++ b/crates/fbuild-core/src/subprocess.rs @@ -605,7 +605,38 @@ fn build_command( capture: bool, stdin_piped: bool, ) -> Result { - let mut cmd = TokioCommand::new(args[0]); + // 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); + let mut cmd = match &launch { + Some(launch) => { + let mut cmd = TokioCommand::new(&launch.loader); + cmd.arg(&launch.image); + cmd + } + None => TokioCommand::new(args[0]), + }; + // Expose fbuild's loader as `ape` on the child's PATH so APE programs the + // tool spawns itself (gcc -> cc1) resolve a loader too. + let ape_path = launch.as_ref().and_then(|launch| { + let inherited = env + .and_then(|vars| vars.iter().rev().find(|(k, _)| *k == "PATH")) + .map(|(_, v)| std::ffi::OsString::from(*v)) + .or_else(|| std::env::var_os("PATH")); + launch.child_path(inherited.as_deref())?.into_string().ok() + }); + let overlay: Option> = match (&ape_path, env) { + (Some(path), env) => Some( + env.unwrap_or_default() + .iter() + .copied() + .filter(|(k, _)| *k != "PATH") + .chain([("PATH", path.as_str())]) + .collect(), + ), + (None, env) => env.map(<[_]>::to_vec), + }; + let env = overlay.as_deref(); if args.len() > 1 { cmd.args(&args[1..]); } diff --git a/crates/fbuild-core/tests/it/platformio_package_resolution.rs b/crates/fbuild-core/tests/it/platformio_package_resolution.rs index 35855f2d2..e565e2f05 100644 --- a/crates/fbuild-core/tests/it/platformio_package_resolution.rs +++ b/crates/fbuild-core/tests/it/platformio_package_resolution.rs @@ -139,6 +139,59 @@ fn resolves_exact_platform_payload_and_checksum() { ); } +/// A registry file with no `system` (or `"system": null`) is a host-universal +/// payload, e.g. one APE build of a tool for every host. It must parse and +/// resolve like `"*"`, while an exact host match still wins. +#[test] +fn registry_file_without_system_is_universal_and_exact_host_still_wins() { + let system = + fbuild_core::platformio_package::host_system(fbuild_core::platform::host::current()) + .unwrap_or("linux_x86_64"); + let spec = parse_package_spec("acme/tool-ape@1.0.0").unwrap(); + let sha = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + + for universal in [ + format!( + r#"{{"download_url":"https://example.test/ape.zip","checksum":{{"sha256":"{sha}"}}}}"# + ), + format!( + r#"{{"system":null,"download_url":"https://example.test/ape.zip","checksum":{{"sha256":"{sha}"}}}}"# + ), + format!( + r#"{{"system":"*","download_url":"https://example.test/ape.zip","checksum":{{"sha256":"{sha}"}}}}"# + ), + ] { + let response = format!( + r#"{{"name":"tool-ape","owner":{{"username":"acme"}}, + "versions":[{{"name":"1.0.0","files":[{universal}]}}]}}"# + ); + let payload = resolve_registry_json( + spec.registry().unwrap(), + PackageKind::Tool, + system, + &response, + ) + .unwrap_or_else(|e| panic!("{universal} should resolve on {system}: {e}")); + assert_eq!(payload.url, "https://example.test/ape.zip"); + } + + let response = format!( + r#"{{"name":"tool-ape","owner":{{"username":"acme"}}, + "versions":[{{"name":"1.0.0","files":[ + {{"download_url":"https://example.test/ape.zip","checksum":{{"sha256":"{sha}"}}}}, + {{"system":["{system}"],"download_url":"https://example.test/native.zip","checksum":{{"sha256":"{sha}"}}}} + ]}}]}}"# + ); + let payload = resolve_registry_json( + spec.registry().unwrap(), + PackageKind::Tool, + system, + &response, + ) + .unwrap(); + assert_eq!(payload.url, "https://example.test/native.zip"); +} + #[test] fn selects_host_file_and_range_without_substituting_another_build() { let response = r#"{ diff --git a/crates/fbuild-daemon/src/handlers/emulator/avr8js_headless.rs b/crates/fbuild-daemon/src/handlers/emulator/avr8js_headless.rs index d2c040b28..fbec338c3 100644 --- a/crates/fbuild-daemon/src/handlers/emulator/avr8js_headless.rs +++ b/crates/fbuild-daemon/src/handlers/emulator/avr8js_headless.rs @@ -56,7 +56,7 @@ pub(crate) async fn run_avr8js_headless( } // allow-direct-spawn: tokio streaming emulator; blocking NativeProcess unsuitable. - let mut cmd = tokio::process::Command::new(node_path); + let mut cmd = fbuild_core::platform::process::tokio_command(node_path); cmd.arg(script_path) .arg("--hex") .arg(hex_path) diff --git a/crates/fbuild-daemon/src/handlers/emulator/runners.rs b/crates/fbuild-daemon/src/handlers/emulator/runners.rs index a781230a9..28069ee20 100644 --- a/crates/fbuild-daemon/src/handlers/emulator/runners.rs +++ b/crates/fbuild-daemon/src/handlers/emulator/runners.rs @@ -240,7 +240,22 @@ impl EmulatorRunner for Avr8jsRunner { /// - macOS: `brew install simavr` /// - Windows: build from source (MSYS2/MinGW) — limited support async fn find_simavr() -> fbuild_core::Result { - let simavr = fbuild_core::platform::executable::name("simavr", "simavr.exe"); + // Prefer an explicit PATH hit so a `simavr.com` APE is found on every + // host; otherwise probe the bare native name (the OS resolves PATH). + let resolved = std::env::var_os("PATH") + .and_then(|path| { + fbuild_core::platform::executable::find_tool_on_paths( + std::env::split_paths(&path), + "simavr", + ) + }) + .map(|path| path.to_string_lossy().into_owned()); + let simavr = resolved + .as_deref() + .unwrap_or(fbuild_core::platform::executable::name( + "simavr", + "simavr.exe", + )); // Try running simavr to verify it exists; route through containment // (issue #32). This is a short-lived probe so the containment // difference is purely consistency. diff --git a/crates/fbuild-daemon/src/handlers/emulator/shared.rs b/crates/fbuild-daemon/src/handlers/emulator/shared.rs index 5229ab3a6..836e4ebd4 100644 --- a/crates/fbuild-daemon/src/handlers/emulator/shared.rs +++ b/crates/fbuild-daemon/src/handlers/emulator/shared.rs @@ -233,7 +233,7 @@ pub(crate) async fn run_qemu_process( options: RunQemuOptions<'_>, ) -> fbuild_core::Result { // allow-direct-spawn: tokio streaming QEMU emulator; blocking NativeProcess unsuitable. - let mut cmd = tokio::process::Command::new(qemu_path); + let mut cmd = fbuild_core::platform::process::tokio_command(qemu_path); cmd.args(args) .stdin(Stdio::null()) .stdout(Stdio::piped()) diff --git a/crates/fbuild-daemon/src/handlers/operations/deploy.rs b/crates/fbuild-daemon/src/handlers/operations/deploy.rs index a31462a91..3fff14772 100644 --- a/crates/fbuild-daemon/src/handlers/operations/deploy.rs +++ b/crates/fbuild-daemon/src/handlers/operations/deploy.rs @@ -21,24 +21,21 @@ use super::common::{native_verify_enabled, native_write_enabled}; /// Resolve a usable `teensy_loader_cli` binary for the Teensy deploy arm. /// /// Search order: -/// 1. `$PATH` (`teensy_loader_cli` on Unix, `teensy_loader_cli.exe` on Win) -/// 2. `~/.platformio/packages/tool-teensy/teensy_loader_cli{.exe}` — the +/// 1. `$PATH` (`teensy_loader_cli` on Unix, `teensy_loader_cli.exe` on Win, +/// or a `.com`/`.exe` APE on any host) +/// 2. `~/.platformio/packages/tool-teensy/teensy_loader_cli{.exe,.com}` — the /// well-known path PlatformIO installs it at on every PIO-using machine. /// /// Returns `None` if neither is found; the TeensyDeployer's default will then /// try a bare `teensy_loader_cli` invocation, which will surface /// `command not found` to the user — clearer than a silent abort here. fn find_teensy_loader_cli() -> Option { - let exe_name = - fbuild_core::platform::executable::name("teensy_loader_cli", "teensy_loader_cli.exe"); + use fbuild_core::platform::executable::{find_tool_in, find_tool_on_paths}; + const STEM: &str = "teensy_loader_cli"; - if let Ok(path_env) = std::env::var("PATH") { - let sep = fbuild_core::platform::host::path_list_separator(); - for dir in path_env.split(sep) { - let candidate = PathBuf::from(dir).join(exe_name); - if candidate.is_file() { - return Some(candidate); - } + if let Some(path_env) = std::env::var_os("PATH") { + if let Some(found) = find_tool_on_paths(std::env::split_paths(&path_env), STEM) { + return Some(found); } } @@ -50,18 +47,11 @@ fn find_teensy_loader_cli() -> Option { } else { std::env::var("HOME").ok() }; - if let Some(home) = pio_root { - let pio_candidate = PathBuf::from(home) - .join(".platformio") - .join("packages") - .join("tool-teensy") - .join(exe_name); - if pio_candidate.is_file() { - return Some(pio_candidate); - } - } - - None + let pio_tool_dir = PathBuf::from(pio_root?) + .join(".platformio") + .join("packages") + .join("tool-teensy"); + find_tool_in(&pio_tool_dir, STEM) } /// POST /api/deploy diff --git a/crates/fbuild-deploy/src/lpc.rs b/crates/fbuild-deploy/src/lpc.rs index 17a9256d6..b8c1f4eb1 100644 --- a/crates/fbuild-deploy/src/lpc.rs +++ b/crates/fbuild-deploy/src/lpc.rs @@ -63,13 +63,7 @@ pub fn find_lpc21isp() -> Option { } } - if let Some(managed) = managed_lpc21isp_path() { - if managed.is_file() { - return Some(managed); - } - } - - None + fbuild_core::platform::executable::find_tool_in(&fbuild_paths::try_get_tools_dir()?, "lpc21isp") } /// Build the "install lpc21isp" hint that surfaces on the failing diff --git a/crates/fbuild-deploy/src/lpc_debugger_reflash.rs b/crates/fbuild-deploy/src/lpc_debugger_reflash.rs index 05bed7bd3..6ea22f83e 100644 --- a/crates/fbuild-deploy/src/lpc_debugger_reflash.rs +++ b/crates/fbuild-deploy/src/lpc_debugger_reflash.rs @@ -105,12 +105,7 @@ fn find_dfu_util_with_override(env_override: Option) -> Opti } } let tools = managed_tools_dir()?; - let exe = fbuild_core::platform::executable::native_name("dfu-util"); - let candidate = tools.join(exe); - if candidate.is_file() { - return Some(candidate); - } - None + fbuild_core::platform::executable::find_tool_in(&tools, "dfu-util").map(NormalizedPath::from) } /// Resolve the CMSIS-DAP hex to flash. Precedence: diff --git a/crates/fbuild-deploy/src/probe_rs.rs b/crates/fbuild-deploy/src/probe_rs.rs index fa4623e0c..0482b50a8 100644 --- a/crates/fbuild-deploy/src/probe_rs.rs +++ b/crates/fbuild-deploy/src/probe_rs.rs @@ -133,7 +133,7 @@ pub fn managed_probe_rs_path() -> Option { /// Search order (first hit wins): /// /// 1. `FBUILD_PROBE_RS_PATH` — explicit override. -/// 2. `~/.fbuild/{prod|dev}/tools/probe-rs/probe-rs[.exe]` — the +/// 2. `~/.fbuild/{prod|dev}/tools/probe-rs/probe-rs[.exe|.com]` — the /// canonical fbuild-managed location populated from the pinned /// FastLED/probe-rs release. /// @@ -149,11 +149,8 @@ pub fn find_probe_rs() -> Option { } } - let managed = managed_probe_rs_path()?; - if managed.is_file() { - return Some(managed); - } - None + fbuild_core::platform::executable::find_tool_in(&managed_probe_rs_dir()?, "probe-rs") + .map(NormalizedPath::from) } /// Resolve and, if needed, install the pinned FastLED/probe-rs binary. @@ -292,34 +289,15 @@ fn probe_rs_temp_install_path(dest_path: &Path) -> NormalizedPath { } fn find_extracted_probe_rs_binary(root: &Path) -> Result { - let exe = fbuild_core::platform::executable::native_name("probe-rs"); - find_file_by_name(root, &exe).ok_or_else(|| { - FbuildError::PackageError(format!( - "probe-rs binary `{exe}` not found after extracting {}", - root.display() - )) - }) -} - -fn find_file_by_name(root: &Path, file_name: &str) -> Option { - let entries = std::fs::read_dir(root).ok()?; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_file() - && path - .file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name == file_name) - { - return Some(NormalizedPath::from(path)); - } - if path.is_dir() { - if let Some(found) = find_file_by_name(&path, file_name) { - return Some(found); - } - } - } - None + fbuild_core::platform::executable::find_tool_in_tree(root, "probe-rs") + .map(NormalizedPath::from) + .ok_or_else(|| { + let exe = fbuild_core::platform::executable::native_name("probe-rs"); + FbuildError::PackageError(format!( + "probe-rs binary `{exe}` not found after extracting {}", + root.display() + )) + }) } /// Map an fbuild `BoardConfig` to the `--chip` name probe-rs expects. diff --git a/crates/fbuild-deploy/src/wchisp.rs b/crates/fbuild-deploy/src/wchisp.rs index 9c4eb29fc..a83fc7a80 100644 --- a/crates/fbuild-deploy/src/wchisp.rs +++ b/crates/fbuild-deploy/src/wchisp.rs @@ -103,9 +103,11 @@ fn extract_wchisp(archive: &Path, staging: &Path, dest: &Path) -> Result<()> { .unpack(&extract_dir) .map_err(|e| FbuildError::PackageError(format!("extract wchisp archive: {e}")))?; } - let name = fbuild_core::platform::executable::native_name("wchisp"); - let binary = find_file(&extract_dir, &name) - .ok_or_else(|| FbuildError::PackageError(format!("wchisp archive lacks {name}")))?; + let binary = fbuild_core::platform::executable::find_tool_in_tree(&extract_dir, "wchisp") + .ok_or_else(|| { + let name = fbuild_core::platform::executable::native_name("wchisp"); + FbuildError::PackageError(format!("wchisp archive lacks {name}")) + })?; if let Some(parent) = dest.parent() { std::fs::create_dir_all(parent) .map_err(|e| FbuildError::PackageError(format!("create wchisp install dir: {e}")))?; @@ -117,21 +119,6 @@ fn extract_wchisp(archive: &Path, staging: &Path, dest: &Path) -> Result<()> { Ok(()) } -fn find_file(root: &Path, name: &str) -> Option { - for entry in std::fs::read_dir(root).ok()?.flatten() { - let path = entry.path(); - if path.is_file() && path.file_name().and_then(|n| n.to_str()) == Some(name) { - return Some(path); - } - if path.is_dir() { - if let Some(found) = find_file(&path, name) { - return Some(found); - } - } - } - None -} - /// Factory USB-ISP support excludes V003/V006, which have no USB peripheral. pub fn supports_mcu(mcu: &str) -> bool { let upper = mcu.to_ascii_uppercase(); diff --git a/crates/fbuild-deploy/src/wlink.rs b/crates/fbuild-deploy/src/wlink.rs index e17c83cf2..f526d0923 100644 --- a/crates/fbuild-deploy/src/wlink.rs +++ b/crates/fbuild-deploy/src/wlink.rs @@ -101,9 +101,11 @@ fn extract_wlink(archive: &Path, staging: &Path, dest: &Path) -> Result<()> { tar.unpack(&extract_dir) .map_err(|e| FbuildError::PackageError(format!("extract wlink archive: {e}")))?; } - let binary_name = fbuild_core::platform::executable::native_name("wlink"); - let binary = find_file(&extract_dir, &binary_name) - .ok_or_else(|| FbuildError::PackageError(format!("wlink archive lacks {binary_name}")))?; + let binary = fbuild_core::platform::executable::find_tool_in_tree(&extract_dir, "wlink") + .ok_or_else(|| { + let binary_name = fbuild_core::platform::executable::native_name("wlink"); + FbuildError::PackageError(format!("wlink archive lacks {binary_name}")) + })?; if let Some(parent) = dest.parent() { std::fs::create_dir_all(parent) .map_err(|e| FbuildError::PackageError(format!("create wlink install dir: {e}")))?; @@ -115,21 +117,6 @@ fn extract_wlink(archive: &Path, staging: &Path, dest: &Path) -> Result<()> { Ok(()) } -fn find_file(root: &Path, name: &str) -> Option { - for entry in std::fs::read_dir(root).ok()?.flatten() { - let path = entry.path(); - if path.is_file() && path.file_name().and_then(|n| n.to_str()) == Some(name) { - return Some(path); - } - if path.is_dir() { - if let Some(found) = find_file(&path, name) { - return Some(found); - } - } - } - None -} - #[derive(Debug, Clone)] pub struct WlinkDeployer { executable: PathBuf, diff --git a/crates/fbuild-library/src/library/esptool.rs b/crates/fbuild-library/src/library/esptool.rs index 9e836aef0..b02e73d34 100644 --- a/crates/fbuild-library/src/library/esptool.rs +++ b/crates/fbuild-library/src/library/esptool.rs @@ -513,7 +513,8 @@ fn host_platform_tag() -> Result<&'static str> { }) } -/// Executable name for the current platform. +/// Native executable name for the current platform (test fixtures). +#[cfg(test)] fn esptool_bin_name() -> &'static str { fbuild_core::platform::executable::name("esptool", "esptool.exe") } @@ -523,8 +524,7 @@ fn esptool_bin_name() -> &'static str { /// `esptool-/esptool`). fn find_esptool_binary(root: &Path) -> Option { fn search(dir: &Path, depth: usize) -> Option { - let candidate = dir.join(esptool_bin_name()); - if candidate.is_file() { + if let Some(candidate) = fbuild_core::platform::executable::find_tool_in(dir, "esptool") { return Some(NormalizedPath::from(candidate)); } if depth == 0 { @@ -707,6 +707,16 @@ mod tests { assert_eq!(found.as_path(), inner.join(esptool_bin_name())); } + #[test] + fn find_binary_accepts_a_nested_ape_com() { + let tmp = tempfile::TempDir::new().unwrap(); + let inner = tmp.path().join("esptool-any"); + std::fs::create_dir_all(&inner).unwrap(); + std::fs::write(inner.join("esptool.com"), b"MZqFpD='\n").unwrap(); + let found = find_esptool_binary(tmp.path()).unwrap(); + assert_eq!(found.as_path(), inner.join("esptool.com")); + } + #[test] fn find_binary_missing_returns_none() { let tmp = tempfile::TempDir::new().unwrap(); diff --git a/crates/fbuild-packages-fetch/src/extractor.rs b/crates/fbuild-packages-fetch/src/extractor.rs index f36507065..ddcce675f 100644 --- a/crates/fbuild-packages-fetch/src/extractor.rs +++ b/crates/fbuild-packages-fetch/src/extractor.rs @@ -34,6 +34,43 @@ pub fn extract(archive_path: &Path, dest_dir: &Path) -> Result<()> { } } +/// Give every APE (Actually Portable Executable) host tool under `root` an +/// exec bit, returning how many files were inspected and found to be APE. +/// +/// Zip archives built without Unix attributes (DOS-attribute or no-attribute +/// entries) extract with mode 0644, so a package shipping one `tool.com` APE +/// for every host would be unrunnable on Linux/macOS. Only regular files whose +/// first bytes carry an APE magic are touched (an 8-byte read each), symlinks +/// are never followed, and the call is a no-op on Windows. +pub fn mark_ape_executables(root: &Path) -> Result { + let mut marked = 0; + let mut pending = vec![root.to_path_buf()]; + while let Some(dir) = pending.pop() { + let entries = std::fs::read_dir(&dir).map_err(|e| { + FbuildError::PackageError(format!("failed to read {}: {}", dir.display(), e)) + })?; + for entry in entries { + let entry = entry?; + // `DirEntry::file_type` does not follow symlinks. + let file_type = entry.file_type()?; + let path = entry.path(); + if file_type.is_dir() { + pending.push(path); + } else if file_type.is_file() && fbuild_core::platform::ape::is_ape_file(&path) { + fbuild_core::platform::fs::ensure_executable(&path).map_err(|e| { + FbuildError::PackageError(format!( + "failed to mark APE {} executable: {}", + path.display(), + e + )) + })?; + marked += 1; + } + } + } + Ok(marked) +} + /// Extract a `.zip` archive. Public so callers (e.g. lnk materializer) /// can dispatch without depending on the source file's extension. pub fn extract_zip_public(archive_path: &Path, dest_dir: &Path) -> Result<()> { @@ -125,6 +162,9 @@ fn extract_zip(archive_path: &Path, dest_dir: &Path) -> Result<()> { e )) })?; + // Zip entries without Unix attributes extract without the exec bit; every + // zip caller (not just staged package installs) needs APE tools runnable. + mark_ape_executables(dest_dir)?; Ok(()) } @@ -234,6 +274,48 @@ mod tests { assert_eq!(bytes, b"hello from tar.xz"); } + #[test] + fn mark_ape_executables_marks_only_apes_and_does_not_follow_symlinks() { + use fbuild_core::platform::fs::is_executable; + + let tmp = tempfile::TempDir::new().unwrap(); + let archive = tmp.path().join("tools.zip"); + make_zip(&archive, "bin/tool.com", b"MZqFpD='\n"); + let dest = tmp.path().join("out"); + std::fs::create_dir_all(&dest).unwrap(); + extract(&archive, &dest).unwrap(); + std::fs::write(dest.join("bin/notes.txt"), b"plain").unwrap(); + let notes_before = std::fs::metadata(dest.join("bin/notes.txt")) + .unwrap() + .permissions(); + + // An APE outside the tree, reachable only through a symlink. + let outside = tmp.path().join("outside.com"); + std::fs::write(&outside, b"MZqFpD='\n").unwrap(); + let outside_before = std::fs::metadata(&outside).unwrap().permissions(); + let linked = + fbuild_core::platform::fs::symlink_file(&outside, &dest.join("bin/link.com")).is_ok(); + + let marked = mark_ape_executables(&dest).unwrap(); + assert_eq!(marked, 1, "only the in-tree regular APE is marked"); + assert!(is_executable( + &std::fs::metadata(dest.join("bin/tool.com")).unwrap() + )); + assert_eq!( + std::fs::metadata(dest.join("bin/notes.txt")) + .unwrap() + .permissions(), + notes_before + ); + if linked { + assert_eq!( + std::fs::metadata(&outside).unwrap().permissions(), + outside_before, + "symlink targets outside the tree must not be modified" + ); + } + } + #[test] fn test_extract_zip() { let tmp = tempfile::TempDir::new().unwrap(); diff --git a/crates/fbuild-packages-fetch/src/lib.rs b/crates/fbuild-packages-fetch/src/lib.rs index 48bb96806..5a8d9429e 100644 --- a/crates/fbuild-packages-fetch/src/lib.rs +++ b/crates/fbuild-packages-fetch/src/lib.rs @@ -458,6 +458,10 @@ impl PackageBase { submodules::prepare_submodules(&self.name, &self.url, &staging_path, &self.submodules) .await?; + // Zip entries without Unix attributes extract as 0644; restore the + // exec bit on APE host tools so one `tool.com` runs on every host. + extractor::mark_ape_executables(&staging_path)?; + // Validate validate(&staging_path)?; @@ -762,6 +766,72 @@ mod toolchain_gcc_ar_tests { assert!(disk_cache::paths::install_complete_sentinel(&installed).exists()); } + /// A zip whose entries carry no exec bits (the `zip` writer default is + /// 0644, like DOS-attribute archives) must still install an APE host tool + /// as executable, while leaving non-APE files exactly as extracted. + #[tokio::test] + async fn staged_install_marks_ape_tools_executable() { + use std::io::{Cursor, Write}; + use zip::write::SimpleFileOptions; + + let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); + zip.start_file("pkg/bin/tool.com", SimpleFileOptions::default()) + .unwrap(); + zip.write_all(b"MZqFpD='\n#!/bin/sh\n").unwrap(); + zip.start_file("pkg/bin/readme.txt", SimpleFileOptions::default()) + .unwrap(); + zip.write_all(b"not a tool").unwrap(); + let bytes = zip.finish().unwrap().into_inner(); + + let tmp = tempfile::TempDir::new().unwrap(); + // Plain extraction (not only staged installs) restores the APE exec + // bit too, and leaves other 0644 entries alone. + let archive = tmp.path().join("ref.zip"); + std::fs::write(&archive, &bytes).unwrap(); + let reference = tmp.path().join("ref"); + std::fs::create_dir_all(&reference).unwrap(); + extractor::extract(&archive, &reference).unwrap(); + if !fbuild_core::platform::host::current().is_windows() { + let ref_ape = std::fs::metadata(reference.join("pkg/bin/tool.com")).unwrap(); + assert!(fbuild_core::platform::fs::is_executable(&ref_ape)); + let ref_txt = std::fs::metadata(reference.join("pkg/bin/readme.txt")).unwrap(); + assert!(!fbuild_core::platform::fs::is_executable(&ref_txt)); + } + + let url = serve_once(bytes).await; + let base = PackageBase::with_cache_root( + "ape-tool", + "1.0", + &url, + "ape-tool", + None, + CacheSubdir::Toolchains, + tmp.path(), + &tmp.path().join("cache"), + ); + let installed = base + .staged_install(|staging| { + let meta = std::fs::metadata(staging.join("pkg/bin/tool.com")).unwrap(); + assert!( + fbuild_core::platform::fs::is_executable(&meta), + "APE must be executable before validation runs" + ); + Ok(()) + }) + .await + .expect("staged install should succeed"); + + let ape = std::fs::metadata(installed.join("pkg/bin/tool.com")).unwrap(); + assert!(fbuild_core::platform::fs::is_executable(&ape)); + let txt = std::fs::metadata(installed.join("pkg/bin/readme.txt")).unwrap(); + let ref_txt = std::fs::metadata(reference.join("pkg/bin/readme.txt")).unwrap(); + assert_eq!( + txt.permissions(), + ref_txt.permissions(), + "non-APE files keep their extracted permissions" + ); + } + /// A warm `staged_install` hit (install dir already exists) must count as /// a use in the DiskCache LRU, exactly like `is_cached()` does. The early /// return used to skip the touch, so a package restored from an Actions diff --git a/crates/fbuild-python/src/daemon.rs b/crates/fbuild-python/src/daemon.rs index 295d3bdd8..f5d5baec8 100644 --- a/crates/fbuild-python/src/daemon.rs +++ b/crates/fbuild-python/src/daemon.rs @@ -255,9 +255,9 @@ async fn ensure_running_async_impl( // stale user-level daemon on PATH. let mut cmd = match spawn_target { // allow-direct-spawn: construction only; execution uses platform::process::spawn_detached. - Some(path) => std::process::Command::new(path), + Some(path) => fbuild_core::platform::process::command(path), // allow-direct-spawn: construction only; execution uses platform::process::spawn_detached. - None => std::process::Command::new(DAEMON_BIN_NAME), + None => fbuild_core::platform::process::command(DAEMON_BIN_NAME), }; if dev_mode { cmd.arg("--dev"); diff --git a/crates/fbuild-serial/src/crash_decoder.rs b/crates/fbuild-serial/src/crash_decoder.rs index 8867793bf..0b89e9a44 100644 --- a/crates/fbuild-serial/src/crash_decoder.rs +++ b/crates/fbuild-serial/src/crash_decoder.rs @@ -380,25 +380,26 @@ impl CrashDecoder { /// The toolchain prefix is everything before `gcc` in the binary name: /// - `riscv32-esp-elf-gcc` → `riscv32-esp-elf-addr2line` /// - `xtensa-esp32s3-elf-gcc` → `xtensa-esp32s3-elf-addr2line` +/// +/// The compiler's executable suffix (`.exe`, or `.com` for a cosmocc APE) is +/// stripped for matching and tried first on the derived tool, so an APE +/// toolchain's `xtensa-esp32s3-elf-gcc.com` maps to +/// `xtensa-esp32s3-elf-addr2line.com`. Otherwise the usual native/APE +/// candidates are probed (see `find_tool_in`). pub fn derive_addr2line_path(cc_path: &Path) -> Option { - let name = cc_path.file_name()?.to_string_lossy(); - - // Strip .exe suffix for matching - let stem = name.replace(".exe", ""); - if !stem.ends_with("gcc") { - return None; - } + use fbuild_core::platform::executable::{find_tool_in, split_tool_suffix}; - let prefix = &stem[..stem.len() - 3]; // e.g. "riscv32-esp-elf-" - let addr2line_name = - fbuild_core::platform::executable::native_name(&format!("{prefix}addr2line")); - let addr2line = cc_path.parent()?.join(addr2line_name); + let name = cc_path.file_name()?.to_string_lossy(); + let (stem, suffix) = split_tool_suffix(&name); + let prefix = stem.strip_suffix("gcc")?; // e.g. "riscv32-esp-elf-" + let bin_dir = cc_path.parent()?; + let tool_stem = format!("{prefix}addr2line"); - if addr2line.exists() { - Some(addr2line) - } else { - None + let same_suffix = bin_dir.join(format!("{tool_stem}{suffix}")); + if same_suffix.is_file() { + return Some(same_suffix); } + find_tool_in(bin_dir, &tool_stem) } #[cfg(test)] @@ -635,6 +636,26 @@ mod tests { assert!(derive_addr2line_path(¬_gcc).is_none()); } + #[test] + fn derive_addr2line_keeps_the_com_suffix_of_an_ape_compiler() { + let dir = tempfile::tempdir().unwrap(); + let gcc = dir.path().join("xtensa-esp32s3-elf-gcc.com"); + let addr2line = dir.path().join("xtensa-esp32s3-elf-addr2line.com"); + std::fs::write(&gcc, b"MZqFpD='\n").unwrap(); + assert!(derive_addr2line_path(&gcc).is_none()); + std::fs::write(&addr2line, b"MZqFpD='\n").unwrap(); + assert_eq!(derive_addr2line_path(&gcc), Some(addr2line)); + } + + #[test] + fn derive_addr2line_from_exe_compiler_keeps_exe() { + let dir = tempfile::tempdir().unwrap(); + let gcc = dir.path().join("riscv32-esp-elf-gcc.exe"); + let addr2line = dir.path().join("riscv32-esp-elf-addr2line.exe"); + std::fs::write(&addr2line, b"MZ").unwrap(); + assert_eq!(derive_addr2line_path(&gcc), Some(addr2line)); + } + // --- Regex pattern tests --- #[test] diff --git a/crates/fbuild-toolchain/src/toolchain/arm.rs b/crates/fbuild-toolchain/src/toolchain/arm.rs index d788ea7e4..0075b6799 100644 --- a/crates/fbuild-toolchain/src/toolchain/arm.rs +++ b/crates/fbuild-toolchain/src/toolchain/arm.rs @@ -119,9 +119,7 @@ impl crate::Package for ArmToolchain { return false; } let root = find_bin_root(&self.base.install_path()); - root.join("bin") - .join(tool_name("arm-none-eabi-gcc")) - .exists() + tool_binary(&root.join("bin"), "arm-none-eabi-gcc").exists() } fn get_info(&self) -> PackageInfo { @@ -247,14 +245,9 @@ fn find_bin_root(install_dir: &Path) -> PathBuf { install_dir.to_path_buf() } -/// Get the tool binary name with .exe extension on Windows. -fn tool_name(name: &str) -> String { - fbuild_core::platform::executable::native_name(name) -} - -/// Get the full path to a tool binary. +/// Get the full path to a tool binary (native spelling, or a `.com`/`.exe` APE). fn tool_binary(bin_dir: &Path, name: &str) -> PathBuf { - bin_dir.join(tool_name(name)) + fbuild_core::platform::executable::resolve_tool_in(bin_dir, name) } #[cfg(test)] @@ -270,15 +263,6 @@ mod tests { assert!(url.contains("arm-none-eabi")); } - #[test] - fn test_tool_name_platform() { - let name = tool_name("arm-none-eabi-gcc"); - assert_eq!( - name, - fbuild_core::platform::executable::native_name("arm-none-eabi-gcc") - ); - } - #[test] fn test_find_bin_root_direct() { let tmp = tempfile::TempDir::new().unwrap(); @@ -313,6 +297,31 @@ mod tests { assert!(!tc.is_installed()); } + /// A package shipping each host tool as one APE `bin/.com` validates, + /// reports installed, and resolves every tool to the `.com` file. + #[test] + fn test_arm_toolchain_finds_ape_com_tools() { + let tmp = tempfile::TempDir::new().unwrap(); + let tc = ArmToolchain::with_cache_root(tmp.path(), &tmp.path().join("cache")); + let install = tc.base.install_path(); + let bin = install.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let tools = ["gcc", "g++", "ar", "objcopy", "size"]; + assert!(ArmToolchain::validate(&install).is_err()); + for tool in tools { + std::fs::write(bin.join(format!("arm-none-eabi-{tool}.com")), b"MZqFpD='\n").unwrap(); + } + ArmToolchain::validate(&install).expect("APE .com tools satisfy validation"); + std::fs::write( + crate::disk_cache::paths::install_complete_sentinel(&install), + "", + ) + .unwrap(); + assert!(tc.is_installed()); + assert_eq!(tc.get_gcc_path(), bin.join("arm-none-eabi-gcc.com")); + assert_eq!(tc.get_size_path(), bin.join("arm-none-eabi-size.com")); + } + /// Checksums that are present must be valid 64-char lowercase hex (SHA-256). /// Catches placeholder hashes like `a000...0002` on all platforms. #[test] diff --git a/crates/fbuild-toolchain/src/toolchain/arm_gcc8.rs b/crates/fbuild-toolchain/src/toolchain/arm_gcc8.rs index 7c820b597..e3feb4c5d 100644 --- a/crates/fbuild-toolchain/src/toolchain/arm_gcc8.rs +++ b/crates/fbuild-toolchain/src/toolchain/arm_gcc8.rs @@ -88,9 +88,7 @@ impl crate::Package for ArmGcc8Toolchain { return false; } let root = find_bin_root(&self.base.install_path()); - root.join("bin") - .join(tool_name("arm-none-eabi-gcc")) - .exists() + tool_binary(&root.join("bin"), "arm-none-eabi-gcc").exists() } fn get_info(&self) -> PackageInfo { @@ -165,12 +163,9 @@ fn find_bin_root(install_dir: &Path) -> PathBuf { install_dir.to_path_buf() } -fn tool_name(name: &str) -> String { - fbuild_core::platform::executable::native_name(name) -} - +/// Get the full path to a tool binary (native spelling, or a `.com`/`.exe` APE). fn tool_binary(bin_dir: &Path, name: &str) -> PathBuf { - bin_dir.join(tool_name(name)) + fbuild_core::platform::executable::resolve_tool_in(bin_dir, name) } #[cfg(test)] diff --git a/crates/fbuild-toolchain/src/toolchain/avr.rs b/crates/fbuild-toolchain/src/toolchain/avr.rs index 5afcf2215..baeabd0c4 100644 --- a/crates/fbuild-toolchain/src/toolchain/avr.rs +++ b/crates/fbuild-toolchain/src/toolchain/avr.rs @@ -126,7 +126,7 @@ impl crate::Package for AvrToolchain { return false; } let root = find_bin_root(&self.base.install_path()); - root.join("bin").join(tool_name("avr-gcc")).exists() + tool_binary(&root.join("bin"), "avr-gcc").exists() } fn get_info(&self) -> PackageInfo { @@ -255,14 +255,9 @@ fn find_bin_root(install_dir: &Path) -> PathBuf { install_dir.to_path_buf() } -/// Get the tool binary name with .exe extension on Windows. -fn tool_name(name: &str) -> String { - fbuild_core::platform::executable::native_name(name) -} - -/// Get the full path to a tool binary. +/// Get the full path to a tool binary (native spelling, or a `.com`/`.exe` APE). fn tool_binary(bin_dir: &Path, name: &str) -> PathBuf { - bin_dir.join(tool_name(name)) + fbuild_core::platform::executable::resolve_tool_in(bin_dir, name) } #[cfg(test)] @@ -382,15 +377,6 @@ mod tests { ); } - #[test] - fn test_tool_name_platform() { - let name = tool_name("avr-gcc"); - assert_eq!( - name, - fbuild_core::platform::executable::native_name("avr-gcc") - ); - } - #[test] fn test_find_bin_root_direct() { let tmp = tempfile::TempDir::new().unwrap(); diff --git a/crates/fbuild-toolchain/src/toolchain/clang.rs b/crates/fbuild-toolchain/src/toolchain/clang.rs index a86b0b805..34b7da3e0 100644 --- a/crates/fbuild-toolchain/src/toolchain/clang.rs +++ b/crates/fbuild-toolchain/src/toolchain/clang.rs @@ -106,8 +106,7 @@ impl ClangComponent { /// Calls `ensure_installed()` internally. pub async fn get_binary(&self, name: &str) -> fbuild_core::Result { let install_dir = self.ensure_installed().await?; - let binary_name = fbuild_core::platform::executable::native_name(name); - find_binary_in_dir(&install_dir, &binary_name).ok_or_else(|| { + find_binary_in_dir(&install_dir, name).ok_or_else(|| { fbuild_core::FbuildError::Other(format!( "'{}' not found in {} installation at {}", name, @@ -240,8 +239,7 @@ impl ClangComponent { fn validate(kind: ClangComponentKind, dir: &Path) -> fbuild_core::Result<()> { for name in kind.required_binaries() { - let binary_name = fbuild_core::platform::executable::native_name(name); - if find_binary_in_dir(dir, &binary_name).is_none() { + if find_binary_in_dir(dir, name).is_none() { return Err(fbuild_core::FbuildError::PackageError(format!( "'{}' not found in extracted {} archive", name, @@ -253,15 +251,16 @@ impl ClangComponent { } } -/// Recursively search for a binary by name in a directory. -/// Checks `dir/bin/name`, then `dir/*/bin/name` (one level of nesting). -pub fn find_binary_in_dir(dir: &Path, name: &str) -> Option { +/// Search for a tool by stem in a directory: the host-native spelling or a +/// `.com`/`.exe` APE (see [`fbuild_core::platform::executable::find_tool_in`]). +/// Checks `dir/bin/`, then `dir/*/bin/` (one level of nesting). +pub fn find_binary_in_dir(dir: &Path, stem: &str) -> Option { + use fbuild_core::platform::executable::find_tool_in; if !dir.exists() { return None; } - // Direct: dir/bin/name - let direct = dir.join("bin").join(name); - if direct.exists() { + // Direct: dir/bin/ + if let Some(direct) = find_tool_in(&dir.join("bin"), stem) { return Some(direct); } // One level nested: dir/subdir/bin/name (archives often have a top-level folder) @@ -269,8 +268,7 @@ pub fn find_binary_in_dir(dir: &Path, name: &str) -> Option { for entry in entries.filter_map(|e| e.ok()) { let p = entry.path(); if p.is_dir() { - let candidate = p.join("bin").join(name); - if candidate.exists() { + if let Some(candidate) = find_tool_in(&p.join("bin"), stem) { return Some(candidate); } } @@ -427,7 +425,26 @@ mod tests { std::fs::create_dir_all(&nested).unwrap(); let name = fbuild_core::platform::executable::native_name("clang-tidy"); std::fs::write(nested.join(&name), b"fake").unwrap(); - assert!(find_binary_in_dir(dir.path(), &name).is_some()); + assert!(find_binary_in_dir(dir.path(), "clang-tidy").is_some()); + } + + #[test] + fn test_find_binary_accepts_ape_com_but_not_foreign_native_binary() { + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("llvm").join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let foreign = if fbuild_core::platform::host::current().is_windows() { + "clang-tidy" + } else { + "clang-tidy.exe" + }; + std::fs::write(bin.join(foreign), b"not an APE").unwrap(); + assert!(find_binary_in_dir(dir.path(), "clang-tidy").is_none()); + std::fs::write(bin.join("clang-tidy.com"), b"MZqFpD='\n").unwrap(); + assert_eq!( + find_binary_in_dir(dir.path(), "clang-tidy"), + Some(bin.join("clang-tidy.com")) + ); } #[test] diff --git a/crates/fbuild-toolchain/src/toolchain/esp32.rs b/crates/fbuild-toolchain/src/toolchain/esp32.rs index 57875e3ec..c4577ceb5 100644 --- a/crates/fbuild-toolchain/src/toolchain/esp32.rs +++ b/crates/fbuild-toolchain/src/toolchain/esp32.rs @@ -204,9 +204,7 @@ impl crate::Package for Esp32Toolchain { return false; } let root = find_bin_root(&self.base.install_path()); - root.join("bin") - .join(tool_name(&format!("{}gcc", self.prefix))) - .exists() + tool_binary(&root.join("bin"), &format!("{}gcc", self.prefix)).exists() } fn get_info(&self) -> PackageInfo { @@ -392,14 +390,9 @@ fn find_bin_root(install_dir: &Path) -> PathBuf { install_dir.to_path_buf() } -/// Get the tool binary name with .exe extension on Windows. -fn tool_name(name: &str) -> String { - fbuild_core::platform::executable::native_name(name) -} - -/// Get the full path to a tool binary. +/// Get the full path to a tool binary (native spelling, or a `.com`/`.exe` APE). fn tool_binary(bin_dir: &Path, name: &str) -> PathBuf { - bin_dir.join(tool_name(name)) + fbuild_core::platform::executable::resolve_tool_in(bin_dir, name) } #[cfg(test)] @@ -421,15 +414,6 @@ mod tests { assert!(url.contains("espressif")); } - #[test] - fn test_tool_name_platform() { - let name = tool_name("riscv32-esp-elf-gcc"); - assert_eq!( - name, - fbuild_core::platform::executable::native_name("riscv32-esp-elf-gcc") - ); - } - #[test] fn test_find_bin_root_direct() { let tmp = tempfile::TempDir::new().unwrap(); diff --git a/crates/fbuild-toolchain/src/toolchain/esp32_metadata.rs b/crates/fbuild-toolchain/src/toolchain/esp32_metadata.rs index 9de575bb3..2547b4e86 100644 --- a/crates/fbuild-toolchain/src/toolchain/esp32_metadata.rs +++ b/crates/fbuild-toolchain/src/toolchain/esp32_metadata.rs @@ -183,16 +183,21 @@ fn parse_tools_json(path: &Path, toolchain_name: &str) -> Result = version - .as_object() - .map(|m| m.keys().map(|k| k.as_str()).collect()) - .unwrap_or_default(); - FbuildError::PackageError(format!( - "platform '{}' not found for {}. Available: {:?}", - platform, toolchain_name, available - )) - })?; + // ESP-IDF marks host-independent tools (scripts, APE builds) with the + // `any` platform key; use it when the host has no dedicated entry. + let platform_info = version + .get(platform) + .or_else(|| version.get("any")) + .ok_or_else(|| { + let available: Vec<&str> = version + .as_object() + .map(|m| m.keys().map(|k| k.as_str()).collect()) + .unwrap_or_default(); + FbuildError::PackageError(format!( + "platform '{}' not found for {}. Available: {:?}", + platform, toolchain_name, available + )) + })?; let url = platform_info .get("url") @@ -281,6 +286,38 @@ mod tests { assert!(result.is_err()); } + #[test] + fn test_parse_tools_json_falls_back_to_any_platform() { + let tmp = tempfile::TempDir::new().unwrap(); + let tools_json = tmp.path().join("tools.json"); + std::fs::write( + &tools_json, + r#"{"tools": [{"name": "tc", "versions": [{ + "other-platform": {"url": "https://example.com/other.zip"}, + "any": {"url": "https://example.com/any.zip", "sha256": "beef"} + }]}]}"#, + ) + .unwrap(); + let result = parse_tools_json(&tools_json, "tc").unwrap(); + assert_eq!(result.url, "https://example.com/any.zip"); + assert_eq!(result.sha256.as_deref(), Some("beef")); + + // The host's own entry still wins over `any`. + std::fs::write( + &tools_json, + format!( + r#"{{"tools": [{{"name": "tc", "versions": [{{ + "any": {{"url": "https://example.com/any.zip"}}, + "{}": {{"url": "https://example.com/host.zip"}} + }}]}}]}}"#, + detect_platform() + ), + ) + .unwrap(); + let result = parse_tools_json(&tools_json, "tc").unwrap(); + assert_eq!(result.url, "https://example.com/host.zip"); + } + #[test] fn test_parse_tools_json_missing_platform() { let tmp = tempfile::TempDir::new().unwrap(); diff --git a/crates/fbuild-toolchain/src/toolchain/esp8266.rs b/crates/fbuild-toolchain/src/toolchain/esp8266.rs index 77db347a9..c41d00a50 100644 --- a/crates/fbuild-toolchain/src/toolchain/esp8266.rs +++ b/crates/fbuild-toolchain/src/toolchain/esp8266.rs @@ -110,9 +110,7 @@ impl crate::Package for Esp8266Toolchain { return false; } let root = find_bin_root(&self.base.install_path()); - root.join("bin") - .join(tool_name(&format!("{PREFIX}gcc"))) - .exists() + tool_binary(&root.join("bin"), &format!("{PREFIX}gcc")).exists() } fn get_info(&self) -> PackageInfo { @@ -249,12 +247,9 @@ fn find_bin_root(install_dir: &Path) -> PathBuf { install_dir.to_path_buf() } -fn tool_name(name: &str) -> String { - fbuild_core::platform::executable::native_name(name) -} - +/// Get the full path to a tool binary (native spelling, or a `.com`/`.exe` APE). fn tool_binary(bin_dir: &Path, name: &str) -> PathBuf { - bin_dir.join(tool_name(name)) + fbuild_core::platform::executable::resolve_tool_in(bin_dir, name) } #[cfg(test)] @@ -269,15 +264,6 @@ mod tests { assert!(url.contains("earlephilhower")); } - #[test] - fn test_tool_name_platform() { - let name = tool_name("xtensa-lx106-elf-gcc"); - assert_eq!( - name, - fbuild_core::platform::executable::native_name("xtensa-lx106-elf-gcc") - ); - } - #[test] fn test_find_bin_root_direct() { let tmp = tempfile::TempDir::new().unwrap(); diff --git a/crates/fbuild-toolchain/src/toolchain/esp_qemu.rs b/crates/fbuild-toolchain/src/toolchain/esp_qemu.rs index 1e957c6bb..9f9b0b977 100644 --- a/crates/fbuild-toolchain/src/toolchain/esp_qemu.rs +++ b/crates/fbuild-toolchain/src/toolchain/esp_qemu.rs @@ -71,6 +71,14 @@ impl EspQemuArch { } } + /// Executable stem, resolved via `find_tool_in` (native or APE spelling). + fn stem(self) -> &'static str { + match self { + Self::Xtensa => "qemu-system-xtensa", + Self::Riscv32 => "qemu-system-riscv32", + } + } + fn binary_name(self) -> &'static str { match self { Self::Xtensa => fbuild_core::platform::executable::name( @@ -127,7 +135,7 @@ impl EspQemu { hydrate_windows_runtime(&path)?; validate_windows_runtime(&path)?; path - } else if let Some(path) = find_on_path(self.arch.binary_name()) { + } else if let Some(path) = find_on_path(self.arch.stem()) { hydrate_windows_runtime(&path)?; validate_windows_runtime(&path)?; path @@ -406,14 +414,14 @@ fn validate_qemu_path(path: PathBuf, source: &str) -> Result { } fn find_qemu_binary(root: &Path, arch: EspQemuArch) -> Result { + use fbuild_core::platform::executable::find_tool_in; let file_name = arch.binary_name(); - let direct = root.join(file_name); - if direct.is_file() { + let stem = arch.stem(); + if let Some(direct) = find_tool_in(root, stem) { return Ok(direct); } - let in_bin = root.join("bin").join(file_name); - if in_bin.is_file() { + if let Some(in_bin) = find_tool_in(&root.join("bin"), stem) { return Ok(in_bin); } @@ -424,13 +432,11 @@ fn find_qemu_binary(root: &Path, arch: EspQemuArch) -> Result { continue; } - let nested_direct = path.join(file_name); - if nested_direct.is_file() { + if let Some(nested_direct) = find_tool_in(&path, stem) { return Ok(nested_direct); } - let nested_bin = path.join("bin").join(file_name); - if nested_bin.is_file() { + if let Some(nested_bin) = find_tool_in(&path.join("bin"), stem) { return Ok(nested_bin); } } @@ -461,15 +467,9 @@ fn qemu_root(install_dir: &Path, arch: EspQemuArch) -> Result { } } -fn find_on_path(binary: &str) -> Option { +fn find_on_path(stem: &str) -> Option { let path_var = std::env::var_os("PATH")?; - for dir in std::env::split_paths(&path_var) { - let candidate = dir.join(binary); - if candidate.is_file() { - return Some(candidate); - } - } - None + fbuild_core::platform::executable::find_tool_on_paths(std::env::split_paths(&path_var), stem) } fn find_existing_idf_qemu(arch: EspQemuArch) -> Option { diff --git a/crates/fbuild-toolchain/src/toolchain/riscv.rs b/crates/fbuild-toolchain/src/toolchain/riscv.rs index ab7727571..2069fb447 100644 --- a/crates/fbuild-toolchain/src/toolchain/riscv.rs +++ b/crates/fbuild-toolchain/src/toolchain/riscv.rs @@ -6,7 +6,6 @@ //! riscv-none-elf-size. use std::path::{Path, PathBuf}; -use std::process::Command; use crate::{CacheSubdir, PackageBase, PackageInfo, Toolchain}; @@ -175,7 +174,7 @@ impl RiscvToolchain { fn get_gcc_multilib_dir(&self, march: &str, mabi: &str) -> Option { // allow-direct-spawn: short synchronous GCC capability probe (-print-multi-directory). - let output = Command::new(self.get_gcc_path()) + let output = fbuild_core::platform::process::command(self.get_gcc_path()) .args([ format!("-march={march}"), format!("-mabi={mabi}"), @@ -261,9 +260,11 @@ impl crate::Package for RiscvToolchain { if self.executable_prefix == "auto" { return detect_tool_prefix(&root.join("bin")).is_some(); } - root.join("bin") - .join(tool_name(&format!("{}-gcc", self.executable_prefix))) - .exists() + tool_binary( + &root.join("bin"), + &format!("{}-gcc", self.executable_prefix), + ) + .exists() } fn get_info(&self) -> PackageInfo { @@ -403,34 +404,34 @@ fn find_bin_root(install_dir: &Path) -> PathBuf { install_dir.to_path_buf() } -/// Get the tool binary name with .exe extension on Windows. -fn tool_name(name: &str) -> String { - fbuild_core::platform::executable::native_name(name) -} - -/// Get the full path to a tool binary. +/// Get the full path to a tool binary (native spelling, or a `.com`/`.exe` APE). fn tool_binary(bin_dir: &Path, name: &str) -> PathBuf { - bin_dir.join(tool_name(name)) + fbuild_core::platform::executable::resolve_tool_in(bin_dir, name) } /// Find one complete RISC-V compiler suite in an extracted PlatformIO /// package. A package with multiple suites is ambiguous and must not be /// selected by directory iteration order. fn detect_tool_prefix(bin_dir: &Path) -> Option { - let gcc_suffix = tool_name("-gcc"); - let mut candidates = std::fs::read_dir(bin_dir) + use fbuild_core::platform::executable::{find_tool_in, split_tool_suffix}; + + // A suite may spell gcc natively or as an APE (`.com`/`.exe`); dedupe so + // two spellings of one suite do not look like two suites. + let prefixes = std::fs::read_dir(bin_dir) .ok()? .filter_map(Result::ok) .filter_map(|entry| { let name = entry.file_name().to_string_lossy().into_owned(); - let prefix = name.strip_suffix(&gcc_suffix)?; - if !prefix.starts_with("riscv") || !entry.path().is_file() { - return None; - } - ["g++", "ar", "objcopy", "size"] + let prefix = split_tool_suffix(&name).0.strip_suffix("-gcc")?; + prefix.starts_with("riscv").then(|| prefix.to_string()) + }) + .collect::>(); + let mut candidates = prefixes + .into_iter() + .filter(|prefix| { + ["gcc", "g++", "ar", "objcopy", "size"] .iter() - .all(|suffix| tool_binary(bin_dir, &format!("{prefix}-{suffix}")).is_file()) - .then(|| prefix.to_string()) + .all(|suffix| find_tool_in(bin_dir, &format!("{prefix}-{suffix}")).is_some()) }) .collect::>(); if candidates.len() == 1 { @@ -453,15 +454,6 @@ mod tests { assert!(url.contains("riscv-none-elf")); } - #[test] - fn test_tool_name_platform() { - let name = tool_name("riscv-none-elf-gcc"); - assert_eq!( - name, - fbuild_core::platform::executable::native_name("riscv-none-elf-gcc") - ); - } - #[test] fn ch32v_platform_toolchain_uses_wch_executable_prefix() { let tmp = tempfile::TempDir::new().unwrap(); @@ -473,11 +465,15 @@ mod tests { RiscvToolchain::with_platform_override(tmp.path(), override_package, "riscv-wch-elf"); assert_eq!( toolchain.get_gcc_path().file_name().unwrap(), - std::ffi::OsStr::new(&tool_name("riscv-wch-elf-gcc")) + std::ffi::OsStr::new(&fbuild_core::platform::executable::native_name( + "riscv-wch-elf-gcc" + )) ); assert_eq!( toolchain.get_ar_path().file_name().unwrap(), - std::ffi::OsStr::new(&tool_name("riscv-wch-elf-ar")) + std::ffi::OsStr::new(&fbuild_core::platform::executable::native_name( + "riscv-wch-elf-ar" + )) ); assert!(toolchain.get_info().url.contains("toolchain-riscv-linux")); } diff --git a/crates/fbuild-toolchain/src/toolchain/rp2040_picotool.rs b/crates/fbuild-toolchain/src/toolchain/rp2040_picotool.rs index d0be3c1a6..5295947c3 100644 --- a/crates/fbuild-toolchain/src/toolchain/rp2040_picotool.rs +++ b/crates/fbuild-toolchain/src/toolchain/rp2040_picotool.rs @@ -67,11 +67,14 @@ impl Rp2040Picotool { } pub fn executable(&self) -> PathBuf { - self.resolved_dir().join(picotool_name()) + fbuild_core::platform::executable::resolve_tool_in(&self.resolved_dir(), PICOTOOL) } fn validate(install_dir: &Path) -> fbuild_core::Result<()> { - let executable = find_picotool_root(install_dir).join(picotool_name()); + let executable = fbuild_core::platform::executable::resolve_tool_in( + &find_picotool_root(install_dir), + PICOTOOL, + ); if !executable.is_file() { return Err(fbuild_core::FbuildError::PackageError(format!( "managed picotool executable not found at {}; managed picotool assets exist for {} (the windows asset is x86_64 and is also what Windows ARM64 receives, relying on x64 emulation) — other host platforms are unsupported", @@ -184,19 +187,18 @@ fn platform_package() -> PlatformPackage { } fn find_picotool_root(install_dir: &Path) -> PathBuf { - if install_dir.join(picotool_name()).is_file() { + if fbuild_core::platform::executable::find_tool_in(install_dir, PICOTOOL).is_some() { return install_dir.to_path_buf(); } let nested = install_dir.join("picotool"); - if nested.join(picotool_name()).is_file() { + if fbuild_core::platform::executable::find_tool_in(&nested, PICOTOOL).is_some() { return nested; } install_dir.to_path_buf() } -fn picotool_name() -> &'static str { - fbuild_core::platform::executable::name("picotool", "picotool.exe") -} +/// Executable stem; the native spelling or a `.com`/`.exe` APE is accepted. +const PICOTOOL: &str = "picotool"; #[cfg(test)] mod tests { @@ -217,7 +219,11 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let nested = temp.path().join("picotool"); std::fs::create_dir(&nested).unwrap(); - std::fs::write(nested.join(picotool_name()), []).unwrap(); + std::fs::write( + nested.join(fbuild_core::platform::executable::native_name(PICOTOOL)), + [], + ) + .unwrap(); assert_eq!(find_picotool_root(temp.path()), nested); } diff --git a/crates/fbuild-toolchain/src/toolchain/rp2040_pqt.rs b/crates/fbuild-toolchain/src/toolchain/rp2040_pqt.rs index ef9c0d04d..ad7bd844a 100644 --- a/crates/fbuild-toolchain/src/toolchain/rp2040_pqt.rs +++ b/crates/fbuild-toolchain/src/toolchain/rp2040_pqt.rs @@ -116,9 +116,7 @@ impl crate::Package for Rp2040PqtToolchain { return false; } let root = find_bin_root(&self.base.install_path()); - root.join("bin") - .join(tool_name("arm-none-eabi-gcc")) - .exists() + tool_binary(&root.join("bin"), "arm-none-eabi-gcc").exists() } fn get_info(&self) -> PackageInfo { @@ -245,12 +243,9 @@ fn find_bin_root(install_dir: &Path) -> PathBuf { install_dir.to_path_buf() } -fn tool_name(name: &str) -> String { - fbuild_core::platform::executable::native_name(name) -} - +/// Get the full path to a tool binary (native spelling, or a `.com`/`.exe` APE). fn tool_binary(bin_dir: &Path, name: &str) -> PathBuf { - bin_dir.join(tool_name(name)) + fbuild_core::platform::executable::resolve_tool_in(bin_dir, name) } #[cfg(test)] @@ -265,15 +260,6 @@ mod tests { assert!(url.contains("arm-none-eabi")); } - #[test] - fn test_tool_name_platform() { - let name = tool_name("arm-none-eabi-gcc"); - assert_eq!( - name, - fbuild_core::platform::executable::native_name("arm-none-eabi-gcc") - ); - } - #[test] fn test_find_bin_root_direct() { let tmp = tempfile::TempDir::new().unwrap(); diff --git a/crates/fbuild-toolchain/src/toolchain/teensy_arm.rs b/crates/fbuild-toolchain/src/toolchain/teensy_arm.rs index aa5437d09..e731cc662 100644 --- a/crates/fbuild-toolchain/src/toolchain/teensy_arm.rs +++ b/crates/fbuild-toolchain/src/toolchain/teensy_arm.rs @@ -109,9 +109,7 @@ impl crate::Package for TeensyArmToolchain { return false; } let root = find_bin_root(&self.base.install_path()); - root.join("bin") - .join(tool_name("arm-none-eabi-gcc")) - .exists() + tool_binary(&root.join("bin"), "arm-none-eabi-gcc").exists() } fn get_info(&self) -> PackageInfo { @@ -245,12 +243,9 @@ fn find_bin_root(install_dir: &Path) -> PathBuf { install_dir.to_path_buf() } -fn tool_name(name: &str) -> String { - fbuild_core::platform::executable::native_name(name) -} - +/// Get the full path to a tool binary (native spelling, or a `.com`/`.exe` APE). fn tool_binary(bin_dir: &Path, name: &str) -> PathBuf { - bin_dir.join(tool_name(name)) + fbuild_core::platform::executable::resolve_tool_in(bin_dir, name) } #[cfg(test)] @@ -268,15 +263,6 @@ mod tests { assert!(url.contains(TEENSY_ARM_TOOLCHAIN_VERSION)); } - #[test] - fn test_tool_name_platform() { - let name = tool_name("arm-none-eabi-gcc"); - assert_eq!( - name, - fbuild_core::platform::executable::native_name("arm-none-eabi-gcc") - ); - } - #[test] fn test_find_bin_root_direct() { let tmp = tempfile::TempDir::new().unwrap(); From 3f2fbf3531ed717a32d8ea485a614d1065ebd48e Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Fri, 2 Oct 2026 18:38:30 -0700 Subject: [PATCH 02/12] fix(ci): spawn-lint markers, split >1000-LOC files, refresh boundary inventory, keep exact-name DLL lookup on PATH - process::command/tokio_command carry allow-direct-spawn markers (they are the sanctioned constructors). - Move the new registry-system and APE staged-install tests into their own files to stay under the 1000-LOC limit. - Regenerate ci/platform_boundary_research.tsv. - esp_qemu: find_on_path is also used for libiconv-2.dll; the tool-name expansion (.exe/.com/APE-only extensionless) never matches a DLL, which broke hydrate_windows_runtime on Windows. Keep an exact-name lookup and use find_tool_on_path only for the qemu binary. --- ci/platform_boundary_research.tsv | 82 +++++++++++++------ crates/fbuild-core/src/platform/process.rs | 4 + crates/fbuild-core/tests/it/main.rs | 1 + .../tests/it/platformio_package_resolution.rs | 50 ----------- .../tests/it/platformio_registry_system.rs | 53 ++++++++++++ .../src/ape_install_tests.rs | 70 ++++++++++++++++ crates/fbuild-packages-fetch/src/lib.rs | 71 +--------------- .../src/toolchain/esp_qemu.rs | 14 +++- 8 files changed, 199 insertions(+), 146 deletions(-) create mode 100644 crates/fbuild-core/tests/it/platformio_registry_system.rs create mode 100644 crates/fbuild-packages-fetch/src/ape_install_tests.rs diff --git a/ci/platform_boundary_research.tsv b/ci/platform_boundary_research.tsv index 2463c3189..5827e810b 100644 --- a/ci/platform_boundary_research.tsv +++ b/ci/platform_boundary_research.tsv @@ -1,43 +1,71 @@ path line kind normalized capability classification -crates/fbuild-core/Cargo.toml 48 native_dependency interprocess ipc host_mechanic -crates/fbuild-core/Cargo.toml 49 native_dependency socket2 ipc host_mechanic -crates/fbuild-core/Cargo.toml 55 target_dependency_table [target.'cfg(unix)'.dependencies] fs host_mechanic -crates/fbuild-core/Cargo.toml 57 native_dependency libc fs host_mechanic -crates/fbuild-core/Cargo.toml 59 target_dependency_table [target.'cfg(windows)'.dependencies] fs host_mechanic -crates/fbuild-core/Cargo.toml 67 native_dependency windows-sys fs host_mechanic -crates/fbuild-core/src/platform/executable.rs 56 native_path std::env::current_exe host_executable host_mechanic +crates/fbuild-core/Cargo.toml 51 native_dependency interprocess ipc host_mechanic +crates/fbuild-core/Cargo.toml 52 native_dependency socket2 ipc host_mechanic +crates/fbuild-core/Cargo.toml 58 target_dependency_table [target.'cfg(unix)'.dependencies] fs host_mechanic +crates/fbuild-core/Cargo.toml 60 native_dependency libc fs host_mechanic +crates/fbuild-core/Cargo.toml 62 target_dependency_table [target.'cfg(windows)'.dependencies] fs host_mechanic +crates/fbuild-core/Cargo.toml 70 native_dependency windows-sys fs host_mechanic +crates/fbuild-core/src/platform/executable.rs 166 native_path std::env::current_exe host_executable host_mechanic +crates/fbuild-core/src/platform/linux/ape.rs 11 native_path std::os::unix::fs::PermissionsExt fs host_mechanic +crates/fbuild-core/src/platform/linux/ape.rs 26 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/ape.rs 63 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/ape.rs 63 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/ape.rs 66 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/ape.rs 70 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/ape.rs 70 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/ape.rs 71 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/ape.rs 78 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/ape.rs 78 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/ape.rs 78 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/ape.rs 78 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/ape.rs 80 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/ape.rs 80 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/ape.rs 155 native_path std::os::unix::fs::symlink fs host_mechanic crates/fbuild-core/src/platform/linux/device.rs 33 native_path std::os::unix::fs::OpenOptionsExt fs host_mechanic crates/fbuild-core/src/platform/linux/device.rs 41 native_path libc:: process host_mechanic crates/fbuild-core/src/platform/linux/device.rs 41 native_path libc:: process host_mechanic crates/fbuild-core/src/platform/linux/fs.rs 2 native_path std::os::unix::fs::PermissionsExt fs host_mechanic crates/fbuild-core/src/platform/linux/fs.rs 13 native_path std::os::unix::fs::MetadataExt fs host_mechanic -crates/fbuild-core/src/platform/linux/fs.rs 57 native_path std::os::unix::fs::symlink fs host_mechanic -crates/fbuild-core/src/platform/linux/fs.rs 61 native_path std::os::unix::fs::symlink fs host_mechanic -crates/fbuild-core/src/platform/linux/fs.rs 105 native_path std::os::unix::ffi::OsStrExt process host_mechanic -crates/fbuild-core/src/platform/linux/fs.rs 110 native_path libc:: process host_mechanic -crates/fbuild-core/src/platform/linux/fs.rs 112 native_path libc:: process host_mechanic -crates/fbuild-core/src/platform/linux/fs.rs 122 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/fs.rs 49 native_path std::os::unix::fs fs host_mechanic +crates/fbuild-core/src/platform/linux/fs.rs 55 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/fs.rs 62 native_path std::os::unix::ffi::OsStrExt process host_mechanic +crates/fbuild-core/src/platform/linux/fs.rs 66 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/fs.rs 68 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/fs.rs 73 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/fs.rs 89 native_path std::os::unix::fs::symlink fs host_mechanic +crates/fbuild-core/src/platform/linux/fs.rs 93 native_path std::os::unix::fs::symlink fs host_mechanic +crates/fbuild-core/src/platform/linux/fs.rs 137 native_path std::os::unix::ffi::OsStrExt process host_mechanic +crates/fbuild-core/src/platform/linux/fs.rs 142 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/fs.rs 144 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/fs.rs 154 native_path libc:: process host_mechanic crates/fbuild-core/src/platform/linux/ipc.rs 1 native_path interprocess::local_socket ipc host_mechanic crates/fbuild-core/src/platform/linux/ipc.rs 2 native_path interprocess::local_socket ipc host_mechanic crates/fbuild-core/src/platform/linux/ipc.rs 3 native_path interprocess::os::unix ipc host_mechanic crates/fbuild-core/src/platform/linux/ipc.rs 4 native_path socket2:: ipc host_mechanic crates/fbuild-core/src/platform/linux/ipc.rs 66 native_path std::os::unix::fs::PermissionsExt ipc host_mechanic -crates/fbuild-core/src/platform/linux/mod.rs 13 compile_host_fact std::env::consts::ARCH host host_mechanic +crates/fbuild-core/src/platform/linux/mod.rs 14 compile_host_fact std::env::consts::ARCH host host_mechanic crates/fbuild-core/src/platform/linux/process.rs 1 native_path std::os::unix::process::ExitStatusExt process host_mechanic +crates/fbuild-core/src/platform/macos/ape.rs 22 native_path libc:: process host_mechanic crates/fbuild-core/src/platform/macos/fs.rs 2 native_path std::os::unix::fs::PermissionsExt fs host_mechanic crates/fbuild-core/src/platform/macos/fs.rs 13 native_path std::os::unix::fs::MetadataExt fs host_mechanic -crates/fbuild-core/src/platform/macos/fs.rs 57 native_path std::os::unix::fs::symlink fs host_mechanic -crates/fbuild-core/src/platform/macos/fs.rs 61 native_path std::os::unix::fs::symlink fs host_mechanic -crates/fbuild-core/src/platform/macos/fs.rs 105 native_path std::os::unix::ffi::OsStrExt process host_mechanic -crates/fbuild-core/src/platform/macos/fs.rs 110 native_path libc:: process host_mechanic -crates/fbuild-core/src/platform/macos/fs.rs 112 native_path libc:: process host_mechanic -crates/fbuild-core/src/platform/macos/fs.rs 122 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/macos/fs.rs 49 native_path std::os::unix::fs fs host_mechanic +crates/fbuild-core/src/platform/macos/fs.rs 55 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/macos/fs.rs 62 native_path std::os::unix::ffi::OsStrExt process host_mechanic +crates/fbuild-core/src/platform/macos/fs.rs 66 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/macos/fs.rs 68 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/macos/fs.rs 73 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/macos/fs.rs 89 native_path std::os::unix::fs::symlink fs host_mechanic +crates/fbuild-core/src/platform/macos/fs.rs 93 native_path std::os::unix::fs::symlink fs host_mechanic +crates/fbuild-core/src/platform/macos/fs.rs 137 native_path std::os::unix::ffi::OsStrExt process host_mechanic +crates/fbuild-core/src/platform/macos/fs.rs 142 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/macos/fs.rs 144 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/macos/fs.rs 154 native_path libc:: process host_mechanic crates/fbuild-core/src/platform/macos/ipc.rs 1 native_path interprocess::local_socket ipc host_mechanic crates/fbuild-core/src/platform/macos/ipc.rs 2 native_path interprocess::local_socket ipc host_mechanic crates/fbuild-core/src/platform/macos/ipc.rs 3 native_path socket2:: ipc host_mechanic crates/fbuild-core/src/platform/macos/ipc.rs 19 native_path std::os::unix::fs::PermissionsExt ipc host_mechanic crates/fbuild-core/src/platform/macos/ipc.rs 72 native_path std::os::unix::fs::PermissionsExt ipc host_mechanic -crates/fbuild-core/src/platform/macos/mod.rs 13 compile_host_fact std::env::consts::ARCH host host_mechanic +crates/fbuild-core/src/platform/macos/mod.rs 14 compile_host_fact std::env::consts::ARCH host host_mechanic crates/fbuild-core/src/platform/macos/process.rs 1 native_path std::os::unix::process::ExitStatusExt process host_mechanic crates/fbuild-core/src/platform/windows/device.rs 18 native_path windows_sys:: process host_mechanic crates/fbuild-core/src/platform/windows/device.rs 27 native_path windows_sys:: process host_mechanic @@ -47,18 +75,18 @@ crates/fbuild-core/src/platform/windows/device.rs 35 native_path windows_sys:: p crates/fbuild-core/src/platform/windows/fs.rs 2 native_path std::os::windows::ffi::OsStrExt process host_mechanic crates/fbuild-core/src/platform/windows/fs.rs 3 native_path std::os::windows::fs fs host_mechanic crates/fbuild-core/src/platform/windows/fs.rs 4 native_path std::os::windows::io::AsRawHandle process host_mechanic -crates/fbuild-core/src/platform/windows/fs.rs 60 native_path std::os::windows::fs::symlink_dir fs host_mechanic -crates/fbuild-core/src/platform/windows/fs.rs 64 native_path std::os::windows::fs::symlink_file fs host_mechanic -crates/fbuild-core/src/platform/windows/fs.rs 92 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/fs.rs 147 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/fs.rs 191 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/fs.rs 73 native_path std::os::windows::fs::symlink_dir fs host_mechanic +crates/fbuild-core/src/platform/windows/fs.rs 77 native_path std::os::windows::fs::symlink_file fs host_mechanic +crates/fbuild-core/src/platform/windows/fs.rs 105 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/fs.rs 160 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/fs.rs 204 native_path windows_sys:: process host_mechanic crates/fbuild-core/src/platform/windows/ipc.rs 1 native_path interprocess::local_socket ipc host_mechanic crates/fbuild-core/src/platform/windows/ipc.rs 2 native_path interprocess::local_socket ipc host_mechanic crates/fbuild-core/src/platform/windows/ipc.rs 3 native_path interprocess::os::windows ipc host_mechanic crates/fbuild-core/src/platform/windows/ipc.rs 4 native_path interprocess::os::windows ipc host_mechanic crates/fbuild-core/src/platform/windows/ipc.rs 5 native_path socket2:: ipc host_mechanic crates/fbuild-core/src/platform/windows/ipc.rs 6 native_path std::os::windows::io::AsRawSocket ipc host_mechanic -crates/fbuild-core/src/platform/windows/mod.rs 13 compile_host_fact std::env::consts::ARCH host host_mechanic +crates/fbuild-core/src/platform/windows/mod.rs 14 compile_host_fact std::env::consts::ARCH host host_mechanic crates/fbuild-core/src/platform/windows/process.rs 87 native_path std::os::windows::ffi::OsStrExt process host_mechanic crates/fbuild-core/src/platform/windows/process.rs 89 native_path windows_sys:: process host_mechanic crates/fbuild-core/src/platform/windows/process.rs 92 native_path windows_sys:: process host_mechanic diff --git a/crates/fbuild-core/src/platform/process.rs b/crates/fbuild-core/src/platform/process.rs index 362480fb1..c7879019f 100644 --- a/crates/fbuild-core/src/platform/process.rs +++ b/crates/fbuild-core/src/platform/process.rs @@ -131,6 +131,7 @@ pub fn exclusive_fork_guard() -> RwLockWriteGuard<'static, ()> { pub fn command(program: impl AsRef) -> Command { match super::ape::plan_launch(program.as_ref(), None, None) { Some(launch) => { + // allow-direct-spawn: construction only; callers execute via spawn_contained/spawn_tokio_contained/spawn_detached. let mut command = Command::new(&launch.loader); command.arg(&launch.image); if let Some(path) = launch.child_path(std::env::var_os("PATH").as_deref()) { @@ -138,6 +139,7 @@ pub fn command(program: impl AsRef) -> Command { } command } + // allow-direct-spawn: construction only; callers execute via spawn_contained/spawn_tokio_contained/spawn_detached. None => Command::new(program), } } @@ -146,6 +148,7 @@ pub fn command(program: impl AsRef) -> Command { pub fn tokio_command(program: impl AsRef) -> tokio::process::Command { match super::ape::plan_launch(program.as_ref(), None, None) { Some(launch) => { + // allow-direct-spawn: construction only; callers execute via spawn_contained/spawn_tokio_contained/spawn_detached. let mut command = tokio::process::Command::new(&launch.loader); command.arg(&launch.image); if let Some(path) = launch.child_path(std::env::var_os("PATH").as_deref()) { @@ -153,6 +156,7 @@ pub fn tokio_command(program: impl AsRef) -> tokio::process::Command { } command } + // allow-direct-spawn: construction only; callers execute via spawn_contained/spawn_tokio_contained/spawn_detached. None => tokio::process::Command::new(program), } } diff --git a/crates/fbuild-core/tests/it/main.rs b/crates/fbuild-core/tests/it/main.rs index 80ded676f..abccb6b2f 100644 --- a/crates/fbuild-core/tests/it/main.rs +++ b/crates/fbuild-core/tests/it/main.rs @@ -8,3 +8,4 @@ mod platformio_avr_resolution; mod platformio_ch32v_resolution; mod platformio_esp8266_resolution; mod platformio_package_resolution; +mod platformio_registry_system; diff --git a/crates/fbuild-core/tests/it/platformio_package_resolution.rs b/crates/fbuild-core/tests/it/platformio_package_resolution.rs index e565e2f05..ad778b476 100644 --- a/crates/fbuild-core/tests/it/platformio_package_resolution.rs +++ b/crates/fbuild-core/tests/it/platformio_package_resolution.rs @@ -142,56 +142,6 @@ fn resolves_exact_platform_payload_and_checksum() { /// A registry file with no `system` (or `"system": null`) is a host-universal /// payload, e.g. one APE build of a tool for every host. It must parse and /// resolve like `"*"`, while an exact host match still wins. -#[test] -fn registry_file_without_system_is_universal_and_exact_host_still_wins() { - let system = - fbuild_core::platformio_package::host_system(fbuild_core::platform::host::current()) - .unwrap_or("linux_x86_64"); - let spec = parse_package_spec("acme/tool-ape@1.0.0").unwrap(); - let sha = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; - - for universal in [ - format!( - r#"{{"download_url":"https://example.test/ape.zip","checksum":{{"sha256":"{sha}"}}}}"# - ), - format!( - r#"{{"system":null,"download_url":"https://example.test/ape.zip","checksum":{{"sha256":"{sha}"}}}}"# - ), - format!( - r#"{{"system":"*","download_url":"https://example.test/ape.zip","checksum":{{"sha256":"{sha}"}}}}"# - ), - ] { - let response = format!( - r#"{{"name":"tool-ape","owner":{{"username":"acme"}}, - "versions":[{{"name":"1.0.0","files":[{universal}]}}]}}"# - ); - let payload = resolve_registry_json( - spec.registry().unwrap(), - PackageKind::Tool, - system, - &response, - ) - .unwrap_or_else(|e| panic!("{universal} should resolve on {system}: {e}")); - assert_eq!(payload.url, "https://example.test/ape.zip"); - } - - let response = format!( - r#"{{"name":"tool-ape","owner":{{"username":"acme"}}, - "versions":[{{"name":"1.0.0","files":[ - {{"download_url":"https://example.test/ape.zip","checksum":{{"sha256":"{sha}"}}}}, - {{"system":["{system}"],"download_url":"https://example.test/native.zip","checksum":{{"sha256":"{sha}"}}}} - ]}}]}}"# - ); - let payload = resolve_registry_json( - spec.registry().unwrap(), - PackageKind::Tool, - system, - &response, - ) - .unwrap(); - assert_eq!(payload.url, "https://example.test/native.zip"); -} - #[test] fn selects_host_file_and_range_without_substituting_another_build() { let response = r#"{ diff --git a/crates/fbuild-core/tests/it/platformio_registry_system.rs b/crates/fbuild-core/tests/it/platformio_registry_system.rs new file mode 100644 index 000000000..5fc0bcf7c --- /dev/null +++ b/crates/fbuild-core/tests/it/platformio_registry_system.rs @@ -0,0 +1,53 @@ +//! Registry files that omit or null `system` are universal packages. + +use fbuild_core::platformio_package::{PackageKind, parse_package_spec, resolve_registry_json}; + +#[test] +fn registry_file_without_system_is_universal_and_exact_host_still_wins() { + let system = + fbuild_core::platformio_package::host_system(fbuild_core::platform::host::current()) + .unwrap_or("linux_x86_64"); + let spec = parse_package_spec("acme/tool-ape@1.0.0").unwrap(); + let sha = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + + for universal in [ + format!( + r#"{{"download_url":"https://example.test/ape.zip","checksum":{{"sha256":"{sha}"}}}}"# + ), + format!( + r#"{{"system":null,"download_url":"https://example.test/ape.zip","checksum":{{"sha256":"{sha}"}}}}"# + ), + format!( + r#"{{"system":"*","download_url":"https://example.test/ape.zip","checksum":{{"sha256":"{sha}"}}}}"# + ), + ] { + let response = format!( + r#"{{"name":"tool-ape","owner":{{"username":"acme"}}, + "versions":[{{"name":"1.0.0","files":[{universal}]}}]}}"# + ); + let payload = resolve_registry_json( + spec.registry().unwrap(), + PackageKind::Tool, + system, + &response, + ) + .unwrap_or_else(|e| panic!("{universal} should resolve on {system}: {e}")); + assert_eq!(payload.url, "https://example.test/ape.zip"); + } + + let response = format!( + r#"{{"name":"tool-ape","owner":{{"username":"acme"}}, + "versions":[{{"name":"1.0.0","files":[ + {{"download_url":"https://example.test/ape.zip","checksum":{{"sha256":"{sha}"}}}}, + {{"system":["{system}"],"download_url":"https://example.test/native.zip","checksum":{{"sha256":"{sha}"}}}} + ]}}]}}"# + ); + let payload = resolve_registry_json( + spec.registry().unwrap(), + PackageKind::Tool, + system, + &response, + ) + .unwrap(); + assert_eq!(payload.url, "https://example.test/native.zip"); +} diff --git a/crates/fbuild-packages-fetch/src/ape_install_tests.rs b/crates/fbuild-packages-fetch/src/ape_install_tests.rs new file mode 100644 index 000000000..2f6c4e33b --- /dev/null +++ b/crates/fbuild-packages-fetch/src/ape_install_tests.rs @@ -0,0 +1,70 @@ +//! Staged installs restore the exec bit on APE (cosmocc) host tools. + +use super::toolchain_gcc_ar_tests::serve_once; +use super::*; + +/// A zip whose entries carry no exec bits (the `zip` writer default is +/// 0644, like DOS-attribute archives) must still install an APE host tool +/// as executable, while leaving non-APE files exactly as extracted. +#[tokio::test] +async fn staged_install_marks_ape_tools_executable() { + use std::io::{Cursor, Write}; + use zip::write::SimpleFileOptions; + + let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); + zip.start_file("pkg/bin/tool.com", SimpleFileOptions::default()) + .unwrap(); + zip.write_all(b"MZqFpD='\n#!/bin/sh\n").unwrap(); + zip.start_file("pkg/bin/readme.txt", SimpleFileOptions::default()) + .unwrap(); + zip.write_all(b"not a tool").unwrap(); + let bytes = zip.finish().unwrap().into_inner(); + + let tmp = tempfile::TempDir::new().unwrap(); + // Plain extraction (not only staged installs) restores the APE exec + // bit too, and leaves other 0644 entries alone. + let archive = tmp.path().join("ref.zip"); + std::fs::write(&archive, &bytes).unwrap(); + let reference = tmp.path().join("ref"); + std::fs::create_dir_all(&reference).unwrap(); + extractor::extract(&archive, &reference).unwrap(); + if !fbuild_core::platform::host::current().is_windows() { + let ref_ape = std::fs::metadata(reference.join("pkg/bin/tool.com")).unwrap(); + assert!(fbuild_core::platform::fs::is_executable(&ref_ape)); + let ref_txt = std::fs::metadata(reference.join("pkg/bin/readme.txt")).unwrap(); + assert!(!fbuild_core::platform::fs::is_executable(&ref_txt)); + } + + let url = serve_once(bytes).await; + let base = PackageBase::with_cache_root( + "ape-tool", + "1.0", + &url, + "ape-tool", + None, + CacheSubdir::Toolchains, + tmp.path(), + &tmp.path().join("cache"), + ); + let installed = base + .staged_install(|staging| { + let meta = std::fs::metadata(staging.join("pkg/bin/tool.com")).unwrap(); + assert!( + fbuild_core::platform::fs::is_executable(&meta), + "APE must be executable before validation runs" + ); + Ok(()) + }) + .await + .expect("staged install should succeed"); + + let ape = std::fs::metadata(installed.join("pkg/bin/tool.com")).unwrap(); + assert!(fbuild_core::platform::fs::is_executable(&ape)); + let txt = std::fs::metadata(installed.join("pkg/bin/readme.txt")).unwrap(); + let ref_txt = std::fs::metadata(reference.join("pkg/bin/readme.txt")).unwrap(); + assert_eq!( + txt.permissions(), + ref_txt.permissions(), + "non-APE files keep their extracted permissions" + ); +} diff --git a/crates/fbuild-packages-fetch/src/lib.rs b/crates/fbuild-packages-fetch/src/lib.rs index 5a8d9429e..ab7b58886 100644 --- a/crates/fbuild-packages-fetch/src/lib.rs +++ b/crates/fbuild-packages-fetch/src/lib.rs @@ -544,11 +544,14 @@ fn clear_package_touch_cache_for_tests() { } } +#[cfg(test)] +mod ape_install_tests; + #[cfg(test)] mod toolchain_gcc_ar_tests { use super::*; - async fn serve_once(body: Vec) -> String { + pub(super) async fn serve_once(body: Vec) -> String { use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; @@ -766,72 +769,6 @@ mod toolchain_gcc_ar_tests { assert!(disk_cache::paths::install_complete_sentinel(&installed).exists()); } - /// A zip whose entries carry no exec bits (the `zip` writer default is - /// 0644, like DOS-attribute archives) must still install an APE host tool - /// as executable, while leaving non-APE files exactly as extracted. - #[tokio::test] - async fn staged_install_marks_ape_tools_executable() { - use std::io::{Cursor, Write}; - use zip::write::SimpleFileOptions; - - let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); - zip.start_file("pkg/bin/tool.com", SimpleFileOptions::default()) - .unwrap(); - zip.write_all(b"MZqFpD='\n#!/bin/sh\n").unwrap(); - zip.start_file("pkg/bin/readme.txt", SimpleFileOptions::default()) - .unwrap(); - zip.write_all(b"not a tool").unwrap(); - let bytes = zip.finish().unwrap().into_inner(); - - let tmp = tempfile::TempDir::new().unwrap(); - // Plain extraction (not only staged installs) restores the APE exec - // bit too, and leaves other 0644 entries alone. - let archive = tmp.path().join("ref.zip"); - std::fs::write(&archive, &bytes).unwrap(); - let reference = tmp.path().join("ref"); - std::fs::create_dir_all(&reference).unwrap(); - extractor::extract(&archive, &reference).unwrap(); - if !fbuild_core::platform::host::current().is_windows() { - let ref_ape = std::fs::metadata(reference.join("pkg/bin/tool.com")).unwrap(); - assert!(fbuild_core::platform::fs::is_executable(&ref_ape)); - let ref_txt = std::fs::metadata(reference.join("pkg/bin/readme.txt")).unwrap(); - assert!(!fbuild_core::platform::fs::is_executable(&ref_txt)); - } - - let url = serve_once(bytes).await; - let base = PackageBase::with_cache_root( - "ape-tool", - "1.0", - &url, - "ape-tool", - None, - CacheSubdir::Toolchains, - tmp.path(), - &tmp.path().join("cache"), - ); - let installed = base - .staged_install(|staging| { - let meta = std::fs::metadata(staging.join("pkg/bin/tool.com")).unwrap(); - assert!( - fbuild_core::platform::fs::is_executable(&meta), - "APE must be executable before validation runs" - ); - Ok(()) - }) - .await - .expect("staged install should succeed"); - - let ape = std::fs::metadata(installed.join("pkg/bin/tool.com")).unwrap(); - assert!(fbuild_core::platform::fs::is_executable(&ape)); - let txt = std::fs::metadata(installed.join("pkg/bin/readme.txt")).unwrap(); - let ref_txt = std::fs::metadata(reference.join("pkg/bin/readme.txt")).unwrap(); - assert_eq!( - txt.permissions(), - ref_txt.permissions(), - "non-APE files keep their extracted permissions" - ); - } - /// A warm `staged_install` hit (install dir already exists) must count as /// a use in the DiskCache LRU, exactly like `is_cached()` does. The early /// return used to skip the touch, so a package restored from an Actions diff --git a/crates/fbuild-toolchain/src/toolchain/esp_qemu.rs b/crates/fbuild-toolchain/src/toolchain/esp_qemu.rs index 9f9b0b977..c03b915ba 100644 --- a/crates/fbuild-toolchain/src/toolchain/esp_qemu.rs +++ b/crates/fbuild-toolchain/src/toolchain/esp_qemu.rs @@ -135,7 +135,7 @@ impl EspQemu { hydrate_windows_runtime(&path)?; validate_windows_runtime(&path)?; path - } else if let Some(path) = find_on_path(self.arch.stem()) { + } else if let Some(path) = find_tool_on_path(self.arch.stem()) { hydrate_windows_runtime(&path)?; validate_windows_runtime(&path)?; path @@ -467,11 +467,21 @@ fn qemu_root(install_dir: &Path, arch: EspQemuArch) -> Result { } } -fn find_on_path(stem: &str) -> Option { +/// A host tool on PATH by stem, in any spelling `find_tool_on_paths` accepts +/// (native, or `.exe`/`.com`/extensionless APE). +fn find_tool_on_path(stem: &str) -> Option { let path_var = std::env::var_os("PATH")?; fbuild_core::platform::executable::find_tool_on_paths(std::env::split_paths(&path_var), stem) } +/// An exact file name on PATH (e.g. a DLL — never a tool-name expansion). +fn find_on_path(file_name: &str) -> Option { + let path_var = std::env::var_os("PATH")?; + std::env::split_paths(&path_var) + .map(|dir| dir.join(file_name)) + .find(|candidate| candidate.is_file()) +} + fn find_existing_idf_qemu(arch: EspQemuArch) -> Option { let tools_subdir = arch.subdir_name(); for root in candidate_idf_tools_roots() { From 5a98ea067846f839e707e12c6864e313a9179bc0 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Fri, 2 Oct 2026 20:09:01 -0700 Subject: [PATCH 03/12] fix(lint): APE + tool lookup on NormalizedPath; no tempdir/create_new/raw prefix compares Dylint (ban_std_pathbuf, ban_file_based_locks, ban_unrooted_tempdir, ban_raw_path_prefix_compare) rejected the new APE and tool-lookup code. - platform::ape and executable tool helpers carry NormalizedPath; callers in PathBuf-allowlisted files convert with into_path_buf(). - Loader installs use create+truncate on a pid/sequence-unique temp name in an owner-only dir instead of create_new. - Drop the $TMPDIR cache candidate. Binaries register fbuild's cache root via ape::set_default_cache_root(get_cache_root()/ape) (daemon + CLI startup); XDG/~/.cache, the runtime dir and the Linux memfd remain fallbacks. - memfd detection is an exact-identity is_anonymous() check, not a /proc/self prefix compare. --- .../src/zccache_embedded.rs | 1 + crates/fbuild-build/tests/it/ape_toolchain.rs | 15 +- crates/fbuild-cli/src/cli/debug.rs | 1 - crates/fbuild-cli/src/cli/symbols_cmd.rs | 2 +- crates/fbuild-cli/src/main.rs | 2 + .../fbuild-core/src/platform/ape/install.rs | 17 ++- crates/fbuild-core/src/platform/ape/mod.rs | 129 ++++++++++++------ crates/fbuild-core/src/platform/ape/native.rs | 46 +++++-- crates/fbuild-core/src/platform/executable.rs | 36 ++--- crates/fbuild-core/src/platform/linux/ape.rs | 40 ++++-- crates/fbuild-core/src/platform/macos/ape.rs | 15 +- .../fbuild-core/src/platform/windows/ape.rs | 10 +- .../src/handlers/operations/deploy.rs | 4 +- crates/fbuild-daemon/src/main.rs | 3 + crates/fbuild-deploy/src/lpc.rs | 1 + .../fbuild-deploy/src/lpc_debugger_reflash.rs | 2 +- crates/fbuild-deploy/src/probe_rs.rs | 17 +-- crates/fbuild-library/src/library/esptool.rs | 2 +- crates/fbuild-serial/src/crash_decoder.rs | 2 +- crates/fbuild-toolchain/src/toolchain/arm.rs | 2 +- .../src/toolchain/arm_gcc8.rs | 2 +- crates/fbuild-toolchain/src/toolchain/avr.rs | 2 +- .../fbuild-toolchain/src/toolchain/clang.rs | 4 +- .../fbuild-toolchain/src/toolchain/esp32.rs | 2 +- .../fbuild-toolchain/src/toolchain/esp8266.rs | 2 +- .../src/toolchain/esp_qemu.rs | 9 +- .../fbuild-toolchain/src/toolchain/riscv.rs | 2 +- .../src/toolchain/rp2040_picotool.rs | 1 + .../src/toolchain/rp2040_pqt.rs | 2 +- .../src/toolchain/teensy_arm.rs | 2 +- 30 files changed, 229 insertions(+), 146 deletions(-) diff --git a/crates/fbuild-build-engine/src/zccache_embedded.rs b/crates/fbuild-build-engine/src/zccache_embedded.rs index 6a3b49f28..7eb32b8fc 100644 --- a/crates/fbuild-build-engine/src/zccache_embedded.rs +++ b/crates/fbuild-build-engine/src/zccache_embedded.rs @@ -302,6 +302,7 @@ impl FbuildZccacheService { // 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()); let req = ZccacheCompileRequest { audit: default_audit_context(), diff --git a/crates/fbuild-build/tests/it/ape_toolchain.rs b/crates/fbuild-build/tests/it/ape_toolchain.rs index 260f32bea..a9db8897f 100644 --- a/crates/fbuild-build/tests/it/ape_toolchain.rs +++ b/crates/fbuild-build/tests/it/ape_toolchain.rs @@ -10,12 +10,13 @@ //! Opt-in: point `FBUILD_TEST_COSMOCC` at an unpacked //! (the directory holding `bin/`). -use std::path::{Path, PathBuf}; +use std::path::Path; use fbuild_build::zccache_embedded::FbuildZccacheService; +use fbuild_core::path::NormalizedPath; use zccache::embedded::ShutdownMode; -fn cosmo_gcc() -> PathBuf { +fn cosmo_gcc() -> NormalizedPath { let root = std::env::var_os("FBUILD_TEST_COSMOCC").expect("FBUILD_TEST_COSMOCC"); let arch = fbuild_core::platform::host::arch_name(); let gcc = Path::new(&root) @@ -26,7 +27,7 @@ fn cosmo_gcc() -> PathBuf { "{} must be an APE image", gcc.display() ); - gcc + NormalizedPath::from(gcc) } /// A packaged-toolchain layout of the APE gcc. Raw cosmocc gcc has no @@ -35,7 +36,7 @@ fn cosmo_gcc() -> PathBuf { /// package ships its gcc configured, so mirror that: a tree of symlinks into /// the unpacked cosmocc with the gcc APE image copied in and a GCC `specs` file /// adding the toolchain's include root. -fn packaged_cosmo_gcc(into: &Path) -> PathBuf { +fn packaged_cosmo_gcc(into: &Path) -> NormalizedPath { let src = cosmo_gcc(); let root = src.parent().unwrap().parent().unwrap(); let triple = format!("{}-linux-cosmo", fbuild_core::platform::host::arch_name()); @@ -68,7 +69,7 @@ fn packaged_cosmo_gcc(into: &Path) -> PathBuf { format!("*cpp:\n+ -isystem {}\n\n", root.join("include").display()), ) .unwrap(); - gcc + NormalizedPath::from(gcc) } fn link(original: &Path, link: &Path) -> std::io::Result<()> { @@ -79,10 +80,10 @@ fn link(original: &Path, link: &Path) -> std::io::Result<()> { } } -fn source(dir: &Path) -> PathBuf { +fn source(dir: &Path) -> NormalizedPath { let src = dir.join("add.c"); std::fs::write(&src, "int add(int a, int b) { return a + b; }\n").unwrap(); - src + NormalizedPath::from(src) } fn compile_args(src: &Path, obj: &Path) -> Vec { diff --git a/crates/fbuild-cli/src/cli/debug.rs b/crates/fbuild-cli/src/cli/debug.rs index 89d044d52..a5a87d36b 100644 --- a/crates/fbuild-cli/src/cli/debug.rs +++ b/crates/fbuild-cli/src/cli/debug.rs @@ -263,7 +263,6 @@ pub(crate) fn find_gdb_in_dirs( candidate_names .iter() .find_map(|name| fbuild_core::platform::executable::find_tool_in(dir, name)) - .map(NormalizedPath::from) }) } diff --git a/crates/fbuild-cli/src/cli/symbols_cmd.rs b/crates/fbuild-cli/src/cli/symbols_cmd.rs index 2d57fd282..ab3abbbc5 100644 --- a/crates/fbuild-cli/src/cli/symbols_cmd.rs +++ b/crates/fbuild-cli/src/cli/symbols_cmd.rs @@ -226,7 +226,7 @@ fn find_nm_on_path() -> Result { if let Some(found) = fbuild_core::platform::executable::find_tool_on_paths(std::env::split_paths(&path), "nm") { - return Ok(found); + return Ok(found.into_path_buf()); } Err(FbuildError::BuildFailed(format!( "{exe_name} not found on PATH; pass --nm to point at a cross toolchain nm" diff --git a/crates/fbuild-cli/src/main.rs b/crates/fbuild-cli/src/main.rs index 5c4eb41c0..dce281076 100644 --- a/crates/fbuild-cli/src/main.rs +++ b/crates/fbuild-cli/src/main.rs @@ -22,6 +22,8 @@ fn main() { } fn async_main_entry() { + // APE (cosmocc) host tools: keep extracted loaders under fbuild's own cache. + fbuild_core::platform::ape::set_default_cache_root(fbuild_paths::get_cache_root().join("ape")); // FastLED/fbuild#1285: derive the dev daemon-identity stamp once, at the // top level, and export the value so every child — including the spawned // daemon — inherits it instead of re-hashing per invocation. Official diff --git a/crates/fbuild-core/src/platform/ape/install.rs b/crates/fbuild-core/src/platform/ape/install.rs index e7f5bed1f..62b62f01f 100644 --- a/crates/fbuild-core/src/platform/ape/install.rs +++ b/crates/fbuild-core/src/platform/ape/install.rs @@ -7,7 +7,9 @@ use std::fs::OpenOptions; use std::io::Write; -use std::path::{Path, PathBuf}; +use std::path::Path; + +use crate::path::NormalizedPath; use std::sync::atomic::{AtomicU64, Ordering}; use super::super::{fs, process}; @@ -16,11 +18,11 @@ use super::super::{fs, process}; /// candidate `dir`, reusing an identical existing file. `None` when no /// candidate is usable. pub(crate) fn install( - dirs: &[PathBuf], + dirs: &[NormalizedPath], subdir: Option<&str>, name: &str, bytes: &[u8], -) -> Option { +) -> Option { dirs.iter().find_map(|dir| { let dir = match subdir { Some(sub) => { @@ -40,14 +42,16 @@ fn usable_dir(dir: &Path) -> bool { fs::ensure_private_dir(dir) && fs::mount_allows_exec(dir) } -pub(crate) fn install_in(dir: &Path, bytes: &[u8], name: &str) -> Option { +pub(crate) fn install_in(dir: &Path, bytes: &[u8], name: &str) -> Option { if !usable_dir(dir) { return None; } - let target = dir.join(name); + let target = NormalizedPath::new(dir.join(name)); if is_installed(&target, bytes) { return Some(target); } + // Unique per process and attempt inside an owner-only directory, so a + // plain create+truncate can't collide with another writer. static SEQ: AtomicU64 = AtomicU64::new(0); let tmp = dir.join(format!( ".{name}.{}.{}", @@ -60,7 +64,8 @@ pub(crate) fn install_in(dir: &Path, bytes: &[u8], name: &str) -> Option bool { /// followed by the caller's original arguments. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ApeLaunch { - pub loader: PathBuf, - pub image: PathBuf, + 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, + pub ape_path_dir: Option, } impl ApeLaunch { @@ -104,7 +106,8 @@ impl ApeLaunch { let dir = self.ape_path_dir.as_ref()?; let rest = inherited.filter(|p| !p.is_empty()); std::env::join_paths( - std::iter::once(dir.clone()).chain(rest.into_iter().flat_map(std::env::split_paths)), + std::iter::once(dir.as_path().to_path_buf()) + .chain(rest.into_iter().flat_map(std::env::split_paths)), ) .ok() } @@ -135,11 +138,7 @@ pub fn plan_launch( }; let path_var = overlay_var("PATH"); let loader_override = overlay_var(LOADER_ENV); - let mut cache_dirs: Vec = overlay_var(CACHE_DIR_ENV) - .map(PathBuf::from) - .into_iter() - .collect(); - cache_dirs.extend(super::selected::ape::default_loader_dirs()); + let cache_dirs = cache_dirs(overlay_var(CACHE_DIR_ENV)); plan_launch_for( host, program, @@ -150,6 +149,28 @@ pub fn plan_launch( ) } +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( @@ -158,7 +179,7 @@ pub fn plan_launch_for( cwd: Option<&Path>, path_var: Option<&OsStr>, loader_override: Option<&OsStr>, - cache_dirs: &[PathBuf], + cache_dirs: &[NormalizedPath], ) -> Option { if host.is_windows() { return None; @@ -188,39 +209,41 @@ fn resolve_program( program: &OsStr, cwd: Option<&Path>, path_var: Option<&OsStr>, -) -> Option { +) -> 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(); + return std::path::absolute(resolved).ok().map(NormalizedPath::new); } find_executable_on_path(program, path_var) } -fn resolve_explicit_loader(explicit: &OsStr, path_var: Option<&OsStr>) -> Option { +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(explicit.to_path_buf()); + return Some(NormalizedPath::new(explicit)); } find_executable_on_path(explicit.as_os_str(), path_var) } -fn resolve_host_loader(path_var: Option<&OsStr>) -> Option { +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(PathBuf::from))) - .or_else(|| first_executable([PathBuf::from("/bin/sh")])) + .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 { +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| dir.join(name))) + first_executable(dirs.map(|dir| NormalizedPath::new(dir.join(name)))) } -fn first_executable(candidates: impl IntoIterator) -> Option { +fn first_executable( + candidates: impl IntoIterator, +) -> Option { candidates .into_iter() .find(|candidate| is_executable_file(candidate)) @@ -233,12 +256,18 @@ fn is_executable_file(path: &Path) -> bool { /// 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 = (PathBuf, u64, Option, HostPlatform, Vec); - -fn image_key(image: &Path, host: HostPlatform, dirs: &[PathBuf]) -> Option { +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(( - image.to_path_buf(), + NormalizedPath::new(image), meta.len(), meta.modified().ok(), host, @@ -247,21 +276,21 @@ fn image_key(image: &Path, host: HostPlatform, dirs: &[PathBuf]) -> Option>>); +struct Memo(Mutex>>); impl Memo { const fn new() -> Self { Self(Mutex::new(None)) } - fn get(&self, key: &ImageKey) -> Option { + 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: PathBuf) { + 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); } @@ -273,7 +302,11 @@ static LOADER_MEMO: Memo = Memo::new(); /// 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: &[PathBuf]) -> Option { +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); @@ -304,12 +337,13 @@ fn embedded_loader(image: &Path, host: HostPlatform, cache_dirs: &[PathBuf]) -> /// 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: &[PathBuf]) -> Option { - if loader.file_name().is_some_and(|n| n == "sh") { +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") && !loader.starts_with("/proc/self") { - return loader.parent().map(Path::to_path_buf); + 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 { @@ -318,12 +352,16 @@ fn ape_path_dir(loader: &Path, cache_dirs: &[PathBuf]) -> Option { let sub = format!("bin-{}", short_hash(&bytes)); install::install(cache_dirs, Some(&sub), "ape", &bytes)? .parent() - .map(Path::to_path_buf) + .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: &[PathBuf]) -> Option { +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)) } @@ -436,7 +474,7 @@ pub fn extract_macos_aarch64_loader_source(image: &Path) -> Option> { /// 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: &[PathBuf]) -> Option { +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 @@ -463,8 +501,8 @@ fn compile_macos_aarch64_loader(image: &Path, dirs: &[PathBuf]) -> Option PathBuf { + 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(); - path + NormalizedPath::new(path) } #[test] @@ -585,7 +623,7 @@ mod tests { &[], ) .expect("APE must be planned"); - assert_eq!(plan.loader, PathBuf::from("/opt/cosmo/ape")); + assert_eq!(plan.loader, NormalizedPath::from("/opt/cosmo/ape")); assert_eq!(plan.image, ape); } @@ -669,8 +707,8 @@ mod tests { } /// The checked-in cosmocc hello-world (`data/ape-hello`). - fn hello_fixture() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("data/ape-hello/hello.com") + fn hello_fixture() -> NormalizedPath { + NormalizedPath::new(Path::new(env!("CARGO_MANIFEST_DIR")).join("data/ape-hello/hello.com")) } #[test] @@ -723,10 +761,10 @@ mod tests { assert_eq!(stdout, "hello world std\n"); } - fn copy_fixture(dir: &Path, name: &str) -> PathBuf { + fn copy_fixture(dir: &Path, name: &str) -> NormalizedPath { let path = dir.join(name); std::fs::copy(hello_fixture(), &path).unwrap(); - path + NormalizedPath::new(path) } /// Hostile/corrupt images never panic and never yield a loader. @@ -859,6 +897,7 @@ mod tests { 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(), diff --git a/crates/fbuild-core/src/platform/ape/native.rs b/crates/fbuild-core/src/platform/ape/native.rs index 26beae9f1..a73f6df49 100644 --- a/crates/fbuild-core/src/platform/ape/native.rs +++ b/crates/fbuild-core/src/platform/ape/native.rs @@ -15,7 +15,9 @@ //! Shims live in content-addressed subdirectories of the APE cache //! (`shim-/`). -use std::path::{Path, PathBuf}; +use std::path::Path; + +use crate::path::NormalizedPath; use super::super::host::{self, HostPlatform}; use super::{ @@ -28,14 +30,13 @@ static NATIVE_MEMO: Memo = Memo::new(); /// `program` is an APE image on a host that can't exec it natively. `None` /// for non-APE programs, on Windows (APE runs natively), or when nothing /// could be materialized — callers then use `program` unchanged. -pub fn native_executable(program: &Path) -> Option { +pub fn native_executable(program: &Path) -> Option { let host = host::current(); if host.is_windows() || !is_ape_file(program) { return None; } let env = |key: &str| std::env::var_os(key).filter(|v| !v.is_empty()); - let mut dirs: Vec = env(CACHE_DIR_ENV).map(PathBuf::from).into_iter().collect(); - dirs.extend(super::super::selected::ape::default_loader_dirs()); + let dirs = super::cache_dirs(env(CACHE_DIR_ENV)); native_executable_for( host, program, @@ -49,10 +50,10 @@ pub fn native_executable(program: &Path) -> Option { pub fn native_executable_for( host: HostPlatform, program: &Path, - dirs: &[PathBuf], + dirs: &[NormalizedPath], loader_override: Option<&std::ffi::OsStr>, path_var: Option<&std::ffi::OsStr>, -) -> Option { +) -> Option { if host.is_windows() || !is_ape_file(program) { return None; } @@ -72,7 +73,7 @@ pub fn native_executable_for( )?; // A memfd path is only valid in this process's direct children; the // shim's exec runs one level further down. - if launch.loader.starts_with("/proc/self") { + if super::super::selected::ape::is_anonymous(&launch.loader) { return None; } let mut shim = String::from("#!/bin/sh\n"); @@ -107,8 +108,8 @@ mod tests { const LINUX_X64: HostPlatform = HostPlatform::new(HostOs::Linux, HostArch::X86_64); const WINDOWS: HostPlatform = HostPlatform::new(HostOs::Windows, HostArch::X86_64); - fn hello() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("data/ape-hello/hello.com") + fn hello() -> NormalizedPath { + NormalizedPath::new(Path::new(env!("CARGO_MANIFEST_DIR")).join("data/ape-hello/hello.com")) } #[test] @@ -123,13 +124,16 @@ mod tests { let shim = native_executable_for( host::current(), &image, - &[cache.path().to_path_buf()], + &[NormalizedPath::new(cache.path())], None, None, ) .expect("shim"); assert_eq!(shim.file_name(), image.file_name()); - assert!(shim.starts_with(cache.path())); + assert!( + shim.relative_to(&NormalizedPath::from(cache.path())) + .is_some() + ); assert!(!is_ape_file(&shim), "the shim itself is a plain script"); let text = std::fs::read_to_string(&shim).unwrap(); assert!(text.starts_with("#!/bin/sh\nPATH="), "{text}"); @@ -151,7 +155,7 @@ mod tests { native_executable_for( host::current(), &image, - &[cache.path().to_path_buf()], + &[NormalizedPath::new(cache.path())], None, None ), @@ -163,13 +167,25 @@ mod tests { fn windows_non_ape_and_unusable_cache_yield_none() { let dir = tempfile::tempdir().unwrap(); assert_eq!( - native_executable_for(WINDOWS, &hello(), &[dir.path().to_path_buf()], None, None), + native_executable_for( + WINDOWS, + &hello(), + &[NormalizedPath::new(dir.path())], + None, + None + ), None ); let script = dir.path().join("tool"); std::fs::write(&script, "#!/bin/sh\n").unwrap(); assert_eq!( - native_executable_for(LINUX_X64, &script, &[dir.path().to_path_buf()], None, None), + native_executable_for( + LINUX_X64, + &script, + &[NormalizedPath::new(dir.path())], + None, + None + ), None ); let file_parent = dir.path().join("f"); @@ -178,7 +194,7 @@ mod tests { native_executable_for( LINUX_X64, &hello(), - &[file_parent.join("cache")], + &[NormalizedPath::new(file_parent.join("cache"))], None, None ), diff --git a/crates/fbuild-core/src/platform/executable.rs b/crates/fbuild-core/src/platform/executable.rs index e9060e581..2975977f6 100644 --- a/crates/fbuild-core/src/platform/executable.rs +++ b/crates/fbuild-core/src/platform/executable.rs @@ -3,7 +3,7 @@ use super::host::{self, HostArch, HostPlatform}; use crate::path::NormalizedPath; use std::io; -use std::path::{Component, Path, PathBuf}; +use std::path::{Component, Path}; /// Select the spelling of an executable or command script for an explicit host. pub const fn name_for<'a>(host: HostPlatform, non_windows: &'a str, windows: &'a str) -> &'a str { @@ -85,7 +85,7 @@ pub fn tool_candidate_names(stem: &str) -> Vec { /// accepted, any other spelling only when it is an APE image (so a /// Windows-only PE `tool.exe` is not picked on Linux, and an extensionless /// shell script is not picked on Windows). -pub fn find_tool_in_for(host: HostPlatform, dir: &Path, stem: &str) -> Option { +pub fn find_tool_in_for(host: HostPlatform, dir: &Path, stem: &str) -> Option { let native = native_name_for(host, stem); tool_candidate_names_for(host, stem) .into_iter() @@ -94,18 +94,18 @@ pub fn find_tool_in_for(host: HostPlatform, dir: &Path, stem: &str) -> Option Option { +pub fn find_tool_in(dir: &Path, stem: &str) -> Option { find_tool_in_for(host::current(), dir, stem) } /// Find a tool named `stem` anywhere below `root` (depth-first), applying /// [`find_tool_in`]'s acceptance rules in each directory. Used for archives /// whose binary sits under an unknown nesting of top-level folders. -pub fn find_tool_in_tree(root: &Path, stem: &str) -> Option { +pub fn find_tool_in_tree(root: &Path, stem: &str) -> Option { if let Some(found) = find_tool_in(root, stem) { return Some(found); } @@ -119,7 +119,7 @@ pub fn find_tool_in_tree(root: &Path, stem: &str) -> Option { /// Find a tool on `PATH` (or any list of search directories) by stem, using /// [`find_tool_in`]'s per-directory candidate order and acceptance rules. -pub fn find_tool_on_paths(dirs: I, stem: &str) -> Option +pub fn find_tool_on_paths(dirs: I, stem: &str) -> Option where I: IntoIterator, I::Item: AsRef, @@ -130,15 +130,16 @@ where /// Resolve a package-provided tool in `dir` for an explicit host, falling back /// to the host-native spelling when no acceptable candidate exists. -pub fn resolve_tool_in_for(host: HostPlatform, dir: &Path, stem: &str) -> PathBuf { - find_tool_in_for(host, dir, stem).unwrap_or_else(|| dir.join(native_name_for(host, stem))) +pub fn resolve_tool_in_for(host: HostPlatform, dir: &Path, stem: &str) -> NormalizedPath { + find_tool_in_for(host, dir, stem) + .unwrap_or_else(|| NormalizedPath::from(dir.join(native_name_for(host, stem)))) } /// Resolve a package-provided tool in `dir` on the current host. /// /// Returns [`find_tool_in`]'s hit, or `dir/` when nothing usable /// exists so callers keep reporting the conventional missing path. -pub fn resolve_tool_in(dir: &Path, stem: &str) -> PathBuf { +pub fn resolve_tool_in(dir: &Path, stem: &str) -> NormalizedPath { resolve_tool_in_for(host::current(), dir, stem) } @@ -202,6 +203,7 @@ pub fn current_image_sibling_candidates(stem: &str) -> io::Result<[NormalizedPat #[cfg(test)] mod tests { + use crate::path::NormalizedPath; use crate::platform::host::{HostArch, HostOs, HostPlatform}; #[test] @@ -257,7 +259,7 @@ mod tests { write_ape(&dir.path().join("tool.com")); assert_eq!( super::find_tool_in_for(LINUX, dir.path(), "tool"), - Some(dir.path().join("tool")) + Some(NormalizedPath::from(dir.path().join("tool"))) ); } @@ -267,14 +269,14 @@ mod tests { write_ape(&dir.path().join("tool.com")); assert_eq!( super::find_tool_in_for(LINUX, dir.path(), "tool"), - Some(dir.path().join("tool.com")) + Some(NormalizedPath::from(dir.path().join("tool.com"))) ); let dir = tempfile::tempdir().unwrap(); write_ape(&dir.path().join("tool.exe")); assert_eq!( super::find_tool_in_for(MACOS_ARM, dir.path(), "tool"), - Some(dir.path().join("tool.exe")) + Some(NormalizedPath::from(dir.path().join("tool.exe"))) ); } @@ -285,7 +287,7 @@ mod tests { assert_eq!(super::find_tool_in_for(LINUX, dir.path(), "tool"), None); assert_eq!( super::resolve_tool_in_for(LINUX, dir.path(), "tool"), - dir.path().join("tool") + NormalizedPath::from(dir.path().join("tool")) ); } @@ -296,19 +298,19 @@ mod tests { assert_eq!(super::find_tool_in_for(WINDOWS, dir.path(), "tool"), None); assert_eq!( super::resolve_tool_in_for(WINDOWS, dir.path(), "tool"), - dir.path().join("tool.exe") + NormalizedPath::from(dir.path().join("tool.exe")) ); write_ape(&dir.path().join("tool.com")); assert_eq!( super::find_tool_in_for(WINDOWS, dir.path(), "tool"), - Some(dir.path().join("tool.com")) + Some(NormalizedPath::from(dir.path().join("tool.com"))) ); write_plain(&dir.path().join("tool.exe"), b"MZ\x90\x00"); assert_eq!( super::find_tool_in_for(WINDOWS, dir.path(), "tool"), - Some(dir.path().join("tool.exe")) + Some(NormalizedPath::from(dir.path().join("tool.exe"))) ); } @@ -319,7 +321,7 @@ mod tests { write_ape(&dir.path().join("tool")); assert_eq!( super::find_tool_in_for(WINDOWS, dir.path(), "tool"), - Some(dir.path().join("tool")) + Some(NormalizedPath::from(dir.path().join("tool"))) ); } diff --git a/crates/fbuild-core/src/platform/linux/ape.rs b/crates/fbuild-core/src/platform/linux/ape.rs index 5bf7f96bf..1b32a4027 100644 --- a/crates/fbuild-core/src/platform/linux/ape.rs +++ b/crates/fbuild-core/src/platform/linux/ape.rs @@ -9,12 +9,12 @@ use std::fs::File; use std::io::Write; use std::os::fd::{AsRawFd, FromRawFd}; use std::os::unix::fs::PermissionsExt; -use std::path::PathBuf; +use crate::path::NormalizedPath; use std::sync::Mutex; /// Default candidate directories, most durable first. -pub(crate) fn default_loader_dirs() -> Vec { - let env_dir = |key: &str| std::env::var_os(key).filter(|v| !v.is_empty()).map(PathBuf::from); +pub(crate) fn default_loader_dirs() -> Vec { + let env_dir = |key: &str| std::env::var_os(key).filter(|v| !v.is_empty()).map(NormalizedPath::new); let mut dirs = Vec::new(); if let Some(cache) = env_dir("XDG_CACHE_HOME").or_else(|| env_dir("HOME").map(|h| h.join(".cache"))) { dirs.push(cache.join("fbuild").join("ape")); @@ -22,22 +22,28 @@ pub(crate) fn default_loader_dirs() -> Vec { if let Some(runtime) = env_dir("XDG_RUNTIME_DIR") { dirs.push(runtime.join("fbuild").join("ape")); } - // SAFETY: geteuid has no preconditions and cannot fail. - let uid = unsafe { libc::geteuid() }; - dirs.push(std::env::temp_dir().join(format!("fbuild-ape-{uid}"))); dirs } +/// Whether `path` is one of this process's anonymous (memfd) executables. +/// Exact identity, not a prefix test: only paths handed out here qualify. +pub(crate) fn is_anonymous(path: &std::path::Path) -> bool { + let guard = MEMFDS.lock().unwrap_or_else(|e| e.into_inner()); + guard + .as_ref() + .is_some_and(|fds| fds.values().any(|file| fd_path(file).as_path() == path)) +} + /// Last-resort executable with no filesystem home: a sealed memfd exec'd via /// `/proc/self/fd/N`. Valid only for direct children of this process. -pub(crate) fn anonymous_executable(bytes: &[u8], name: &str) -> Option { +pub(crate) fn anonymous_executable(bytes: &[u8], name: &str) -> Option { memfd_loader(bytes, name) } /// Sealed memfds kept open for the process lifetime, keyed by loader name. static MEMFDS: Mutex>> = Mutex::new(None); -fn memfd_loader(bytes: &[u8], name: &str) -> Option { +fn memfd_loader(bytes: &[u8], name: &str) -> Option { let mut guard = MEMFDS.lock().unwrap_or_else(|e| e.into_inner()); let fds = guard.get_or_insert_with(HashMap::new); if let Some(file) = fds.get(name) { @@ -83,8 +89,8 @@ fn create_sealed_memfd(bytes: &[u8], name: &str) -> Option { Some(file) } -fn fd_path(file: &File) -> PathBuf { - PathBuf::from(format!("/proc/self/fd/{}", file.as_raw_fd())) +fn fd_path(file: &File) -> NormalizedPath { + NormalizedPath::from(format!("/proc/self/fd/{}", file.as_raw_fd())) } #[cfg(test)] @@ -95,8 +101,8 @@ mod tests { use std::fs::OpenOptions; use std::path::Path; - fn hello() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("data/ape-hello/hello.com") + fn hello() -> NormalizedPath { + NormalizedPath::new(Path::new(env!("CARGO_MANIFEST_DIR")).join("data/ape-hello/hello.com")) } fn host_loader() -> Vec { @@ -121,7 +127,7 @@ mod tests { #[test] fn memfd_loader_runs_real_image() { let loader = memfd_loader(&host_loader(), "ape-loader-test-memfd").expect("memfd"); - assert!(loader.starts_with("/proc/self/fd")); + assert!(is_anonymous(&loader)); let out = run(&loader, &["memfd"]); assert!(out.success(), "stderr: {}", out.stderr); assert_eq!(out.stdout, "hello world memfd\n"); @@ -139,10 +145,14 @@ mod tests { let shared = root.path().join("shared"); std::fs::create_dir(&shared).unwrap(); std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o777)).unwrap(); - let dirs = [readonly.join("cache"), shared.clone(), PathBuf::from("/proc/fbuild-nope")]; + let dirs = [ + NormalizedPath::new(readonly.join("cache")), + NormalizedPath::new(&shared), + NormalizedPath::from("/proc/fbuild-nope"), + ]; let loader = crate::platform::ape::materialize(&host_loader(), "ape-loader-test-fallback", &dirs).unwrap(); - assert!(loader.starts_with("/proc/self/fd"), "got {}", loader.display()); + assert!(is_anonymous(&loader), "got {}", loader.display()); assert!(std::fs::read_dir(&shared).unwrap().next().is_none(), "nothing planted in shared dir"); let out = run(&loader, &["fallback"]); assert_eq!(out.stdout, "hello world fallback\n", "stderr: {}", out.stderr); diff --git a/crates/fbuild-core/src/platform/macos/ape.rs b/crates/fbuild-core/src/platform/macos/ape.rs index 35c1eccb3..fe2a37ab2 100644 --- a/crates/fbuild-core/src/platform/macos/ape.rs +++ b/crates/fbuild-core/src/platform/macos/ape.rs @@ -2,14 +2,14 @@ //! anonymous-executable fallback (no memfd), so a usable cache directory is //! required; `$TMPDIR` is already per-user there. -use std::path::PathBuf; +use crate::path::NormalizedPath; /// Default candidate directories, most durable first. -pub(crate) fn default_loader_dirs() -> Vec { +pub(crate) fn default_loader_dirs() -> Vec { let env_dir = |key: &str| { std::env::var_os(key) .filter(|v| !v.is_empty()) - .map(PathBuf::from) + .map(NormalizedPath::new) }; let mut dirs = Vec::new(); if let Some(cache) = env_dir("XDG_CACHE_HOME") { @@ -18,12 +18,13 @@ pub(crate) fn default_loader_dirs() -> Vec { if let Some(home) = env_dir("HOME") { dirs.push(home.join("Library").join("Caches").join("fbuild").join("ape")); } - // SAFETY: geteuid has no preconditions and cannot fail. - let uid = unsafe { libc::geteuid() }; - dirs.push(std::env::temp_dir().join(format!("fbuild-ape-{uid}"))); dirs } -pub(crate) fn anonymous_executable(_bytes: &[u8], _name: &str) -> Option { +pub(crate) fn anonymous_executable(_bytes: &[u8], _name: &str) -> Option { None } + +pub(crate) fn is_anonymous(_path: &std::path::Path) -> bool { + false +} diff --git a/crates/fbuild-core/src/platform/windows/ape.rs b/crates/fbuild-core/src/platform/windows/ape.rs index 4cd1faf72..851dc29c1 100644 --- a/crates/fbuild-core/src/platform/windows/ape.rs +++ b/crates/fbuild-core/src/platform/windows/ape.rs @@ -1,12 +1,16 @@ //! Windows runs APE images natively (an APE file is a valid PE), so there is //! nothing to extract or cache. -use std::path::PathBuf; +use crate::path::NormalizedPath; -pub(crate) fn default_loader_dirs() -> Vec { +pub(crate) fn default_loader_dirs() -> Vec { Vec::new() } -pub(crate) fn anonymous_executable(_bytes: &[u8], _name: &str) -> Option { +pub(crate) fn anonymous_executable(_bytes: &[u8], _name: &str) -> Option { None } + +pub(crate) fn is_anonymous(_path: &std::path::Path) -> bool { + false +} diff --git a/crates/fbuild-daemon/src/handlers/operations/deploy.rs b/crates/fbuild-daemon/src/handlers/operations/deploy.rs index 3fff14772..c84152eb6 100644 --- a/crates/fbuild-daemon/src/handlers/operations/deploy.rs +++ b/crates/fbuild-daemon/src/handlers/operations/deploy.rs @@ -35,7 +35,7 @@ fn find_teensy_loader_cli() -> Option { if let Some(path_env) = std::env::var_os("PATH") { if let Some(found) = find_tool_on_paths(std::env::split_paths(&path_env), STEM) { - return Some(found); + return Some(found.into_path_buf()); } } @@ -51,7 +51,7 @@ fn find_teensy_loader_cli() -> Option { .join(".platformio") .join("packages") .join("tool-teensy"); - find_tool_in(&pio_tool_dir, STEM) + find_tool_in(&pio_tool_dir, STEM).map(fbuild_core::path::NormalizedPath::into_path_buf) } /// POST /api/deploy diff --git a/crates/fbuild-daemon/src/main.rs b/crates/fbuild-daemon/src/main.rs index 0e7be2237..e21ae348f 100644 --- a/crates/fbuild-daemon/src/main.rs +++ b/crates/fbuild-daemon/src/main.rs @@ -106,6 +106,9 @@ async fn main() { eprintln!("warning: failed to install process containment: {}", e); } + // APE (cosmocc) host tools: keep extracted loaders under fbuild's own cache. + fbuild_core::platform::ape::set_default_cache_root(fbuild_paths::get_cache_root().join("ape")); + let port = args.port.unwrap_or_else(fbuild_paths::get_daemon_port); // Build the broadcast hub before installing the tracing subscriber diff --git a/crates/fbuild-deploy/src/lpc.rs b/crates/fbuild-deploy/src/lpc.rs index b8c1f4eb1..1231e893b 100644 --- a/crates/fbuild-deploy/src/lpc.rs +++ b/crates/fbuild-deploy/src/lpc.rs @@ -64,6 +64,7 @@ pub fn find_lpc21isp() -> Option { } fbuild_core::platform::executable::find_tool_in(&fbuild_paths::try_get_tools_dir()?, "lpc21isp") + .map(fbuild_core::path::NormalizedPath::into_path_buf) } /// Build the "install lpc21isp" hint that surfaces on the failing diff --git a/crates/fbuild-deploy/src/lpc_debugger_reflash.rs b/crates/fbuild-deploy/src/lpc_debugger_reflash.rs index 6ea22f83e..038a4e1e0 100644 --- a/crates/fbuild-deploy/src/lpc_debugger_reflash.rs +++ b/crates/fbuild-deploy/src/lpc_debugger_reflash.rs @@ -105,7 +105,7 @@ fn find_dfu_util_with_override(env_override: Option) -> Opti } } let tools = managed_tools_dir()?; - fbuild_core::platform::executable::find_tool_in(&tools, "dfu-util").map(NormalizedPath::from) + fbuild_core::platform::executable::find_tool_in(&tools, "dfu-util") } /// Resolve the CMSIS-DAP hex to flash. Precedence: diff --git a/crates/fbuild-deploy/src/probe_rs.rs b/crates/fbuild-deploy/src/probe_rs.rs index 0482b50a8..8421df51c 100644 --- a/crates/fbuild-deploy/src/probe_rs.rs +++ b/crates/fbuild-deploy/src/probe_rs.rs @@ -150,7 +150,6 @@ pub fn find_probe_rs() -> Option { } fbuild_core::platform::executable::find_tool_in(&managed_probe_rs_dir()?, "probe-rs") - .map(NormalizedPath::from) } /// Resolve and, if needed, install the pinned FastLED/probe-rs binary. @@ -289,15 +288,13 @@ fn probe_rs_temp_install_path(dest_path: &Path) -> NormalizedPath { } fn find_extracted_probe_rs_binary(root: &Path) -> Result { - fbuild_core::platform::executable::find_tool_in_tree(root, "probe-rs") - .map(NormalizedPath::from) - .ok_or_else(|| { - let exe = fbuild_core::platform::executable::native_name("probe-rs"); - FbuildError::PackageError(format!( - "probe-rs binary `{exe}` not found after extracting {}", - root.display() - )) - }) + fbuild_core::platform::executable::find_tool_in_tree(root, "probe-rs").ok_or_else(|| { + let exe = fbuild_core::platform::executable::native_name("probe-rs"); + FbuildError::PackageError(format!( + "probe-rs binary `{exe}` not found after extracting {}", + root.display() + )) + }) } /// Map an fbuild `BoardConfig` to the `--chip` name probe-rs expects. diff --git a/crates/fbuild-library/src/library/esptool.rs b/crates/fbuild-library/src/library/esptool.rs index b02e73d34..6ad6683f3 100644 --- a/crates/fbuild-library/src/library/esptool.rs +++ b/crates/fbuild-library/src/library/esptool.rs @@ -525,7 +525,7 @@ fn esptool_bin_name() -> &'static str { fn find_esptool_binary(root: &Path) -> Option { fn search(dir: &Path, depth: usize) -> Option { if let Some(candidate) = fbuild_core::platform::executable::find_tool_in(dir, "esptool") { - return Some(NormalizedPath::from(candidate)); + return Some(candidate); } if depth == 0 { return None; diff --git a/crates/fbuild-serial/src/crash_decoder.rs b/crates/fbuild-serial/src/crash_decoder.rs index 0b89e9a44..b8473b033 100644 --- a/crates/fbuild-serial/src/crash_decoder.rs +++ b/crates/fbuild-serial/src/crash_decoder.rs @@ -399,7 +399,7 @@ pub fn derive_addr2line_path(cc_path: &Path) -> Option { if same_suffix.is_file() { return Some(same_suffix); } - find_tool_in(bin_dir, &tool_stem) + find_tool_in(bin_dir, &tool_stem).map(fbuild_core::path::NormalizedPath::into_path_buf) } #[cfg(test)] diff --git a/crates/fbuild-toolchain/src/toolchain/arm.rs b/crates/fbuild-toolchain/src/toolchain/arm.rs index 0075b6799..478241f24 100644 --- a/crates/fbuild-toolchain/src/toolchain/arm.rs +++ b/crates/fbuild-toolchain/src/toolchain/arm.rs @@ -247,7 +247,7 @@ fn find_bin_root(install_dir: &Path) -> PathBuf { /// Get the full path to a tool binary (native spelling, or a `.com`/`.exe` APE). fn tool_binary(bin_dir: &Path, name: &str) -> PathBuf { - fbuild_core::platform::executable::resolve_tool_in(bin_dir, name) + fbuild_core::platform::executable::resolve_tool_in(bin_dir, name).into_path_buf() } #[cfg(test)] diff --git a/crates/fbuild-toolchain/src/toolchain/arm_gcc8.rs b/crates/fbuild-toolchain/src/toolchain/arm_gcc8.rs index e3feb4c5d..644ace99d 100644 --- a/crates/fbuild-toolchain/src/toolchain/arm_gcc8.rs +++ b/crates/fbuild-toolchain/src/toolchain/arm_gcc8.rs @@ -165,7 +165,7 @@ fn find_bin_root(install_dir: &Path) -> PathBuf { /// Get the full path to a tool binary (native spelling, or a `.com`/`.exe` APE). fn tool_binary(bin_dir: &Path, name: &str) -> PathBuf { - fbuild_core::platform::executable::resolve_tool_in(bin_dir, name) + fbuild_core::platform::executable::resolve_tool_in(bin_dir, name).into_path_buf() } #[cfg(test)] diff --git a/crates/fbuild-toolchain/src/toolchain/avr.rs b/crates/fbuild-toolchain/src/toolchain/avr.rs index baeabd0c4..bdf2867e5 100644 --- a/crates/fbuild-toolchain/src/toolchain/avr.rs +++ b/crates/fbuild-toolchain/src/toolchain/avr.rs @@ -257,7 +257,7 @@ fn find_bin_root(install_dir: &Path) -> PathBuf { /// Get the full path to a tool binary (native spelling, or a `.com`/`.exe` APE). fn tool_binary(bin_dir: &Path, name: &str) -> PathBuf { - fbuild_core::platform::executable::resolve_tool_in(bin_dir, name) + fbuild_core::platform::executable::resolve_tool_in(bin_dir, name).into_path_buf() } #[cfg(test)] diff --git a/crates/fbuild-toolchain/src/toolchain/clang.rs b/crates/fbuild-toolchain/src/toolchain/clang.rs index 34b7da3e0..da8068b2b 100644 --- a/crates/fbuild-toolchain/src/toolchain/clang.rs +++ b/crates/fbuild-toolchain/src/toolchain/clang.rs @@ -261,7 +261,7 @@ pub fn find_binary_in_dir(dir: &Path, stem: &str) -> Option { } // Direct: dir/bin/ if let Some(direct) = find_tool_in(&dir.join("bin"), stem) { - return Some(direct); + return Some(direct.into_path_buf()); } // One level nested: dir/subdir/bin/name (archives often have a top-level folder) if let Ok(entries) = std::fs::read_dir(dir) { @@ -269,7 +269,7 @@ pub fn find_binary_in_dir(dir: &Path, stem: &str) -> Option { let p = entry.path(); if p.is_dir() { if let Some(candidate) = find_tool_in(&p.join("bin"), stem) { - return Some(candidate); + return Some(candidate.into_path_buf()); } } } diff --git a/crates/fbuild-toolchain/src/toolchain/esp32.rs b/crates/fbuild-toolchain/src/toolchain/esp32.rs index c4577ceb5..dab916aca 100644 --- a/crates/fbuild-toolchain/src/toolchain/esp32.rs +++ b/crates/fbuild-toolchain/src/toolchain/esp32.rs @@ -392,7 +392,7 @@ fn find_bin_root(install_dir: &Path) -> PathBuf { /// Get the full path to a tool binary (native spelling, or a `.com`/`.exe` APE). fn tool_binary(bin_dir: &Path, name: &str) -> PathBuf { - fbuild_core::platform::executable::resolve_tool_in(bin_dir, name) + fbuild_core::platform::executable::resolve_tool_in(bin_dir, name).into_path_buf() } #[cfg(test)] diff --git a/crates/fbuild-toolchain/src/toolchain/esp8266.rs b/crates/fbuild-toolchain/src/toolchain/esp8266.rs index c41d00a50..4b558874d 100644 --- a/crates/fbuild-toolchain/src/toolchain/esp8266.rs +++ b/crates/fbuild-toolchain/src/toolchain/esp8266.rs @@ -249,7 +249,7 @@ fn find_bin_root(install_dir: &Path) -> PathBuf { /// Get the full path to a tool binary (native spelling, or a `.com`/`.exe` APE). fn tool_binary(bin_dir: &Path, name: &str) -> PathBuf { - fbuild_core::platform::executable::resolve_tool_in(bin_dir, name) + fbuild_core::platform::executable::resolve_tool_in(bin_dir, name).into_path_buf() } #[cfg(test)] diff --git a/crates/fbuild-toolchain/src/toolchain/esp_qemu.rs b/crates/fbuild-toolchain/src/toolchain/esp_qemu.rs index c03b915ba..449eb1ccb 100644 --- a/crates/fbuild-toolchain/src/toolchain/esp_qemu.rs +++ b/crates/fbuild-toolchain/src/toolchain/esp_qemu.rs @@ -418,11 +418,11 @@ fn find_qemu_binary(root: &Path, arch: EspQemuArch) -> Result { let file_name = arch.binary_name(); let stem = arch.stem(); if let Some(direct) = find_tool_in(root, stem) { - return Ok(direct); + return Ok(direct.into_path_buf()); } if let Some(in_bin) = find_tool_in(&root.join("bin"), stem) { - return Ok(in_bin); + return Ok(in_bin.into_path_buf()); } if let Ok(entries) = std::fs::read_dir(root) { @@ -433,11 +433,11 @@ fn find_qemu_binary(root: &Path, arch: EspQemuArch) -> Result { } if let Some(nested_direct) = find_tool_in(&path, stem) { - return Ok(nested_direct); + return Ok(nested_direct.into_path_buf()); } if let Some(nested_bin) = find_tool_in(&path.join("bin"), stem) { - return Ok(nested_bin); + return Ok(nested_bin.into_path_buf()); } } } @@ -472,6 +472,7 @@ fn qemu_root(install_dir: &Path, arch: EspQemuArch) -> Result { fn find_tool_on_path(stem: &str) -> Option { let path_var = std::env::var_os("PATH")?; fbuild_core::platform::executable::find_tool_on_paths(std::env::split_paths(&path_var), stem) + .map(fbuild_core::path::NormalizedPath::into_path_buf) } /// An exact file name on PATH (e.g. a DLL — never a tool-name expansion). diff --git a/crates/fbuild-toolchain/src/toolchain/riscv.rs b/crates/fbuild-toolchain/src/toolchain/riscv.rs index 2069fb447..0c1f6f20a 100644 --- a/crates/fbuild-toolchain/src/toolchain/riscv.rs +++ b/crates/fbuild-toolchain/src/toolchain/riscv.rs @@ -406,7 +406,7 @@ fn find_bin_root(install_dir: &Path) -> PathBuf { /// Get the full path to a tool binary (native spelling, or a `.com`/`.exe` APE). fn tool_binary(bin_dir: &Path, name: &str) -> PathBuf { - fbuild_core::platform::executable::resolve_tool_in(bin_dir, name) + fbuild_core::platform::executable::resolve_tool_in(bin_dir, name).into_path_buf() } /// Find one complete RISC-V compiler suite in an extracted PlatformIO diff --git a/crates/fbuild-toolchain/src/toolchain/rp2040_picotool.rs b/crates/fbuild-toolchain/src/toolchain/rp2040_picotool.rs index 5295947c3..7d54aba34 100644 --- a/crates/fbuild-toolchain/src/toolchain/rp2040_picotool.rs +++ b/crates/fbuild-toolchain/src/toolchain/rp2040_picotool.rs @@ -68,6 +68,7 @@ impl Rp2040Picotool { pub fn executable(&self) -> PathBuf { fbuild_core::platform::executable::resolve_tool_in(&self.resolved_dir(), PICOTOOL) + .into_path_buf() } fn validate(install_dir: &Path) -> fbuild_core::Result<()> { diff --git a/crates/fbuild-toolchain/src/toolchain/rp2040_pqt.rs b/crates/fbuild-toolchain/src/toolchain/rp2040_pqt.rs index ad7bd844a..51cc32ad5 100644 --- a/crates/fbuild-toolchain/src/toolchain/rp2040_pqt.rs +++ b/crates/fbuild-toolchain/src/toolchain/rp2040_pqt.rs @@ -245,7 +245,7 @@ fn find_bin_root(install_dir: &Path) -> PathBuf { /// Get the full path to a tool binary (native spelling, or a `.com`/`.exe` APE). fn tool_binary(bin_dir: &Path, name: &str) -> PathBuf { - fbuild_core::platform::executable::resolve_tool_in(bin_dir, name) + fbuild_core::platform::executable::resolve_tool_in(bin_dir, name).into_path_buf() } #[cfg(test)] diff --git a/crates/fbuild-toolchain/src/toolchain/teensy_arm.rs b/crates/fbuild-toolchain/src/toolchain/teensy_arm.rs index e731cc662..9f8e69825 100644 --- a/crates/fbuild-toolchain/src/toolchain/teensy_arm.rs +++ b/crates/fbuild-toolchain/src/toolchain/teensy_arm.rs @@ -245,7 +245,7 @@ fn find_bin_root(install_dir: &Path) -> PathBuf { /// Get the full path to a tool binary (native spelling, or a `.com`/`.exe` APE). fn tool_binary(bin_dir: &Path, name: &str) -> PathBuf { - fbuild_core::platform::executable::resolve_tool_in(bin_dir, name) + fbuild_core::platform::executable::resolve_tool_in(bin_dir, name).into_path_buf() } #[cfg(test)] From ed66172a01e2914d267622e50e45918e116fadd9 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Fri, 2 Oct 2026 20:12:46 -0700 Subject: [PATCH 04/12] chore(ci): refresh platform boundary inventory --- ci/platform_boundary_research.tsv | 42 +++++++++++++++---------------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/ci/platform_boundary_research.tsv b/ci/platform_boundary_research.tsv index 5827e810b..ddf6a04ae 100644 --- a/ci/platform_boundary_research.tsv +++ b/ci/platform_boundary_research.tsv @@ -5,22 +5,21 @@ crates/fbuild-core/Cargo.toml 58 target_dependency_table [target.'cfg(unix)'.dep crates/fbuild-core/Cargo.toml 60 native_dependency libc fs host_mechanic crates/fbuild-core/Cargo.toml 62 target_dependency_table [target.'cfg(windows)'.dependencies] fs host_mechanic crates/fbuild-core/Cargo.toml 70 native_dependency windows-sys fs host_mechanic -crates/fbuild-core/src/platform/executable.rs 166 native_path std::env::current_exe host_executable host_mechanic +crates/fbuild-core/src/platform/executable.rs 167 native_path std::env::current_exe host_executable host_mechanic crates/fbuild-core/src/platform/linux/ape.rs 11 native_path std::os::unix::fs::PermissionsExt fs host_mechanic -crates/fbuild-core/src/platform/linux/ape.rs 26 native_path libc:: process host_mechanic -crates/fbuild-core/src/platform/linux/ape.rs 63 native_path libc:: process host_mechanic -crates/fbuild-core/src/platform/linux/ape.rs 63 native_path libc:: process host_mechanic -crates/fbuild-core/src/platform/linux/ape.rs 66 native_path libc:: process host_mechanic -crates/fbuild-core/src/platform/linux/ape.rs 70 native_path libc:: process host_mechanic -crates/fbuild-core/src/platform/linux/ape.rs 70 native_path libc:: process host_mechanic -crates/fbuild-core/src/platform/linux/ape.rs 71 native_path libc:: process host_mechanic -crates/fbuild-core/src/platform/linux/ape.rs 78 native_path libc:: process host_mechanic -crates/fbuild-core/src/platform/linux/ape.rs 78 native_path libc:: process host_mechanic -crates/fbuild-core/src/platform/linux/ape.rs 78 native_path libc:: process host_mechanic -crates/fbuild-core/src/platform/linux/ape.rs 78 native_path libc:: process host_mechanic -crates/fbuild-core/src/platform/linux/ape.rs 80 native_path libc:: process host_mechanic -crates/fbuild-core/src/platform/linux/ape.rs 80 native_path libc:: process host_mechanic -crates/fbuild-core/src/platform/linux/ape.rs 155 native_path std::os::unix::fs::symlink fs host_mechanic +crates/fbuild-core/src/platform/linux/ape.rs 69 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/ape.rs 69 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/ape.rs 72 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/ape.rs 76 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/ape.rs 76 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/ape.rs 77 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/ape.rs 84 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/ape.rs 84 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/ape.rs 84 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/ape.rs 84 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/ape.rs 86 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/ape.rs 86 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/ape.rs 165 native_path std::os::unix::fs::symlink fs host_mechanic crates/fbuild-core/src/platform/linux/device.rs 33 native_path std::os::unix::fs::OpenOptionsExt fs host_mechanic crates/fbuild-core/src/platform/linux/device.rs 41 native_path libc:: process host_mechanic crates/fbuild-core/src/platform/linux/device.rs 41 native_path libc:: process host_mechanic @@ -45,7 +44,6 @@ crates/fbuild-core/src/platform/linux/ipc.rs 4 native_path socket2:: ipc host_me crates/fbuild-core/src/platform/linux/ipc.rs 66 native_path std::os::unix::fs::PermissionsExt ipc host_mechanic crates/fbuild-core/src/platform/linux/mod.rs 14 compile_host_fact std::env::consts::ARCH host host_mechanic crates/fbuild-core/src/platform/linux/process.rs 1 native_path std::os::unix::process::ExitStatusExt process host_mechanic -crates/fbuild-core/src/platform/macos/ape.rs 22 native_path libc:: process host_mechanic crates/fbuild-core/src/platform/macos/fs.rs 2 native_path std::os::unix::fs::PermissionsExt fs host_mechanic crates/fbuild-core/src/platform/macos/fs.rs 13 native_path std::os::unix::fs::MetadataExt fs host_mechanic crates/fbuild-core/src/platform/macos/fs.rs 49 native_path std::os::unix::fs fs host_mechanic @@ -134,12 +132,12 @@ crates/fbuild-paths/src/executable_hash.rs 71 attr_cfg #[cfg(unix)] host host_me crates/fbuild-paths/src/executable_hash.rs 76 attr_cfg #[cfg(unix)] host host_mechanic crates/fbuild-paths/src/executable_hash.rs 85 attr_cfg #[cfg(all(test,unix))] host host_mechanic crates/fbuild-python/tests/python_facades/main.rs 317 native_path std::env::current_exe host_executable host_mechanic -crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 528 attr_cfg #[cfg(windows)] host_executable host_mechanic -crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 534 attr_cfg #[cfg(windows)] host_executable host_mechanic -crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 565 attr_cfg #[cfg(not(windows))] host_executable host_mechanic -crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 570 attr_cfg #[cfg(not(windows))] host_executable host_mechanic -crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 728 attr_cfg #[cfg(windows)] host_executable host_mechanic -crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 755 attr_cfg #[cfg(windows)] host_executable host_mechanic +crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 539 attr_cfg #[cfg(windows)] host_executable host_mechanic +crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 545 attr_cfg #[cfg(windows)] host_executable host_mechanic +crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 576 attr_cfg #[cfg(not(windows))] host_executable host_mechanic +crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 581 attr_cfg #[cfg(not(windows))] host_executable host_mechanic +crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 739 attr_cfg #[cfg(windows)] host_executable host_mechanic +crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 766 attr_cfg #[cfg(windows)] host_executable host_mechanic crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs 253 attr_cfg #[cfg_attr(not(target_os=),allow(dead_code))] host_executable host_artifact_policy crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs 295 attr_cfg #[cfg(not(target_os=))] host_executable host_artifact_policy crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs 301 attr_cfg #[cfg(target_os=)] host_executable host_artifact_policy From 0b0747889014a40a8da1e661f0f2a74afa937703 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Fri, 2 Oct 2026 20:37:27 -0700 Subject: [PATCH 05/12] =?UTF-8?q?fix(ape):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20shim=20carries=20image=20identity,=20fork-lock=20gap,=20ETXT?= =?UTF-8?q?BSY=20retry,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - native shim embeds the APE image's len/mtime so an in-place compiler upgrade changes the shim zccache fingerprints. - process::shared_fork_guard is public; the allowlisted direct spawns (riscv probe, gdb, clang tools) fork under it. - run_command* retry ETXTBSY for APE launches (third-party spawners such as zccache fork without fbuild's lock). - README/platform docs describe the macOS loaders and current cache dirs. --- README.md | 4 +- crates/fbuild-cli/src/cli/clang_tools.rs | 42 +++++++++++++--- crates/fbuild-cli/src/cli/debug.rs | 15 ++++-- crates/fbuild-core/src/platform/README.md | 27 ++++++----- crates/fbuild-core/src/platform/ape/README.md | 9 ++-- crates/fbuild-core/src/platform/ape/native.rs | 43 ++++++++++++++++- crates/fbuild-core/src/platform/process.rs | 12 +++-- crates/fbuild-core/src/subprocess.rs | 48 ++++++++++++++----- .../fbuild-toolchain/src/toolchain/riscv.rs | 19 ++++---- 9 files changed, 167 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 50a7320b9..77ceee37d 100644 --- a/README.md +++ b/README.md @@ -344,10 +344,10 @@ through a loader: |---|---| | 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 | Best effort: an installed `ape`, otherwise the tool's own `/bin/sh` prologue, which may compile its loader with `cc`. | +| 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. | Loader precedence is `FBUILD_APE_LOADER` (explicit override), then the loader -embedded in the tool (Linux), then `ape` on `PATH`, `/usr/bin/ape` and +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 diff --git a/crates/fbuild-cli/src/cli/clang_tools.rs b/crates/fbuild-cli/src/cli/clang_tools.rs index b6d420c93..c637a10db 100644 --- a/crates/fbuild-cli/src/cli/clang_tools.rs +++ b/crates/fbuild-cli/src/cli/clang_tools.rs @@ -298,8 +298,21 @@ pub async fn run_iwyu( cmd.arg(&file); // FastLED/fbuild#810: cap each IWYU invocation at 120s so a wedged // subprocess can't hang the whole fan-out forever. - let output = - match tokio::time::timeout(std::time::Duration::from_secs(120), cmd.output()).await + // Fork under the shared fork lock (released before the await), with + // the stdio `output()` would set. + cmd.stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + let child = { + let _fork = fbuild_core::platform::process::shared_fork_guard(); + cmd.spawn() + }; + let output = match child { + Ok(child) => match tokio::time::timeout( + std::time::Duration::from_secs(120), + child.wait_with_output(), + ) + .await { Ok(res) => res, Err(_) => { @@ -310,7 +323,9 @@ pub async fn run_iwyu( src_path, ); } - }; + }, + Err(e) => Err(e), + }; match output { Ok(out) => { @@ -584,15 +599,30 @@ pub async fn run_clang_tool( } // FastLED/fbuild#810: cap each clang-tidy invocation at 120s so a // wedged subprocess can't hang the whole fan-out forever. - let output = - match tokio::time::timeout(std::time::Duration::from_secs(120), cmd.output()).await + // Fork under the shared fork lock (released before the await), with + // the stdio `output()` would set. + cmd.stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + let child = { + let _fork = fbuild_core::platform::process::shared_fork_guard(); + cmd.spawn() + }; + let output = match child { + Ok(child) => match tokio::time::timeout( + std::time::Duration::from_secs(120), + child.wait_with_output(), + ) + .await { Ok(res) => res, Err(_) => Err(std::io::Error::new( std::io::ErrorKind::TimedOut, "clang-tidy timed out after 120s", )), - }; + }, + Err(e) => Err(e), + }; (file, output) }); handles.push(handle); diff --git a/crates/fbuild-cli/src/cli/debug.rs b/crates/fbuild-cli/src/cli/debug.rs index a5a87d36b..1189203db 100644 --- a/crates/fbuild-cli/src/cli/debug.rs +++ b/crates/fbuild-cli/src/cli/debug.rs @@ -540,10 +540,17 @@ pub async fn run_debug( // interactively in the user's terminal exactly as if they'd typed the // command themselves. // allow-direct-spawn: interactive gdb must inherit the user's terminal stdio; the capturing subprocess helpers would break the session. - let status = fbuild_core::platform::process::command(&gdb_path) - .args(&argv) - .status() - .map_err(|e| FbuildError::Other(format!("failed to launch {}: {e}", gdb_path.display())))?; + let mut gdb = fbuild_core::platform::process::command(&gdb_path); + gdb.args(&argv); + let launch_err = |e: std::io::Error| { + FbuildError::Other(format!("failed to launch {}: {e}", gdb_path.display())) + }; + // Fork under the shared fork lock; release it before the interactive wait. + let mut child = { + let _fork = fbuild_core::platform::process::shared_fork_guard(); + gdb.spawn().map_err(launch_err)? + }; + let status = child.wait().map_err(launch_err)?; if !status.success() { return Err(FbuildError::CommandFailed { diff --git a/crates/fbuild-core/src/platform/README.md b/crates/fbuild-core/src/platform/README.md index b9eb10779..b1338e4f0 100644 --- a/crates/fbuild-core/src/platform/README.md +++ b/crates/fbuild-core/src/platform/README.md @@ -28,21 +28,26 @@ route through `process` into the daemon's neutral shutdown channel. (`MZqFpD='`, `jartsr='`, `APEDBG='`) and, on Unix hosts, launches them as ` `, because `posix_spawn` cannot exec an APE without a `binfmt_misc` handler (NixOS ships none). Loader order: `FBUILD_APE_LOADER`, then -(Linux) the loader embedded in the image itself, then `ape` on PATH, then +the loader embedded in the image itself (Linux ELF, macOS x86_64 Mach-O, or +Apple Silicon `ape-m1.c` compiled once with `cc`), then `ape` on PATH, then `/usr/bin/ape` / `/usr/local/bin/ape`, then `/bin/sh` (which runs the image's 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, checked to be a 64-bit ELF for that -CPU, and installed content-addressed into the first owner-only (0700, not +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`, `$XDG_CACHE_HOME`/`~/.cache` `fbuild/ape`, -`$XDG_RUNTIME_DIR/fbuild/ape`, and `$TMPDIR/fbuild-ape-`; 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`. 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 holds shared across fork→exec — so no concurrently forked child can -inherit the writable descriptor and make the exec fail with `ETXTBSY`. +`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. diff --git a/crates/fbuild-core/src/platform/ape/README.md b/crates/fbuild-core/src/platform/ape/README.md index a65d705b6..1ff876ae2 100644 --- a/crates/fbuild-core/src/platform/ape/README.md +++ b/crates/fbuild-core/src/platform/ape/README.md @@ -6,12 +6,13 @@ any host APE support installed. - `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 (loader blobs, loader - source, `--assimilate` headers). +- `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` — host-native stand-ins (assimilated copy, or a `/bin/sh` shim - on macOS arm64) for spawners fbuild doesn't control, such as zccache. +- `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 `. Host specifics (cache dirs, Linux `memfd` fallback) live in `../{linux,macos,windows}/ape.rs`. diff --git a/crates/fbuild-core/src/platform/ape/native.rs b/crates/fbuild-core/src/platform/ape/native.rs index a73f6df49..10cc02cbb 100644 --- a/crates/fbuild-core/src/platform/ape/native.rs +++ b/crates/fbuild-core/src/platform/ape/native.rs @@ -77,6 +77,18 @@ pub fn native_executable_for( return None; } let mut shim = String::from("#!/bin/sh\n"); + // The image's identity goes into the shim bytes (and so its cache path): + // zccache fingerprints a compiler by its path, mtime and size, so a shim + // that stayed byte-identical across an in-place compiler upgrade would let + // zccache reuse the old compiler's cache entries. + let mtime = key + .2 + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map_or(0, |d| d.as_nanos()); + shim.push_str(&format!( + "# fbuild-ape-image: len={} mtime={mtime}\n", + key.1 + )); if let Some(dir) = &launch.ape_path_dir { shim.push_str(&format!( "PATH={}\"${{PATH:+:$PATH}}\"; export PATH\n", @@ -136,7 +148,11 @@ mod tests { ); assert!(!is_ape_file(&shim), "the shim itself is a plain script"); let text = std::fs::read_to_string(&shim).unwrap(); - assert!(text.starts_with("#!/bin/sh\nPATH="), "{text}"); + assert!( + text.starts_with("#!/bin/sh\n# fbuild-ape-image: len="), + "{text}" + ); + assert!(text.contains("\nPATH="), "{text}"); // Spawned directly — no fbuild loader routing involved. let out = crate::subprocess::run_command_blocking_retrying_exec_busy( &[shim.to_str().unwrap(), "via", "shim"], @@ -163,6 +179,31 @@ mod tests { ); } + /// An in-place compiler upgrade must change the shim zccache sees, or + /// zccache's path/mtime/size fingerprint would reuse the old compiler. + #[test] + fn upgrading_the_image_in_place_changes_the_shim() { + if host::current().is_windows() { + return; + } + let cache = tempfile::tempdir().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let image = dir.path().join("x86_64-linux-cosmo-gcc"); + std::fs::copy(hello(), &image).unwrap(); + let dirs = [NormalizedPath::new(cache.path())]; + let before = native_executable_for(host::current(), &image, &dirs, None, None).unwrap(); + let mut grown = std::fs::read(hello()).unwrap(); + grown.extend_from_slice(b"\0upgrade"); + std::fs::write(&image, grown).unwrap(); + let after = native_executable_for(host::current(), &image, &dirs, None, None).unwrap(); + assert_ne!(before, after); + assert_ne!( + std::fs::read(&before).unwrap(), + std::fs::read(&after).unwrap() + ); + assert_eq!(before.file_name(), after.file_name()); + } + #[test] fn windows_non_ape_and_unusable_cache_yield_none() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/fbuild-core/src/platform/process.rs b/crates/fbuild-core/src/platform/process.rs index c7879019f..0f890af3d 100644 --- a/crates/fbuild-core/src/platform/process.rs +++ b/crates/fbuild-core/src/platform/process.rs @@ -113,7 +113,11 @@ pub fn containment_is_initialized() -> bool { /// descriptor is open, so no child can inherit it. static FORK_LOCK: RwLock<()> = RwLock::new(()); -fn fork_guard() -> RwLockReadGuard<'static, ()> { +/// 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. +pub fn shared_fork_guard() -> RwLockReadGuard<'static, ()> { FORK_LOCK.read().unwrap_or_else(|e| e.into_inner()) } @@ -182,7 +186,7 @@ pub fn spawn_contained( ..running_process::SpawnStdio::default() }; let inner = { - let _fork = fork_guard(); + let _fork = shared_fork_guard(); match CONTAINMENT.get() { Some(group) => group.spawn(command, stdio)?, None => running_process::spawn(command, stdio)?, @@ -209,7 +213,7 @@ pub fn spawn_detached( stderr: Option<&File>, environment: DetachedEnvironment, ) -> std::io::Result { - let _fork = fork_guard(); + let _fork = shared_fork_guard(); super::selected::process::spawn_detached(command, stderr, environment) } @@ -225,7 +229,7 @@ pub fn spawn_tokio_contained( super::selected::process::configure_tokio_owner_death(command)?; } let child = { - let _fork = fork_guard(); + let _fork = shared_fork_guard(); running_process::spawn_tokio(command, running_process::TokioSpawnOptions::default())? }; if CONTAINMENT.get().is_some() { diff --git a/crates/fbuild-core/src/subprocess.rs b/crates/fbuild-core/src/subprocess.rs index 4fec7155e..e3eebee7e 100644 --- a/crates/fbuild-core/src/subprocess.rs +++ b/crates/fbuild-core/src/subprocess.rs @@ -242,10 +242,7 @@ async fn run_command_inner( if args.is_empty() { return Err(FbuildError::Other("empty command".to_string())); } - let mut cmd = build_command( - args, cwd, env, /*capture=*/ true, /*stdin_piped=*/ false, - )?; - let child = process::spawn_tokio_contained(&mut cmd).map_err(|e| spawn_err(args, e))?; + let child = spawn_retrying_ape_busy(args, cwd, env, true, false).await?; wait_and_capture(child, args, timeout).await } @@ -287,10 +284,7 @@ async fn run_command_with_stdin_inner( if args.is_empty() { return Err(FbuildError::Other("empty command".to_string())); } - let mut cmd = build_command( - args, cwd, env, /*capture=*/ true, /*stdin_piped=*/ true, - )?; - let mut child = process::spawn_tokio_contained(&mut cmd).map_err(|e| spawn_err(args, e))?; + let mut child = spawn_retrying_ape_busy(args, cwd, env, true, true).await?; // Take the stdin handle and concurrently write the payload while // tokio drains stdout/stderr in the background. Dropping `stdin` @@ -374,10 +368,7 @@ async fn run_command_passthrough_inner( if args.is_empty() { return Err(FbuildError::Other("empty command".to_string())); } - let mut cmd = build_command( - args, cwd, env, /*capture=*/ false, /*stdin_piped=*/ false, - )?; - let mut child = process::spawn_tokio_contained(&mut cmd).map_err(|e| spawn_err(args, e))?; + let mut child = spawn_retrying_ape_busy(args, cwd, env, false, false).await?; let status = match wait_with_timeout(&mut child, timeout).await? { Some(status) => status, None => { @@ -598,6 +589,39 @@ fn exit_code_from(status: std::process::ExitStatus) -> i32 { process::exit_code(status) } +/// Attempts for an APE launch whose freshly installed loader is momentarily +/// busy. fbuild's own spawns hold the fork lock, but spawners it doesn't +/// control (zccache via kernal-api) can fork while a loader is being written; +/// that child holds the writable descriptor until its own exec, microseconds. +const APE_EXEC_BUSY_ATTEMPTS: u32 = 8; + +/// Build and spawn `args`, retrying `ETXTBSY` only for APE launches (whose +/// loader fbuild may have just written). Every other error returns at once. +async fn spawn_retrying_ape_busy( + args: &[&str], + cwd: Option<&Path>, + env: Option<&[(&str, &str)]>, + capture: bool, + stdin_piped: bool, +) -> Result { + let mut attempt = 1; + loop { + let mut cmd = build_command(args, cwd, env, capture, stdin_piped)?; + match process::spawn_tokio_contained(&mut cmd) { + Ok(child) => return Ok(child), + Err(error) + if is_exec_busy(&error) + && attempt < APE_EXEC_BUSY_ATTEMPTS + && process::ape_launch(args[0], cwd, env).is_some() => + { + tokio::time::sleep(EXEC_BUSY_BACKOFF * attempt).await; + attempt += 1; + } + Err(error) => return Err(spawn_err(args, error)), + } + } +} + fn build_command( args: &[&str], cwd: Option<&Path>, diff --git a/crates/fbuild-toolchain/src/toolchain/riscv.rs b/crates/fbuild-toolchain/src/toolchain/riscv.rs index 0c1f6f20a..5567d8388 100644 --- a/crates/fbuild-toolchain/src/toolchain/riscv.rs +++ b/crates/fbuild-toolchain/src/toolchain/riscv.rs @@ -174,14 +174,17 @@ impl RiscvToolchain { fn get_gcc_multilib_dir(&self, march: &str, mabi: &str) -> Option { // allow-direct-spawn: short synchronous GCC capability probe (-print-multi-directory). - let output = fbuild_core::platform::process::command(self.get_gcc_path()) - .args([ - format!("-march={march}"), - format!("-mabi={mabi}"), - "-print-multi-directory".into(), - ]) - .output() - .ok()?; + let mut probe = fbuild_core::platform::process::command(self.get_gcc_path()); + probe.args([ + format!("-march={march}"), + format!("-mabi={mabi}"), + "-print-multi-directory".into(), + ]); + // Short probe: hold the shared fork lock for the whole run. + let output = { + let _fork = fbuild_core::platform::process::shared_fork_guard(); + probe.output().ok()? + }; if !output.status.success() { return None; } From a47021c96a03ad9758a699925407213623d5902e Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Fri, 2 Oct 2026 21:04:08 -0700 Subject: [PATCH 06/12] fix(ape): caller-PATH teensy lookup, one-time APE perm repair, APE-only foreign addr2line; merge main - teensy_loader_cli lookup searches the requesting CLI's PATH first (deploy_teensy.rs). - Installs extracted before APE support get their APE exec bits repaired once (ape_perms.rs, .fbuild-ape-perms-v1 marker); fresh installs write the marker. - derive_addr2line_path accepts a non-native sibling only when it is an APE. - Refresh boundary inventory after merging main. --- ci/platform_boundary_research.tsv | 2 +- .../src/handlers/operations/deploy.rs | 40 +---- .../src/handlers/operations/deploy_teensy.rs | 131 +++++++++++++++ .../src/handlers/operations/mod.rs | 1 + .../src/ape_install_tests.rs | 4 + crates/fbuild-packages-fetch/src/ape_perms.rs | 152 ++++++++++++++++++ crates/fbuild-packages-fetch/src/lib.rs | 6 +- crates/fbuild-serial/src/crash_decoder.rs | 38 ++++- 8 files changed, 328 insertions(+), 46 deletions(-) create mode 100644 crates/fbuild-daemon/src/handlers/operations/deploy_teensy.rs create mode 100644 crates/fbuild-packages-fetch/src/ape_perms.rs diff --git a/ci/platform_boundary_research.tsv b/ci/platform_boundary_research.tsv index ddf6a04ae..422604c06 100644 --- a/ci/platform_boundary_research.tsv +++ b/ci/platform_boundary_research.tsv @@ -42,7 +42,7 @@ crates/fbuild-core/src/platform/linux/ipc.rs 2 native_path interprocess::local_s crates/fbuild-core/src/platform/linux/ipc.rs 3 native_path interprocess::os::unix ipc host_mechanic crates/fbuild-core/src/platform/linux/ipc.rs 4 native_path socket2:: ipc host_mechanic crates/fbuild-core/src/platform/linux/ipc.rs 66 native_path std::os::unix::fs::PermissionsExt ipc host_mechanic -crates/fbuild-core/src/platform/linux/mod.rs 14 compile_host_fact std::env::consts::ARCH host host_mechanic +crates/fbuild-core/src/platform/linux/mod.rs 15 compile_host_fact std::env::consts::ARCH host host_mechanic crates/fbuild-core/src/platform/linux/process.rs 1 native_path std::os::unix::process::ExitStatusExt process host_mechanic crates/fbuild-core/src/platform/macos/fs.rs 2 native_path std::os::unix::fs::PermissionsExt fs host_mechanic crates/fbuild-core/src/platform/macos/fs.rs 13 native_path std::os::unix::fs::MetadataExt fs host_mechanic diff --git a/crates/fbuild-daemon/src/handlers/operations/deploy.rs b/crates/fbuild-daemon/src/handlers/operations/deploy.rs index c84152eb6..946d867f0 100644 --- a/crates/fbuild-daemon/src/handlers/operations/deploy.rs +++ b/crates/fbuild-daemon/src/handlers/operations/deploy.rs @@ -6,6 +6,7 @@ use super::common::{ resolve_client_path, resolve_request_project_dir, trust_device_hash_enabled, }; use super::deploy_port::{append_warning_to_stderr, choose_deploy_port}; +use super::deploy_teensy::find_teensy_loader_cli; use super::monitor::{MonitorOutcome, run_monitor_loop}; use crate::context::DaemonContext; use crate::models::{DeployRequest, OperationResponse}; @@ -18,42 +19,6 @@ use std::sync::Arc; #[cfg(feature = "espflash-native")] use super::common::{native_verify_enabled, native_write_enabled}; -/// Resolve a usable `teensy_loader_cli` binary for the Teensy deploy arm. -/// -/// Search order: -/// 1. `$PATH` (`teensy_loader_cli` on Unix, `teensy_loader_cli.exe` on Win, -/// or a `.com`/`.exe` APE on any host) -/// 2. `~/.platformio/packages/tool-teensy/teensy_loader_cli{.exe,.com}` — the -/// well-known path PlatformIO installs it at on every PIO-using machine. -/// -/// Returns `None` if neither is found; the TeensyDeployer's default will then -/// try a bare `teensy_loader_cli` invocation, which will surface -/// `command not found` to the user — clearer than a silent abort here. -fn find_teensy_loader_cli() -> Option { - use fbuild_core::platform::executable::{find_tool_in, find_tool_on_paths}; - const STEM: &str = "teensy_loader_cli"; - - if let Some(path_env) = std::env::var_os("PATH") { - if let Some(found) = find_tool_on_paths(std::env::split_paths(&path_env), STEM) { - return Some(found.into_path_buf()); - } - } - - // PlatformIO drops the binary here on every platform. Reusing it means a - // user who already has PIO working doesn't need to install anything else - // to deploy via fbuild. - let pio_root = if fbuild_core::platform::host::is_windows() { - std::env::var("USERPROFILE").ok() - } else { - std::env::var("HOME").ok() - }; - let pio_tool_dir = PathBuf::from(pio_root?) - .join(".platformio") - .join("packages") - .join("tool-teensy"); - find_tool_in(&pio_tool_dir, STEM).map(fbuild_core::path::NormalizedPath::into_path_buf) -} - /// POST /api/deploy pub async fn deploy( State(ctx): State>, @@ -839,7 +804,8 @@ pub async fn deploy( Some(deploy_project.as_path()), ); let loader_params = fbuild_deploy::teensy::TeensyLoaderParams::default(); - let loader_path = find_teensy_loader_cli(); + let loader_path = find_teensy_loader_cli(deploy_caller_path.as_deref()) + .map(fbuild_core::path::NormalizedPath::into_path_buf); let deployer = fbuild_deploy::teensy::TeensyDeployer::new( &board_config.board.to_uppercase(), &loader_params, diff --git a/crates/fbuild-daemon/src/handlers/operations/deploy_teensy.rs b/crates/fbuild-daemon/src/handlers/operations/deploy_teensy.rs new file mode 100644 index 000000000..304126bdc --- /dev/null +++ b/crates/fbuild-daemon/src/handlers/operations/deploy_teensy.rs @@ -0,0 +1,131 @@ +//! `teensy_loader_cli` discovery for the Teensy deploy arm. + +use fbuild_core::path::NormalizedPath; +use fbuild_core::platform::executable::{find_tool_in, find_tool_on_paths}; +use std::ffi::OsStr; +use std::path::Path; + +const STEM: &str = "teensy_loader_cli"; + +/// Resolve a usable `teensy_loader_cli` binary for the Teensy deploy arm. +/// +/// Search order: +/// 1. the requesting CLI's forwarded `PATH` (`caller_path`, +/// FastLED/fbuild#1234) — the daemon's own environment can be stale or +/// minimal, so a tool the user installed in their shell must win; +/// 2. the daemon's `$PATH`; +/// 3. `~/.platformio/packages/tool-teensy/` — the well-known path +/// PlatformIO installs it at on every PIO-using machine. +/// +/// Each directory accepts `teensy_loader_cli` (Unix) / `teensy_loader_cli.exe` +/// (Windows), or a `.com`/`.exe` APE on any host. +/// +/// Returns `None` if nothing is found; the TeensyDeployer's default will then +/// try a bare `teensy_loader_cli` invocation, which will surface +/// `command not found` to the user — clearer than a silent abort here. +pub(super) fn find_teensy_loader_cli(caller_path: Option<&str>) -> Option { + let home = if fbuild_core::platform::host::is_windows() { + std::env::var_os("USERPROFILE") + } else { + std::env::var_os("HOME") + }; + find_teensy_loader_cli_in( + caller_path.map(OsStr::new), + std::env::var_os("PATH").as_deref(), + home.as_deref().map(Path::new), + ) +} + +/// [`find_teensy_loader_cli`] with every environment input passed explicitly. +fn find_teensy_loader_cli_in( + caller_path: Option<&OsStr>, + daemon_path: Option<&OsStr>, + home: Option<&Path>, +) -> Option { + for path_env in [caller_path, daemon_path].into_iter().flatten() { + if let Some(found) = find_tool_on_paths(std::env::split_paths(path_env), STEM) { + return Some(found); + } + } + + // PlatformIO drops the binary here on every platform. Reusing it means a + // user who already has PIO working doesn't need to install anything else + // to deploy via fbuild. + let pio_tool_dir = home? + .join(".platformio") + .join("packages") + .join("tool-teensy"); + find_tool_in(&pio_tool_dir, STEM) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::OsString; + + fn write_ape(path: &Path) { + std::fs::write(path, b"MZqFpD='\n#!/bin/sh\n").unwrap(); + } + + fn join(dirs: &[&Path]) -> OsString { + std::env::join_paths(dirs).unwrap() + } + + /// CodeRabbit on FastLED/fbuild#1633: a `teensy_loader_cli.com` APE that + /// exists only on the requesting CLI's PATH must be found even though the + /// daemon's own PATH does not contain it. + #[test] + fn caller_path_ape_is_found_when_daemon_path_lacks_it() { + let caller = tempfile::TempDir::new().unwrap(); + let daemon = tempfile::TempDir::new().unwrap(); + let tool = caller.path().join("teensy_loader_cli.com"); + write_ape(&tool); + + let found = find_teensy_loader_cli_in( + Some(&join(&[caller.path()])), + Some(&join(&[daemon.path()])), + None, + ); + assert_eq!(found, Some(NormalizedPath::from(tool))); + } + + #[test] + fn caller_path_wins_over_daemon_path() { + let caller = tempfile::TempDir::new().unwrap(); + let daemon = tempfile::TempDir::new().unwrap(); + let caller_tool = caller.path().join("teensy_loader_cli.com"); + write_ape(&caller_tool); + write_ape(&daemon.path().join("teensy_loader_cli.com")); + + let found = find_teensy_loader_cli_in( + Some(&join(&[caller.path()])), + Some(&join(&[daemon.path()])), + None, + ); + assert_eq!(found, Some(NormalizedPath::from(caller_tool))); + } + + #[test] + fn falls_back_to_daemon_path_then_platformio() { + let caller = tempfile::TempDir::new().unwrap(); + let daemon = tempfile::TempDir::new().unwrap(); + let home = tempfile::TempDir::new().unwrap(); + let daemon_tool = daemon.path().join("teensy_loader_cli.com"); + write_ape(&daemon_tool); + + let found = find_teensy_loader_cli_in( + Some(&join(&[caller.path()])), + Some(&join(&[daemon.path()])), + Some(home.path()), + ); + assert_eq!(found, Some(NormalizedPath::from(daemon_tool))); + + let pio_dir = home.path().join(".platformio/packages/tool-teensy"); + std::fs::create_dir_all(&pio_dir).unwrap(); + let pio_tool = pio_dir.join("teensy_loader_cli.com"); + write_ape(&pio_tool); + let found = + find_teensy_loader_cli_in(Some(&join(&[caller.path()])), None, Some(home.path())); + assert_eq!(found, Some(NormalizedPath::from(pio_tool))); + } +} diff --git a/crates/fbuild-daemon/src/handlers/operations/mod.rs b/crates/fbuild-daemon/src/handlers/operations/mod.rs index 2f956bef6..6fe8a5920 100644 --- a/crates/fbuild-daemon/src/handlers/operations/mod.rs +++ b/crates/fbuild-daemon/src/handlers/operations/mod.rs @@ -9,6 +9,7 @@ mod build; mod common; mod deploy; mod deploy_port; +mod deploy_teensy; mod install_deps; mod monitor; mod recovery_request; diff --git a/crates/fbuild-packages-fetch/src/ape_install_tests.rs b/crates/fbuild-packages-fetch/src/ape_install_tests.rs index 2f6c4e33b..3c1e19a82 100644 --- a/crates/fbuild-packages-fetch/src/ape_install_tests.rs +++ b/crates/fbuild-packages-fetch/src/ape_install_tests.rs @@ -58,6 +58,10 @@ async fn staged_install_marks_ape_tools_executable() { .await .expect("staged install should succeed"); + assert!( + ape_perms::is_repaired(&installed), + "a fresh install records its APE scan so it is never repaired again" + ); let ape = std::fs::metadata(installed.join("pkg/bin/tool.com")).unwrap(); assert!(fbuild_core::platform::fs::is_executable(&ape)); let txt = std::fs::metadata(installed.join("pkg/bin/readme.txt")).unwrap(); diff --git a/crates/fbuild-packages-fetch/src/ape_perms.rs b/crates/fbuild-packages-fetch/src/ape_perms.rs new file mode 100644 index 000000000..1d83fbe19 --- /dev/null +++ b/crates/fbuild-packages-fetch/src/ape_perms.rs @@ -0,0 +1,152 @@ +//! Once-per-install exec-bit repair for APE (cosmocc) host tools. +//! +//! Zip entries without Unix attributes extract as 0644. Fresh installs mark +//! APE tools executable during [`crate::PackageBase::staged_install`], but an +//! install extracted by an older fbuild can still hold a 0644 `tool.com`. +//! A marker file next to the `.install_complete` sentinel records that the +//! tree has been scanned, so every install pays for at most one scan, ever. + +use crate::extractor; +use std::path::Path; + +/// Marker written into an install dir once its APE tools are known executable. +/// Bump the suffix to force one more scan of every existing install. +const APE_PERMS_MARKER: &str = ".fbuild-ape-perms-v1"; + +/// Whether `installed_dir` has already been scanned for APE exec bits. +pub(crate) fn is_repaired(installed_dir: &Path) -> bool { + installed_dir.join(APE_PERMS_MARKER).exists() +} + +/// Mark APE tools executable in a freshly extracted tree and record the scan, +/// so the committed install never needs a repair pass. +pub(crate) fn mark_fresh(dir: &Path) -> fbuild_core::Result<()> { + extractor::mark_ape_executables(dir)?; + write_marker(dir); + Ok(()) +} + +/// Repair an existing install's APE exec bits unless it was already scanned. +/// Best-effort: a failed scan is logged and retried on the next install hit. +/// Callers hold the package's install lock. +pub(crate) fn repair_once(installed_dir: &Path) { + if is_repaired(installed_dir) { + return; + } + match extractor::mark_ape_executables(installed_dir) { + Ok(marked) => { + if marked > 0 { + tracing::info!( + "marked {marked} APE tool(s) executable in {}", + installed_dir.display() + ); + } + write_marker(installed_dir); + } + Err(e) => tracing::warn!( + "failed to repair APE permissions in {}: {e}", + installed_dir.display() + ), + } +} + +fn write_marker(dir: &Path) { + if let Err(e) = std::fs::write(dir.join(APE_PERMS_MARKER), b"") { + tracing::warn!("failed to write APE permission marker: {e}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{CacheSubdir, PackageBase}; + + const APE_BYTES: &[u8] = b"MZqFpD='\n#!/bin/sh\n"; + + fn is_executable(path: &Path) -> bool { + fbuild_core::platform::fs::is_executable(&std::fs::metadata(path).unwrap()) + } + + /// Recreate the file so it carries no exec bit (fresh files never do). + fn clear_exec_bits(path: &Path) { + let bytes = std::fs::read(path).unwrap(); + std::fs::remove_file(path).unwrap(); + std::fs::write(path, bytes).unwrap(); + } + + #[test] + fn mark_fresh_writes_marker() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write(tmp.path().join("tool.com"), APE_BYTES).unwrap(); + mark_fresh(tmp.path()).unwrap(); + assert!(is_repaired(tmp.path())); + assert!(is_executable(&tmp.path().join("tool.com"))); + } + + /// The repair runs once: after the marker is written, a 0644 APE is no + /// longer rescanned (it stays non-executable), proving the scan is not + /// repeated on every install hit. + #[test] + fn repair_once_scans_only_once() { + if fbuild_core::platform::host::current().is_windows() { + return; + } + let tmp = tempfile::TempDir::new().unwrap(); + let tool = tmp.path().join("bin").join("tool.com"); + std::fs::create_dir_all(tool.parent().unwrap()).unwrap(); + std::fs::write(&tool, APE_BYTES).unwrap(); + clear_exec_bits(&tool); + + repair_once(tmp.path()); + assert!( + is_executable(&tool), + "first repair marks the APE executable" + ); + assert!(is_repaired(tmp.path())); + + clear_exec_bits(&tool); + repair_once(tmp.path()); + assert!(!is_executable(&tool), "a marked install is never rescanned"); + } + + /// CodeRabbit on FastLED/fbuild#1633: an install extracted before APE + /// support (0644 `tool.com`, sentinel present, no marker) is repaired by + /// the next `staged_install` hit, which never re-downloads. + #[tokio::test] + async fn staged_install_repairs_legacy_install_once() { + if fbuild_core::platform::host::current().is_windows() { + return; + } + let tmp = tempfile::TempDir::new().unwrap(); + let base = PackageBase::with_cache_root( + "legacy-ape", + "1.0", + "http://127.0.0.1:9/unreachable.zip", + "legacy-ape", + None, + CacheSubdir::Toolchains, + tmp.path(), + &tmp.path().join("cache"), + ); + let install_path = base.install_path(); + let tool = install_path.join("bin").join("tool.com"); + std::fs::create_dir_all(tool.parent().unwrap()).unwrap(); + std::fs::write(&tool, APE_BYTES).unwrap(); + clear_exec_bits(&tool); + std::fs::write( + crate::disk_cache::paths::install_complete_sentinel(&install_path), + b"", + ) + .unwrap(); + + let installed = base.staged_install(|_| Ok(())).await.unwrap(); + assert_eq!(installed, install_path); + assert!(is_executable(&tool), "legacy 0644 APE must be repaired"); + assert!(is_repaired(&install_path)); + + // Already-marked installs take the lock-free fast path untouched. + clear_exec_bits(&tool); + base.staged_install(|_| Ok(())).await.unwrap(); + assert!(!is_executable(&tool), "marked install is not rescanned"); + } +} diff --git a/crates/fbuild-packages-fetch/src/lib.rs b/crates/fbuild-packages-fetch/src/lib.rs index ab7b58886..5e276c1ce 100644 --- a/crates/fbuild-packages-fetch/src/lib.rs +++ b/crates/fbuild-packages-fetch/src/lib.rs @@ -15,6 +15,7 @@ pub mod platformio_registry; pub mod platformio_repository; pub mod submodules; +mod ape_perms; mod install_lock; pub use cache::Cache; @@ -363,7 +364,7 @@ impl PackageBase { { let install_path = self.install_path(); - if install_path.exists() { + if install_path.exists() && ape_perms::is_repaired(&install_path) { // An existing-install hit is a use: refresh the DiskCache LRU so // background GC doesn't treat this package as the oldest unused // entry and delete it mid-build (FastLED/fbuild#1341). @@ -382,6 +383,7 @@ impl PackageBase { let _install_lock = install_lock::acquire_for_install(&install_path, &self.name, &self.version).await?; if install_path.exists() { + ape_perms::repair_once(&install_path); self.touch_disk_cache(); return Ok(install_path); } @@ -460,7 +462,7 @@ impl PackageBase { // Zip entries without Unix attributes extract as 0644; restore the // exec bit on APE host tools so one `tool.com` runs on every host. - extractor::mark_ape_executables(&staging_path)?; + ape_perms::mark_fresh(&staging_path)?; // Validate validate(&staging_path)?; diff --git a/crates/fbuild-serial/src/crash_decoder.rs b/crates/fbuild-serial/src/crash_decoder.rs index b8473b033..46deeaafc 100644 --- a/crates/fbuild-serial/src/crash_decoder.rs +++ b/crates/fbuild-serial/src/crash_decoder.rs @@ -384,10 +384,13 @@ impl CrashDecoder { /// The compiler's executable suffix (`.exe`, or `.com` for a cosmocc APE) is /// stripped for matching and tried first on the derived tool, so an APE /// toolchain's `xtensa-esp32s3-elf-gcc.com` maps to -/// `xtensa-esp32s3-elf-addr2line.com`. Otherwise the usual native/APE -/// candidates are probed (see `find_tool_in`). +/// `xtensa-esp32s3-elf-addr2line.com`. That sibling obeys `find_tool_in`'s +/// acceptance rule: a non-native spelling counts only if it is an APE, so a +/// Windows-only `addr2line.exe` never shadows a runnable `addr2line.com` on +/// Unix. Otherwise the usual native/APE candidates are probed. pub fn derive_addr2line_path(cc_path: &Path) -> Option { - use fbuild_core::platform::executable::{find_tool_in, split_tool_suffix}; + use fbuild_core::platform::ape::is_ape_file; + use fbuild_core::platform::executable::{find_tool_in, native_name, split_tool_suffix}; let name = cc_path.file_name()?.to_string_lossy(); let (stem, suffix) = split_tool_suffix(&name); @@ -395,8 +398,10 @@ pub fn derive_addr2line_path(cc_path: &Path) -> Option { let bin_dir = cc_path.parent()?; let tool_stem = format!("{prefix}addr2line"); - let same_suffix = bin_dir.join(format!("{tool_stem}{suffix}")); - if same_suffix.is_file() { + let same_name = format!("{tool_stem}{suffix}"); + let same_suffix = bin_dir.join(&same_name); + if same_suffix.is_file() && (same_name == native_name(&tool_stem) || is_ape_file(&same_suffix)) + { return Some(same_suffix); } find_tool_in(bin_dir, &tool_stem).map(fbuild_core::path::NormalizedPath::into_path_buf) @@ -649,13 +654,34 @@ mod tests { #[test] fn derive_addr2line_from_exe_compiler_keeps_exe() { + // An APE `.exe` runs on every host, so the compiler's suffix is kept. let dir = tempfile::tempdir().unwrap(); let gcc = dir.path().join("riscv32-esp-elf-gcc.exe"); let addr2line = dir.path().join("riscv32-esp-elf-addr2line.exe"); - std::fs::write(&addr2line, b"MZ").unwrap(); + std::fs::write(&addr2line, b"MZqFpD='\n").unwrap(); assert_eq!(derive_addr2line_path(&gcc), Some(addr2line)); } + /// CodeRabbit on FastLED/fbuild#1633: the compiler's own non-native + /// suffix is accepted only for an APE. A Windows-only PE `addr2line.exe` + /// next to an APE `addr2line.com` must lose to the runnable `.com` on + /// Unix (on Windows the native `.exe` legitimately wins). + #[test] + fn derive_addr2line_skips_non_ape_foreign_suffix() { + let dir = tempfile::tempdir().unwrap(); + let gcc = dir.path().join("riscv32-esp-elf-gcc.exe"); + let pe = dir.path().join("riscv32-esp-elf-addr2line.exe"); + let ape = dir.path().join("riscv32-esp-elf-addr2line.com"); + std::fs::write(&pe, b"MZ\x90\x00").unwrap(); + std::fs::write(&ape, b"MZqFpD='\n").unwrap(); + let expected = if fbuild_core::platform::host::is_windows() { + pe + } else { + ape + }; + assert_eq!(derive_addr2line_path(&gcc), Some(expected)); + } + // --- Regex pattern tests --- #[test] From 561336e290302e784650ff47c8ac0351d188eac1 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Fri, 2 Oct 2026 21:36:33 -0700 Subject: [PATCH 07/12] fix(lint): rename APE perm marker off the reserved .fbuild prefix --- crates/fbuild-packages-fetch/src/ape_perms.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/fbuild-packages-fetch/src/ape_perms.rs b/crates/fbuild-packages-fetch/src/ape_perms.rs index 1d83fbe19..49d9cc613 100644 --- a/crates/fbuild-packages-fetch/src/ape_perms.rs +++ b/crates/fbuild-packages-fetch/src/ape_perms.rs @@ -11,7 +11,7 @@ use std::path::Path; /// Marker written into an install dir once its APE tools are known executable. /// Bump the suffix to force one more scan of every existing install. -const APE_PERMS_MARKER: &str = ".fbuild-ape-perms-v1"; +const APE_PERMS_MARKER: &str = ".ape_perms_v1"; /// Whether `installed_dir` has already been scanned for APE exec bits. pub(crate) fn is_repaired(installed_dir: &Path) -> bool { From da84a0aa91aeb351cf043af0a5baae0e8eddc9b1 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Fri, 2 Oct 2026 21:59:46 -0700 Subject: [PATCH 08/12] fix(lint): teensy lookup tests use async fbuild_core::fs in the daemon --- .../src/handlers/operations/deploy_teensy.rs | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/crates/fbuild-daemon/src/handlers/operations/deploy_teensy.rs b/crates/fbuild-daemon/src/handlers/operations/deploy_teensy.rs index 304126bdc..782c1d1fa 100644 --- a/crates/fbuild-daemon/src/handlers/operations/deploy_teensy.rs +++ b/crates/fbuild-daemon/src/handlers/operations/deploy_teensy.rs @@ -63,8 +63,10 @@ mod tests { use super::*; use std::ffi::OsString; - fn write_ape(path: &Path) { - std::fs::write(path, b"MZqFpD='\n#!/bin/sh\n").unwrap(); + async fn write_ape(path: &Path) { + fbuild_core::fs::write(path, b"MZqFpD='\n#!/bin/sh\n") + .await + .unwrap(); } fn join(dirs: &[&Path]) -> OsString { @@ -74,12 +76,12 @@ mod tests { /// CodeRabbit on FastLED/fbuild#1633: a `teensy_loader_cli.com` APE that /// exists only on the requesting CLI's PATH must be found even though the /// daemon's own PATH does not contain it. - #[test] - fn caller_path_ape_is_found_when_daemon_path_lacks_it() { + #[tokio::test] + async fn caller_path_ape_is_found_when_daemon_path_lacks_it() { let caller = tempfile::TempDir::new().unwrap(); let daemon = tempfile::TempDir::new().unwrap(); let tool = caller.path().join("teensy_loader_cli.com"); - write_ape(&tool); + write_ape(&tool).await; let found = find_teensy_loader_cli_in( Some(&join(&[caller.path()])), @@ -89,13 +91,13 @@ mod tests { assert_eq!(found, Some(NormalizedPath::from(tool))); } - #[test] - fn caller_path_wins_over_daemon_path() { + #[tokio::test] + async fn caller_path_wins_over_daemon_path() { let caller = tempfile::TempDir::new().unwrap(); let daemon = tempfile::TempDir::new().unwrap(); let caller_tool = caller.path().join("teensy_loader_cli.com"); - write_ape(&caller_tool); - write_ape(&daemon.path().join("teensy_loader_cli.com")); + write_ape(&caller_tool).await; + write_ape(&daemon.path().join("teensy_loader_cli.com")).await; let found = find_teensy_loader_cli_in( Some(&join(&[caller.path()])), @@ -105,13 +107,13 @@ mod tests { assert_eq!(found, Some(NormalizedPath::from(caller_tool))); } - #[test] - fn falls_back_to_daemon_path_then_platformio() { + #[tokio::test] + async fn falls_back_to_daemon_path_then_platformio() { let caller = tempfile::TempDir::new().unwrap(); let daemon = tempfile::TempDir::new().unwrap(); let home = tempfile::TempDir::new().unwrap(); let daemon_tool = daemon.path().join("teensy_loader_cli.com"); - write_ape(&daemon_tool); + write_ape(&daemon_tool).await; let found = find_teensy_loader_cli_in( Some(&join(&[caller.path()])), @@ -121,9 +123,9 @@ mod tests { assert_eq!(found, Some(NormalizedPath::from(daemon_tool))); let pio_dir = home.path().join(".platformio/packages/tool-teensy"); - std::fs::create_dir_all(&pio_dir).unwrap(); + fbuild_core::fs::create_dir_all(&pio_dir).await.unwrap(); let pio_tool = pio_dir.join("teensy_loader_cli.com"); - write_ape(&pio_tool); + write_ape(&pio_tool).await; let found = find_teensy_loader_cli_in(Some(&join(&[caller.path()])), None, Some(home.path())); assert_eq!(found, Some(NormalizedPath::from(pio_tool))); From d58eaaba6d5bd94bec9a61e220930da23e0a9508 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Tue, 27 Oct 2026 05:32:09 -0700 Subject: [PATCH 09/12] Add AGENTS.md symlink to CLAUDE.md Agents that read AGENTS.md now get the same instructions as Claude. A relative symlink (not a copy) so the two can never drift. --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) create mode 120000 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 000000000..681311eb9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file From 682db4f2d9708823c3103c8f7198a550681c67bf Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Tue, 27 Oct 2026 05:33:06 -0700 Subject: [PATCH 10/12] fix(toolchain): release the fork guard before waiting on the riscv GCC probe `get_gcc_multilib_dir` held the shared fork lock across the whole `probe.output()`, so a stalled GCC probe blocked loader installation from acquiring the exclusive guard. Fork under the shared lock (released before the wait) with the stdio `output()` would set, matching the pattern already used by the clang_tools and debug direct spawns. Addresses the CodeRabbit review thread on #1633 (crates/fbuild-toolchain/src/toolchain/riscv.rs:187). --- crates/fbuild-toolchain/src/toolchain/riscv.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/fbuild-toolchain/src/toolchain/riscv.rs b/crates/fbuild-toolchain/src/toolchain/riscv.rs index 5567d8388..91f3733df 100644 --- a/crates/fbuild-toolchain/src/toolchain/riscv.rs +++ b/crates/fbuild-toolchain/src/toolchain/riscv.rs @@ -180,11 +180,18 @@ impl RiscvToolchain { format!("-mabi={mabi}"), "-print-multi-directory".into(), ]); - // Short probe: hold the shared fork lock for the whole run. - let output = { + // Fork under the shared fork lock (released before the wait), with the + // stdio `output()` would set, so a stalled probe cannot block loader + // installation (coderabbit review on #1633). + probe + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + let child = { let _fork = fbuild_core::platform::process::shared_fork_guard(); - probe.output().ok()? + probe.spawn().ok()? }; + let output = child.wait_with_output().ok()?; if !output.status.success() { return None; } From f11d08e4619ffc0a586d2fc461b9e27827a3222b Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Tue, 27 Oct 2026 05:58:21 -0700 Subject: [PATCH 11/12] test(cli): make the foreign-root-owner message assertion host-portable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `foreign_root_owner_message_names_the_owner` asserted the message contains the unix literal `/venv/bin/fbuild-daemon`, but `foreign_root_owner_message` renders `claim.exe.display()`, which `NormalizedPath` makes host-native — on Windows that is `\venv\bin\fbuild-daemon`, so the assertion failed and the `full / windows / Check (windows-latest)` job went red. Assert against `claim.exe.display()` itself so the test holds on every host. This is a latent main-side (#1739) test that only the full Windows lane runs. Validated on Linux: `soldr cargo test -p fbuild-cli --bin fbuild daemon_client::tests` → 16 passed; `soldr cargo clippy --workspace --all-targets -- -D warnings` clean. --- crates/fbuild-cli/src/daemon_client/tests.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/fbuild-cli/src/daemon_client/tests.rs b/crates/fbuild-cli/src/daemon_client/tests.rs index 1f39fcb75..475ed24d8 100644 --- a/crates/fbuild-cli/src/daemon_client/tests.rs +++ b/crates/fbuild-cli/src/daemon_client/tests.rs @@ -236,13 +236,17 @@ fn foreign_root_owner_message_names_the_owner() { cache_root_key: "/c".to_string(), port: 60903, }; + // NormalizedPath renders host-native separators, so assert against the + // same path the message will carry on any platform (Windows would turn + // the unix literal below into backslashes and fail the match). + let exe = claim.exe.display().to_string(); let message = foreign_root_owner_message(&claim, 19142); for needle in [ "98258", "2.5.44", "60903", "19142", - "/venv/bin/fbuild-daemon", + exe.as_str(), "No compilation", ] { assert!(message.contains(needle), "{needle} missing from: {message}"); From 97b6a0c643ecfc3756cf96aa6185f25812c5450a Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Tue, 27 Oct 2026 13:43:51 -0700 Subject: [PATCH 12/12] test(esptool): cover nested .com APE discovery find_esptool_binary must accept an esptool.com APE image nested one level deep (the tasmota zip layout). Locks the acceptance rule from platform::executable::find_tool_in for the esptool caller. --- crates/fbuild-library/src/library/esptool_tests.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/fbuild-library/src/library/esptool_tests.rs b/crates/fbuild-library/src/library/esptool_tests.rs index b318e4e4e..37ca5d7a3 100644 --- a/crates/fbuild-library/src/library/esptool_tests.rs +++ b/crates/fbuild-library/src/library/esptool_tests.rs @@ -99,6 +99,16 @@ fn find_binary_nested_one_level() { assert_eq!(found.as_path(), inner.join(esptool_bin_name())); } +#[test] +fn find_binary_accepts_a_nested_ape_com() { + let tmp = tempfile::TempDir::new().unwrap(); + let inner = tmp.path().join("esptool-any"); + std::fs::create_dir_all(&inner).unwrap(); + std::fs::write(inner.join("esptool.com"), b"MZqFpD='\n").unwrap(); + let found = find_esptool_binary(tmp.path()).unwrap(); + assert_eq!(found.as_path(), inner.join("esptool.com")); +} + #[test] fn find_binary_missing_returns_none() { let tmp = tempfile::TempDir::new().unwrap();