From 160b1cf1e89205b20e07999e8bdb5e8c14ac1130 Mon Sep 17 00:00:00 2001 From: zackees Date: Sat, 22 Aug 2026 12:16:50 -0700 Subject: [PATCH 1/7] feat(qemu): provision Linux runtime libraries instead of requiring apt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Espressif QEMU tarballs ship only the emulator binary, its ROM blobs and a static libfdt.a. The binaries carry no RPATH/RUNPATH and link five non-glibc libraries: libpixman-1, libgcrypt, libSDL2, libz and libslirp. A stock ubuntu-24.04 (what ubuntu-latest resolves to) has none of libslirp/libSDL2/libpixman, so qemu-system-xtensa and qemu-system-riscv32 die at exec with error while loading shared libraries: libslirp.so.0 Until now the only fix was an apt-get install step outside fbuild — exactly the kind of external bootstrap fbuild exists to remove, and the cause of FastLED/FastLED's red QEMU badges. fbuild now provisions the libraries itself: - `ci/build_qemu_linux_runtime.py` walks `ldd` over both real QEMU binaries in ubuntu:22.04 and archives the full transitive closure minus the glibc family and the loader (52 libraries, 5.1 MB zstd). Closure-walked rather than hand-listed: Ubuntu's SDL2 drags in X11, Wayland and PulseAudio, and libslirp pulls glib — a curated list rots the first time upstream adds a dependency. Built on 22.04 so the bundle's glibc floor (2.35) covers every host fbuild targets. - `esp_qemu_runtime` downloads that bundle from the qemu-linux-runtime-v1 release, sha256-pinned, and exposes the lib dir. - QEMU resolution probes `--version` first and only fetches the bundle when the host genuinely cannot start QEMU, so hosts that already carry the libraries download nothing and keep their own copies unshadowed. - The emulator spawn path prepends the bundle to LD_LIBRARY_PATH, the Linux twin of the existing Windows PATH hydration. Also corrects `qemu_validate_bundled_libs`' doc comment, which claimed the tarball ships lib/libslirp.so.0 with an $ORIGIN rpath. It does not — `lib/` holds only a static libfdt.a, which is why that check passed while QEMU could not start. Tests: `crates/fbuild-toolchain/tests/qemu_linux_runtime.rs` resolves both QEMU binaries and asserts they start during invocation, run by `.github/workflows/qemu-linux-runtime.yml` on a stock ubuntu-latest with deliberately no apt preinstall step. Unit tests cover the probe's exit-127 classification and that the bundle actually reaches LD_LIBRARY_PATH. aarch64: the bundle must be built on an ubuntu-22.04-arm runner (`qemu-runtime-bundle.yml`) because Docker Desktop's arm64 emulation cannot run dpkg's maintainer scripts. Until it is published, linux-arm64 hosts get an explicit error naming the distro packages to install. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/qemu-linux-runtime.yml | 69 ++++ .github/workflows/qemu-runtime-bundle.yml | 46 +++ ci/README.md | 1 + ci/build_qemu_linux_runtime.py | 299 ++++++++++++++++++ .../src/handlers/emulator/qemu_deploy.rs | 1 + .../src/handlers/emulator/runners.rs | 3 + .../src/handlers/emulator/shared.rs | 27 +- .../src/handlers/emulator/tests_process.rs | 10 + .../src/toolchain/esp_qemu.rs | 208 ++++++++---- .../src/toolchain/esp_qemu_runtime.rs | 283 +++++++++++++++++ crates/fbuild-toolchain/src/toolchain/mod.rs | 5 + crates/fbuild-toolchain/tests/README.md | 18 ++ .../tests/qemu_linux_runtime.rs | 88 ++++++ 13 files changed, 1002 insertions(+), 56 deletions(-) create mode 100644 .github/workflows/qemu-linux-runtime.yml create mode 100644 .github/workflows/qemu-runtime-bundle.yml create mode 100644 ci/build_qemu_linux_runtime.py create mode 100644 crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs create mode 100644 crates/fbuild-toolchain/tests/README.md create mode 100644 crates/fbuild-toolchain/tests/qemu_linux_runtime.rs diff --git a/.github/workflows/qemu-linux-runtime.yml b/.github/workflows/qemu-linux-runtime.yml new file mode 100644 index 00000000..dac94720 --- /dev/null +++ b/.github/workflows/qemu-linux-runtime.yml @@ -0,0 +1,69 @@ +name: QEMU Linux Runtime + +# Proves fbuild can start Espressif QEMU on a stock Linux runner without any +# host library installation. `ubuntu-latest` (24.04) ships none of libslirp, +# libSDL2 or libpixman, which the Espressif QEMU binaries link against — so +# this job is red unless fbuild provisions them itself. +# +# There is deliberately NO apt-get step here. Adding one would make the job +# pass for the wrong reason. + +on: + workflow_dispatch: {} + push: + branches: [main] + paths: + - 'crates/fbuild-toolchain/**' + - 'crates/fbuild-daemon/src/handlers/emulator/**' + - 'ci/build_qemu_linux_runtime.py' + - '.github/workflows/qemu-linux-runtime.yml' + pull_request: + branches: [main] + paths: + - 'crates/fbuild-toolchain/**' + - 'crates/fbuild-daemon/src/handlers/emulator/**' + - 'ci/build_qemu_linux_runtime.py' + - '.github/workflows/qemu-linux-runtime.yml' + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: "-D warnings" + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + qemu_runtime: + name: QEMU starts with no host libraries (ubuntu-latest) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + + - name: Confirm the runner really is missing the libraries + # Documents the premise of the test. Informational: a future runner + # image that ships them makes the test exercise the host path, which + # is still a valid pass. + run: | + for lib in libslirp.so.0 libSDL2-2.0.so.0 libpixman-1.so.0; do + if ldconfig -p | grep -q "$lib"; then + echo "present: $lib" + else + echo "absent: $lib <- fbuild must provision this" + fi + done + + - uses: astral-sh/setup-uv@v3 + + - name: Setup soldr + uses: zackees/setup-soldr@v0 + with: + version: 0.8.23 + cache: true + build-cache: true + target-cache: true + prebuild-deps: none + linker: platform-default + + - name: QEMU runtime provisioning test + run: | + soldr cargo test -p fbuild-toolchain --test qemu_linux_runtime -- \ + --ignored --nocapture diff --git a/.github/workflows/qemu-runtime-bundle.yml b/.github/workflows/qemu-runtime-bundle.yml new file mode 100644 index 00000000..5a1b9710 --- /dev/null +++ b/.github/workflows/qemu-runtime-bundle.yml @@ -0,0 +1,46 @@ +name: Build QEMU Linux Runtime Bundle + +# Manually-dispatched builder for the runtime-library bundles fbuild downloads +# when a Linux host cannot start Espressif QEMU on its own. +# +# Runs on ubuntu-22.04 (and its arm64 sibling) because the bundled libraries +# inherit the build host's glibc floor: 22.04's glibc 2.35 covers every runner +# and distro fbuild targets, while building on 24.04 would not. +# +# Upload the resulting artifacts to the `qemu-linux-runtime-v1` release and +# paste the printed SHA-256 into +# crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs. + +on: + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + bundle: + name: ${{ matrix.arch }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - arch: x86_64 + runner: ubuntu-22.04 + - arch: aarch64 + runner: ubuntu-22.04-arm + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v3 + + - name: Build bundle + run: | + uv run --no-project python ci/build_qemu_linux_runtime.py \ + --arch ${{ matrix.arch }} --native --out dist/qemu-runtime + + - uses: actions/upload-artifact@v4 + with: + name: qemu-esp-linux-runtime-${{ matrix.arch }} + path: dist/qemu-runtime/* + if-no-files-found: error diff --git a/ci/README.md b/ci/README.md index 3c711eee..c4119be7 100644 --- a/ci/README.md +++ b/ci/README.md @@ -5,6 +5,7 @@ Python scripts for CI, packaging, and development tooling. All invoked via `uv r ## Contents - **`build_dist.py`** -- Triggers GitHub Actions native builds, downloads artifacts, and assembles `dist/` for PyPI packaging +- **`build_qemu_linux_runtime.py`** -- Builds the Linux runtime-library bundle Espressif QEMU needs (`ldd` closure minus glibc, built on ubuntu:22.04). Published to the `qemu-linux-runtime-v1` release and downloaded on demand by `fbuild-toolchain`'s `esp_qemu_runtime` module; run in CI by `qemu-runtime-bundle.yml` - **`check_workspace_crates.py`** -- Monocrate guard: fails if the root `Cargo.toml` `[workspace] members` list gains a crate outside the approved allowlist (run by `crate-gate.yml`) - **`check_rust_toolchain_pins.py`** -- Prevents fbuild-owned Rust 1.95.0 MSRV, toolchain, workflow, and bootstrap declarations from drifting - **`enforce_platform_boundary.py`** -- Independent whole-tree and manifest checker for the exact host-platform occurrence ledger diff --git a/ci/build_qemu_linux_runtime.py b/ci/build_qemu_linux_runtime.py new file mode 100644 index 00000000..d966c511 --- /dev/null +++ b/ci/build_qemu_linux_runtime.py @@ -0,0 +1,299 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.10" +# /// +"""Build the Linux runtime-library bundle that Espressif QEMU needs. + +Why this exists +--------------- +The Espressif QEMU tarballs (`qemu-{xtensa,riscv32}-softmmu-*.tar.xz`) ship +**only** the emulator binary, its ROM blobs, and a static `libfdt.a`. The +binaries carry no `RPATH`/`RUNPATH` and dynamically link against five +non-glibc libraries: + + libpixman-1.so.0 libgcrypt.so.20 libSDL2-2.0.so.0 libz.so.1 libslirp.so.0 + +`ubuntu-24.04` — what `ubuntu-latest` resolves to on GitHub Actions — carries +none of libslirp/libSDL2/libpixman, so `qemu-system-xtensa` dies at exec with +`error while loading shared libraries: libslirp.so.0`. Requiring every caller +to `apt-get install` the set first makes QEMU emulation depend on an external +bootstrap step, which is exactly what fbuild exists to remove. + +This script produces the bundle fbuild downloads on demand instead. It runs +`ldd` over both real QEMU binaries inside a pinned container and copies the +**full transitive closure minus the glibc family** (libc/libm/libpthread/ +librt/libdl/libutil/libresolv/libgcc_s and the loader itself — shipping those +without the matching `ld-linux` is how you get a segfault, not a fix). + +The closure is walked mechanically rather than hand-listed on purpose: SDL2 on +Ubuntu pulls in X11, Wayland, PulseAudio and friends, and libslirp pulls glib. +A curated list silently rots the first time a dependency is added upstream. + +Build host is **ubuntu:22.04**, not 24.04: the bundled libraries inherit the +build image's glibc floor, and 22.04 (glibc 2.35) covers every runner and +distro fbuild targets while 24.04 (glibc 2.39) would not. + +Usage:: + + uv run python ci/build_qemu_linux_runtime.py --arch x86_64 + uv run python ci/build_qemu_linux_runtime.py --arch aarch64 --out dist/ + uv run python ci/build_qemu_linux_runtime.py --native # on a Linux runner + +`--native` runs the payload directly instead of in a container, for use on a +GitHub `ubuntu-22.04` / `ubuntu-22.04-arm` runner — the runner image *is* the +build image, and it is the only practical way to produce the aarch64 bundle +(dpkg's maintainer scripts fail under Docker Desktop's arm64 emulation). + +Emits `qemu-esp-linux-runtime--.tar.zst`, a `.manifest.txt` +listing what went in, and prints the SHA-256 to paste into +`crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs`. +""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import subprocess +import sys +from pathlib import Path + + +# Keep in sync with QEMU_RELEASE_TAG in +# crates/fbuild-toolchain/src/toolchain/esp_qemu.rs. +QEMU_RELEASE_TAG = "esp-develop-9.2.2-20250817" +QEMU_ARCHIVE_VERSION = "esp_develop_9.2.2_20250817" + +BUILD_IMAGE = "ubuntu:22.04" + +# Docker --platform value per target architecture. +DOCKER_PLATFORM = { + "x86_64": "linux/amd64", + "aarch64": "linux/arm64", +} + +# Espressif's archive suffix per target architecture. +QEMU_ARCHIVE_SUFFIX = { + "x86_64": "x86_64-linux-gnu", + "aarch64": "aarch64-linux-gnu", +} + +CONTAINER_SCRIPT = r""" +set -euo pipefail +export DEBIAN_FRONTEND=noninteractive + +ARCH_SUFFIX="__ARCH_SUFFIX__" +TAG="__TAG__" +VERSION="__VERSION__" +OUT_NAME="__OUT_NAME__" + +apt-get update -qq +apt-get install -y --no-install-recommends \ + ca-certificates curl xz-utils zstd \ + libsdl2-2.0-0 libslirp0 libpixman-1-0 libgcrypt20 zlib1g >/dev/null + +cd /tmp +for a in xtensa riscv32; do + url="https://github.com/espressif/qemu/releases/download/${TAG}/qemu-${a}-softmmu-${VERSION}-${ARCH_SUFFIX}.tar.xz" + echo "downloading ${url}" + curl -sSfL -o "q-${a}.tar.xz" "${url}" + mkdir -p "x-${a}" + tar xf "q-${a}.tar.xz" -C "x-${a}" +done + +XTENSA=/tmp/x-xtensa/qemu/bin/qemu-system-xtensa +RISCV=/tmp/x-riscv32/qemu/bin/qemu-system-riscv32 +test -x "$XTENSA" +test -x "$RISCV" + +mkdir -p /tmp/bundle/lib + +# Resolved shared-object paths for one ELF, one per line. +deps() { + ldd "$1" 2>/dev/null | awk '{print $3}' | grep '^/' || true +} + +# The glibc family and the dynamic loader are deliberately excluded: they +# must come from the host, and a bundled libc without its matching +# ld-linux is unloadable. +is_glibc_family() { + case "$1" in + libc.so.6|libm.so.6|libpthread.so.0|librt.so.1|libdl.so.2) return 0;; + libutil.so.1|libresolv.so.2|libgcc_s.so.1|ld-linux*) return 0;; + *) return 1;; + esac +} + +QUEUE="$(deps "$XTENSA"; deps "$RISCV")" +for _round in 1 2 3 4 5 6 7 8; do + NEXT="" + for f in $QUEUE; do + b="$(basename "$f")" + if is_glibc_family "$b"; then continue; fi + if [ ! -f "/tmp/bundle/lib/$b" ]; then + cp -L "$f" "/tmp/bundle/lib/$b" + NEXT="$NEXT $(deps "$f")" + fi + done + [ -z "$(echo $NEXT)" ] && break + QUEUE="$NEXT" +done + +# Fail loudly if the libraries this bundle exists for are absent. +for required in libslirp.so.0 libSDL2-2.0.so.0 libpixman-1.so.0 libgcrypt.so.20 libz.so.1; do + if [ ! -f "/tmp/bundle/lib/$required" ]; then + echo "FATAL: closure is missing $required" >&2 + exit 1 + fi +done + +{ + echo "# Espressif QEMU ${TAG} Linux runtime libraries (${ARCH_SUFFIX})" + echo "# Built from ${BUILD_IMAGE:-ubuntu:22.04}; glibc family intentionally excluded." + (cd /tmp/bundle/lib && ls -1 | sort) +} > /tmp/bundle/MANIFEST.txt + +cd /tmp/bundle +tar cf - lib MANIFEST.txt | zstd -19 -T0 -q -o "/tmp/${OUT_NAME}" + +cp /tmp/bundle/MANIFEST.txt "/tmp/${OUT_NAME}.manifest.txt" + +echo "=== bundle ===" +cat /tmp/bundle/MANIFEST.txt +echo "libraries: $(ls -1 /tmp/bundle/lib | wc -l)" +echo "archive: $(stat -c %s "/tmp/${OUT_NAME}") bytes" + +# Prove the bundle actually satisfies both binaries, with no host packages +# in play beyond glibc: strip the apt-installed copies first so a stale +# system library cannot mask a gap in the closure. +apt-get remove -y libsdl2-2.0-0 libslirp0 libpixman-1-0 >/dev/null 2>&1 || true +for bin in "$XTENSA" "$RISCV"; do + if ! LD_LIBRARY_PATH=/tmp/bundle/lib "$bin" --version >/dev/null; then + echo "FATAL: $bin still cannot start with the bundle applied" >&2 + exit 1 + fi +done +echo "selftest: both QEMU binaries start with LD_LIBRARY_PATH=/lib" +""" + + +def out_name(arch: str) -> str: + return f"qemu-esp-linux-runtime-{arch}-{QEMU_RELEASE_TAG}.tar.zst" + + +def build(arch: str, out_dir: Path) -> Path: + script = ( + CONTAINER_SCRIPT.replace("__ARCH_SUFFIX__", QEMU_ARCHIVE_SUFFIX[arch]) + .replace("__TAG__", QEMU_RELEASE_TAG) + .replace("__VERSION__", QEMU_ARCHIVE_VERSION) + .replace("__OUT_NAME__", out_name(arch)) + ) + payload = base64.b64encode(script.encode()).decode() + container = f"fbuild-qemu-runtime-{arch}" + + subprocess.run(["docker", "rm", "-f", container], capture_output=True, check=False) + proc = subprocess.run( + [ + "docker", + "run", + "--name", + container, + "--platform", + DOCKER_PLATFORM[arch], + BUILD_IMAGE, + "bash", + "-c", + f"echo {payload} | base64 -d > /tmp/build.sh && bash /tmp/build.sh", + ], + capture_output=True, + text=True, + check=False, + ) + sys.stdout.write(proc.stdout) + sys.stderr.write(proc.stderr) + if proc.returncode != 0: + raise SystemExit(f"container build failed with exit code {proc.returncode}") + + out_dir.mkdir(parents=True, exist_ok=True) + archive = out_dir / out_name(arch) + for remote, local in ( + (f"/tmp/{out_name(arch)}", archive), + (f"/tmp/{out_name(arch)}.manifest.txt", Path(f"{archive}.manifest.txt")), + ): + subprocess.run( + ["docker", "cp", f"{container}:{remote}", str(local)], check=True + ) + subprocess.run(["docker", "rm", "-f", container], capture_output=True, check=False) + return archive + + +def build_native(arch: str, out_dir: Path) -> Path: + """Run the payload directly on this Linux host (CI runner path).""" + import platform + import shutil + import tempfile + + host_arch = platform.machine() + expected = {"x86_64": "x86_64", "aarch64": "aarch64"}[arch] + if host_arch != expected: + raise SystemExit( + f"--native needs a {expected} host to build the {arch} bundle; this host is {host_arch}" + ) + + script = ( + CONTAINER_SCRIPT.replace("__ARCH_SUFFIX__", QEMU_ARCHIVE_SUFFIX[arch]) + .replace("__TAG__", QEMU_RELEASE_TAG) + .replace("__VERSION__", QEMU_ARCHIVE_VERSION) + .replace("__OUT_NAME__", out_name(arch)) + ) + with tempfile.NamedTemporaryFile("w", suffix=".sh", delete=False) as handle: + handle.write(script) + script_path = handle.name + + proc = subprocess.run(["sudo", "bash", script_path], check=False) + if proc.returncode != 0: + raise SystemExit(f"native build failed with exit code {proc.returncode}") + + out_dir.mkdir(parents=True, exist_ok=True) + archive = out_dir / out_name(arch) + shutil.copy(f"/tmp/{out_name(arch)}", archive) + shutil.copy(f"/tmp/{out_name(arch)}.manifest.txt", f"{archive}.manifest.txt") + return archive + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--arch", + choices=sorted(DOCKER_PLATFORM), + default="x86_64", + help="target architecture of the bundle (default: x86_64)", + ) + parser.add_argument( + "--out", + type=Path, + default=Path("dist/qemu-runtime"), + help="output directory (default: dist/qemu-runtime)", + ) + parser.add_argument( + "--native", + action="store_true", + help="build directly on this Linux host instead of in a container", + ) + args = parser.parse_args() + + archive = ( + build_native(args.arch, args.out) + if args.native + else build(args.arch, args.out) + ) + digest = hashlib.sha256(archive.read_bytes()).hexdigest() + print() + print(f"archive: {archive}") + print(f"sha256: {digest}") + print(f"size: {archive.stat().st_size} bytes") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/crates/fbuild-daemon/src/handlers/emulator/qemu_deploy.rs b/crates/fbuild-daemon/src/handlers/emulator/qemu_deploy.rs index 17369d27..73ac80c6 100644 --- a/crates/fbuild-daemon/src/handlers/emulator/qemu_deploy.rs +++ b/crates/fbuild-daemon/src/handlers/emulator/qemu_deploy.rs @@ -277,6 +277,7 @@ pub async fn deploy_qemu( show_timestamp, verbose, process_label: "QEMU", + project_dir: Some(&project_dir), }, ) .await diff --git a/crates/fbuild-daemon/src/handlers/emulator/runners.rs b/crates/fbuild-daemon/src/handlers/emulator/runners.rs index 61c4bc51..a781230a 100644 --- a/crates/fbuild-daemon/src/handlers/emulator/runners.rs +++ b/crates/fbuild-daemon/src/handlers/emulator/runners.rs @@ -132,6 +132,7 @@ impl EmulatorRunner for QemuRunner { show_timestamp: config.show_timestamp, verbose: config.verbose, process_label: "QEMU", + project_dir: Some(&self.project_dir), }, ) .await?; @@ -345,6 +346,8 @@ impl EmulatorRunner for SimavrRunner { show_timestamp: config.show_timestamp, verbose: config.verbose, process_label: "simavr", + // simavr is a host-native binary; no QEMU runtime bundle. + project_dir: None, }, ) .await?; diff --git a/crates/fbuild-daemon/src/handlers/emulator/shared.rs b/crates/fbuild-daemon/src/handlers/emulator/shared.rs index 4bfdac4a..54208114 100644 --- a/crates/fbuild-daemon/src/handlers/emulator/shared.rs +++ b/crates/fbuild-daemon/src/handlers/emulator/shared.rs @@ -42,6 +42,9 @@ pub(crate) struct RunQemuOptions<'a> { pub verbose: bool, /// Label used in user-visible messages (e.g. "QEMU", "simavr"). pub process_label: &'a str, + /// Project directory, used to locate fbuild's Linux QEMU runtime-library + /// bundle. `None` for non-QEMU runners and for tests that spawn a stub. + pub project_dir: Option<&'a Path>, } /// Configuration for an emulator test run (user-facing options). @@ -156,7 +159,11 @@ pub(crate) async fn resolve_esp32_toolchain_gcc_path( Ok(toolchain.get_gcc_path()) } -fn apply_process_environment(cmd: &mut tokio::process::Command, exe_path: &Path) { +fn apply_process_environment( + cmd: &mut tokio::process::Command, + exe_path: &Path, + project_dir: Option<&Path>, +) { if fbuild_core::platform::host::is_windows() { let current_path = std::env::var("PATH").unwrap_or_default(); if let Ok(path_env) = @@ -164,6 +171,22 @@ fn apply_process_environment(cmd: &mut tokio::process::Command, exe_path: &Path) { cmd.env("PATH", path_env); } + return; + } + + // Linux: the Espressif QEMU binaries bundle no shared libraries and + // carry no RPATH. When the host could not start QEMU on its own, + // toolchain resolution installed fbuild's runtime bundle; point the + // emulator at it. Hosts that never needed the bundle get `None` here + // and keep their own libraries. + if let (true, Some(project_dir)) = (fbuild_core::platform::host::is_linux(), project_dir) { + let current = std::env::var("LD_LIBRARY_PATH").ok(); + if let Some(value) = fbuild_packages::toolchain::build_linux_qemu_ld_library_path( + project_dir, + current.as_deref(), + ) { + cmd.env("LD_LIBRARY_PATH", value); + } } } @@ -190,7 +213,7 @@ pub(crate) async fn run_qemu_process( .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); - apply_process_environment(&mut cmd, qemu_path); + apply_process_environment(&mut cmd, qemu_path, options.project_dir); let label = options.process_label; if options.verbose { diff --git a/crates/fbuild-daemon/src/handlers/emulator/tests_process.rs b/crates/fbuild-daemon/src/handlers/emulator/tests_process.rs index 2364a49c..0443ab76 100644 --- a/crates/fbuild-daemon/src/handlers/emulator/tests_process.rs +++ b/crates/fbuild-daemon/src/handlers/emulator/tests_process.rs @@ -45,6 +45,7 @@ async fn run_qemu_process_reports_expected_success_output() { show_timestamp: false, verbose: false, process_label: "QEMU", + project_dir: None, }, ) .await @@ -76,6 +77,7 @@ async fn run_qemu_process_surfaces_crash_decoder_output() { show_timestamp: false, verbose: false, process_label: "QEMU", + project_dir: None, }, ) .await @@ -215,6 +217,7 @@ async fn run_real_esp32s3_fixture_in_qemu() { show_timestamp: false, verbose: true, process_label: "QEMU", + project_dir: None, }, ) .await @@ -251,6 +254,7 @@ async fn run_avr8js_headless_captures_stdout() { show_timestamp: false, verbose: false, process_label: "QEMU", + project_dir: None, }, ) .await @@ -282,6 +286,7 @@ async fn run_avr8js_headless_halt_on_success() { show_timestamp: false, verbose: false, process_label: "QEMU", + project_dir: None, }, ) .await @@ -311,6 +316,7 @@ async fn run_avr8js_headless_halt_on_error() { show_timestamp: false, verbose: false, process_label: "QEMU", + project_dir: None, }, ) .await @@ -346,6 +352,7 @@ async fn simavr_runner_captures_stdout_via_process_runner() { show_timestamp: false, verbose: false, process_label: "simavr", + project_dir: None, }, ) .await @@ -374,6 +381,7 @@ async fn simavr_runner_halt_on_success() { show_timestamp: false, verbose: false, process_label: "simavr", + project_dir: None, }, ) .await @@ -403,6 +411,7 @@ async fn simavr_runner_halt_on_error() { show_timestamp: false, verbose: false, process_label: "simavr", + project_dir: None, }, ) .await @@ -432,6 +441,7 @@ async fn simavr_runner_timeout() { show_timestamp: false, verbose: false, process_label: "simavr", + project_dir: None, }, ) .await diff --git a/crates/fbuild-toolchain/src/toolchain/esp_qemu.rs b/crates/fbuild-toolchain/src/toolchain/esp_qemu.rs index 9f6f345a..0b105199 100644 --- a/crates/fbuild-toolchain/src/toolchain/esp_qemu.rs +++ b/crates/fbuild-toolchain/src/toolchain/esp_qemu.rs @@ -89,6 +89,7 @@ impl EspQemuArch { pub struct EspQemu { base: PackageBase, arch: EspQemuArch, + project_dir: PathBuf, } impl EspQemu { @@ -111,6 +112,7 @@ impl EspQemu { project_dir, ), arch, + project_dir: project_dir.to_path_buf(), }) } @@ -150,8 +152,10 @@ impl EspQemu { // deps resolve) before handing it to the caller. On Linux, a // missing .so exits with code 127 — much clearer to report here // with the toolchain path than as a bare "exited with code 127" - // from the emulator runner. - preflight_qemu_binary(&resolved)?; + // from the emulator runner. When the host is missing libraries, + // fbuild provisions them itself rather than asking the caller to + // apt-get anything. + ensure_qemu_can_start(&resolved, &self.project_dir).await?; Ok(resolved) } @@ -209,18 +213,33 @@ fn qemu_validate_bundled_libs(qemu_binary: &Path) -> Result<()> { ))) } +/// Result of probing a QEMU binary with `--version`. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +enum QemuProbe { + /// The binary started; its shared-library dependencies all resolved. + Started, + /// The dynamic linker could not satisfy a dependency (exit code 127). + /// Carries the linker's own line when it could be recovered. + MissingSharedLibrary(String), + /// Probe could not be interpreted (spawn failure, or some other + /// non-127 exit). Treated as non-fatal: the real run reports it with + /// full context. + Inconclusive, +} + /// Probe the QEMU binary with `--version` to verify its shared library /// dependencies resolve at runtime. /// -/// Returns `Ok(())` if the probe succeeds, or a diagnostic `Err` if the -/// binary exits with code 127 (dynamic linker failure — missing .so). -/// Other probe failures are treated as non-fatal (the real run will -/// surface the error with full context). -fn preflight_qemu_binary(qemu_binary: &Path) -> Result<()> { +/// `lib_dir`, when given, is prepended to `LD_LIBRARY_PATH` for the probe so +/// the caller can ask "does it start *with* the bundled runtime libraries?". +/// +/// Probing is Linux-only. Windows resolves its DLLs through the `PATH` +/// hydration path above, and macOS builds are self-contained. +fn probe_qemu_binary(qemu_binary: &Path, lib_dir: Option<&Path>) -> QemuProbe { #[cfg(not(target_os = "linux"))] { - let _ = qemu_binary; - Ok(()) + let _ = (qemu_binary, lib_dir); + QemuProbe::Started } #[cfg(target_os = "linux")] @@ -229,49 +248,96 @@ fn preflight_qemu_binary(qemu_binary: &Path) -> Result<()> { // hand it to the async emulator runner. Uses run_command_blocking which // routes through containment (no console flash on Windows, containment // group on all platforms) and is ~100 ms. + let ld_library_path = lib_dir.map(|dir| { + crate::toolchain::esp_qemu_runtime::ld_library_path_with( + dir, + std::env::var("LD_LIBRARY_PATH").ok().as_deref(), + ) + }); + let env: Option> = ld_library_path + .as_deref() + .map(|value| vec![("LD_LIBRARY_PATH", value)]); + let probe_result = fbuild_core::subprocess::run_command_blocking( &[&qemu_binary.to_string_lossy(), "--version"], None, // cwd - None, // env + env.as_deref(), Some(std::time::Duration::from_secs(5)), ); match probe_result { - Ok(out) if out.success() => Ok(()), + Ok(out) if out.success() => QemuProbe::Started, Ok(out) if out.exit_code == 127 => { - // Try to identify which library is missing from the linker error. - let missing = out + let detail = out .stderr .lines() .find(|l| l.contains("error while loading shared libraries")) - .map(|l| l.trim().to_string()); - - Err(FbuildError::PackageError(format!( - "QEMU at {} cannot start: a required shared library is missing.\n\ - {}\n\ - The cached QEMU toolchain appears incomplete or corrupt.\n\ - To fix, delete the cached toolchain and retry:\n rm -rf {}", - qemu_binary.display(), - missing.as_deref().unwrap_or(&format!( - "The dynamic linker reported: {}", - out.stderr.trim() - )), - qemu_binary - .parent() - .and_then(|p| p.parent()) - .unwrap_or(qemu_binary.parent().unwrap_or(qemu_binary)) - .display(), - ))) - } - Ok(_) | Err(_) => { - // Non-127 exit or spawn failure: non-fatal at this stage. - // The real QEMU run will surface the error with full context. - Ok(()) + .map(|l| l.trim().to_string()) + .unwrap_or_else(|| out.stderr.trim().to_string()); + QemuProbe::MissingSharedLibrary(detail) } + Ok(_) | Err(_) => QemuProbe::Inconclusive, } } } +/// Make sure the resolved QEMU binary can actually start on this host. +/// +/// The Espressif tarballs bundle no shared libraries and carry no `RPATH`, so +/// on Linux the binary needs libslirp/libSDL2/libpixman/libgcrypt/libz from +/// somewhere. A stock `ubuntu-24.04` has none of the first three. Rather than +/// make every caller `apt-get install` them first — an external bootstrap step +/// fbuild exists to remove — fbuild downloads its own runtime bundle and +/// re-probes with it applied. +/// +/// The bundle is fetched lazily: a host that can already start QEMU never +/// downloads it and never has its own libraries shadowed. +async fn ensure_qemu_can_start(qemu_binary: &Path, project_dir: &Path) -> Result<()> { + let missing = match probe_qemu_binary(qemu_binary, None) { + QemuProbe::Started | QemuProbe::Inconclusive => return Ok(()), + QemuProbe::MissingSharedLibrary(detail) => detail, + }; + + tracing::info!( + "QEMU at {} is missing a host shared library ({}); fetching the fbuild runtime bundle", + qemu_binary.display(), + missing + ); + + let runtime = crate::toolchain::esp_qemu_runtime::QemuLinuxRuntime::new(project_dir) + .map_err(|e| runtime_unavailable_error(qemu_binary, &missing, &e.to_string()))?; + let lib_dir = runtime + .ensure_lib_dir() + .await + .map_err(|e| runtime_unavailable_error(qemu_binary, &missing, &e.to_string()))?; + + match probe_qemu_binary(qemu_binary, Some(&lib_dir)) { + QemuProbe::Started | QemuProbe::Inconclusive => Ok(()), + QemuProbe::MissingSharedLibrary(still_missing) => Err(FbuildError::PackageError(format!( + "QEMU at {} cannot start even with the fbuild runtime bundle at {} applied.\n\ + {}\n\ + The bundle carries the full non-glibc dependency closure of the Espressif QEMU binaries, so this points at a host glibc older than the bundle's build image (ubuntu 22.04, glibc 2.35), or at a library the closure does not cover.\n\ + Please report it at https://github.com/FastLED/fbuild/issues.", + qemu_binary.display(), + lib_dir.display(), + still_missing, + ))), + } +} + +/// Error for "the host cannot start QEMU and fbuild could not provision the +/// libraries either" — keeps the original linker complaint in view. +fn runtime_unavailable_error(qemu_binary: &Path, missing: &str, cause: &str) -> FbuildError { + FbuildError::PackageError(format!( + "QEMU at {} cannot start: a required shared library is missing.\n\ + {}\n\ + fbuild could not provision its Linux runtime bundle: {}", + qemu_binary.display(), + missing, + cause, + )) +} + #[async_trait::async_trait] impl Package for EspQemu { async fn ensure_installed(&self) -> Result { @@ -884,13 +950,13 @@ mod tests { ); } - // ── preflight_qemu_binary ─────────────────────────────────────── + // ── probe_qemu_binary ─────────────────────────────────────────── #[test] - fn preflight_ok_when_binary_runs_version_successfully() { - // On Linux, a real QEMU binary would pass. On non-Linux, - // preflight is a no-op. We test with a shell script that exits 0 - // so the probe succeeds cross-platform. + fn probe_reports_started_when_binary_runs_version_successfully() { + // On Linux, a real QEMU binary would pass. On non-Linux, probing is + // a no-op that always reports Started. We use a script that exits 0 + // so the assertion holds cross-platform. let tmp = tempfile::TempDir::new().unwrap(); let probe = tmp.path().join("probe_qemu"); if fbuild_core::platform::host::is_windows() { @@ -899,17 +965,14 @@ mod tests { std::fs::write(&probe, b"#!/bin/sh\nexit 0\n").unwrap(); fbuild_core::platform::fs::set_executable(&probe).unwrap(); }; - // preflight is a no-op on non-Linux, and on Linux with a fake - // script that exits 0 it should pass. - let result = preflight_qemu_binary(&probe); assert!( - result.is_ok(), - "preflight should pass when binary returns 0" + matches!(probe_qemu_binary(&probe, None), QemuProbe::Started), + "probe should report Started when the binary returns 0" ); } #[test] - fn preflight_linux_detects_missing_shared_library_exit_127() { + fn probe_linux_detects_missing_shared_library_exit_127() { if fbuild_core::platform::host::current().os() != fbuild_core::platform::host::HostOs::Linux { return; @@ -925,16 +988,53 @@ mod tests { .unwrap(); fbuild_core::platform::fs::set_executable(&probe).unwrap(); - let err = preflight_qemu_binary(&probe).unwrap_err(); - let msg = err.to_string(); + match probe_qemu_binary(&probe, None) { + QemuProbe::MissingSharedLibrary(detail) => assert!( + detail.contains("libslirp.so.0"), + "probe should name the missing library: {detail}" + ), + _ => panic!("exit 127 must be reported as a missing shared library"), + } + } + + #[test] + fn probe_linux_exports_the_bundle_on_ld_library_path() { + if fbuild_core::platform::host::current().os() != fbuild_core::platform::host::HostOs::Linux + { + return; + } + let tmp = tempfile::TempDir::new().unwrap(); + // Script that succeeds only when LD_LIBRARY_PATH leads with the + // directory we asked the probe to apply — i.e. the bundle actually + // reaches the QEMU invocation rather than merely being installed. + let probe = tmp.path().join("fake_qemu_needs_lib_dir"); + let lib_dir = tmp.path().join("bundle-lib"); + std::fs::create_dir_all(&lib_dir).unwrap(); + std::fs::write( + &probe, + format!( + "#!/bin/sh\ncase \"$LD_LIBRARY_PATH\" in\n {}:*|{}) exit 0;;\nesac\necho 'error while loading shared libraries: libslirp.so.0' >&2\nexit 127\n", + lib_dir.display(), + lib_dir.display() + ) + .as_bytes(), + ) + .unwrap(); + fbuild_core::platform::fs::set_executable(&probe).unwrap(); + assert!( - msg.contains("shared library"), - "should report missing shared library: {msg}" + matches!( + probe_qemu_binary(&probe, None), + QemuProbe::MissingSharedLibrary(_) + ), + "without the bundle the stub must fail like a real missing .so" ); assert!( - msg.contains("libslirp.so.0"), - "should name the missing library: {msg}" + matches!( + probe_qemu_binary(&probe, Some(&lib_dir)), + QemuProbe::Started + ), + "probe must put the bundle directory on LD_LIBRARY_PATH" ); - assert!(msg.contains("rm -rf"), "should suggest deletion: {msg}"); } } diff --git a/crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs b/crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs new file mode 100644 index 00000000..d6215927 --- /dev/null +++ b/crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs @@ -0,0 +1,283 @@ +//! Linux runtime shared libraries for Espressif QEMU. +//! +//! The Espressif QEMU tarballs ship the emulator binary, its ROM blobs and a +//! static `libfdt.a` — and nothing else. The binaries carry no `RPATH`/ +//! `RUNPATH` and dynamically link five non-glibc libraries: +//! +//! ```text +//! libpixman-1.so.0 libgcrypt.so.20 libSDL2-2.0.so.0 libz.so.1 libslirp.so.0 +//! ``` +//! +//! On a stock `ubuntu-24.04` image (what `ubuntu-latest` resolves to on GitHub +//! Actions) libslirp, libSDL2 and libpixman are all absent, so +//! `qemu-system-xtensa` dies at exec with +//! `error while loading shared libraries: libslirp.so.0`. +//! +//! Making callers `apt-get install` the set first turns QEMU emulation into +//! something you have to bootstrap from outside fbuild. Instead fbuild +//! downloads a prebuilt bundle of the full non-glibc dependency closure and +//! points the emulator at it with `LD_LIBRARY_PATH`. +//! +//! The bundle is **lazy**: it is only downloaded when a probe proves the host +//! cannot start QEMU on its own. Hosts that already carry the libraries never +//! fetch it and never have their libraries shadowed. +//! +//! Bundle provenance: `ci/build_qemu_linux_runtime.py` walks `ldd` over both +//! real QEMU binaries inside `ubuntu:22.04` and archives the transitive +//! closure minus the glibc family. glibc and the loader deliberately stay on +//! the host — a bundled `libc.so.6` without its matching `ld-linux` is a +//! segfault, not a fix. + +use std::path::{Path, PathBuf}; + +use fbuild_core::platform::host::{self, HostArch, HostPlatform}; +use fbuild_core::{FbuildError, Result}; + +use crate::{CacheSubdir, Package, PackageBase, PackageInfo}; + +/// Release tag of the QEMU build this bundle was closed over. Kept in sync +/// with `QEMU_RELEASE_TAG` in `esp_qemu.rs` and with +/// `ci/build_qemu_linux_runtime.py`. +const RUNTIME_VERSION: &str = "esp-develop-9.2.2-20250817"; + +/// Release that hosts the prebuilt bundles. +const RUNTIME_RELEASE_TAG: &str = "qemu-linux-runtime-v1"; + +/// Subdirectory the archive extracts its libraries into. +const LIB_SUBDIR: &str = "lib"; + +/// One library that must be present for the bundle to be considered valid — +/// it is the one whose absence broke CI in the first place. +const SENTINEL_LIB: &str = "libslirp.so.0"; + +/// The bundled Linux runtime libraries for Espressif QEMU. +pub struct QemuLinuxRuntime { + base: PackageBase, +} + +impl QemuLinuxRuntime { + pub fn new(project_dir: &Path) -> Result { + Self::for_host(host::current(), project_dir) + } + + fn for_host(host: HostPlatform, project_dir: &Path) -> Result { + let arch = runtime_arch(host)?; + let url = format!( + "https://github.com/FastLED/fbuild/releases/download/{}/qemu-esp-linux-runtime-{}-{}.tar.zst", + RUNTIME_RELEASE_TAG, arch, RUNTIME_VERSION + ); + Ok(Self { + base: PackageBase::new( + "esp-qemu-linux-runtime", + RUNTIME_VERSION, + &url, + &format!("qemu-linux-runtime-{arch}"), + Some(runtime_sha256(arch)?), + CacheSubdir::Toolchains, + project_dir, + ), + }) + } + + /// Directory to place on `LD_LIBRARY_PATH`. + pub fn lib_dir(&self) -> PathBuf { + self.base.install_path().join(LIB_SUBDIR) + } + + /// Install if needed and return the directory to put on `LD_LIBRARY_PATH`. + pub async fn ensure_lib_dir(&self) -> Result { + self.ensure_installed().await?; + Ok(self.lib_dir()) + } +} + +#[async_trait::async_trait] +impl Package for QemuLinuxRuntime { + async fn ensure_installed(&self) -> Result { + if self.is_installed() { + return Ok(self.base.install_path()); + } + self.base.staged_install(validate_runtime_install).await + } + + fn is_installed(&self) -> bool { + self.base.is_cached() && self.lib_dir().join(SENTINEL_LIB).is_file() + } + + fn get_info(&self) -> PackageInfo { + self.base.get_info() + } +} + +/// Reject an extracted tree that does not carry the libraries it exists for. +fn validate_runtime_install(install_dir: &Path) -> Result<()> { + let lib_dir = install_dir.join(LIB_SUBDIR); + if lib_dir.join(SENTINEL_LIB).is_file() { + return Ok(()); + } + Err(FbuildError::PackageError(format!( + "QEMU Linux runtime bundle at {} is incomplete: {}/{} not found", + install_dir.display(), + LIB_SUBDIR, + SENTINEL_LIB, + ))) +} + +/// Architecture token used in the bundle's asset name. +fn runtime_arch(host: HostPlatform) -> Result<&'static str> { + if !host.is_linux() { + return Err(FbuildError::PackageError(format!( + "the QEMU runtime-library bundle is Linux-only; host is {}", + host.os_name() + ))); + } + match host.arch() { + HostArch::X86_64 => Ok("x86_64"), + HostArch::Aarch64 => Ok("aarch64"), + _ => Err(FbuildError::PackageError(format!( + "no QEMU runtime-library bundle is published for linux-{}", + host.arch_name() + ))), + } +} + +/// SHA-256 of each published bundle. An architecture without an entry has no +/// bundle yet: report that plainly instead of downloading something unpinned. +/// +/// aarch64 is built by `.github/workflows/qemu-runtime-bundle.yml` on an +/// `ubuntu-22.04-arm` runner — Docker Desktop's arm64 emulation cannot run +/// dpkg's maintainer scripts, so it cannot be produced from a developer +/// workstation the way the x86_64 bundle was. +fn runtime_sha256(arch: &str) -> Result<&'static str> { + match arch { + "x86_64" => Ok("e4f22c9b88a1a032dcba07aec2ac7ada01563b7fe6ccdd3a1a0b3d740aec51df"), + other => Err(FbuildError::PackageError(format!( + "no QEMU runtime-library bundle is published for linux-{other} yet (tracked in the {RUNTIME_RELEASE_TAG} release).\n\ + Install the QEMU runtime libraries from your distribution — on Debian/Ubuntu: libslirp0, libsdl2-2.0-0, libpixman-1-0." + ))), + } +} + +/// Prepend `lib_dir` to an existing `LD_LIBRARY_PATH` value. +/// +/// Returns the combined value. An empty or absent current value yields just +/// the bundle directory. The bundle goes first because the host is, by +/// construction, missing at least one of the libraries it carries. +pub fn ld_library_path_with(lib_dir: &Path, current: Option<&str>) -> String { + let bundle = lib_dir.to_string_lossy().to_string(); + match current { + Some(existing) if !existing.is_empty() => format!("{bundle}:{existing}"), + _ => bundle, + } +} + +/// Spawn-time lookup: the bundle directory, if this host needed it and it is +/// already installed. +/// +/// Stateless on purpose — mirrors `build_windows_qemu_path_env`, so the +/// emulator spawn path does not have to thread a resolution result through +/// every call site. A host that never needed the bundle has nothing cached +/// here and gets `None`. +pub fn installed_lib_dir(project_dir: &Path) -> Option { + let runtime = QemuLinuxRuntime::new(project_dir).ok()?; + if runtime.is_installed() { + Some(runtime.lib_dir()) + } else { + None + } +} + +/// `LD_LIBRARY_PATH` for a QEMU spawn, or `None` when the host does not need +/// the bundle. +pub fn build_linux_qemu_ld_library_path( + project_dir: &Path, + current: Option<&str>, +) -> Option { + let lib_dir = installed_lib_dir(project_dir)?; + Some(ld_library_path_with(&lib_dir, current)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn linux(arch: HostArch) -> HostPlatform { + HostPlatform::new(fbuild_core::platform::host::HostOs::Linux, arch) + } + + #[test] + fn arch_token_for_linux_hosts() { + assert_eq!(runtime_arch(linux(HostArch::X86_64)).unwrap(), "x86_64"); + assert_eq!(runtime_arch(linux(HostArch::Aarch64)).unwrap(), "aarch64"); + } + + #[test] + fn non_linux_hosts_are_rejected() { + let win = HostPlatform::new( + fbuild_core::platform::host::HostOs::Windows, + HostArch::X86_64, + ); + let err = runtime_arch(win).unwrap_err().to_string(); + assert!(err.contains("Linux-only"), "unexpected error: {err}"); + } + + #[test] + fn url_points_at_the_published_asset() { + let tmp = tempfile::TempDir::new().unwrap(); + let rt = QemuLinuxRuntime::for_host(linux(HostArch::X86_64), tmp.path()).unwrap(); + let info = rt.get_info(); + assert!( + info.url.ends_with(&format!( + "qemu-esp-linux-runtime-x86_64-{RUNTIME_VERSION}.tar.zst" + )), + "unexpected url: {}", + info.url + ); + assert!(info.url.contains(RUNTIME_RELEASE_TAG), "url: {}", info.url); + } + + #[test] + fn ld_library_path_puts_bundle_first() { + let combined = ld_library_path_with(Path::new("/cache/rt/lib"), Some("/usr/local/lib")); + assert_eq!(combined, "/cache/rt/lib:/usr/local/lib"); + } + + #[test] + fn ld_library_path_without_existing_value() { + assert_eq!( + ld_library_path_with(Path::new("/cache/rt/lib"), None), + "/cache/rt/lib" + ); + assert_eq!( + ld_library_path_with(Path::new("/cache/rt/lib"), Some("")), + "/cache/rt/lib" + ); + } + + #[test] + fn validate_rejects_tree_without_sentinel_library() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::create_dir_all(tmp.path().join(LIB_SUBDIR)).unwrap(); + let err = validate_runtime_install(tmp.path()) + .unwrap_err() + .to_string(); + assert!(err.contains(SENTINEL_LIB), "unexpected error: {err}"); + } + + #[test] + fn validate_accepts_tree_with_sentinel_library() { + let tmp = tempfile::TempDir::new().unwrap(); + let lib = tmp.path().join(LIB_SUBDIR); + std::fs::create_dir_all(&lib).unwrap(); + std::fs::write(lib.join(SENTINEL_LIB), b"").unwrap(); + validate_runtime_install(tmp.path()).expect("complete tree should validate"); + } + + #[test] + fn uninstalled_bundle_yields_no_ld_library_path() { + let tmp = tempfile::TempDir::new().unwrap(); + // Nothing is cached under a fresh project dir, so the spawn-time + // lookup must stay quiet rather than inventing a path. + assert!(build_linux_qemu_ld_library_path(tmp.path(), Some("/usr/lib")).is_none()); + } +} diff --git a/crates/fbuild-toolchain/src/toolchain/mod.rs b/crates/fbuild-toolchain/src/toolchain/mod.rs index 2df693aa..b08cf120 100644 --- a/crates/fbuild-toolchain/src/toolchain/mod.rs +++ b/crates/fbuild-toolchain/src/toolchain/mod.rs @@ -8,6 +8,7 @@ pub mod esp32; pub mod esp32_metadata; pub mod esp8266; pub mod esp_qemu; +pub mod esp_qemu_runtime; pub mod riscv; pub mod rp2040_picotool; pub mod rp2040_pqt; @@ -19,6 +20,10 @@ pub use avr::AvrToolchain; pub use clang::{ClangComponent, ClangComponentKind}; pub use esp_qemu::build_windows_qemu_path_env; pub use esp_qemu::{EspQemu, EspQemuArch, EspQemuRiscv32, EspQemuXtensa}; +pub use esp_qemu_runtime::{ + QemuLinuxRuntime, build_linux_qemu_ld_library_path, + installed_lib_dir as qemu_linux_runtime_lib_dir, +}; pub use esp32::Esp32Toolchain; pub use esp8266::Esp8266Toolchain; pub use riscv::RiscvToolchain; diff --git a/crates/fbuild-toolchain/tests/README.md b/crates/fbuild-toolchain/tests/README.md new file mode 100644 index 00000000..0afada34 --- /dev/null +++ b/crates/fbuild-toolchain/tests/README.md @@ -0,0 +1,18 @@ +# fbuild-toolchain integration tests + +Tests that exercise toolchain resolution against the real network and the real +package cache, rather than in-process fixtures. + +- **`qemu_linux_runtime.rs`** — Linux-only. Proves fbuild can start Espressif + QEMU on a host that carries none of libslirp / libSDL2 / libpixman, by + provisioning its own runtime-library bundle. `#[ignore]`d by default because + it downloads QEMU (~15 MB) and, when needed, the bundle (~5 MB). Run in CI by + `.github/workflows/qemu-linux-runtime.yml` on a stock `ubuntu-latest` runner + with **no** apt preinstall step — that missing preinstall is the point of the + test. + +Run locally on Linux: + +```bash +soldr cargo test -p fbuild-toolchain --test qemu_linux_runtime -- --ignored --nocapture +``` diff --git a/crates/fbuild-toolchain/tests/qemu_linux_runtime.rs b/crates/fbuild-toolchain/tests/qemu_linux_runtime.rs new file mode 100644 index 00000000..0420c93f --- /dev/null +++ b/crates/fbuild-toolchain/tests/qemu_linux_runtime.rs @@ -0,0 +1,88 @@ +//! Linux end-to-end check: fbuild can start Espressif QEMU on a host that +//! does **not** carry libslirp / libSDL2 / libpixman. +//! +//! This is the regression test for FastLED/FastLED#3964's root cause. The +//! Espressif QEMU tarballs bundle no shared libraries and carry no `RPATH`, +//! and `ubuntu-24.04` — `ubuntu-latest` on GitHub Actions — ships none of the +//! three libraries above. Before fbuild provisioned them itself, every QEMU +//! emulation lane died at exec with +//! `error while loading shared libraries: libslirp.so.0`, and the only fix +//! was an `apt-get install` step *outside* fbuild. +//! +//! The test is meaningful only on a host missing those libraries, which is +//! exactly what the `qemu-linux-runtime.yml` workflow provides: a stock +//! `ubuntu-latest` runner with no apt preinstall step. On a developer machine +//! that already has the libraries the test still passes — it just proves the +//! host path rather than the bundle path, which is also a behaviour worth +//! keeping (fbuild must not download the bundle it does not need). +#![cfg(target_os = "linux")] + +use std::path::Path; + +use fbuild_toolchain::toolchain::{ + EspQemu, EspQemuArch, build_linux_qemu_ld_library_path, qemu_linux_runtime_lib_dir, +}; + +/// Run ` --version` the way the emulator runner does, and report +/// whether it started. +fn qemu_starts(qemu: &Path, project_dir: &Path) -> (bool, String) { + let ld_library_path = build_linux_qemu_ld_library_path( + project_dir, + std::env::var("LD_LIBRARY_PATH").ok().as_deref(), + ); + let env: Option> = ld_library_path + .as_deref() + .map(|value| vec![("LD_LIBRARY_PATH", value)]); + + let out = fbuild_core::subprocess::run_command_blocking( + &[&qemu.to_string_lossy(), "--version"], + None, + env.as_deref(), + Some(std::time::Duration::from_secs(10)), + ) + .expect("probe should spawn"); + (out.success(), format!("{}{}", out.stdout, out.stderr)) +} + +#[tokio::test] +#[ignore = "downloads Espressif QEMU (~15 MB) and, on a host missing the libraries, the runtime bundle (~5 MB)"] +async fn esp_qemu_starts_without_any_host_library_install() { + let project = tempfile::TempDir::new().expect("temp project dir"); + + for arch in [EspQemuArch::Xtensa, EspQemuArch::Riscv32] { + let qemu = EspQemu::new(project.path(), arch) + .expect("package handle") + .resolve_executable() + .await + .unwrap_or_else(|e| panic!("{arch:?}: fbuild could not provide a usable QEMU: {e}")); + + let (started, output) = qemu_starts(&qemu, project.path()); + assert!( + started, + "{arch:?}: {} could not start during invocation:\n{output}", + qemu.display() + ); + assert!( + output.contains("QEMU emulator version"), + "{arch:?}: unexpected --version output:\n{output}" + ); + } + + // On a host that needed the bundle, it must now be installed and exported + // through LD_LIBRARY_PATH; on a host that did not, fbuild must not have + // downloaded it at all. Both are correct — what would not be correct is a + // bundle that exists but never reaches the QEMU invocation. + if let Some(lib_dir) = qemu_linux_runtime_lib_dir(project.path()) { + assert!( + lib_dir.join("libslirp.so.0").is_file(), + "installed bundle is missing libslirp.so.0: {}", + lib_dir.display() + ); + let ld = build_linux_qemu_ld_library_path(project.path(), None) + .expect("installed bundle must produce an LD_LIBRARY_PATH"); + assert!( + ld.starts_with(&lib_dir.to_string_lossy().to_string()), + "bundle must come first on LD_LIBRARY_PATH, got: {ld}" + ); + } +} From 51052782f6fbf02efb0ce4fc70a688e6d577595b Mon Sep 17 00:00:00 2001 From: zackees Date: Sat, 22 Aug 2026 12:25:05 -0700 Subject: [PATCH 2/7] refactor(qemu): move runtime probing into esp_qemu_runtime + update ledgers Keeps esp_qemu.rs under the 1000-LOC gate and puts the probe, the bundle-provisioning fallback and their tests next to the package they drive. Also registers the new target_os occurrences in both platform-boundary ledgers, and softens qemu_validate_bundled_libs' error text, which still read as "your cache is corrupt" for what is purely an extraction-completeness check. Co-Authored-By: Claude Opus 5 (1M context) --- ci/platform_boundary_ledger.tsv | 6 +- .../src/toolchain/esp_qemu.rs | 238 ++---------------- .../src/toolchain/esp_qemu_runtime.rs | 209 +++++++++++++++ .../src/baseline.txt | 6 +- 4 files changed, 235 insertions(+), 224 deletions(-) diff --git a/ci/platform_boundary_ledger.tsv b/ci/platform_boundary_ledger.tsv index e7ab8af1..18698e97 100644 --- a/ci/platform_boundary_ledger.tsv +++ b/ci/platform_boundary_ledger.tsv @@ -3,11 +3,13 @@ crates/fbuild-daemon/src/handlers/emulator/tests_npm_cache.rs attr_cfg #[cfg(win crates/fbuild-daemon/src/handlers/emulator/tests_process.rs attr_cfg #[cfg(not(windows))] 0 host_executable host_artifact_policy crates/fbuild-daemon/src/handlers/emulator/tests_process.rs attr_cfg #[cfg(windows)] 0 host_executable host_artifact_policy crates/fbuild-paths/src/dev_daemon_namespace.rs native_path std::env::current_exe 0 host_executable host_mechanic -crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg #[cfg(not(target_os=))] 0 host_executable host_mechanic crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg #[cfg(not(windows))] 0 host_executable host_mechanic crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg #[cfg(not(windows))] 1 host_executable host_mechanic -crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg #[cfg(target_os=)] 0 host_executable host_mechanic crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg #[cfg(windows)] 0 host_executable host_mechanic crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg #[cfg(windows)] 1 host_executable host_mechanic crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg #[cfg(windows)] 2 host_executable host_mechanic crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg #[cfg(windows)] 3 host_executable host_mechanic +crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs attr_cfg #[cfg(not(target_os=))] 0 host_executable host_artifact_policy +crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs attr_cfg #[cfg(target_os=)] 0 host_executable host_artifact_policy +crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs attr_cfg #[cfg_attr(not(target_os=),allow(dead_code))] 0 host_executable host_artifact_policy +crates/fbuild-toolchain/tests/qemu_linux_runtime.rs attr_cfg #![cfg(target_os=)] 0 host_executable host_artifact_policy diff --git a/crates/fbuild-toolchain/src/toolchain/esp_qemu.rs b/crates/fbuild-toolchain/src/toolchain/esp_qemu.rs index 0b105199..1e957c6b 100644 --- a/crates/fbuild-toolchain/src/toolchain/esp_qemu.rs +++ b/crates/fbuild-toolchain/src/toolchain/esp_qemu.rs @@ -155,7 +155,8 @@ impl EspQemu { // from the emulator runner. When the host is missing libraries, // fbuild provisions them itself rather than asking the caller to // apt-get anything. - ensure_qemu_can_start(&resolved, &self.project_dir).await?; + crate::toolchain::esp_qemu_runtime::ensure_qemu_can_start(&resolved, &self.project_dir) + .await?; Ok(resolved) } @@ -172,16 +173,25 @@ impl EspQemu { } } -/// Validate that the bundled `lib/` directory shipped by the Espressif QEMU -/// tarball is present alongside the binary. +/// Validate that the `lib/` directory shipped by the Espressif QEMU tarball is +/// present alongside the binary — an extraction-completeness check, nothing +/// more. /// /// The tarball layout is: /// ```text /// qemu/ /// ├── bin/qemu-system-xtensa -/// └── lib/libslirp.so.0 (with rpath $ORIGIN/../lib) +/// ├── share/qemu/*.bin (ROM blobs) +/// └── lib//libfdt.a (static; no shared libraries, no rpath) /// ``` /// +/// **The tarball ships no runtime shared libraries and the binaries carry no +/// `RPATH`/`RUNPATH`.** libslirp/libSDL2/libpixman/libgcrypt/libz come from the +/// host, or — when the host does not have them — from the bundle +/// `esp_qemu_runtime` downloads. An earlier version of this comment claimed +/// `lib/libslirp.so.0` was bundled with an `$ORIGIN/../lib` rpath; it is not, +/// which is why this check passed on hosts where QEMU could not start. +/// /// `staged_install` already extracts-to-staging-then-atomic-rename and writes /// a `.install_complete` sentinel, so a complete tree carries both. This /// check defends against cache restoration from an older fbuild version that @@ -201,9 +211,10 @@ fn qemu_validate_bundled_libs(qemu_binary: &Path) -> Result<()> { } } Err(FbuildError::PackageError(format!( - "Espressif QEMU installation at {} appears incomplete: \ - bundled lib/ directory not found. The cached toolchain may be corrupt. \ - Delete the cache entry and retry:\n rm -rf {}", + "Espressif QEMU extraction at {} is incomplete: the tarball's lib/ \ + directory is missing, so the archive was only partially unpacked. \ + (This is about extraction, not about host shared libraries — those \ + are handled separately.) Delete the cache entry and retry:\n rm -rf {}", qemu_binary.display(), qemu_binary .parent() @@ -213,131 +224,6 @@ fn qemu_validate_bundled_libs(qemu_binary: &Path) -> Result<()> { ))) } -/// Result of probing a QEMU binary with `--version`. -#[cfg_attr(not(target_os = "linux"), allow(dead_code))] -enum QemuProbe { - /// The binary started; its shared-library dependencies all resolved. - Started, - /// The dynamic linker could not satisfy a dependency (exit code 127). - /// Carries the linker's own line when it could be recovered. - MissingSharedLibrary(String), - /// Probe could not be interpreted (spawn failure, or some other - /// non-127 exit). Treated as non-fatal: the real run reports it with - /// full context. - Inconclusive, -} - -/// Probe the QEMU binary with `--version` to verify its shared library -/// dependencies resolve at runtime. -/// -/// `lib_dir`, when given, is prepended to `LD_LIBRARY_PATH` for the probe so -/// the caller can ask "does it start *with* the bundled runtime libraries?". -/// -/// Probing is Linux-only. Windows resolves its DLLs through the `PATH` -/// hydration path above, and macOS builds are self-contained. -fn probe_qemu_binary(qemu_binary: &Path, lib_dir: Option<&Path>) -> QemuProbe { - #[cfg(not(target_os = "linux"))] - { - let _ = (qemu_binary, lib_dir); - QemuProbe::Started - } - - #[cfg(target_os = "linux")] - { - // Short synchronous probe: verify the QEMU binary can start before we - // hand it to the async emulator runner. Uses run_command_blocking which - // routes through containment (no console flash on Windows, containment - // group on all platforms) and is ~100 ms. - let ld_library_path = lib_dir.map(|dir| { - crate::toolchain::esp_qemu_runtime::ld_library_path_with( - dir, - std::env::var("LD_LIBRARY_PATH").ok().as_deref(), - ) - }); - let env: Option> = ld_library_path - .as_deref() - .map(|value| vec![("LD_LIBRARY_PATH", value)]); - - let probe_result = fbuild_core::subprocess::run_command_blocking( - &[&qemu_binary.to_string_lossy(), "--version"], - None, // cwd - env.as_deref(), - Some(std::time::Duration::from_secs(5)), - ); - - match probe_result { - Ok(out) if out.success() => QemuProbe::Started, - Ok(out) if out.exit_code == 127 => { - let detail = out - .stderr - .lines() - .find(|l| l.contains("error while loading shared libraries")) - .map(|l| l.trim().to_string()) - .unwrap_or_else(|| out.stderr.trim().to_string()); - QemuProbe::MissingSharedLibrary(detail) - } - Ok(_) | Err(_) => QemuProbe::Inconclusive, - } - } -} - -/// Make sure the resolved QEMU binary can actually start on this host. -/// -/// The Espressif tarballs bundle no shared libraries and carry no `RPATH`, so -/// on Linux the binary needs libslirp/libSDL2/libpixman/libgcrypt/libz from -/// somewhere. A stock `ubuntu-24.04` has none of the first three. Rather than -/// make every caller `apt-get install` them first — an external bootstrap step -/// fbuild exists to remove — fbuild downloads its own runtime bundle and -/// re-probes with it applied. -/// -/// The bundle is fetched lazily: a host that can already start QEMU never -/// downloads it and never has its own libraries shadowed. -async fn ensure_qemu_can_start(qemu_binary: &Path, project_dir: &Path) -> Result<()> { - let missing = match probe_qemu_binary(qemu_binary, None) { - QemuProbe::Started | QemuProbe::Inconclusive => return Ok(()), - QemuProbe::MissingSharedLibrary(detail) => detail, - }; - - tracing::info!( - "QEMU at {} is missing a host shared library ({}); fetching the fbuild runtime bundle", - qemu_binary.display(), - missing - ); - - let runtime = crate::toolchain::esp_qemu_runtime::QemuLinuxRuntime::new(project_dir) - .map_err(|e| runtime_unavailable_error(qemu_binary, &missing, &e.to_string()))?; - let lib_dir = runtime - .ensure_lib_dir() - .await - .map_err(|e| runtime_unavailable_error(qemu_binary, &missing, &e.to_string()))?; - - match probe_qemu_binary(qemu_binary, Some(&lib_dir)) { - QemuProbe::Started | QemuProbe::Inconclusive => Ok(()), - QemuProbe::MissingSharedLibrary(still_missing) => Err(FbuildError::PackageError(format!( - "QEMU at {} cannot start even with the fbuild runtime bundle at {} applied.\n\ - {}\n\ - The bundle carries the full non-glibc dependency closure of the Espressif QEMU binaries, so this points at a host glibc older than the bundle's build image (ubuntu 22.04, glibc 2.35), or at a library the closure does not cover.\n\ - Please report it at https://github.com/FastLED/fbuild/issues.", - qemu_binary.display(), - lib_dir.display(), - still_missing, - ))), - } -} - -/// Error for "the host cannot start QEMU and fbuild could not provision the -/// libraries either" — keeps the original linker complaint in view. -fn runtime_unavailable_error(qemu_binary: &Path, missing: &str, cause: &str) -> FbuildError { - FbuildError::PackageError(format!( - "QEMU at {} cannot start: a required shared library is missing.\n\ - {}\n\ - fbuild could not provision its Linux runtime bundle: {}", - qemu_binary.display(), - missing, - cause, - )) -} - #[async_trait::async_trait] impl Package for EspQemu { async fn ensure_installed(&self) -> Result { @@ -949,92 +835,4 @@ mod tests { "should reject root binary without lib/" ); } - - // ── probe_qemu_binary ─────────────────────────────────────────── - - #[test] - fn probe_reports_started_when_binary_runs_version_successfully() { - // On Linux, a real QEMU binary would pass. On non-Linux, probing is - // a no-op that always reports Started. We use a script that exits 0 - // so the assertion holds cross-platform. - let tmp = tempfile::TempDir::new().unwrap(); - let probe = tmp.path().join("probe_qemu"); - if fbuild_core::platform::host::is_windows() { - std::fs::write(&probe, b"@echo off\r\nexit /b 0\r\n").unwrap(); - } else { - std::fs::write(&probe, b"#!/bin/sh\nexit 0\n").unwrap(); - fbuild_core::platform::fs::set_executable(&probe).unwrap(); - }; - assert!( - matches!(probe_qemu_binary(&probe, None), QemuProbe::Started), - "probe should report Started when the binary returns 0" - ); - } - - #[test] - fn probe_linux_detects_missing_shared_library_exit_127() { - if fbuild_core::platform::host::current().os() != fbuild_core::platform::host::HostOs::Linux - { - return; - } - let tmp = tempfile::TempDir::new().unwrap(); - // Script that prints the canonical dynamic-linker error to stderr - // and exits 127 — same observable as a missing .so. - let probe = tmp.path().join("fake_qemu_missing_so"); - std::fs::write( - &probe, - b"#!/bin/sh\necho 'error while loading shared libraries: libslirp.so.0: cannot open shared object file' >&2\nexit 127\n", - ) - .unwrap(); - fbuild_core::platform::fs::set_executable(&probe).unwrap(); - - match probe_qemu_binary(&probe, None) { - QemuProbe::MissingSharedLibrary(detail) => assert!( - detail.contains("libslirp.so.0"), - "probe should name the missing library: {detail}" - ), - _ => panic!("exit 127 must be reported as a missing shared library"), - } - } - - #[test] - fn probe_linux_exports_the_bundle_on_ld_library_path() { - if fbuild_core::platform::host::current().os() != fbuild_core::platform::host::HostOs::Linux - { - return; - } - let tmp = tempfile::TempDir::new().unwrap(); - // Script that succeeds only when LD_LIBRARY_PATH leads with the - // directory we asked the probe to apply — i.e. the bundle actually - // reaches the QEMU invocation rather than merely being installed. - let probe = tmp.path().join("fake_qemu_needs_lib_dir"); - let lib_dir = tmp.path().join("bundle-lib"); - std::fs::create_dir_all(&lib_dir).unwrap(); - std::fs::write( - &probe, - format!( - "#!/bin/sh\ncase \"$LD_LIBRARY_PATH\" in\n {}:*|{}) exit 0;;\nesac\necho 'error while loading shared libraries: libslirp.so.0' >&2\nexit 127\n", - lib_dir.display(), - lib_dir.display() - ) - .as_bytes(), - ) - .unwrap(); - fbuild_core::platform::fs::set_executable(&probe).unwrap(); - - assert!( - matches!( - probe_qemu_binary(&probe, None), - QemuProbe::MissingSharedLibrary(_) - ), - "without the bundle the stub must fail like a real missing .so" - ); - assert!( - matches!( - probe_qemu_binary(&probe, Some(&lib_dir)), - QemuProbe::Started - ), - "probe must put the bundle directory on LD_LIBRARY_PATH" - ); - } } diff --git a/crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs b/crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs index d6215927..06eec6b4 100644 --- a/crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs +++ b/crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs @@ -197,6 +197,127 @@ pub fn build_linux_qemu_ld_library_path( Some(ld_library_path_with(&lib_dir, current)) } +/// Result of probing a QEMU binary with `--version`. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +enum QemuProbe { + /// The binary started; its shared-library dependencies all resolved. + Started, + /// The dynamic linker could not satisfy a dependency (exit code 127). + /// Carries the linker's own line when it could be recovered. + MissingSharedLibrary(String), + /// Probe could not be interpreted (spawn failure, or some other + /// non-127 exit). Treated as non-fatal: the real run reports it with + /// full context. + Inconclusive, +} + +/// Probe the QEMU binary with `--version` to verify its shared library +/// dependencies resolve at runtime. +/// +/// `lib_dir`, when given, is prepended to `LD_LIBRARY_PATH` for the probe so +/// the caller can ask "does it start *with* the bundled runtime libraries?". +/// +/// Probing is Linux-only. Windows resolves its DLLs through the `PATH` +/// hydration path above, and macOS builds are self-contained. +fn probe_qemu_binary(qemu_binary: &Path, lib_dir: Option<&Path>) -> QemuProbe { + #[cfg(not(target_os = "linux"))] + { + let _ = (qemu_binary, lib_dir); + QemuProbe::Started + } + + #[cfg(target_os = "linux")] + { + // Short synchronous probe: verify the QEMU binary can start before we + // hand it to the async emulator runner. Uses run_command_blocking which + // routes through containment (no console flash on Windows, containment + // group on all platforms) and is ~100 ms. + let ld_library_path = lib_dir + .map(|dir| ld_library_path_with(dir, std::env::var("LD_LIBRARY_PATH").ok().as_deref())); + let env: Option> = ld_library_path + .as_deref() + .map(|value| vec![("LD_LIBRARY_PATH", value)]); + + let probe_result = fbuild_core::subprocess::run_command_blocking( + &[&qemu_binary.to_string_lossy(), "--version"], + None, // cwd + env.as_deref(), + Some(std::time::Duration::from_secs(5)), + ); + + match probe_result { + Ok(out) if out.success() => QemuProbe::Started, + Ok(out) if out.exit_code == 127 => { + let detail = out + .stderr + .lines() + .find(|l| l.contains("error while loading shared libraries")) + .map(|l| l.trim().to_string()) + .unwrap_or_else(|| out.stderr.trim().to_string()); + QemuProbe::MissingSharedLibrary(detail) + } + Ok(_) | Err(_) => QemuProbe::Inconclusive, + } + } +} + +/// Make sure the resolved QEMU binary can actually start on this host. +/// +/// The Espressif tarballs bundle no shared libraries and carry no `RPATH`, so +/// on Linux the binary needs libslirp/libSDL2/libpixman/libgcrypt/libz from +/// somewhere. A stock `ubuntu-24.04` has none of the first three. Rather than +/// make every caller `apt-get install` them first — an external bootstrap step +/// fbuild exists to remove — fbuild downloads its own runtime bundle and +/// re-probes with it applied. +/// +/// The bundle is fetched lazily: a host that can already start QEMU never +/// downloads it and never has its own libraries shadowed. +pub(crate) async fn ensure_qemu_can_start(qemu_binary: &Path, project_dir: &Path) -> Result<()> { + let missing = match probe_qemu_binary(qemu_binary, None) { + QemuProbe::Started | QemuProbe::Inconclusive => return Ok(()), + QemuProbe::MissingSharedLibrary(detail) => detail, + }; + + tracing::info!( + "QEMU at {} is missing a host shared library ({}); fetching the fbuild runtime bundle", + qemu_binary.display(), + missing + ); + + let runtime = QemuLinuxRuntime::new(project_dir) + .map_err(|e| runtime_unavailable_error(qemu_binary, &missing, &e.to_string()))?; + let lib_dir = runtime + .ensure_lib_dir() + .await + .map_err(|e| runtime_unavailable_error(qemu_binary, &missing, &e.to_string()))?; + + match probe_qemu_binary(qemu_binary, Some(&lib_dir)) { + QemuProbe::Started | QemuProbe::Inconclusive => Ok(()), + QemuProbe::MissingSharedLibrary(still_missing) => Err(FbuildError::PackageError(format!( + "QEMU at {} cannot start even with the fbuild runtime bundle at {} applied.\n\ + {}\n\ + The bundle carries the full non-glibc dependency closure of the Espressif QEMU binaries, so this points at a host glibc older than the bundle's build image (ubuntu 22.04, glibc 2.35), or at a library the closure does not cover.\n\ + Please report it at https://github.com/FastLED/fbuild/issues.", + qemu_binary.display(), + lib_dir.display(), + still_missing, + ))), + } +} + +/// Error for "the host cannot start QEMU and fbuild could not provision the +/// libraries either" — keeps the original linker complaint in view. +fn runtime_unavailable_error(qemu_binary: &Path, missing: &str, cause: &str) -> FbuildError { + FbuildError::PackageError(format!( + "QEMU at {} cannot start: a required shared library is missing.\n\ + {}\n\ + fbuild could not provision its Linux runtime bundle: {}", + qemu_binary.display(), + missing, + cause, + )) +} + #[cfg(test)] mod tests { use super::*; @@ -280,4 +401,92 @@ mod tests { // lookup must stay quiet rather than inventing a path. assert!(build_linux_qemu_ld_library_path(tmp.path(), Some("/usr/lib")).is_none()); } + + // ── probe_qemu_binary ─────────────────────────────────────────── + + #[test] + fn probe_reports_started_when_binary_runs_version_successfully() { + // On Linux, a real QEMU binary would pass. On non-Linux, probing is + // a no-op that always reports Started. We use a script that exits 0 + // so the assertion holds cross-platform. + let tmp = tempfile::TempDir::new().unwrap(); + let probe = tmp.path().join("probe_qemu"); + if fbuild_core::platform::host::is_windows() { + std::fs::write(&probe, b"@echo off\r\nexit /b 0\r\n").unwrap(); + } else { + std::fs::write(&probe, b"#!/bin/sh\nexit 0\n").unwrap(); + fbuild_core::platform::fs::set_executable(&probe).unwrap(); + }; + assert!( + matches!(probe_qemu_binary(&probe, None), QemuProbe::Started), + "probe should report Started when the binary returns 0" + ); + } + + #[test] + fn probe_linux_detects_missing_shared_library_exit_127() { + if fbuild_core::platform::host::current().os() != fbuild_core::platform::host::HostOs::Linux + { + return; + } + let tmp = tempfile::TempDir::new().unwrap(); + // Script that prints the canonical dynamic-linker error to stderr + // and exits 127 — same observable as a missing .so. + let probe = tmp.path().join("fake_qemu_missing_so"); + std::fs::write( + &probe, + b"#!/bin/sh\necho 'error while loading shared libraries: libslirp.so.0: cannot open shared object file' >&2\nexit 127\n", + ) + .unwrap(); + fbuild_core::platform::fs::set_executable(&probe).unwrap(); + + match probe_qemu_binary(&probe, None) { + QemuProbe::MissingSharedLibrary(detail) => assert!( + detail.contains("libslirp.so.0"), + "probe should name the missing library: {detail}" + ), + _ => panic!("exit 127 must be reported as a missing shared library"), + } + } + + #[test] + fn probe_linux_exports_the_bundle_on_ld_library_path() { + if fbuild_core::platform::host::current().os() != fbuild_core::platform::host::HostOs::Linux + { + return; + } + let tmp = tempfile::TempDir::new().unwrap(); + // Script that succeeds only when LD_LIBRARY_PATH leads with the + // directory we asked the probe to apply — i.e. the bundle actually + // reaches the QEMU invocation rather than merely being installed. + let probe = tmp.path().join("fake_qemu_needs_lib_dir"); + let lib_dir = tmp.path().join("bundle-lib"); + std::fs::create_dir_all(&lib_dir).unwrap(); + std::fs::write( + &probe, + format!( + "#!/bin/sh\ncase \"$LD_LIBRARY_PATH\" in\n {}:*|{}) exit 0;;\nesac\necho 'error while loading shared libraries: libslirp.so.0' >&2\nexit 127\n", + lib_dir.display(), + lib_dir.display() + ) + .as_bytes(), + ) + .unwrap(); + fbuild_core::platform::fs::set_executable(&probe).unwrap(); + + assert!( + matches!( + probe_qemu_binary(&probe, None), + QemuProbe::MissingSharedLibrary(_) + ), + "without the bundle the stub must fail like a real missing .so" + ); + assert!( + matches!( + probe_qemu_binary(&probe, Some(&lib_dir)), + QemuProbe::Started + ), + "probe must put the bundle directory on LD_LIBRARY_PATH" + ); + } } diff --git a/dylints/enforce_platform_boundary/src/baseline.txt b/dylints/enforce_platform_boundary/src/baseline.txt index aaeaf7bc..77af85bd 100644 --- a/dylints/enforce_platform_boundary/src/baseline.txt +++ b/dylints/enforce_platform_boundary/src/baseline.txt @@ -3,11 +3,13 @@ crates/fbuild-daemon/src/handlers/emulator/tests_npm_cache.rs attr_cfg windows 0 crates/fbuild-daemon/src/handlers/emulator/tests_process.rs attr_cfg windows 0 crates/fbuild-daemon/src/handlers/emulator/tests_process.rs attr_cfg windows 1 crates/fbuild-paths/src/dev_daemon_namespace.rs native_import std::env::current_exe 0 -crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg target_os 0 -crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg target_os 1 crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg windows 0 crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg windows 1 crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg windows 2 crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg windows 3 crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg windows 4 crates/fbuild-toolchain/src/toolchain/esp_qemu.rs attr_cfg windows 5 +crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs attr_cfg target_os 0 +crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs attr_cfg target_os 1 +crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs attr_cfg target_os 2 +crates/fbuild-toolchain/tests/qemu_linux_runtime.rs attr_cfg target_os 0 From 45bf93a4a1311e9805e78370cfa45e492ff2a42c Mon Sep 17 00:00:00 2001 From: zackees Date: Sat, 22 Aug 2026 13:00:00 -0700 Subject: [PATCH 3/7] fix(qemu): use NormalizedPath in the runtime module + refresh inventory `ban_std_pathbuf` denies raw `std::path::PathBuf` in new files, so the runtime bundle exposes `NormalizedPath` and keeps its install/lookup methods inherent rather than implementing `Package` (whose signature is PathBuf-typed). Nothing consumed it as a `dyn Package`. Also regenerates ci/platform_boundary_research.tsv after the module split. Co-Authored-By: Claude Opus 5 (1M context) --- ci/platform_boundary_research.tsv | 18 +++++----- .../src/toolchain/esp_qemu_runtime.rs | 34 ++++++++----------- uv.lock | 2 +- 3 files changed, 25 insertions(+), 29 deletions(-) diff --git a/ci/platform_boundary_research.tsv b/ci/platform_boundary_research.tsv index 2e0d5bae..7c71f777 100644 --- a/ci/platform_boundary_research.tsv +++ b/ci/platform_boundary_research.tsv @@ -68,11 +68,13 @@ crates/fbuild-daemon/src/handlers/emulator/tests_npm_cache.rs 146 attr_cfg #[cfg crates/fbuild-daemon/src/handlers/emulator/tests_process.rs 9 attr_cfg #[cfg(windows)] host_executable host_artifact_policy crates/fbuild-daemon/src/handlers/emulator/tests_process.rs 21 attr_cfg #[cfg(not(windows))] host_executable host_artifact_policy crates/fbuild-paths/src/dev_daemon_namespace.rs 83 native_path std::env::current_exe host_executable host_mechanic -crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 220 attr_cfg #[cfg(not(target_os=))] host_executable host_mechanic -crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 226 attr_cfg #[cfg(target_os=)] host_executable host_mechanic -crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 576 attr_cfg #[cfg(windows)] host_executable host_mechanic -crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 582 attr_cfg #[cfg(windows)] host_executable host_mechanic -crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 613 attr_cfg #[cfg(not(windows))] host_executable host_mechanic -crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 618 attr_cfg #[cfg(not(windows))] host_executable host_mechanic -crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 776 attr_cfg #[cfg(windows)] host_executable host_mechanic -crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 803 attr_cfg #[cfg(windows)] 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_runtime.rs 195 attr_cfg #[cfg_attr(not(target_os=),allow(dead_code))] host_executable host_artifact_policy +crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs 217 attr_cfg #[cfg(not(target_os=))] host_executable host_artifact_policy +crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs 223 attr_cfg #[cfg(target_os=)] host_executable host_artifact_policy +crates/fbuild-toolchain/tests/qemu_linux_runtime.rs 18 attr_cfg #![cfg(target_os=)] host_executable host_artifact_policy diff --git a/crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs b/crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs index 06eec6b4..86bed8e2 100644 --- a/crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs +++ b/crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs @@ -28,12 +28,13 @@ //! the host — a bundled `libc.so.6` without its matching `ld-linux` is a //! segfault, not a fix. -use std::path::{Path, PathBuf}; +use std::path::Path; +use fbuild_core::path::NormalizedPath; use fbuild_core::platform::host::{self, HostArch, HostPlatform}; use fbuild_core::{FbuildError, Result}; -use crate::{CacheSubdir, Package, PackageBase, PackageInfo}; +use crate::{CacheSubdir, PackageBase, PackageInfo}; /// Release tag of the QEMU build this bundle was closed over. Kept in sync /// with `QEMU_RELEASE_TAG` in `esp_qemu.rs` and with @@ -80,31 +81,24 @@ impl QemuLinuxRuntime { } /// Directory to place on `LD_LIBRARY_PATH`. - pub fn lib_dir(&self) -> PathBuf { - self.base.install_path().join(LIB_SUBDIR) + pub fn lib_dir(&self) -> NormalizedPath { + NormalizedPath::from(self.base.install_path()).join(LIB_SUBDIR) } /// Install if needed and return the directory to put on `LD_LIBRARY_PATH`. - pub async fn ensure_lib_dir(&self) -> Result { - self.ensure_installed().await?; - Ok(self.lib_dir()) - } -} - -#[async_trait::async_trait] -impl Package for QemuLinuxRuntime { - async fn ensure_installed(&self) -> Result { - if self.is_installed() { - return Ok(self.base.install_path()); + pub async fn ensure_lib_dir(&self) -> Result { + if !self.is_installed() { + self.base.staged_install(validate_runtime_install).await?; } - self.base.staged_install(validate_runtime_install).await + Ok(self.lib_dir()) } - fn is_installed(&self) -> bool { + /// Whether a complete bundle is already unpacked in the cache. + pub fn is_installed(&self) -> bool { self.base.is_cached() && self.lib_dir().join(SENTINEL_LIB).is_file() } - fn get_info(&self) -> PackageInfo { + pub fn get_info(&self) -> PackageInfo { self.base.get_info() } } @@ -178,7 +172,7 @@ pub fn ld_library_path_with(lib_dir: &Path, current: Option<&str>) -> String { /// emulator spawn path does not have to thread a resolution result through /// every call site. A host that never needed the bundle has nothing cached /// here and gets `None`. -pub fn installed_lib_dir(project_dir: &Path) -> Option { +pub fn installed_lib_dir(project_dir: &Path) -> Option { let runtime = QemuLinuxRuntime::new(project_dir).ok()?; if runtime.is_installed() { Some(runtime.lib_dir()) @@ -291,7 +285,7 @@ pub(crate) async fn ensure_qemu_can_start(qemu_binary: &Path, project_dir: &Path .await .map_err(|e| runtime_unavailable_error(qemu_binary, &missing, &e.to_string()))?; - match probe_qemu_binary(qemu_binary, Some(&lib_dir)) { + match probe_qemu_binary(qemu_binary, Some(lib_dir.as_path())) { QemuProbe::Started | QemuProbe::Inconclusive => Ok(()), QemuProbe::MissingSharedLibrary(still_missing) => Err(FbuildError::PackageError(format!( "QEMU at {} cannot start even with the fbuild runtime bundle at {} applied.\n\ diff --git a/uv.lock b/uv.lock index a92a052b..65157e05 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.10" [[package]] name = "fbuild" -version = "2.5.17" +version = "2.5.19" source = { editable = "." } [package.dev-dependencies] From ec98f15f3a4281283c700aa3ab8e478c96d6bf41 Mon Sep 17 00:00:00 2001 From: zackees Date: Sat, 22 Aug 2026 13:10:46 -0700 Subject: [PATCH 4/7] fix(qemu): lower bundle glibc floor to 2.30 and harden the builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the runtime bundle: - Build the bundle in ubuntu:20.04 instead of 22.04. The bundled libraries inherit the build image's glibc floor, and 22.04 pushed it to GLIBC_2.34 — above the GLIBC_2.30 the Espressif QEMU binaries themselves need, so the bundle could lock out a host that could otherwise run QEMU. A 20.04 closure needs exactly GLIBC_2.30, making the bundle never the binding constraint. It is also smaller: 43 libraries / 4.2 MB, down from 52 / 5.1 MB. - Stage the build in a private `mktemp -d` with trap cleanup and hand the artifacts over through a fresh root-owned drop directory. The payload runs as root, so fixed /tmp names were pre-creatable as symlinks by a local attacker. - Drop `--native`: both arches now build in the container, and the runner only supplies the CPU architecture (aarch64 needs a native arm64 runner because dpkg's maintainer scripts fail under emulation). - Workflows: least-privilege `permissions:` and `persist-credentials: false`. - Pass the project dir in the real-QEMU fixture so the spawn exports the bundle on a host that needed provisioning. Re-verified on bare ubuntu:24.04: qemu-system-xtensa exits 127 without the bundle and 0 with it; max required symbol version is GLIBC_2.30. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/qemu-linux-runtime.yml | 7 + .github/workflows/qemu-runtime-bundle.yml | 16 +- ci/build_qemu_linux_runtime.py | 138 ++++++++---------- .../src/handlers/emulator/tests_process.rs | 4 +- .../src/toolchain/esp_qemu_runtime.rs | 16 +- 5 files changed, 88 insertions(+), 93 deletions(-) diff --git a/.github/workflows/qemu-linux-runtime.yml b/.github/workflows/qemu-linux-runtime.yml index dac94720..78451e39 100644 --- a/.github/workflows/qemu-linux-runtime.yml +++ b/.github/workflows/qemu-linux-runtime.yml @@ -25,6 +25,9 @@ on: - 'ci/build_qemu_linux_runtime.py' - '.github/workflows/qemu-linux-runtime.yml' +permissions: + contents: read + env: CARGO_TERM_COLOR: always RUSTFLAGS: "-D warnings" @@ -35,8 +38,12 @@ jobs: name: QEMU starts with no host libraries (ubuntu-latest) runs-on: ubuntu-latest timeout-minutes: 30 + permissions: + contents: read steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - name: Confirm the runner really is missing the libraries # Documents the premise of the test. Informational: a future runner diff --git a/.github/workflows/qemu-runtime-bundle.yml b/.github/workflows/qemu-runtime-bundle.yml index 5a1b9710..e66f8bc2 100644 --- a/.github/workflows/qemu-runtime-bundle.yml +++ b/.github/workflows/qemu-runtime-bundle.yml @@ -3,9 +3,11 @@ name: Build QEMU Linux Runtime Bundle # Manually-dispatched builder for the runtime-library bundles fbuild downloads # when a Linux host cannot start Espressif QEMU on its own. # -# Runs on ubuntu-22.04 (and its arm64 sibling) because the bundled libraries -# inherit the build host's glibc floor: 22.04's glibc 2.35 covers every runner -# and distro fbuild targets, while building on 24.04 would not. +# The bundle is always built inside an ubuntu:20.04 container, so the libraries +# it carries need at most GLIBC_2.30 — the same floor the Espressif QEMU +# binaries themselves have. The runner only supplies the CPU architecture: +# aarch64 needs a native arm64 runner because dpkg's maintainer scripts fail +# under emulated arm64. # # Upload the resulting artifacts to the `qemu-linux-runtime-v1` release and # paste the printed SHA-256 into @@ -27,17 +29,19 @@ jobs: matrix: include: - arch: x86_64 - runner: ubuntu-22.04 + runner: ubuntu-latest - arch: aarch64 - runner: ubuntu-22.04-arm + runner: ubuntu-24.04-arm steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - uses: astral-sh/setup-uv@v3 - name: Build bundle run: | uv run --no-project python ci/build_qemu_linux_runtime.py \ - --arch ${{ matrix.arch }} --native --out dist/qemu-runtime + --arch ${{ matrix.arch }} --out dist/qemu-runtime - uses: actions/upload-artifact@v4 with: diff --git a/ci/build_qemu_linux_runtime.py b/ci/build_qemu_linux_runtime.py index d966c511..7a7fdbd4 100644 --- a/ci/build_qemu_linux_runtime.py +++ b/ci/build_qemu_linux_runtime.py @@ -29,20 +29,20 @@ Ubuntu pulls in X11, Wayland, PulseAudio and friends, and libslirp pulls glib. A curated list silently rots the first time a dependency is added upstream. -Build host is **ubuntu:22.04**, not 24.04: the bundled libraries inherit the -build image's glibc floor, and 22.04 (glibc 2.35) covers every runner and -distro fbuild targets while 24.04 (glibc 2.39) would not. +Build host is **ubuntu:20.04**. The bundled libraries inherit the build image's +glibc floor, and the goal is for the bundle never to be the binding constraint: +the Espressif QEMU binaries themselves require up to `GLIBC_2.30`, and a +20.04-built closure requires exactly the same. Building on 22.04 would push the +floor to `GLIBC_2.34` and lock out hosts that could otherwise run QEMU. Usage:: uv run python ci/build_qemu_linux_runtime.py --arch x86_64 uv run python ci/build_qemu_linux_runtime.py --arch aarch64 --out dist/ - uv run python ci/build_qemu_linux_runtime.py --native # on a Linux runner -`--native` runs the payload directly instead of in a container, for use on a -GitHub `ubuntu-22.04` / `ubuntu-22.04-arm` runner — the runner image *is* the -build image, and it is the only practical way to produce the aarch64 bundle -(dpkg's maintainer scripts fail under Docker Desktop's arm64 emulation). +Both arches build in a container, but the aarch64 bundle needs a **native** +arm64 host (an `ubuntu-24.04-arm` runner, via `qemu-runtime-bundle.yml`): +dpkg's maintainer scripts fail under Docker Desktop's arm64 emulation. Emits `qemu-esp-linux-runtime--.tar.zst`, a `.manifest.txt` listing what went in, and prints the SHA-256 to paste into @@ -64,7 +64,7 @@ QEMU_RELEASE_TAG = "esp-develop-9.2.2-20250817" QEMU_ARCHIVE_VERSION = "esp_develop_9.2.2_20250817" -BUILD_IMAGE = "ubuntu:22.04" +BUILD_IMAGE = "ubuntu:20.04" # Docker --platform value per target architecture. DOCKER_PLATFORM = { @@ -86,13 +86,19 @@ TAG="__TAG__" VERSION="__VERSION__" OUT_NAME="__OUT_NAME__" +DROP="__DROP_DIR__" + +# Private staging directory: this payload runs as root, so fixed /tmp names +# would let a local attacker pre-create them as symlinks and redirect +# privileged mkdir/cp/zstd writes. +WORK="$(mktemp -d)" +chmod 700 "$WORK" +trap 'rm -rf "$WORK"' EXIT apt-get update -qq -apt-get install -y --no-install-recommends \ - ca-certificates curl xz-utils zstd \ - libsdl2-2.0-0 libslirp0 libpixman-1-0 libgcrypt20 zlib1g >/dev/null +apt-get install -y --no-install-recommends ca-certificates curl xz-utils zstd libsdl2-2.0-0 libslirp0 libpixman-1-0 libgcrypt20 zlib1g >/dev/null -cd /tmp +cd "$WORK" for a in xtensa riscv32; do url="https://github.com/espressif/qemu/releases/download/${TAG}/qemu-${a}-softmmu-${VERSION}-${ARCH_SUFFIX}.tar.xz" echo "downloading ${url}" @@ -101,12 +107,13 @@ tar xf "q-${a}.tar.xz" -C "x-${a}" done -XTENSA=/tmp/x-xtensa/qemu/bin/qemu-system-xtensa -RISCV=/tmp/x-riscv32/qemu/bin/qemu-system-riscv32 +XTENSA="$WORK/x-xtensa/qemu/bin/qemu-system-xtensa" +RISCV="$WORK/x-riscv32/qemu/bin/qemu-system-riscv32" test -x "$XTENSA" test -x "$RISCV" -mkdir -p /tmp/bundle/lib +BUNDLE="$WORK/bundle" +mkdir -p "$BUNDLE/lib" # Resolved shared-object paths for one ELF, one per line. deps() { @@ -130,8 +137,8 @@ for f in $QUEUE; do b="$(basename "$f")" if is_glibc_family "$b"; then continue; fi - if [ ! -f "/tmp/bundle/lib/$b" ]; then - cp -L "$f" "/tmp/bundle/lib/$b" + if [ ! -f "$BUNDLE/lib/$b" ]; then + cp -L "$f" "$BUNDLE/lib/$b" NEXT="$NEXT $(deps "$f")" fi done @@ -141,7 +148,7 @@ # Fail loudly if the libraries this bundle exists for are absent. for required in libslirp.so.0 libSDL2-2.0.so.0 libpixman-1.so.0 libgcrypt.so.20 libz.so.1; do - if [ ! -f "/tmp/bundle/lib/$required" ]; then + if [ ! -f "$BUNDLE/lib/$required" ]; then echo "FATAL: closure is missing $required" >&2 exit 1 fi @@ -149,45 +156,57 @@ { echo "# Espressif QEMU ${TAG} Linux runtime libraries (${ARCH_SUFFIX})" - echo "# Built from ${BUILD_IMAGE:-ubuntu:22.04}; glibc family intentionally excluded." - (cd /tmp/bundle/lib && ls -1 | sort) -} > /tmp/bundle/MANIFEST.txt - -cd /tmp/bundle -tar cf - lib MANIFEST.txt | zstd -19 -T0 -q -o "/tmp/${OUT_NAME}" + echo "# glibc family intentionally excluded; comes from the host." + (cd "$BUNDLE/lib" && ls -1 | sort) +} > "$BUNDLE/MANIFEST.txt" -cp /tmp/bundle/MANIFEST.txt "/tmp/${OUT_NAME}.manifest.txt" +cd "$BUNDLE" +tar cf - lib MANIFEST.txt | zstd -19 -T0 -q -o "$WORK/${OUT_NAME}" echo "=== bundle ===" -cat /tmp/bundle/MANIFEST.txt -echo "libraries: $(ls -1 /tmp/bundle/lib | wc -l)" -echo "archive: $(stat -c %s "/tmp/${OUT_NAME}") bytes" +cat "$BUNDLE/MANIFEST.txt" +echo "libraries: $(ls -1 "$BUNDLE/lib" | wc -l)" +echo "archive: $(stat -c %s "$WORK/${OUT_NAME}") bytes" -# Prove the bundle actually satisfies both binaries, with no host packages -# in play beyond glibc: strip the apt-installed copies first so a stale -# system library cannot mask a gap in the closure. +# Prove the bundle actually satisfies both binaries with no host packages in +# play beyond glibc: strip the apt-installed copies first, so a system library +# left behind cannot mask a gap in the closure. apt-get remove -y libsdl2-2.0-0 libslirp0 libpixman-1-0 >/dev/null 2>&1 || true for bin in "$XTENSA" "$RISCV"; do - if ! LD_LIBRARY_PATH=/tmp/bundle/lib "$bin" --version >/dev/null; then + if ! LD_LIBRARY_PATH="$BUNDLE/lib" "$bin" --version >/dev/null; then echo "FATAL: $bin still cannot start with the bundle applied" >&2 exit 1 fi done echo "selftest: both QEMU binaries start with LD_LIBRARY_PATH=/lib" + +# Hand the artifacts to the caller through a fresh, root-owned drop directory. +rm -rf "$DROP" +mkdir -m 700 -p "$DROP" +cp "$WORK/${OUT_NAME}" "$DROP/${OUT_NAME}" +cp "$BUNDLE/MANIFEST.txt" "$DROP/${OUT_NAME}.manifest.txt" """ +# Root-owned directory the payload copies finished artifacts into. +DROP_DIR = "/var/tmp/fbuild-qemu-runtime" + def out_name(arch: str) -> str: return f"qemu-esp-linux-runtime-{arch}-{QEMU_RELEASE_TAG}.tar.zst" -def build(arch: str, out_dir: Path) -> Path: - script = ( +def render_script(arch: str) -> str: + return ( CONTAINER_SCRIPT.replace("__ARCH_SUFFIX__", QEMU_ARCHIVE_SUFFIX[arch]) .replace("__TAG__", QEMU_RELEASE_TAG) .replace("__VERSION__", QEMU_ARCHIVE_VERSION) .replace("__OUT_NAME__", out_name(arch)) + .replace("__DROP_DIR__", DROP_DIR) ) + + +def build(arch: str, out_dir: Path) -> Path: + script = render_script(arch) payload = base64.b64encode(script.encode()).decode() container = f"fbuild-qemu-runtime-{arch}" @@ -217,8 +236,8 @@ def build(arch: str, out_dir: Path) -> Path: out_dir.mkdir(parents=True, exist_ok=True) archive = out_dir / out_name(arch) for remote, local in ( - (f"/tmp/{out_name(arch)}", archive), - (f"/tmp/{out_name(arch)}.manifest.txt", Path(f"{archive}.manifest.txt")), + (f"{DROP_DIR}/{out_name(arch)}", archive), + (f"{DROP_DIR}/{out_name(arch)}.manifest.txt", Path(f"{archive}.manifest.txt")), ): subprocess.run( ["docker", "cp", f"{container}:{remote}", str(local)], check=True @@ -227,40 +246,6 @@ def build(arch: str, out_dir: Path) -> Path: return archive -def build_native(arch: str, out_dir: Path) -> Path: - """Run the payload directly on this Linux host (CI runner path).""" - import platform - import shutil - import tempfile - - host_arch = platform.machine() - expected = {"x86_64": "x86_64", "aarch64": "aarch64"}[arch] - if host_arch != expected: - raise SystemExit( - f"--native needs a {expected} host to build the {arch} bundle; this host is {host_arch}" - ) - - script = ( - CONTAINER_SCRIPT.replace("__ARCH_SUFFIX__", QEMU_ARCHIVE_SUFFIX[arch]) - .replace("__TAG__", QEMU_RELEASE_TAG) - .replace("__VERSION__", QEMU_ARCHIVE_VERSION) - .replace("__OUT_NAME__", out_name(arch)) - ) - with tempfile.NamedTemporaryFile("w", suffix=".sh", delete=False) as handle: - handle.write(script) - script_path = handle.name - - proc = subprocess.run(["sudo", "bash", script_path], check=False) - if proc.returncode != 0: - raise SystemExit(f"native build failed with exit code {proc.returncode}") - - out_dir.mkdir(parents=True, exist_ok=True) - archive = out_dir / out_name(arch) - shutil.copy(f"/tmp/{out_name(arch)}", archive) - shutil.copy(f"/tmp/{out_name(arch)}.manifest.txt", f"{archive}.manifest.txt") - return archive - - def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( @@ -275,18 +260,9 @@ def main() -> int: default=Path("dist/qemu-runtime"), help="output directory (default: dist/qemu-runtime)", ) - parser.add_argument( - "--native", - action="store_true", - help="build directly on this Linux host instead of in a container", - ) args = parser.parse_args() - archive = ( - build_native(args.arch, args.out) - if args.native - else build(args.arch, args.out) - ) + archive = build(args.arch, args.out) digest = hashlib.sha256(archive.read_bytes()).hexdigest() print() print(f"archive: {archive}") diff --git a/crates/fbuild-daemon/src/handlers/emulator/tests_process.rs b/crates/fbuild-daemon/src/handlers/emulator/tests_process.rs index 0443ab76..a503b36d 100644 --- a/crates/fbuild-daemon/src/handlers/emulator/tests_process.rs +++ b/crates/fbuild-daemon/src/handlers/emulator/tests_process.rs @@ -217,7 +217,9 @@ async fn run_real_esp32s3_fixture_in_qemu() { show_timestamp: false, verbose: true, process_label: "QEMU", - project_dir: None, + // Real QEMU: on a host that needed fbuild's runtime bundle, + // resolve_executable() installed it and the spawn must export it. + project_dir: Some(&project_dir), }, ) .await diff --git a/crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs b/crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs index 86bed8e2..7ac2bd1e 100644 --- a/crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs +++ b/crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs @@ -23,10 +23,16 @@ //! fetch it and never have their libraries shadowed. //! //! Bundle provenance: `ci/build_qemu_linux_runtime.py` walks `ldd` over both -//! real QEMU binaries inside `ubuntu:22.04` and archives the transitive +//! real QEMU binaries inside `ubuntu:20.04` and archives the transitive //! closure minus the glibc family. glibc and the loader deliberately stay on //! the host — a bundled `libc.so.6` without its matching `ld-linux` is a //! segfault, not a fix. +//! +//! The 20.04 build image is chosen so the bundle is never the binding +//! portability constraint: its libraries need at most `GLIBC_2.30`, which is +//! exactly what the Espressif QEMU binaries themselves require. Building on +//! 22.04 would raise the floor to `GLIBC_2.34` and lock out hosts that could +//! otherwise run QEMU. use std::path::Path; @@ -138,13 +144,13 @@ fn runtime_arch(host: HostPlatform) -> Result<&'static str> { /// SHA-256 of each published bundle. An architecture without an entry has no /// bundle yet: report that plainly instead of downloading something unpinned. /// -/// aarch64 is built by `.github/workflows/qemu-runtime-bundle.yml` on an -/// `ubuntu-22.04-arm` runner — Docker Desktop's arm64 emulation cannot run -/// dpkg's maintainer scripts, so it cannot be produced from a developer +/// aarch64 is built by `.github/workflows/qemu-runtime-bundle.yml` on a native +/// arm64 runner — Docker Desktop's arm64 emulation cannot run dpkg's +/// maintainer scripts, so it cannot be produced from an x86_64 developer /// workstation the way the x86_64 bundle was. fn runtime_sha256(arch: &str) -> Result<&'static str> { match arch { - "x86_64" => Ok("e4f22c9b88a1a032dcba07aec2ac7ada01563b7fe6ccdd3a1a0b3d740aec51df"), + "x86_64" => Ok("b3318ccf60df8e17a42b5b0f61180440f56337fadb0babe867bff1f3dfecd99f"), other => Err(FbuildError::PackageError(format!( "no QEMU runtime-library bundle is published for linux-{other} yet (tracked in the {RUNTIME_RELEASE_TAG} release).\n\ Install the QEMU runtime libraries from your distribution — on Debian/Ubuntu: libslirp0, libsdl2-2.0-0, libpixman-1-0." From 42a9886c587ee1cc1fb2d3fcc9953eb321ba527d Mon Sep 17 00:00:00 2001 From: zackees Date: Sat, 22 Aug 2026 13:31:40 -0700 Subject: [PATCH 5/7] chore(ci): refresh platform-boundary inventory line numbers The deterministic inventory records line numbers, so it drifts on any edit inside a file that carries a boundary occurrence. Co-Authored-By: Claude Opus 5 (1M context) --- ci/platform_boundary_research.tsv | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ci/platform_boundary_research.tsv b/ci/platform_boundary_research.tsv index 7c71f777..6e80142c 100644 --- a/ci/platform_boundary_research.tsv +++ b/ci/platform_boundary_research.tsv @@ -74,7 +74,7 @@ crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 565 attr_cfg #[cfg(not(windows 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_runtime.rs 195 attr_cfg #[cfg_attr(not(target_os=),allow(dead_code))] host_executable host_artifact_policy -crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs 217 attr_cfg #[cfg(not(target_os=))] host_executable host_artifact_policy -crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs 223 attr_cfg #[cfg(target_os=)] host_executable host_artifact_policy +crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs 201 attr_cfg #[cfg_attr(not(target_os=),allow(dead_code))] host_executable host_artifact_policy +crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs 223 attr_cfg #[cfg(not(target_os=))] host_executable host_artifact_policy +crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs 229 attr_cfg #[cfg(target_os=)] host_executable host_artifact_policy crates/fbuild-toolchain/tests/qemu_linux_runtime.rs 18 attr_cfg #![cfg(target_os=)] host_executable host_artifact_policy From d01726c1d22960d100da082c2d4b5e06c54dd2dd Mon Sep 17 00:00:00 2001 From: zackees Date: Sat, 22 Aug 2026 13:58:45 -0700 Subject: [PATCH 6/7] test(ci): raise the platform-boundary ledger count to 14 The ledger row count is pinned in ci/test_enforce_platform_boundary.py. The QEMU runtime bundle adds three target_os gates in esp_qemu_runtime.rs plus the Linux-only integration test, and moves the two pre-existing target_os gates out of esp_qemu.rs. Co-Authored-By: Claude Opus 5 (1M context) --- ci/test_enforce_platform_boundary.py | 40 +++++++++++++++++++++------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/ci/test_enforce_platform_boundary.py b/ci/test_enforce_platform_boundary.py index ffb5b411..d61d63db 100644 --- a/ci/test_enforce_platform_boundary.py +++ b/ci/test_enforce_platform_boundary.py @@ -17,7 +17,12 @@ def test_committed_exact_occurrence_ledger_matches_whole_tree(self) -> None: # Phase 8b (FastLED/fbuild#1314): the 15 `host` and 6 `process` # namespace rows migrated behind the platform facades are gone; # only `host_executable` work (phase 8c) remains. - self.assertEqual(len(self.expected), 12) + # + # 12 -> 14: the Espressif QEMU Linux runtime bundle adds three + # `target_os = "linux"` gates in `esp_qemu_runtime.rs` plus the + # Linux-only integration test, and moves the two pre-existing + # `target_os` gates out of `esp_qemu.rs`. + self.assertEqual(len(self.expected), 14) self.assertFalse(boundary.validate_ledger(self.expected)) self.assertFalse(boundary.compare(self.expected, self.observed)) @@ -68,12 +73,14 @@ def test_private_platform_implementation_findings_are_not_baselined(self) -> Non manifest_finding, normalized="winapi", ) - self.assertEqual(len(boundary.rows_from_findings([unauthorized_manifest_finding])), 1) + self.assertEqual( + len(boundary.rows_from_findings([unauthorized_manifest_finding])), 1 + ) unauthorized_facade_finding = dataclasses.replace( image_finding, kind="cfg_macro", - normalized='cfg!(windows)', + normalized="cfg!(windows)", ) self.assertEqual( boundary.rows_from_findings([unauthorized_facade_finding]), @@ -102,9 +109,9 @@ def test_no_filesystem_mechanics_remain_outside_the_boundary(self) -> None: self.assertFalse([row for row in self.expected if row.capability == "fs"]) def test_rp2040_filesystem_mechanics_use_the_neutral_facade(self) -> None: - source = ( - boundary.ROOT / "crates/fbuild-deploy/src/rp2040.rs" - ).read_text(encoding="utf-8") + source = (boundary.ROOT / "crates/fbuild-deploy/src/rp2040.rs").read_text( + encoding="utf-8" + ) for forbidden in ("AsRawHandle", "CancelSynchronousIo", ".raw_os_error()"): self.assertNotIn(forbidden, source) @@ -150,7 +157,12 @@ def test_duplicate_and_non_contiguous_ordinal_are_rejected(self) -> None: def test_second_identical_occurrence_in_grandfathered_file_is_new(self) -> None: first = self.expected[0] - same_group = [row for row in self.expected if (row.path, row.kind, row.normalized) == (first.path, first.kind, first.normalized)] + same_group = [ + row + for row in self.expected + if (row.path, row.kind, row.normalized) + == (first.path, first.kind, first.normalized) + ] extra = dataclasses.replace(first, ordinal=len(same_group)) failures = boundary.compare(self.expected, [*self.observed, extra]) @@ -173,7 +185,14 @@ def test_actual_dylint_undercount_is_rejected(self) -> None: source, kind, normalized = next(iter(expected)) process = "123" sources = {(process, source)} - findings = boundary.collections.Counter({(process, source, kind, normalized): expected[(source, kind, normalized)] - 1}) + findings = boundary.collections.Counter( + { + (process, source, kind, normalized): expected[ + (source, kind, normalized) + ] + - 1 + } + ) failures = boundary.compare_dylint_observations(expected, sources, findings) @@ -181,7 +200,10 @@ def test_actual_dylint_undercount_is_rejected(self) -> None: def test_one_selector_and_six_neutral_namespaces(self) -> None: platform = boundary.ROOT / "crates/fbuild-core/src/platform" - all_source = "\n".join(path.read_text(encoding="utf-8") for path in (boundary.ROOT / "crates").rglob("*.rs")) + all_source = "\n".join( + path.read_text(encoding="utf-8") + for path in (boundary.ROOT / "crates").rglob("*.rs") + ) selector = (platform / "mod.rs").read_text(encoding="utf-8") self.assertEqual(all_source.count("std::cfg_select!"), 1) From 6be03280a211516ae9421c6d2411af5181e5acdc Mon Sep 17 00:00:00 2001 From: zackees Date: Sat, 22 Aug 2026 14:20:44 -0700 Subject: [PATCH 7/7] fix(qemu): require every direct dependency before accepting a cached bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `is_installed()` checked one sentinel library, so a partially-restored cache (a CI cache saved mid-extract, say) counted as complete. The install was then never repaired, and the follow-up probe failed with "cannot start even with the bundle applied" — an unrecoverable error for a recoverable state. Both the cache check and the post-extract validation now require all five libraries the QEMU binaries link directly, and the error names the ones that are actually missing. Co-Authored-By: Claude Opus 5 (1M context) --- ci/platform_boundary_research.tsv | 6 +- .../src/toolchain/esp_qemu_runtime.rs | 76 +++++++++++++++---- 2 files changed, 65 insertions(+), 17 deletions(-) diff --git a/ci/platform_boundary_research.tsv b/ci/platform_boundary_research.tsv index 6e80142c..735e488d 100644 --- a/ci/platform_boundary_research.tsv +++ b/ci/platform_boundary_research.tsv @@ -74,7 +74,7 @@ crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 565 attr_cfg #[cfg(not(windows 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_runtime.rs 201 attr_cfg #[cfg_attr(not(target_os=),allow(dead_code))] host_executable host_artifact_policy -crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs 223 attr_cfg #[cfg(not(target_os=))] host_executable host_artifact_policy -crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs 229 attr_cfg #[cfg(target_os=)] host_executable host_artifact_policy +crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs 224 attr_cfg #[cfg_attr(not(target_os=),allow(dead_code))] host_executable host_artifact_policy +crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs 246 attr_cfg #[cfg(not(target_os=))] host_executable host_artifact_policy +crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs 252 attr_cfg #[cfg(target_os=)] host_executable host_artifact_policy crates/fbuild-toolchain/tests/qemu_linux_runtime.rs 18 attr_cfg #![cfg(target_os=)] host_executable host_artifact_policy diff --git a/crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs b/crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs index 7ac2bd1e..573f436e 100644 --- a/crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs +++ b/crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs @@ -53,9 +53,22 @@ const RUNTIME_RELEASE_TAG: &str = "qemu-linux-runtime-v1"; /// Subdirectory the archive extracts its libraries into. const LIB_SUBDIR: &str = "lib"; -/// One library that must be present for the bundle to be considered valid — -/// it is the one whose absence broke CI in the first place. -const SENTINEL_LIB: &str = "libslirp.so.0"; +/// Every non-glibc library the Espressif QEMU binaries link directly. All of +/// them must be present for a cached bundle to count as complete: checking one +/// sentinel would let a half-extracted tree pass `is_installed()`, and the +/// install would then never be repaired — the second probe would just fail +/// with "cannot start even with the bundle applied". +/// +/// The bundle carries their transitive closure too, but those are reachable +/// only through these five, so a tree that has all five and nothing else is +/// already impossible from `staged_install`'s atomic rename. +const REQUIRED_LIBS: &[&str] = &[ + "libslirp.so.0", + "libSDL2-2.0.so.0", + "libpixman-1.so.0", + "libgcrypt.so.20", + "libz.so.1", +]; /// The bundled Linux runtime libraries for Espressif QEMU. pub struct QemuLinuxRuntime { @@ -101,7 +114,7 @@ impl QemuLinuxRuntime { /// Whether a complete bundle is already unpacked in the cache. pub fn is_installed(&self) -> bool { - self.base.is_cached() && self.lib_dir().join(SENTINEL_LIB).is_file() + self.base.is_cached() && missing_libs(self.lib_dir().as_path()).is_empty() } pub fn get_info(&self) -> PackageInfo { @@ -109,17 +122,27 @@ impl QemuLinuxRuntime { } } +/// Which of the required libraries are absent from an extracted `lib/` dir. +fn missing_libs(lib_dir: &Path) -> Vec<&'static str> { + REQUIRED_LIBS + .iter() + .copied() + .filter(|lib| !lib_dir.join(lib).is_file()) + .collect() +} + /// Reject an extracted tree that does not carry the libraries it exists for. fn validate_runtime_install(install_dir: &Path) -> Result<()> { let lib_dir = install_dir.join(LIB_SUBDIR); - if lib_dir.join(SENTINEL_LIB).is_file() { + let missing = missing_libs(&lib_dir); + if missing.is_empty() { return Ok(()); } Err(FbuildError::PackageError(format!( - "QEMU Linux runtime bundle at {} is incomplete: {}/{} not found", + "QEMU Linux runtime bundle at {} is incomplete: {} missing from {}/", install_dir.display(), + missing.join(", "), LIB_SUBDIR, - SENTINEL_LIB, ))) } @@ -375,22 +398,47 @@ mod tests { ); } + /// Lay down an extracted-bundle tree carrying `libs`. + fn bundle_tree(root: &Path, libs: &[&str]) { + let lib = root.join(LIB_SUBDIR); + std::fs::create_dir_all(&lib).unwrap(); + for name in libs { + std::fs::write(lib.join(name), b"").unwrap(); + } + } + #[test] - fn validate_rejects_tree_without_sentinel_library() { + fn validate_rejects_empty_tree_and_names_every_missing_library() { let tmp = tempfile::TempDir::new().unwrap(); - std::fs::create_dir_all(tmp.path().join(LIB_SUBDIR)).unwrap(); + bundle_tree(tmp.path(), &[]); let err = validate_runtime_install(tmp.path()) .unwrap_err() .to_string(); - assert!(err.contains(SENTINEL_LIB), "unexpected error: {err}"); + for lib in REQUIRED_LIBS { + assert!(err.contains(lib), "error should name {lib}: {err}"); + } } #[test] - fn validate_accepts_tree_with_sentinel_library() { + fn validate_rejects_tree_missing_one_library() { + // A partially-restored CI cache must be re-installed, not accepted: + // otherwise `is_installed()` short-circuits the repair and the QEMU + // probe fails afterwards with nothing left to fix it. let tmp = tempfile::TempDir::new().unwrap(); - let lib = tmp.path().join(LIB_SUBDIR); - std::fs::create_dir_all(&lib).unwrap(); - std::fs::write(lib.join(SENTINEL_LIB), b"").unwrap(); + bundle_tree(tmp.path(), &REQUIRED_LIBS[1..]); + let err = validate_runtime_install(tmp.path()) + .unwrap_err() + .to_string(); + assert!( + err.contains(REQUIRED_LIBS[0]), + "error should name the one missing library: {err}" + ); + } + + #[test] + fn validate_accepts_tree_with_every_required_library() { + let tmp = tempfile::TempDir::new().unwrap(); + bundle_tree(tmp.path(), REQUIRED_LIBS); validate_runtime_install(tmp.path()).expect("complete tree should validate"); }