diff --git a/ci/spec22/README.md b/ci/spec22/README.md index b0e6d6e..f2753f6 100644 --- a/ci/spec22/README.md +++ b/ci/spec22/README.md @@ -79,3 +79,18 @@ uses the main binary's own `dlopen`/`dlerror` definitions from this patch, which preempt process-wide (verify on ELF with `nm -D | grep ' T dlopen'`). The probe is POSIX-only; windows is out of phase-1 scope. + +## The ELF leg (linux/amd64, containerized) + +`elf/` runs the same four assertions on linux/amd64 (qemu-user on Apple +Silicon, or any Docker host) — one container, one command: + +```sh +ci/spec22/elf/run-elf-leg.sh +``` + +plus the ELF-only gates (`nm -D` export of `dlopen`/`dlerror`; zero +GNU_UNIQUE in the staged `libtfs.a`). See `elf/README.md` for the layout, +the container-local deviations (the libjemalloc neuter, the reseal), and +the reseal procedure. Proven green on ruby 4.0.6 (linux-gnu x86_64): +`SPEC22-ACCEPTANCE-OK 4.0.6`. diff --git a/ci/spec22/elf/README.md b/ci/spec22/elf/README.md new file mode 100644 index 0000000..4d11e08 --- /dev/null +++ b/ci/spec22/elf/README.md @@ -0,0 +1,96 @@ +# ci/spec22/elf — the ELF (linux/amd64) acceptance leg, containerized + +The linux/amd64 twin of `../run.sh` (macOS): same four jailed assertions +(`fiddle`, `cext-self-dlopen`, `named-error`, `jail-deny`) plus two ELF-only +gates: + +- **GNU_UNIQUE gate** — the staged `libtfs.a` carries zero `STB_GNU_UNIQUE` + definitions (gcc's libstdc++ emits inline-template statics as GNU_UNIQUE + inside the vendored rnp closure; they break the static-archive model and + mis-link under qemu-user). See *Reseal* below when the gate fires. +- **nm -D export gate** — the runtime exe dynamically exports `dlopen` and + `dlerror`. ruby compiles `dln.c` with `-fvisibility=hidden`; the patch's + definitions carry `__attribute__((visibility("default")))` so they can + preempt process-wide (`-Wl,-export-dynamic` cannot export a hidden + symbol — the pre-fix builds failed here with exit 66). + +Everything runs inside one container (`linux/amd64` under Rosetta/qemu-user +on Apple Silicon); the host only runs `run-elf-leg.sh`. + +## Layout (populate once) + +``` +/tmp/spec22-linux-scratch/ # $SCRATCH, bind-mounted + ruby/ # THIS repo checkout — the harness rides it + factory/ # tamatebako/tebako-runtime-ruby + # @feat/boot-smoke-loader-interpose + tebako-runtime/ # tamatebako/tebako-runtime + # @feat/drop-class-l-adapters (gem 0.8.2) + ws/tebako-rs/ # tamatebako/tebako @feat/tfs-mount-of + ws/dwarfs-rs/ # tamatebako/dwarfs-rs (+ dwarfs-t submodule) + ws/limnifs/limnifs/ # limnifs/limnifs (tebako-rs contract-tests + # sibling path dep; cargo metadata fails + # without it) +/tmp/spec22-link-unit-linux/ # $LINK_UNIT, bind-mounted (empty dir) +``` + +Paths are overridable (`SCRATCH`, `LINK_UNIT`, `NAME`, `IMAGE` in the env); +the defaults match this document. The ruby checkout MUST live at +`$SCRATCH/ruby` — the driver refuses to run from anywhere else (the scripts +have to be visible inside the container through the bind mount). + +## Run + +```sh +ci/spec22/elf/run-elf-leg.sh +``` + +Steps: container up → `setup-toolchain.sh` (once; cmake 3.31, clang-19, +gcc-11, rustup, vcpkg, the libjemalloc neuter below) → `roll-source.sh` +(rolls `tfs-ruby-4.0.6-src.tar.gz` from this checkout into +`$SCRATCH/mirror`, guarding the interpose block landed) → +`stage-gem-repo.sh` (builds the adapter-less gem into a file:// repo + +`gemrc`) → `build-link-unit.sh` (cargo + `tools/stage_link_unit`, nm +evidence, preload-deps gate, GNU_UNIQUE gate) → `build-runtime.sh` +(factory `tools/build_runtime` with `--src-mirror` + `GEMRC`, then the +nm -D export gate) → `probe.sh` (builds the probe natives from +`../fixtures/`, packs the payload with the staged `tfs` CLI, runs the exe +jailed: `TEBAKO_JAIL="deny;$SCRATCH:$SCRATCH:rw"`). + +Success prints `SPEC22-ACCEPTANCE-OK 4.0.6` and `RUN-ELF-LEG-OK`. + +## Container-local deviations (documented, not portable) + +1. **libjemalloc neuter** (`setup-toolchain.sh`, idempotent): the ruby + build's `-ljemalloc` probe resolves the distro's static + `/lib/x86_64-linux-gnu/libjemalloc.a` via the `-l:` fallback and links + it into miniruby — under qemu-user that combination hangs the build. + The setup replaces the distro archive with an empty one + (`libjemalloc.a.real` rides alongside as the backup). Container fs + only; never run this on a host. + +2. **Reseal** (manual, gated): when the GNU_UNIQUE gate fires, the flagged + members (rnp/sexp objects carrying `std::__detail::__to_chars_10_impl:: + __digits` / `std::_Sp_make_shared_tag::_S_ti()::__tag` as `u` in `nm`) + must be rebuilt with clang-19 (which emits them hidden, not GNU_UNIQUE) + and resealed: + + ```sh + # inside the container: rebuild the flagged TUs with clang-19, then + nm /tmp/spec22-link-unit-linux/libtfs.a | grep -B1 ' u ' # the members + cp /tmp/spec22-link-unit-linux/libtfs.a{,.pre-reseal-backup} + python3 reseal.py /tmp/spec22-link-unit-linux/libtfs.a{,.resealed} + mv /tmp/spec22-link-unit-linux/libtfs.a{.resealed,} + ``` + + `reseal.py` = `swap-members.py` (prefix-rename exactly as tebako-arscope + would emit) + `ar-rebuild.py` (positional extract/rebuild — GNU ar 2.34 + mangles this writer's archive on in-place rewrite; llvm-ar rebuilds + cleanly). The durable fix is upstream (arscope must neutralize + GNU_UNIQUE, or the rnp closure builds with clang); the gate keeps the + manual step honest meanwhile. + +3. **qemu-user clock**: the container's clock can lag the host by hours; + artifact mtimes (cache keys, `make` decisions) follow the CONTAINER + clock. Nothing in the harness compares mtimes across the boundary — + keep it that way. diff --git a/ci/spec22/elf/ar-rebuild.py b/ci/spec22/elf/ar-rebuild.py new file mode 100644 index 0000000..a6d7021 --- /dev/null +++ b/ci/spec22/elf/ar-rebuild.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""ar-rebuild.py — positional extract/rebuild for an ar archive that GNU +ar's rewriter mangles. Extracts every member to a directory with +position-unique names, then rebuilds a fresh GNU archive in the ORIGINAL +member order. Members named in (by original member name) +are swapped for the replacement file contents. + +Usage: ar-rebuild.py [replace-dir]""" + +import os +import shutil +import struct +import subprocess +import sys + + +def parse(data): + assert data[:8] == b"!\n", "not an ar archive" + members = [] # (name, bytes) + strtab = b"" + off = 8 + while off < len(data): + raw_name = data[off:off + 16].decode("ascii", "replace") + size = int(data[off + 48:off + 58].decode("ascii").strip()) + body_off = off + 60 + body = data[body_off:body_off + size] + off = body_off + size + (size & 1) + field = raw_name.strip() + if field == "/": + continue # symbol table — regenerated by ranlib + if field == "//": + strtab = body + continue + if field.startswith("/") and field[1:].isdigit(): + start = int(field[1:]) + end = strtab.index(b"\n", start) + name = strtab[start:end].decode("ascii", "replace").rstrip("/") + else: + name = field.rstrip("/").strip() + members.append((name, body)) + return members + + +def main(): + src, dst = sys.argv[1], sys.argv[2] + replace_dir = sys.argv[3] if len(sys.argv) > 3 else None + with open(src, "rb") as f: + members = parse(f.read()) + + tmp = "/tmp/ar-rebuild-work" + shutil.rmtree(tmp, ignore_errors=True) + os.makedirs(tmp) + seen = {} + files = [] + replaced = [] + for i, (name, body) in enumerate(members): + seen[name] = seen.get(name, 0) + 1 + fname = f"{i:05d}-{name.replace('/', '_')}" + path = os.path.join(tmp, fname) + if replace_dir and os.path.exists(os.path.join(replace_dir, name)): + with open(os.path.join(replace_dir, name), "rb") as f: + body = f.read() + replaced.append(name) + with open(path, "wb") as f: + f.write(body) + files.append(path) + + if os.path.exists(dst): + os.remove(dst) + # GNU ar keys members by basename; the position-prefixed names would + # leak into the member table, so archive through basename-preserved + # hardlinks in per-member dirs is overkill — GNU ar stores the name + # as given on the command line (basename). Use a manifest pass. + subprocess.run(["llvm-ar-18", "rcs", dst] + files, check=True) + return members, replaced, dst + + +if __name__ == "__main__": + members, replaced, dst = main() + print(f"members: {len(members)}, replaced: {replaced}") + out = subprocess.run(["llvm-ar-18", "t", dst], capture_output=True, text=True).stdout.splitlines() + print(f"out members: {len(out)}") diff --git a/ci/spec22/elf/build-link-unit.sh b/ci/spec22/elf/build-link-unit.sh new file mode 100755 index 0000000..4614210 --- /dev/null +++ b/ci/spec22/elf/build-link-unit.sh @@ -0,0 +1,71 @@ +#!/bin/bash +# build-link-unit.sh — the linux-gnu v2 link unit + tfs CLI, inside the +# toolchain container. Mirrors ci/gnu-floor-build.sh's env and ordering: +# serialized sqfs pre-install first (sqfs-sys's build.rs would otherwise +# race dwarfs-t-sys's vcpkg run on the root lock), then the cargo build, +# then tools/stage_link_unit --skip-build into $LINK_UNIT. +set -euo pipefail +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/env.sh" +cd "$SCRATCH/ws/tebako-rs" + +echo "== vcpkg pre-install squashfs-tools-ng ($TRIPLET) ==" +if [ ! -f "$SQFS_SYS_VCPKG_INSTALLED_DIR/lib/libsquashfs.a" ]; then + "$VCPKG_ROOT/vcpkg" install \ + --vcpkg-root "$VCPKG_ROOT" \ + --x-wait-for-lock \ + --x-manifest-root crates/sqfs-sys \ + --x-install-root /sqfs-installed \ + --triplet "$TRIPLET" \ + --overlay-triplets crates/sqfs-sys/vcpkg_triplets \ + --overlay-ports crates/sqfs-sys/vcpkg_ports +fi + +echo "== cargo build --release ($TARGET): tfs, tebako-driver, libtfs-preload, tfs-cli ==" +cargo build --release --target "$TARGET" -p tfs -p tebako-driver -p libtfs-preload -p tfs-cli + +echo "== bridge the pre-installed sqfs tree into the sqfs-sys out dir ==" +# (link-unit-stage.sh's bridge: the harvest globs /vcpkg_installed/ +# /lib/libsquashfs.a, which a pre-install leaves empty) +sqfs_out=$(ls -dt "/cargo-target/$TARGET/release/build"/sqfs-sys-*/out | head -1) +trip=$(basename "$SQFS_SYS_VCPKG_INSTALLED_DIR") +if [ ! -e "$sqfs_out/vcpkg_installed/$trip/lib" ]; then + mkdir -p "$sqfs_out/vcpkg_installed" + ln -sfn "$SQFS_SYS_VCPKG_INSTALLED_DIR" "$sqfs_out/vcpkg_installed/$trip" + echo "bridged $SQFS_SYS_VCPKG_INSTALLED_DIR -> $sqfs_out/vcpkg_installed/$trip" +fi + +echo "== stage the link unit ==" +ruby tools/stage_link_unit $LINK_UNIT --target "$TARGET" --skip-build + +echo "== nm evidence ==" +for sym in tebako_fs_mount_of tebako_fs_dlmap2file tebako_path_is_embedded; do + hit_tfs=$(nm $LINK_UNIT/libtfs.a 2>/dev/null | grep -c " T $sym\$" || true) + hit_drv=$(nm $LINK_UNIT/libtebako_driver.a 2>/dev/null | grep -c " T $sym\$" || true) + echo " $sym: libtfs.a T-defs=$hit_tfs libtebako_driver.a T-defs=$hit_drv" +done +echo "== preload cdylib dynamic deps (must be glibc-only) ==" +ls -la $LINK_UNIT/ +NEEDED=$(readelf -d $LINK_UNIT/libtfs_preload.so | grep NEEDED || true) +echo "$NEEDED" +echo "$NEEDED" | grep -E 'libstdc\+\+|libgcc_s' && { echo "FAIL: preload NEEDs the C++ runtime chain"; exit 65; } || true + +echo "== GNU_UNIQUE gate (libtfs.a must carry none) ==" +# gcc's libstdc++ emits inline-template statics (std::__detail::__to_chars_ +# _10_impl::__digits, _Sp_make_shared_tag::__tag) as STB_GNU_UNIQUE inside +# the vendored rnp closure — process-singleton semantics that break the +# static-archive model (and mis-link under qemu-user). tebako-arscope's +# prefixing does not rewrite them away (upstream fix owed); the acceptance +# gate is that the STAGED archive carries zero. clang-built members do not +# emit them, so the container-local remedy is to rebuild the flagged +# members with clang-19 and reseal (ci/spec22/elf/reseal.py — see the +# README's reseal section). Backup the pre-reseal archive first. +UNIQUE=$(nm $LINK_UNIT/libtfs.a 2>/dev/null | grep -cE ' u [a-zA-Z_]' || true) +echo " GNU_UNIQUE defs in libtfs.a: $UNIQUE" +[ "$UNIQUE" -eq 0 ] || { + echo "GATE-FAIL: libtfs.a carries $UNIQUE GNU_UNIQUE definition(s) —" + echo "rebuild the flagged members with clang-19 and reseal per" + echo "ci/spec22/elf/README.md (reseal section), then re-run." + exit 65 +} + +echo "BUILD-LINK-UNIT-OK" diff --git a/ci/spec22/elf/build-runtime.sh b/ci/spec22/elf/build-runtime.sh new file mode 100755 index 0000000..c3ec312 --- /dev/null +++ b/ci/spec22/elf/build-runtime.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# build-runtime.sh — the factory build of ruby 4.0.6 against the local +# mirror + the staged linux link unit + the adapter-less tebako-runtime gem +# (file:// gem repo via GEMRC). Mirrors the factory CI's container leg +# (build-linux-gnu -> _build-platform.yml): TEBAKO_RUST_LIBDIR set, --patchelf. +set -euo pipefail +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/env.sh" + +# env.sh's CFLAGS/CXXFLAGS=-pthread belong to the LINK-UNIT build (the +# rnp/vcpkg glibc-pthreads floor). Exported here they poison the ruby +# configure: ruby's configure only flows the command-line cflags= (the +# tebako include paths) into the Makefile when CFLAGS is NOT set in the +# environment (configure: `if test -z "${CFLAGS+set}"`), so an exported +# CFLAGS silently drops -I/build/include and main.c stops +# finding . The CI container leg exports neither. +unset CFLAGS CXXFLAGS + +VERSION=4.0.6 +TFS_CLI="/cargo-target/$TARGET/release/tfs" +RUNTIME_PKG="$SCRATCH/runtime-packages/tebako-runtime-local-$VERSION-linux-amd64" + +echo "== gem repo sanity (adapter-less tebako-runtime resolves from file://) ==" +rm -rf /tmp/gemtest +GEMRC="$SCRATCH/gemrc" gem install tebako-runtime --no-document --install-dir /tmp/gemtest +gem contents --gem-install-dir /tmp/gemtest tebako-runtime 2>/dev/null | head -3 || \ + find /tmp/gemtest/gems -maxdepth 1 -name 'tebako-runtime-*' + +echo "== factory ruby tooling ==" +cd "$SCRATCH/factory" +# BUNDLED WITH 4.0.16 — the image's bundler is 2.4.19. +gem list -i bundler -v 4.0.16 >/dev/null 2>&1 || gem install bundler -v 4.0.16 --no-document +bundle install --quiet + +echo "== build_runtime ==" +[ -x "$TFS_CLI" ] || { echo "tfs CLI missing at $TFS_CLI"; exit 64; } +# fresh src tag per content change: the fetcher caches SHA256SUMS per tag +tag="local-spec22-$(sha256sum "$SCRATCH/mirror/tfs-ruby-$VERSION-src.tar.gz" | cut -c1-8)" +GEMRC="$SCRATCH/gemrc" \ +TEBAKO_RUST_LIBDIR=$LINK_UNIT \ +TEBAKO_TFS="$TFS_CLI" \ +tools/build_runtime --ruby "$VERSION" \ + --src-mirror "file://$SCRATCH/mirror" --src-release "$tag" \ + --prefix "$SCRATCH/factory-prefix" \ + --output "$RUNTIME_PKG" \ + --patchelf + +RUNTIME_EXE="$RUNTIME_PKG" +RUNTIME_IMAGE="$RUNTIME_PKG.tfs" +[ -x "$RUNTIME_EXE" ] || { echo "runtime exe missing at $RUNTIME_EXE"; exit 65; } +[ -f "$RUNTIME_IMAGE" ] || { echo "env image missing at $RUNTIME_IMAGE"; exit 65; } + +echo "== CRITICAL GATE: the exe must dynamically export dlopen/dlerror ==" +nm -D "$RUNTIME_EXE" | grep -E ' (dlopen|dlerror)$' || { + echo "GATE-FAIL: $RUNTIME_EXE does not dynamically export dlopen/dlerror —" + echo "the interposition cannot preempt libdl. Link line follows in the log." + exit 66 +} + +echo "BUILD-RUNTIME-OK $RUNTIME_EXE" diff --git a/ci/spec22/elf/env.sh b/ci/spec22/elf/env.sh new file mode 100755 index 0000000..394ca67 --- /dev/null +++ b/ci/spec22/elf/env.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# env.sh — sourced by every container-side step of the spec22 ELF leg. +# Layout contract: +# $SCRATCH (bind mount, host-persistent): sources, mirror, gem repo, +# HOME caches, outputs. Default +# /tmp/spec22-linux-scratch (override in the +# env); populate per ci/spec22/elf/README.md. +# $LINK_UNIT (bind mount, host-persistent): the staged link unit. +# Default: /tmp/spec22-link-unit-linux. +# /vcpkg /cargo-target /sqfs-installed (container fs): heavy build trees — +# the vcpkg ARCHIVES cache (~/.cache/vcpkg) and +# the cargo registry/git db (~/.cargo) persist +# via HOME on the bind mount, so a re-run +# restores instead of rebuilding. +SCRATCH="${SCRATCH:-/tmp/spec22-linux-scratch}" +export SCRATCH +export LINK_UNIT="${LINK_UNIT:-/tmp/spec22-link-unit-linux}" +export HOME="$SCRATCH/home" +export PATH="$HOME/.cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" +export DEBIAN_FRONTEND=noninteractive + +export CARGO_TARGET_DIR=/cargo-target +export CARGO_NET_GIT_FETCH_WITH_CLI=true + +export VCPKG_ROOT=/vcpkg +export DWARFS_RS_VCPKG_ROOT=/vcpkg +export SQFS_SYS_VCPKG_ROOT=/vcpkg +export DWARFS_RS_VCPKG_TRIPLET=x64-linux-static +export SQFS_SYS_VCPKG_TRIPLET=x64-linux-static +export SQFS_SYS_VCPKG_INSTALLED_DIR=/sqfs-installed/x64-linux-static + +# bindgen (rnp-rs) dlopens libclang; focal's stock is too old — clang-19 from +# apt.llvm.org (the gnu-floor-build.sh recipe). +export LIBCLANG_PATH=/usr/lib/llvm-19/lib + +# glibc 2.31 pthreads vs librnp examples (gnu-floor-build.sh): driver-flag +# -pthread covers dwarfs-t-sys and every rnp-src dep configure. +export CFLAGS=-pthread +export CXXFLAGS=-pthread + +# Absorb libstdc++/libgcc_s statically into the produced binaries (the floor +# rule); the wrapper rewrites the -l tokens at the driver boundary. +export RUSTFLAGS="-C linker=$SCRATCH/ws/tebako-rs/ci/linux-link-wrap.sh" + +export TARGET=x86_64-unknown-linux-gnu +export TRIPLET=x64-linux-static +export VCPKG_COMMIT=f14401ca0f2754347c3864da7488a9b955b4e47a +export RUST_VERSION=1.94.1 diff --git a/ci/spec22/elf/probe.sh b/ci/spec22/elf/probe.sh new file mode 100755 index 0000000..9c07425 --- /dev/null +++ b/ci/spec22/elf/probe.sh @@ -0,0 +1,77 @@ +#!/bin/bash +# probe.sh — the jailed acceptance run (linux adaptation of ci/spec22/run.sh +# steps 4-5): build the probe natives against the factory build tree's ruby +# headers, pack the payload image with the tfs CLI, run the runtime exe +# jailed, assert the four PROBE lines + exit 0. +set -euo pipefail +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/env.sh" + +VERSION=4.0.6 +TFS_CLI="/cargo-target/$TARGET/release/tfs" +RUNTIME_PKG="$SCRATCH/runtime-packages/tebako-runtime-local-$VERSION-linux-amd64" +RUNTIME_EXE="$RUNTIME_PKG" +RUNTIME_IMAGE="$RUNTIME_PKG.tfs" +FIXTURES="${FIXTURES:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../fixtures" && pwd)}" +PROBE_TREE="$SCRATCH/probe-tree" +PAYLOAD_IMG="$SCRATCH/probe-$VERSION.tfs" +PROBE_LIB="/probe/lib/libvfsprobe.so" + +echo "== build probe natives (gcc, ELF) ==" +# The build tree is deps/src/_ruby_$VERSION (headers at include/ruby.h); +# a blanket find can land on the deps/stash_* installed-headers copy +# (include/ruby-X/ruby.h — one level deeper), which breaks the dirname +# math (same trap ci/spec22/run.sh documents for macOS). +RB_BUILD_DIR="$SCRATCH/factory-prefix/deps/src/_ruby_$VERSION" +if [ ! -f "$RB_BUILD_DIR/include/ruby.h" ]; then + RB_BUILD_DIR="$(dirname "$(find "$SCRATCH/factory-prefix/deps/src" -mindepth 3 -maxdepth 3 -path '*/include/ruby.h' 2>/dev/null | head -1)")/.." +fi +[ -f "$RB_BUILD_DIR/include/ruby.h" ] || { echo "ruby build headers not found under $SCRATCH/factory-prefix/deps/src"; exit 64; } +rm -rf "$PROBE_TREE" +mkdir -p "$PROBE_TREE/probe/lib" +cp "$FIXTURES/probe.rb" "$PROBE_TREE/probe/" + +# libvfsdep: the leaf the closure walk must extract next to libvfsprobe +gcc -shared -fPIC -O2 "$FIXTURES/vfsdep.c" \ + -Wl,-soname,"libvfsdep.so" -o "$PROBE_TREE/probe/lib/libvfsdep.so" +# libvfsprobe: DT_NEEDED libvfsdep.so + $ORIGIN runpath (the ELF twin of +# the macOS @rpath/@loader_path pair) +gcc -shared -fPIC -O2 "$FIXTURES/vfsprobe.c" \ + -L"$PROBE_TREE/probe/lib" -lvfsdep -Wl,-rpath,'$ORIGIN' \ + -o "$PROBE_TREE/probe/lib/libvfsprobe.so" +# probe_ext: a ruby C extension that self-dlopens the VFS path from Init. +# Undefined rb_* symbols resolve from the exe at load (linux C-extension +# convention; no -lruby). +# .ext/include holds BOTH the arch dir (x86_64-linux/ruby/config.h) and a +# plain ruby/ entry on linux — pick the one carrying ruby/config.h, never +# a positional first-dir (order is unspecified). +ARCH_DIR="$(basename "$(find "$RB_BUILD_DIR/.ext/include" -mindepth 3 -maxdepth 3 -path '*/ruby/config.h' 2>/dev/null | head -1 | xargs -r dirname | xargs -r dirname)")" +[ -f "$RB_BUILD_DIR/.ext/include/$ARCH_DIR/ruby/config.h" ] || { echo "arch config.h not found under $RB_BUILD_DIR/.ext/include"; exit 64; } +gcc -shared -fPIC -O2 \ + -I"$RB_BUILD_DIR/include" -I"$RB_BUILD_DIR/.ext/include/$ARCH_DIR" \ + -DPROBE_LIB_PATH="\"$PROBE_LIB\"" \ + "$FIXTURES/probe_ext.c" -o "$PROBE_TREE/probe/lib/probe_ext.so" + +echo "== pack the payload image ==" +rm -f "$PAYLOAD_IMG" +"$TFS_CLI" mkimage --format dwarfs "$PROBE_TREE" --output "$PAYLOAD_IMG" + +echo "== the jailed proof (TEBAKO_JAIL=deny;\$SCRATCH:\$SCRATCH:rw) ==" +mkdir -p "$SCRATCH"/{probe-home,probe-tmp,tebako-home} +set +e +out="$(env -i \ + HOME="$SCRATCH/probe-home" \ + TMPDIR="$SCRATCH/probe-tmp" \ + PATH="/usr/bin:/bin" \ + TEBAKO_HOME="$SCRATCH/tebako-home" \ + TEBAKO_RUNTIME_IMAGE="$RUNTIME_IMAGE" \ + TEBAKO_JAIL="deny;$SCRATCH:$SCRATCH:rw" \ + "$RUNTIME_EXE" --tebako-image "$PAYLOAD_IMG:-:/" --tebako-entry /probe/probe.rb 2>&1)" +status=$? +set -e +echo "$out" +echo "$out" | grep -q "^PROBE fiddle ok 42" || { echo "FAIL spec22 (fiddle leg)"; exit 1; } +echo "$out" | grep -q "^PROBE cext-self-dlopen ok 42" || { echo "FAIL spec22 (cext self-dlopen leg)"; exit 1; } +echo "$out" | grep -q "^PROBE named-error ok " || { echo "FAIL spec22 (named-error leg)"; exit 1; } +echo "$out" | grep -q "^PROBE jail-deny ok " || { echo "FAIL spec22 (jail leg)"; exit 1; } +[ "$status" -eq 0 ] || { echo "FAIL spec22 (probe exit $status)"; exit 1; } +echo "SPEC22-ACCEPTANCE-OK $VERSION ($RUNTIME_EXE)" diff --git a/ci/spec22/elf/reseal.py b/ci/spec22/elf/reseal.py new file mode 100644 index 0000000..d77ebcf --- /dev/null +++ b/ci/spec22/elf/reseal.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""reseal.py — replace gcc-built members inside the tebako-arscope-staged +libtfs.a with clang-built ones, prefix-renamed exactly as arscope would +have emitted them, and rebuild the archive positionally (GNU ar 2.34 +mangles this writer's archive on rewrite; llvm-ar rebuilds cleanly). + +Rename rule (arscope's): every defined non-tebako_* symbol gets the +__tebako_internal_ prefix; undefined refs ride the prefix iff their name +is defined anywhere in the staged archive. + +Usage: reseal.py [ ...] +""" + +import os +import shutil +import subprocess +import sys +import tempfile + +PREFIX = "__tebako_internal_" + + +def run(*args, check=True): + return subprocess.run(args, capture_output=True, text=True, check=check) + + +def parse_archive(path): + with open(path, "rb") as f: + data = f.read() + assert data[:8] == b"!\n", "not an ar archive" + members = [] + strtab = b"" + off = 8 + while off < len(data): + raw_name = data[off:off + 16].decode("ascii", "replace") + size = int(data[off + 48:off + 58].decode("ascii").strip()) + body = data[off + 60:off + 60 + size] + off += 60 + size + (size & 1) + field = raw_name.strip() + if field == "/": + continue + if field == "//": + strtab = body + continue + if field.startswith("#1/"): + # BSD extended name: the first bytes of the body are the + # name (NUL-padded to alignment by this writer); the real + # content follows. + namelen = int(field[3:]) + name = body[:namelen].decode("ascii", "replace").rstrip("\x00") + body = body[namelen:] + elif field.startswith("/") and field[1:].isdigit(): + start = int(field[1:]) + name = strtab[start:strtab.index(b"\n", start)].decode("ascii", "replace").rstrip("/") + else: + name = field.rstrip("/").strip() + members.append([name, body]) + return members + + +def nm_names(path, defined_only): + args = ["nm", "--defined-only", path] if defined_only else ["nm", path] + names = set() + for line in run(*args).stdout.splitlines(): + parts = line.split() + if len(parts) >= 2: + names.add(parts[-1]) + return names + + +def main(): + staged, out_path, fresh = sys.argv[1], sys.argv[2], sys.argv[3:] + members = parse_archive(staged) + defined = nm_names(staged, defined_only=True) + originals = {n[len(PREFIX):] if n.startswith(PREFIX) else n for n in defined} + + tmp = tempfile.mkdtemp(prefix="reseal-") + swapped = {} + for member in fresh: + defs, refs = set(), set() + for line in run("nm", member).stdout.splitlines(): + parts = line.split() + if len(parts) >= 2 and parts[-2] == "U": + refs.add(parts[-1]) + elif len(parts) >= 3: + defs.add(parts[-1]) + pairs = [(n, PREFIX + n) for n in (defs | refs) + if not n.startswith(("tebako_", PREFIX)) and (n in defs or n in originals)] + map_path = os.path.join(tmp, "map") + with open(map_path, "w") as f: + f.writelines(f"{old} {new}\n" for old, new in pairs) + base = os.path.basename(member) + stem = base[:-2] if base.endswith(".o") else base + hits = [i for i, (name, _) in enumerate(members) + if name == base or (name.startswith(stem) and len(name) <= len(base))] + if not hits: + sys.exit(f"FAIL: no member matches {base}") + renamed = os.path.join(tmp, f"renamed-{base}") + run("objcopy", "--redefine-syms", map_path, member, renamed) + with open(renamed, "rb") as f: + body = f.read() + for i in hits: + members[i][1] = body + swapped[members[i][0]] = len(pairs) + + files = [] + for i, (name, body) in enumerate(members): + member_dir = os.path.join(tmp, f"{i:05d}") + os.makedirs(member_dir) + path = os.path.join(member_dir, name) + with open(path, "wb") as f: + f.write(body) + files.append(path) + + if os.path.exists(out_path): + os.remove(out_path) + run("llvm-ar-18", "rcs", out_path, *files) + print(f"members: {len(members)}; swapped: {swapped}") + + out_members = run("llvm-ar-18", "t", out_path).stdout.splitlines() + uniq, bare = [], [] + for line in run("nm", "--defined-only", out_path).stdout.splitlines(): + parts = line.split() + if len(parts) < 2: + continue + binding, name = parts[-2], parts[-1] + if binding == "u": + uniq.append(name) + if binding in "TWDGB" and not name.startswith(("tebako_", PREFIX)): + bare.append(name) + print(f"out members: {len(out_members)}; GNU_UNIQUE defs left: {len(uniq)}; " + f"unprefixed non-tebako global defs left: {len(bare)}") + for n in (uniq + bare)[:10]: + print(f" LEFT {n}") + shutil.rmtree(tmp, ignore_errors=True) + + +if __name__ == "__main__": + main() diff --git a/ci/spec22/elf/roll-source.sh b/ci/spec22/elf/roll-source.sh new file mode 100755 index 0000000..7a38ab6 --- /dev/null +++ b/ci/spec22/elf/roll-source.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# roll-source.sh — roll the patched source tarball(s) for the ELF leg from +# a checkout of THIS repo (tamatebako/ruby, the dln_c_loader_interpose +# branch) into $SCRATCH/mirror, laid out exactly as the release assets are +# (tarball + SHA256SUMS — the SourceFetcher contract). linux-gnu scenario, +# unsuffixed asset names. VERSIONS selects the version set (default: the +# phase-1 line tip). +set -euo pipefail +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/env.sh" + +VERSIONS="${VERSIONS:-4.0.6}" +RUBY_SRC="${RUBY_SRC:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)}" +[ -x "$RUBY_SRC/tools/apply" ] || { echo "tamatebako/ruby checkout missing at $RUBY_SRC (set RUBY_SRC)"; exit 64; } + +MIRROR="$SCRATCH/mirror" +mkdir -p "$MIRROR" "$SCRATCH/pristine" +for v in $VERSIONS; do + work="$SCRATCH/.roll-work/$v" + rm -rf "$work" + TFS_CACHE_DIR="$SCRATCH/pristine" "$RUBY_SRC/tools/apply" "$v" "$work" --platform linux-gnu + # The chain gate's whole point: the interpose block MUST be in the roll. + grep -q tfs_dlopen_route "$work/tfs-ruby-$v-src/dln.c" || { + echo "$v: rolled WITHOUT the loader-interpose block"; exit 1; } + tar -czf "$MIRROR/tfs-ruby-$v-src.tar.gz" -C "$work" "tfs-ruby-$v-src" + rm -rf "$work" + echo "rolled $v" +done +( cd "$MIRROR" && sha256sum tfs-ruby-*-src.tar.gz > SHA256SUMS ) +echo "ROLL-SOURCE-OK $MIRROR" diff --git a/ci/spec22/elf/run-elf-leg.sh b/ci/spec22/elf/run-elf-leg.sh new file mode 100755 index 0000000..5f89f35 --- /dev/null +++ b/ci/spec22/elf/run-elf-leg.sh @@ -0,0 +1,73 @@ +#!/bin/bash +# run-elf-leg.sh — one-shot reproducible ELF (linux/amd64) acceptance leg for +# tebako spec 22 phase 1 (loader interposition). HOST-side driver: macOS + +# Docker Desktop, the container runs linux/amd64 under Rosetta/qemu-user. +# +# What it proves (the same four assertions as the macOS leg, ci/spec22/run.sh): +# PROBE fiddle ok 42 / cext-self-dlopen ok 42 / named-error ok / jail-deny ok +# plus two ELF-only gates: the staged libtfs.a carries zero GNU_UNIQUE defs, +# and the runtime exe dynamically exports dlopen/dlerror (nm -D), else +# interposition cannot preempt libdl. +# +# Layout (populate once per ci/spec22/elf/README.md; everything resumes, a +# re-run never rebuilds what is already staged): +# $SCRATCH/ bind mount, host-persistent +# ruby/ THIS repo checkout (the harness rides it) +# factory/ tamatebako/tebako-runtime-ruby checkout +# tebako-runtime/ tamatebako/tebako-runtime checkout +# (@feat/drop-class-l-adapters) +# ws/tebako-rs/ tamatebako/tebako checkout (@feat/tfs-mount-of) +# ws/dwarfs-rs/ tamatebako/dwarfs-rs (+ dwarfs-t submodule) +# ws/limnifs/limnifs/ limnifs/limnifs (contract-tests sibling +# path dep — cargo metadata fails without it) +# $LINK_UNIT/ bind mount for the staged link unit +# +set -euo pipefail + +SCRATCH="${SCRATCH:-/tmp/spec22-linux-scratch}" +LINK_UNIT="${LINK_UNIT:-/tmp/spec22-link-unit-linux}" +IMAGE="${IMAGE:-ghcr.io/tamatebako/tebako-ubuntu-20.04:0.16.2-amd64}" +NAME="${NAME:-spec22-elf-build}" +ELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # ci/spec22/elf, inside $SCRATCH/ruby + +case "$ELF" in + "$SCRATCH"/*) ;; + *) { echo "this harness rides the ruby checkout at \$SCRATCH/ruby — $ELF is outside $SCRATCH"; exit 64; } ;; +esac + +echo "== 0. docker daemon ==" +if ! docker info >/dev/null 2>&1; then + open -a Docker 2>/dev/null || true + for _ in $(seq 1 30); do docker info >/dev/null 2>&1 && break; sleep 5; done + docker info >/dev/null 2>&1 || { echo "FAIL: docker daemon did not come up"; exit 64; } +fi + +echo "== 1. container ==" +if ! docker inspect "$NAME" >/dev/null 2>&1; then + docker run -d --name "$NAME" --platform linux/amd64 \ + -v "$SCRATCH:$SCRATCH" -v "$LINK_UNIT:$LINK_UNIT" \ + "$IMAGE" sleep infinity >/dev/null +fi +[ "$(docker inspect -f '{{.State.Running}}' "$NAME")" = "true" ] || docker start "$NAME" >/dev/null + +echo "== 2. toolchain (skip if already provisioned) ==" +if ! docker exec "$NAME" bash -c "command -v cmake >/dev/null && cmake --version | grep -q 3.31 && [ -x '$SCRATCH/home/.cargo/bin/cargo' ] && [ -d /vcpkg ]" 2>/dev/null; then + docker exec "$NAME" bash "$ELF/setup-toolchain.sh" +else + echo " toolchain present — skipping" +fi + +echo "== 3. roll the patched source (mirror) + stage the adapter-less gem repo ==" +docker exec "$NAME" bash "$ELF/roll-source.sh" +docker exec "$NAME" bash "$ELF/stage-gem-repo.sh" + +echo "== 4. link unit (tfs, tebako-driver, libtfs-preload, tfs-cli) + GNU_UNIQUE gate ==" +docker exec "$NAME" bash "$ELF/build-link-unit.sh" + +echo "== 5. factory runtime build + nm -D export gate ==" +docker exec "$NAME" bash "$ELF/build-runtime.sh" + +echo "== 6. jailed acceptance probe ==" +docker exec "$NAME" bash "$ELF/probe.sh" + +echo "RUN-ELF-LEG-OK" diff --git a/ci/spec22/elf/setup-toolchain.sh b/ci/spec22/elf/setup-toolchain.sh new file mode 100755 index 0000000..13da9fa --- /dev/null +++ b/ci/spec22/elf/setup-toolchain.sh @@ -0,0 +1,94 @@ +#!/bin/bash +# setup-toolchain.sh — run ONCE inside the container (as root). +# Reproduces the tebako-rs gnu-floor toolchain (ci/gnu-floor-build.sh) on top +# of the factory's CI image ghcr.io/tamatebako/tebako-ubuntu-20.04:0.16.2-amd64 +# (which already carries: gcc-9, ruby 3.2.6 + bundler, git, autoconf/automake/ +# bison, curl/wget, pkg-config, ninja, zip/unzip, and the ruby build deps +# libssl/zlib1g/readline/libyaml/libffi -dev — but NOT a modern cmake: focal's +# stock is 3.16, below dwarfs-t's 3.28 floor, so 3.31.9 is installed below). +set -euo pipefail +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/env.sh" + +echo "== apt: base + link-unit additions ==" +apt-get update -qq +apt-get install -y -qq --no-install-recommends \ + build-essential ninja-build pkg-config \ + autoconf automake autoconf-archive libtool \ + curl zip unzip tar ca-certificates git gnupg lsb-release wget \ + libbz2-dev patchelf flex gawk + +# cmake >= 3.28 (dwarfs-t's floor): the image carries only focal's stock +# 3.16, so the Kitware binary tarball goes to /usr/local (first on PATH). +echo "== cmake 3.31.9 (kitware binary; focal's apt cmake is 3.16) ==" +curl -fsSL https://github.com/Kitware/CMake/releases/download/v3.31.9/cmake-3.31.9-linux-x86_64.tar.gz \ + | tar xz -C /usr/local --strip-components=1 +cmake --version | head -1 + +# focal git 2.25 + mounted uid-mismatched repos: trust everything (the +# build trees under the bind mount are owned by the host uid). +git config --global --add safe.directory '*' + +echo "== clang-19 (llvm.org focal) ==" +curl -fsSL https://apt.llvm.org/llvm-snapshot.gpg.key | gpg --dearmor -o /usr/share/keyrings/llvm.gpg +echo "deb [signed-by=/usr/share/keyrings/llvm.gpg] http://apt.llvm.org/focal/ llvm-toolchain-focal-19 main" \ + > /etc/apt/sources.list.d/llvm19.list +apt-get update -qq +apt-get install -y -qq --no-install-recommends clang-19 libclang-19-dev +echo "/usr/lib/llvm-19/lib" > /etc/ld.so.conf.d/llvm19.conf +ldconfig + +echo "== gcc-11 (ubuntu-toolchain-r ppa; Botan 3.12 hard-gates gcc >= 11) ==" +curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x1E9377A2BA9EF27F" \ + | gpg --dearmor -o /usr/share/keyrings/toolchainr.gpg +echo "deb [signed-by=/usr/share/keyrings/toolchainr.gpg] http://ppa.launchpad.net/ubuntu-toolchain-r/test/ubuntu focal main" \ + > /etc/apt/sources.list.d/toolchainr.list +apt-get update -qq +apt-get install -y -qq --no-install-recommends gcc-11 g++-11 +update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-11 110 \ + --slave /usr/bin/g++ g++ /usr/bin/g++-11 +update-alternatives --install /usr/bin/cc cc /usr/bin/gcc-11 110 +update-alternatives --install /usr/bin/c++ c++ /usr/bin/g++-11 110 +gcc --version | head -1 + +echo "== rustup ($RUST_VERSION) ==" +curl -fsSL https://sh.rustup.rs -o /tmp/rustup-init.sh +sh /tmp/rustup-init.sh -y --profile minimal --default-toolchain "$RUST_VERSION" --target "$TARGET" +rustc --version + +echo "== vcpkg ($VCPKG_COMMIT) ==" +git clone --quiet https://github.com/microsoft/vcpkg "$VCPKG_ROOT" +git -C "$VCPKG_ROOT" checkout --quiet "$VCPKG_COMMIT" +"$VCPKG_ROOT/bootstrap-vcpkg.sh" -disableMetrics + +echo "== git metadata for the dwarfs-t copy (version.cmake) ==" +# dwarfs-t's cmake/version.cmake dies without git metadata ("missing +# version files"); the scratch copy is a git-less rsync, so give it a +# one-commit repo (describe falls back to v0.0.0-dev-, which the +# version parser accepts). Scratch copy only — the checkout is untouched. +git -C "$SCRATCH/ws/dwarfs-rs/dwarfs-t" init -q +git -C "$SCRATCH/ws/dwarfs-rs/dwarfs-t" -c user.email=spec22@local -c user.name=spec22 \ + commit -q --allow-empty -m "spec22 scratch (git metadata for version.cmake)" + +echo "== cargo target off the bind mount ==" +mkdir -p /cargo-target /sqfs-installed +ln -sfn /cargo-target "$SCRATCH/ws/tebako-rs/target" + +echo "== neuter the distro libjemalloc.a (qemu/user-mode emulation) ==" +# The ruby build's -ljemalloc probe resolves the DISTRO static archive via +# the -l: fallback and links it into miniruby; under qemu-user (Rosetta) +# that combination hangs the build. The distro archive is irrelevant to +# the runtime (the link unit brings its own allocator story), so replace +# it with an empty archive (a .real backup rides alongside for forensics). +# Idempotent; a no-op on real hardware runs where the file was already +# moved. DO NOT do this on a host you care about — container fs only. +JEM=/lib/x86_64-linux-gnu/libjemalloc.a +if [ -f "$JEM" ] && [ ! -f "$JEM.real" ]; then + mv "$JEM" "$JEM.real" + ar rc "$JEM" # empty archive + ranlib "$JEM" 2>/dev/null || true + echo "neutered $JEM (backup: $JEM.real)" +else + echo "libjemalloc.a already neutered or absent" +fi + +echo "SETUP-TOOLCHAIN-OK" diff --git a/ci/spec22/elf/stage-gem-repo.sh b/ci/spec22/elf/stage-gem-repo.sh new file mode 100755 index 0000000..34bf7fa --- /dev/null +++ b/ci/spec22/elf/stage-gem-repo.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# stage-gem-repo.sh — build the adapter-less tebako-runtime gem from a +# checkout of tamatebako/tebako-runtime@feat/drop-class-l-adapters (GEM_SRC, +# default $SCRATCH/tebako-runtime) and stage it as a file:// gem repo at +# $SCRATCH/gem-repo (+ $SCRATCH/gemrc consumed by build-runtime.sh). The +# env image's deploy does an unpinned `gem install tebako-runtime` and +# rubygems.org still serves the adapter-ful 0.8.1 — GEMRC points the +# install here instead. +set -euo pipefail +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/env.sh" + +GEM_SRC="${GEM_SRC:-$SCRATCH/tebako-runtime}" +[ -f "$GEM_SRC/tebako-runtime.gemspec" ] || { + echo "gem checkout missing at $GEM_SRC —" + echo "clone tamatebako/tebako-runtime@feat/drop-class-l-adapters there or set GEM_SRC" + exit 64 +} + +repo="$SCRATCH/gem-repo" +rm -rf "$repo" +mkdir -p "$repo/gems" +( cd "$GEM_SRC" && gem build tebako-runtime.gemspec ) +built=$(ls "$GEM_SRC"/tebako-runtime-*.gem | head -1) +case "$built" in + *tebako-runtime-0.8.1.gem) + echo "the chain gem must not carry the published 0.8.1 identity (adapter-ful); the adapter-less line is 0.8.2" + exit 1 ;; +esac +cp "$built" "$repo/gems/" +gem generate_index --directory "$repo" +printf ':sources:\n- file://%s/gem-repo\ngem: --no-document\n' "$SCRATCH" > "$SCRATCH/gemrc" +echo "STAGE-GEM-REPO-OK $repo ($(basename "$built"))" diff --git a/ci/spec22/elf/swap-members.py b/ci/spec22/elf/swap-members.py new file mode 100644 index 0000000..c0c7843 --- /dev/null +++ b/ci/spec22/elf/swap-members.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""swap-members.py — replace gcc-built members inside the staged libtfs.a +with clang-built ones, prefix-renamed exactly as tebako-arscope would have +emitted them (every defined non-tebako_* symbol gets the __tebako_internal_ +prefix; undefined refs ride the prefix iff their name is defined anywhere +in the staged archive). + +Usage: swap-members.py [ ...] +Writes .swapped and verifies: no unprefixed non-tebako global +definitions and no GNU_UNIQUE definitions remain.""" + +import os +import subprocess +import sys +import tempfile + +PREFIX = "__tebako_internal_" + + +def run(*args): + return subprocess.run(args, capture_output=True, text=True) + + +def archive_defined(archive): + names = set() + for line in run("nm", "--defined-only", archive).stdout.splitlines(): + parts = line.split() + if len(parts) >= 2: + names.add(parts[-1]) + return names + + +def member_syms(obj): + defs, refs = set(), set() + for line in run("nm", obj).stdout.splitlines(): + parts = line.split() + if len(parts) >= 2 and parts[-2] in ("U",): + refs.add(parts[-1]) + elif len(parts) >= 3: + defs.add(parts[-1]) + return defs, refs + + +def main(): + archive, fresh = sys.argv[1], sys.argv[2:] + originals = set() + for name in archive_defined(archive): + originals.add(name[len(PREFIX):] if name.startswith(PREFIX) else name) + + out_path = archive + ".swapped" + run("cp", archive, out_path) + before = run("ar", "t", out_path).stdout.splitlines() + + with tempfile.TemporaryDirectory() as tmp: + for member in fresh: + defs, refs = member_syms(member) + pairs = [] + for name in defs | refs: + if name.startswith(("tebako_", PREFIX)): + continue + if name in defs or name in originals: + pairs.append((name, PREFIX + name)) + map_path = os.path.join(tmp, "map") + with open(map_path, "w") as f: + f.writelines(f"{old} {new}\n" for old, new in pairs) + + base = os.path.basename(member) + stem = base[:-2] if base.endswith(".o") else base + hits = [m for m in before if m == base or (m.startswith(stem) and len(m) <= len(base))] + if not hits: + sys.exit(f"FAIL: no archive member matches {base}") + target = hits[0] + staged_member = os.path.join(tmp, target) + run("objcopy", "--redefine-syms", map_path, member, staged_member) + rc = run("ar", "r", out_path, staged_member) + if rc.returncode != 0: + sys.exit(f"FAIL: ar r {target}: {rc.stderr}") + after = run("ar", "t", out_path).stdout.splitlines() + if len(after) != len(before): + sys.exit(f"FAIL: ar appended {target} (member-name mismatch)") + print(f"swapped {target} ({len(pairs)} symbols renamed)") + + run("ranlib", out_path) + uniq, bare = [], [] + for line in run("nm", "--defined-only", out_path).stdout.splitlines(): + parts = line.split() + if len(parts) < 2: + continue + binding, name = parts[-2], parts[-1] + if binding == "u": + uniq.append(name) + if binding in "TWDGB" and not name.startswith(("tebako_", PREFIX)): + bare.append(name) + print(f"verify: GNU_UNIQUE defs left: {len(uniq)}; unprefixed non-tebako global defs left: {len(bare)}") + for n in (uniq + bare)[:10]: + print(f" LEFT {n}") + + +if __name__ == "__main__": + main() diff --git a/ci/spec22/run.sh b/ci/spec22/run.sh index e2b6362..30b4ef9 100755 --- a/ci/spec22/run.sh +++ b/ci/spec22/run.sh @@ -104,6 +104,9 @@ gem: --no-document GEMRC # fresh src tag per content change: the fetcher caches SHA256SUMS per tag tag="$SRC_TAG-$(shasum -a 256 "$SCRATCH/mirror/tfs-ruby-$VERSION-src.tar.gz" | cut -c1-8)" + # stock macOS ships bison 2.3; ruby regenerates parse.c (bison >= 3.0) + # whenever a patch nudges the dependency graph — prefer homebrew's. + [ -x /opt/homebrew/opt/bison/bin/bison ] && export PATH="/opt/homebrew/opt/bison/bin:$PATH" ( cd "$FACTORY_WT" && \ GEMRC="$SCRATCH/gemrc" \ TEBAKO_RUST_LIBDIR="$SCRATCH/link-unit" \ @@ -113,9 +116,17 @@ GEMRC --prefix "$SCRATCH/factory" \ --output "$RUNTIME_PKG" ) fi -RUNTIME_EXE="$RUNTIME_PKG/$(basename "$RUNTIME_PKG")" +# the factory emits the package in one of two layouts: the SIBLING form +# (the exe IS $RUNTIME_PKG, the env image $RUNTIME_PKG.tfs beside it) or +# the DIRECTORY form (a dir holding + .tfs). +if [ -x "$RUNTIME_PKG" ] && [ ! -d "$RUNTIME_PKG" ]; then + RUNTIME_EXE="$RUNTIME_PKG" + RUNTIME_IMAGE="$RUNTIME_PKG.tfs" +else + RUNTIME_EXE="$RUNTIME_PKG/$(basename "$RUNTIME_PKG")" + RUNTIME_IMAGE="$RUNTIME_PKG/$(basename "$RUNTIME_PKG").tfs" +fi [ -x "$RUNTIME_EXE" ] || die "runtime exe missing at $RUNTIME_EXE" -RUNTIME_IMAGE="$RUNTIME_PKG/$(basename "$RUNTIME_PKG").tfs" [ -f "$RUNTIME_IMAGE" ] || die "env image missing at $RUNTIME_IMAGE" # --- 4. probe payload image ------------------------------------------------ @@ -123,8 +134,16 @@ PROBE_TREE="$SCRATCH/probe-tree" PAYLOAD_IMG="$SCRATCH/probe-$VERSION.tfs" if [ ! -f "$PAYLOAD_IMG" ]; then step "build probe natives + payload image" - RB_BUILD_DIR="$(dirname "$(find "$SCRATCH/factory" -name 'ruby.h' -path '*/include/*' 2>/dev/null | head -1)")/.." - [ -f "$RB_BUILD_DIR/include/ruby.h" ] || die "ruby build headers not found under $SCRATCH/factory" + # the build tree is deps/src/_ruby_$VERSION (headers at include/ruby.h); + # a blanket find can land on the deps/stash_* installed-headers copy + # (include/ruby-X/ruby.h — one level deeper), which breaks the dirname + # arithmetic below. Canonical path first, depth-constrained find as + # fallback. + RB_BUILD_DIR="$SCRATCH/factory/deps/src/_ruby_$VERSION" + if [ ! -f "$RB_BUILD_DIR/include/ruby.h" ]; then + RB_BUILD_DIR="$(dirname "$(find "$SCRATCH/factory/deps/src" -mindepth 3 -maxdepth 3 -path '*/include/ruby.h' 2>/dev/null | head -1)")/.." + fi + [ -f "$RB_BUILD_DIR/include/ruby.h" ] || die "ruby build headers not found under $SCRATCH/factory/deps/src" mkdir -p "$PROBE_TREE/probe/lib" cp "$SELF_DIR/fixtures/probe.rb" "$PROBE_TREE/probe/" PROBE_LIB="/probe/lib/libvfsprobe.$LIBEXT" @@ -134,8 +153,9 @@ if [ ! -f "$PAYLOAD_IMG" ]; then clang -dynamiclib -O2 "$SELF_DIR/fixtures/vfsprobe.c" "$PROBE_TREE/probe/lib/libvfsdep.$LIBEXT" \ -install_name "@rpath/libvfsprobe.$LIBEXT" -Wl,-rpath,"@loader_path" \ -o "$PROBE_TREE/probe/lib/libvfsprobe.$LIBEXT" + ARCH_DIR="$(basename "$(find "$RB_BUILD_DIR/.ext/include" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | head -1)")" clang -bundle -O2 -undefined dynamic_lookup \ - -I"$RB_BUILD_DIR/include" -I"$RB_BUILD_DIR/.ext/include/arm64-darwin24" \ + -I"$RB_BUILD_DIR/include" -I"$RB_BUILD_DIR/.ext/include/$ARCH_DIR" \ -DPROBE_LIB_PATH="\"$PROBE_LIB\"" \ "$SELF_DIR/fixtures/probe_ext.c" -o "$PROBE_TREE/probe/lib/probe_ext.$EXTEXT" else diff --git a/patches/3.1/dln_c_loader_interpose.patch b/patches/3.1/dln_c_loader_interpose.patch index d08aa35..d99328a 100644 --- a/patches/3.1/dln_c_loader_interpose.patch +++ b/patches/3.1/dln_c_loader_interpose.patch @@ -25,7 +25,7 @@ diff --git a/dln.c b/dln.c index 44e8c06..7c641f3 100644 --- a/dln.c +++ b/dln.c -@@ -100,6 +100,131 @@ dln_loaderror(const char *format, ...) +@@ -100,6 +100,136 @@ dln_loaderror(const char *format, ...) # define USE_DLN_DLOPEN #endif @@ -47,7 +47,12 @@ index 44e8c06..7c641f3 100644 + cannot resolve its own original on musl -- no dlvsym). + macOS is excluded: dyld applies __interpose tuples only from dylibs, + never from the main executable, so the macOS interposition is the -+ driver's self-insertion at boot (crates/tebako-driver), not this TU. */ ++ driver's self-insertion at boot (crates/tebako-driver), not this TU. ++ Visibility: ruby compiles dln.c with -fvisibility=hidden (XCFLAGS), ++ exporting upstream symbols via RUBY_SYMBOL_EXPORT pragmas in dln.h; ++ these new definitions must declare default visibility themselves or ++ they stay LOCAL in the exe and cannot preempt (-Wl,-export-dynamic ++ cannot export a hidden symbol). */ + +#include +#include @@ -140,13 +145,13 @@ index 44e8c06..7c641f3 100644 + return real_fn; +} + -+void * ++__attribute__((visibility("default"))) void * +dlopen(const char *path, int mode) +{ + return tfs_dlopen_route(path, mode, tfs_real_dlopen()); +} + -+char * ++__attribute__((visibility("default"))) char * +dlerror(void) +{ + return tfs_dlerror_route(tfs_real_dlerror()); diff --git a/patches/3.1/process_c_tebako_spawn.patch b/patches/3.1/process_c_tebako_spawn.patch index c4f51a5..4e58a6e 100644 --- a/patches/3.1/process_c_tebako_spawn.patch +++ b/patches/3.1/process_c_tebako_spawn.patch @@ -8,21 +8,35 @@ # materialized to the host cache (tebako_fs_exec_materialize — home-layout mounts whole-tree) and the exec # pair (command_name, command_abspath — the child execve()s # command_abspath) is pointed at the host copy. The child re-enters the -# namespace through the preload shim (DYLD_INSERT_LIBRARIES/LD_PRELOAD) -# plus the current mounts (TEBAKO_TFS_MOUNTS), injected via -# env_modification in ruby's internal representation (hidden array of -# hidden [key, val] pairs — a hash here crashes parent_start1). +# namespace through the preload shim (DYLD_INSERT_LIBRARIES/LD_PRELOAD +# at the in-VFS path read from TEBAKO_PRELOAD_SHIM — the driver's flow +# of the env image's layout grant, with a literal fallback for images +# predating the grant) plus the current mounts (TEBAKO_TFS_MOUNTS), +# injected via env_modification in ruby's internal representation +# (hidden array of hidden [key, val] pairs — a hash here crashes +# parent_start1). # Host targets pass through untouched; a covered path the mounts do not # hold answers ENOENT and exec fails honestly. Shell-form spawns and # bare command names (PATH search) do not resolve through the VFS. # The hook is parent-side (before fork), so the materialization work # (malloc/extract) never runs in the async-signal-safe child. +# darwin24 (macos arm64, factory run 31699651270): dyld TERMINATES an +# Apple-restricted binary at exec when the inherited +# DYLD_INSERT_LIBRARIES names a foreign dylib — darwin23 stripped the +# variable instead. Under the driver's process-wide export that killed +# /bin/sh and /usr/bin/cc for every system()/backtick of a packaged +# interpreter. The hook drops the inherited variable per spawn whose +# target is restricted (any shell form; anything resolving into Apple's +# system binary dirs) — a restricted target can never honor the +# insertion on any host, so the scrub loses nothing. Non-restricted +# host targets (a third-party JRE) keep the driver's delivery — spec 22 +# §3.1's array form. # X-Redesign: v2-spawn diff --git a/process.c b/process.c index aff3503..aff3ba4 100644 --- a/process.c +++ b/process.c -@@ -2746,6 +2746,81 @@ +@@ -2746,6 +2746,174 @@ return eargp; } @@ -49,6 +63,84 @@ index aff3503..aff3ba4 100644 + EXPORT_STR(rb_str_new_cstr(val))))); +} + ++#if defined(__APPLE__) ++/* dyld TERMINATES an Apple-restricted binary at exec when ++ DYLD_INSERT_LIBRARIES names a foreign dylib (factory run 31699651270, ++ macos arm64 darwin24: /bin/sh and /usr/bin/cc died under the armed ++ env; darwin23 stripped the variable instead — spec 22 §3.1). Drop the ++ inherited variable per spawn whose target is restricted; the nil pair ++ DELETES the inherited entry (rb_execarg_parent_start1's NIL_P branch, ++ st_delete). */ ++static void ++tfs_env_unset(struct rb_execarg *eargp, const char *key) ++{ ++ VALUE env = eargp->env_modification; ++ if (!RB_TYPE_P(env, T_ARRAY)) { ++ env = hide_obj(rb_ary_new()); ++ eargp->env_modification = env; ++ } ++ rb_ary_push(env, hide_obj(rb_assoc_new(EXPORT_STR(rb_str_new_cstr(key)), ++ Qnil))); ++} ++ ++/* execvp's PATH search mirrored parent-side (empty elements mean "."): ++ the first executable candidate wins; no hit returns prog unchanged — ++ the child's own search then fails the same way. */ ++static const char * ++tfs_resolve_in_path(const char *prog, char *buf, size_t buflen) ++{ ++ const char *path = getenv("PATH"); ++ const char *p, *e; ++ size_t len; ++ ++ if (path == NULL) return prog; ++ for (p = path;; p = e + 1) { ++ e = strchr(p, ':'); ++ len = e != NULL ? (size_t)(e - p) : strlen(p); ++ if (len == 0) { ++ if (access(prog, X_OK) == 0) return prog; ++ } ++ else if (len + strlen(prog) + 2 <= buflen) { ++ memcpy(buf, p, len); ++ buf[len] = '/'; ++ strcpy(buf + len + 1, prog); ++ if (access(buf, X_OK) == 0) return buf; ++ } ++ if (e == NULL) break; ++ } ++ return prog; ++} ++ ++/* The spawn target dyld would kill under the armed env: any shell form ++ (the shell is always a platform binary) and anything resolving into ++ Apple's system binary dirs. A bare name resolves through the PATH ++ mirror above — /usr/bin/cc is restricted even when named "cc". */ ++static int ++tfs_spawn_target_restricted(struct rb_execarg *eargp) ++{ ++ static const char *const restricted_dirs[] = { ++ "/bin/", "/sbin/", "/usr/bin/", "/usr/sbin/", "/usr/libexec/", "/System/" ++ }; ++ char resolved[MAXPATHLEN]; ++ const char *prog; ++ size_t i; ++ ++ if (eargp->use_shell) return 1; ++ prog = RB_TYPE_P(eargp->invoke.cmd.command_abspath, T_STRING) ++ ? RSTRING_PTR(eargp->invoke.cmd.command_abspath) : NULL; ++ if (prog == NULL && RB_TYPE_P(eargp->invoke.cmd.command_name, T_STRING)) ++ prog = RSTRING_PTR(eargp->invoke.cmd.command_name); ++ if (prog == NULL) return 0; ++ if (strchr(prog, '/') == NULL) ++ prog = tfs_resolve_in_path(prog, resolved, sizeof(resolved)); ++ for (i = 0; i < sizeof(restricted_dirs) / sizeof(restricted_dirs[0]); i++) { ++ if (strncmp(prog, restricted_dirs[i], strlen(restricted_dirs[i])) == 0) ++ return 1; ++ } ++ return 0; ++} ++#endif ++ +/* Spawn of a memfs-resident binary: materialize the exec target to the + host cache, point command_abspath at it (the child execve()s + command_abspath; command_name follows for error reporting), and put @@ -60,10 +152,20 @@ index aff3503..aff3ba4 100644 +tfs_spawn_prepare(struct rb_execarg *eargp) +{ + const char *prog; ++ const char *shim; + char *mapped; + char *preload; + char *mounts; + ++#if defined(__APPLE__) ++ /* The scrub first: a restricted target dies under the inherited ++ variable at exec, before the VFS flow below could matter. */ ++ if (tfs_spawn_target_restricted(eargp)) { ++ tfs_env_unset(eargp, "DYLD_INSERT_LIBRARIES"); ++ return; ++ } ++#endif ++ + if (eargp->use_shell || !RB_TYPE_P(eargp->invoke.cmd.command_name, T_STRING)) return; + prog = RSTRING_PTR(eargp->invoke.cmd.command_name); + if (prog == NULL || !tebako_path_is_embedded(prog)) return; @@ -73,18 +175,23 @@ index aff3503..aff3ba4 100644 + eargp->invoke.cmd.command_abspath = rb_str_new_cstr(mapped); + free(mapped); + -+ /* The preload shim rides the runtime's own env image (/lib/tebako/... -+ under the memfs root); its absence only means an older runtime -+ image — the exec proceeds without VFS, answering honestly for -+ paths the host lacks. */ ++ /* The shim's in-VFS path flows from the env image's layout grant ++ (TEBAKO_PRELOAD_SHIM, exported by the driver — the SSOT); the ++ literal is the transition fallback for an image predating the ++ grant. Its absence only means an older runtime image — the exec ++ proceeds without VFS, answering honestly for paths the host ++ lacks. */ ++ shim = getenv("TEBAKO_PRELOAD_SHIM"); +#if defined(__APPLE__) -+ preload = tebako_fs_dlmap2file("/__tfs__/lib/tebako/libtfs_preload.dylib"); ++ if (shim == NULL || *shim == '\0') shim = "/__tfs__/lib/tebako/libtfs_preload.dylib"; ++ preload = tebako_fs_dlmap2file(shim); + if (preload != NULL) { + tfs_env_set(eargp, "DYLD_INSERT_LIBRARIES", preload); + free(preload); + } +#else -+ preload = tebako_fs_dlmap2file("/__tfs__/lib/tebako/libtfs_preload.so"); ++ if (shim == NULL || *shim == '\0') shim = "/__tfs__/lib/tebako/libtfs_preload.so"; ++ preload = tebako_fs_dlmap2file(shim); + if (preload != NULL) { + tfs_env_set(eargp, "LD_PRELOAD", preload); + free(preload); @@ -104,7 +211,7 @@ index aff3503..aff3ba4 100644 static VALUE rb_execarg_init(int argc, const VALUE *orig_argv, int accept_shell, VALUE execarg_obj) { -@@ -2758,6 +2833,9 @@ +@@ -2758,6 +2926,9 @@ prog = rb_exec_getargs(&argc, &argv, accept_shell, &env, &opthash); rb_exec_fillarg(prog, argc, argv, env, opthash, execarg_obj); ALLOCV_END(argv_buf); diff --git a/patches/3.2/dln_c_loader_interpose.patch b/patches/3.2/dln_c_loader_interpose.patch index d667534..b704148 100644 --- a/patches/3.2/dln_c_loader_interpose.patch +++ b/patches/3.2/dln_c_loader_interpose.patch @@ -25,7 +25,7 @@ diff --git a/dln.c b/dln.c index 0edd709..e89fb9e 100644 --- a/dln.c +++ b/dln.c -@@ -93,6 +93,131 @@ dln_loaderror(const char *format, ...) +@@ -93,6 +93,136 @@ dln_loaderror(const char *format, ...) # define USE_DLN_DLOPEN #endif @@ -47,7 +47,12 @@ index 0edd709..e89fb9e 100644 + cannot resolve its own original on musl -- no dlvsym). + macOS is excluded: dyld applies __interpose tuples only from dylibs, + never from the main executable, so the macOS interposition is the -+ driver's self-insertion at boot (crates/tebako-driver), not this TU. */ ++ driver's self-insertion at boot (crates/tebako-driver), not this TU. ++ Visibility: ruby compiles dln.c with -fvisibility=hidden (XCFLAGS), ++ exporting upstream symbols via RUBY_SYMBOL_EXPORT pragmas in dln.h; ++ these new definitions must declare default visibility themselves or ++ they stay LOCAL in the exe and cannot preempt (-Wl,-export-dynamic ++ cannot export a hidden symbol). */ + +#include +#include @@ -140,13 +145,13 @@ index 0edd709..e89fb9e 100644 + return real_fn; +} + -+void * ++__attribute__((visibility("default"))) void * +dlopen(const char *path, int mode) +{ + return tfs_dlopen_route(path, mode, tfs_real_dlopen()); +} + -+char * ++__attribute__((visibility("default"))) char * +dlerror(void) +{ + return tfs_dlerror_route(tfs_real_dlerror()); diff --git a/patches/3.2/process_c_tebako_spawn.patch b/patches/3.2/process_c_tebako_spawn.patch index c4f51a5..4e58a6e 100644 --- a/patches/3.2/process_c_tebako_spawn.patch +++ b/patches/3.2/process_c_tebako_spawn.patch @@ -8,21 +8,35 @@ # materialized to the host cache (tebako_fs_exec_materialize — home-layout mounts whole-tree) and the exec # pair (command_name, command_abspath — the child execve()s # command_abspath) is pointed at the host copy. The child re-enters the -# namespace through the preload shim (DYLD_INSERT_LIBRARIES/LD_PRELOAD) -# plus the current mounts (TEBAKO_TFS_MOUNTS), injected via -# env_modification in ruby's internal representation (hidden array of -# hidden [key, val] pairs — a hash here crashes parent_start1). +# namespace through the preload shim (DYLD_INSERT_LIBRARIES/LD_PRELOAD +# at the in-VFS path read from TEBAKO_PRELOAD_SHIM — the driver's flow +# of the env image's layout grant, with a literal fallback for images +# predating the grant) plus the current mounts (TEBAKO_TFS_MOUNTS), +# injected via env_modification in ruby's internal representation +# (hidden array of hidden [key, val] pairs — a hash here crashes +# parent_start1). # Host targets pass through untouched; a covered path the mounts do not # hold answers ENOENT and exec fails honestly. Shell-form spawns and # bare command names (PATH search) do not resolve through the VFS. # The hook is parent-side (before fork), so the materialization work # (malloc/extract) never runs in the async-signal-safe child. +# darwin24 (macos arm64, factory run 31699651270): dyld TERMINATES an +# Apple-restricted binary at exec when the inherited +# DYLD_INSERT_LIBRARIES names a foreign dylib — darwin23 stripped the +# variable instead. Under the driver's process-wide export that killed +# /bin/sh and /usr/bin/cc for every system()/backtick of a packaged +# interpreter. The hook drops the inherited variable per spawn whose +# target is restricted (any shell form; anything resolving into Apple's +# system binary dirs) — a restricted target can never honor the +# insertion on any host, so the scrub loses nothing. Non-restricted +# host targets (a third-party JRE) keep the driver's delivery — spec 22 +# §3.1's array form. # X-Redesign: v2-spawn diff --git a/process.c b/process.c index aff3503..aff3ba4 100644 --- a/process.c +++ b/process.c -@@ -2746,6 +2746,81 @@ +@@ -2746,6 +2746,174 @@ return eargp; } @@ -49,6 +63,84 @@ index aff3503..aff3ba4 100644 + EXPORT_STR(rb_str_new_cstr(val))))); +} + ++#if defined(__APPLE__) ++/* dyld TERMINATES an Apple-restricted binary at exec when ++ DYLD_INSERT_LIBRARIES names a foreign dylib (factory run 31699651270, ++ macos arm64 darwin24: /bin/sh and /usr/bin/cc died under the armed ++ env; darwin23 stripped the variable instead — spec 22 §3.1). Drop the ++ inherited variable per spawn whose target is restricted; the nil pair ++ DELETES the inherited entry (rb_execarg_parent_start1's NIL_P branch, ++ st_delete). */ ++static void ++tfs_env_unset(struct rb_execarg *eargp, const char *key) ++{ ++ VALUE env = eargp->env_modification; ++ if (!RB_TYPE_P(env, T_ARRAY)) { ++ env = hide_obj(rb_ary_new()); ++ eargp->env_modification = env; ++ } ++ rb_ary_push(env, hide_obj(rb_assoc_new(EXPORT_STR(rb_str_new_cstr(key)), ++ Qnil))); ++} ++ ++/* execvp's PATH search mirrored parent-side (empty elements mean "."): ++ the first executable candidate wins; no hit returns prog unchanged — ++ the child's own search then fails the same way. */ ++static const char * ++tfs_resolve_in_path(const char *prog, char *buf, size_t buflen) ++{ ++ const char *path = getenv("PATH"); ++ const char *p, *e; ++ size_t len; ++ ++ if (path == NULL) return prog; ++ for (p = path;; p = e + 1) { ++ e = strchr(p, ':'); ++ len = e != NULL ? (size_t)(e - p) : strlen(p); ++ if (len == 0) { ++ if (access(prog, X_OK) == 0) return prog; ++ } ++ else if (len + strlen(prog) + 2 <= buflen) { ++ memcpy(buf, p, len); ++ buf[len] = '/'; ++ strcpy(buf + len + 1, prog); ++ if (access(buf, X_OK) == 0) return buf; ++ } ++ if (e == NULL) break; ++ } ++ return prog; ++} ++ ++/* The spawn target dyld would kill under the armed env: any shell form ++ (the shell is always a platform binary) and anything resolving into ++ Apple's system binary dirs. A bare name resolves through the PATH ++ mirror above — /usr/bin/cc is restricted even when named "cc". */ ++static int ++tfs_spawn_target_restricted(struct rb_execarg *eargp) ++{ ++ static const char *const restricted_dirs[] = { ++ "/bin/", "/sbin/", "/usr/bin/", "/usr/sbin/", "/usr/libexec/", "/System/" ++ }; ++ char resolved[MAXPATHLEN]; ++ const char *prog; ++ size_t i; ++ ++ if (eargp->use_shell) return 1; ++ prog = RB_TYPE_P(eargp->invoke.cmd.command_abspath, T_STRING) ++ ? RSTRING_PTR(eargp->invoke.cmd.command_abspath) : NULL; ++ if (prog == NULL && RB_TYPE_P(eargp->invoke.cmd.command_name, T_STRING)) ++ prog = RSTRING_PTR(eargp->invoke.cmd.command_name); ++ if (prog == NULL) return 0; ++ if (strchr(prog, '/') == NULL) ++ prog = tfs_resolve_in_path(prog, resolved, sizeof(resolved)); ++ for (i = 0; i < sizeof(restricted_dirs) / sizeof(restricted_dirs[0]); i++) { ++ if (strncmp(prog, restricted_dirs[i], strlen(restricted_dirs[i])) == 0) ++ return 1; ++ } ++ return 0; ++} ++#endif ++ +/* Spawn of a memfs-resident binary: materialize the exec target to the + host cache, point command_abspath at it (the child execve()s + command_abspath; command_name follows for error reporting), and put @@ -60,10 +152,20 @@ index aff3503..aff3ba4 100644 +tfs_spawn_prepare(struct rb_execarg *eargp) +{ + const char *prog; ++ const char *shim; + char *mapped; + char *preload; + char *mounts; + ++#if defined(__APPLE__) ++ /* The scrub first: a restricted target dies under the inherited ++ variable at exec, before the VFS flow below could matter. */ ++ if (tfs_spawn_target_restricted(eargp)) { ++ tfs_env_unset(eargp, "DYLD_INSERT_LIBRARIES"); ++ return; ++ } ++#endif ++ + if (eargp->use_shell || !RB_TYPE_P(eargp->invoke.cmd.command_name, T_STRING)) return; + prog = RSTRING_PTR(eargp->invoke.cmd.command_name); + if (prog == NULL || !tebako_path_is_embedded(prog)) return; @@ -73,18 +175,23 @@ index aff3503..aff3ba4 100644 + eargp->invoke.cmd.command_abspath = rb_str_new_cstr(mapped); + free(mapped); + -+ /* The preload shim rides the runtime's own env image (/lib/tebako/... -+ under the memfs root); its absence only means an older runtime -+ image — the exec proceeds without VFS, answering honestly for -+ paths the host lacks. */ ++ /* The shim's in-VFS path flows from the env image's layout grant ++ (TEBAKO_PRELOAD_SHIM, exported by the driver — the SSOT); the ++ literal is the transition fallback for an image predating the ++ grant. Its absence only means an older runtime image — the exec ++ proceeds without VFS, answering honestly for paths the host ++ lacks. */ ++ shim = getenv("TEBAKO_PRELOAD_SHIM"); +#if defined(__APPLE__) -+ preload = tebako_fs_dlmap2file("/__tfs__/lib/tebako/libtfs_preload.dylib"); ++ if (shim == NULL || *shim == '\0') shim = "/__tfs__/lib/tebako/libtfs_preload.dylib"; ++ preload = tebako_fs_dlmap2file(shim); + if (preload != NULL) { + tfs_env_set(eargp, "DYLD_INSERT_LIBRARIES", preload); + free(preload); + } +#else -+ preload = tebako_fs_dlmap2file("/__tfs__/lib/tebako/libtfs_preload.so"); ++ if (shim == NULL || *shim == '\0') shim = "/__tfs__/lib/tebako/libtfs_preload.so"; ++ preload = tebako_fs_dlmap2file(shim); + if (preload != NULL) { + tfs_env_set(eargp, "LD_PRELOAD", preload); + free(preload); @@ -104,7 +211,7 @@ index aff3503..aff3ba4 100644 static VALUE rb_execarg_init(int argc, const VALUE *orig_argv, int accept_shell, VALUE execarg_obj) { -@@ -2758,6 +2833,9 @@ +@@ -2758,6 +2926,9 @@ prog = rb_exec_getargs(&argc, &argv, accept_shell, &env, &opthash); rb_exec_fillarg(prog, argc, argv, env, opthash, execarg_obj); ALLOCV_END(argv_buf); diff --git a/patches/3.3/process_c_tebako_spawn.patch b/patches/3.3/process_c_tebako_spawn.patch index c4f51a5..4e58a6e 100644 --- a/patches/3.3/process_c_tebako_spawn.patch +++ b/patches/3.3/process_c_tebako_spawn.patch @@ -8,21 +8,35 @@ # materialized to the host cache (tebako_fs_exec_materialize — home-layout mounts whole-tree) and the exec # pair (command_name, command_abspath — the child execve()s # command_abspath) is pointed at the host copy. The child re-enters the -# namespace through the preload shim (DYLD_INSERT_LIBRARIES/LD_PRELOAD) -# plus the current mounts (TEBAKO_TFS_MOUNTS), injected via -# env_modification in ruby's internal representation (hidden array of -# hidden [key, val] pairs — a hash here crashes parent_start1). +# namespace through the preload shim (DYLD_INSERT_LIBRARIES/LD_PRELOAD +# at the in-VFS path read from TEBAKO_PRELOAD_SHIM — the driver's flow +# of the env image's layout grant, with a literal fallback for images +# predating the grant) plus the current mounts (TEBAKO_TFS_MOUNTS), +# injected via env_modification in ruby's internal representation +# (hidden array of hidden [key, val] pairs — a hash here crashes +# parent_start1). # Host targets pass through untouched; a covered path the mounts do not # hold answers ENOENT and exec fails honestly. Shell-form spawns and # bare command names (PATH search) do not resolve through the VFS. # The hook is parent-side (before fork), so the materialization work # (malloc/extract) never runs in the async-signal-safe child. +# darwin24 (macos arm64, factory run 31699651270): dyld TERMINATES an +# Apple-restricted binary at exec when the inherited +# DYLD_INSERT_LIBRARIES names a foreign dylib — darwin23 stripped the +# variable instead. Under the driver's process-wide export that killed +# /bin/sh and /usr/bin/cc for every system()/backtick of a packaged +# interpreter. The hook drops the inherited variable per spawn whose +# target is restricted (any shell form; anything resolving into Apple's +# system binary dirs) — a restricted target can never honor the +# insertion on any host, so the scrub loses nothing. Non-restricted +# host targets (a third-party JRE) keep the driver's delivery — spec 22 +# §3.1's array form. # X-Redesign: v2-spawn diff --git a/process.c b/process.c index aff3503..aff3ba4 100644 --- a/process.c +++ b/process.c -@@ -2746,6 +2746,81 @@ +@@ -2746,6 +2746,174 @@ return eargp; } @@ -49,6 +63,84 @@ index aff3503..aff3ba4 100644 + EXPORT_STR(rb_str_new_cstr(val))))); +} + ++#if defined(__APPLE__) ++/* dyld TERMINATES an Apple-restricted binary at exec when ++ DYLD_INSERT_LIBRARIES names a foreign dylib (factory run 31699651270, ++ macos arm64 darwin24: /bin/sh and /usr/bin/cc died under the armed ++ env; darwin23 stripped the variable instead — spec 22 §3.1). Drop the ++ inherited variable per spawn whose target is restricted; the nil pair ++ DELETES the inherited entry (rb_execarg_parent_start1's NIL_P branch, ++ st_delete). */ ++static void ++tfs_env_unset(struct rb_execarg *eargp, const char *key) ++{ ++ VALUE env = eargp->env_modification; ++ if (!RB_TYPE_P(env, T_ARRAY)) { ++ env = hide_obj(rb_ary_new()); ++ eargp->env_modification = env; ++ } ++ rb_ary_push(env, hide_obj(rb_assoc_new(EXPORT_STR(rb_str_new_cstr(key)), ++ Qnil))); ++} ++ ++/* execvp's PATH search mirrored parent-side (empty elements mean "."): ++ the first executable candidate wins; no hit returns prog unchanged — ++ the child's own search then fails the same way. */ ++static const char * ++tfs_resolve_in_path(const char *prog, char *buf, size_t buflen) ++{ ++ const char *path = getenv("PATH"); ++ const char *p, *e; ++ size_t len; ++ ++ if (path == NULL) return prog; ++ for (p = path;; p = e + 1) { ++ e = strchr(p, ':'); ++ len = e != NULL ? (size_t)(e - p) : strlen(p); ++ if (len == 0) { ++ if (access(prog, X_OK) == 0) return prog; ++ } ++ else if (len + strlen(prog) + 2 <= buflen) { ++ memcpy(buf, p, len); ++ buf[len] = '/'; ++ strcpy(buf + len + 1, prog); ++ if (access(buf, X_OK) == 0) return buf; ++ } ++ if (e == NULL) break; ++ } ++ return prog; ++} ++ ++/* The spawn target dyld would kill under the armed env: any shell form ++ (the shell is always a platform binary) and anything resolving into ++ Apple's system binary dirs. A bare name resolves through the PATH ++ mirror above — /usr/bin/cc is restricted even when named "cc". */ ++static int ++tfs_spawn_target_restricted(struct rb_execarg *eargp) ++{ ++ static const char *const restricted_dirs[] = { ++ "/bin/", "/sbin/", "/usr/bin/", "/usr/sbin/", "/usr/libexec/", "/System/" ++ }; ++ char resolved[MAXPATHLEN]; ++ const char *prog; ++ size_t i; ++ ++ if (eargp->use_shell) return 1; ++ prog = RB_TYPE_P(eargp->invoke.cmd.command_abspath, T_STRING) ++ ? RSTRING_PTR(eargp->invoke.cmd.command_abspath) : NULL; ++ if (prog == NULL && RB_TYPE_P(eargp->invoke.cmd.command_name, T_STRING)) ++ prog = RSTRING_PTR(eargp->invoke.cmd.command_name); ++ if (prog == NULL) return 0; ++ if (strchr(prog, '/') == NULL) ++ prog = tfs_resolve_in_path(prog, resolved, sizeof(resolved)); ++ for (i = 0; i < sizeof(restricted_dirs) / sizeof(restricted_dirs[0]); i++) { ++ if (strncmp(prog, restricted_dirs[i], strlen(restricted_dirs[i])) == 0) ++ return 1; ++ } ++ return 0; ++} ++#endif ++ +/* Spawn of a memfs-resident binary: materialize the exec target to the + host cache, point command_abspath at it (the child execve()s + command_abspath; command_name follows for error reporting), and put @@ -60,10 +152,20 @@ index aff3503..aff3ba4 100644 +tfs_spawn_prepare(struct rb_execarg *eargp) +{ + const char *prog; ++ const char *shim; + char *mapped; + char *preload; + char *mounts; + ++#if defined(__APPLE__) ++ /* The scrub first: a restricted target dies under the inherited ++ variable at exec, before the VFS flow below could matter. */ ++ if (tfs_spawn_target_restricted(eargp)) { ++ tfs_env_unset(eargp, "DYLD_INSERT_LIBRARIES"); ++ return; ++ } ++#endif ++ + if (eargp->use_shell || !RB_TYPE_P(eargp->invoke.cmd.command_name, T_STRING)) return; + prog = RSTRING_PTR(eargp->invoke.cmd.command_name); + if (prog == NULL || !tebako_path_is_embedded(prog)) return; @@ -73,18 +175,23 @@ index aff3503..aff3ba4 100644 + eargp->invoke.cmd.command_abspath = rb_str_new_cstr(mapped); + free(mapped); + -+ /* The preload shim rides the runtime's own env image (/lib/tebako/... -+ under the memfs root); its absence only means an older runtime -+ image — the exec proceeds without VFS, answering honestly for -+ paths the host lacks. */ ++ /* The shim's in-VFS path flows from the env image's layout grant ++ (TEBAKO_PRELOAD_SHIM, exported by the driver — the SSOT); the ++ literal is the transition fallback for an image predating the ++ grant. Its absence only means an older runtime image — the exec ++ proceeds without VFS, answering honestly for paths the host ++ lacks. */ ++ shim = getenv("TEBAKO_PRELOAD_SHIM"); +#if defined(__APPLE__) -+ preload = tebako_fs_dlmap2file("/__tfs__/lib/tebako/libtfs_preload.dylib"); ++ if (shim == NULL || *shim == '\0') shim = "/__tfs__/lib/tebako/libtfs_preload.dylib"; ++ preload = tebako_fs_dlmap2file(shim); + if (preload != NULL) { + tfs_env_set(eargp, "DYLD_INSERT_LIBRARIES", preload); + free(preload); + } +#else -+ preload = tebako_fs_dlmap2file("/__tfs__/lib/tebako/libtfs_preload.so"); ++ if (shim == NULL || *shim == '\0') shim = "/__tfs__/lib/tebako/libtfs_preload.so"; ++ preload = tebako_fs_dlmap2file(shim); + if (preload != NULL) { + tfs_env_set(eargp, "LD_PRELOAD", preload); + free(preload); @@ -104,7 +211,7 @@ index aff3503..aff3ba4 100644 static VALUE rb_execarg_init(int argc, const VALUE *orig_argv, int accept_shell, VALUE execarg_obj) { -@@ -2758,6 +2833,9 @@ +@@ -2758,6 +2926,9 @@ prog = rb_exec_getargs(&argc, &argv, accept_shell, &env, &opthash); rb_exec_fillarg(prog, argc, argv, env, opthash, execarg_obj); ALLOCV_END(argv_buf); diff --git a/patches/3.4/process_c_tebako_spawn.patch b/patches/3.4/process_c_tebako_spawn.patch index c4f51a5..4e58a6e 100644 --- a/patches/3.4/process_c_tebako_spawn.patch +++ b/patches/3.4/process_c_tebako_spawn.patch @@ -8,21 +8,35 @@ # materialized to the host cache (tebako_fs_exec_materialize — home-layout mounts whole-tree) and the exec # pair (command_name, command_abspath — the child execve()s # command_abspath) is pointed at the host copy. The child re-enters the -# namespace through the preload shim (DYLD_INSERT_LIBRARIES/LD_PRELOAD) -# plus the current mounts (TEBAKO_TFS_MOUNTS), injected via -# env_modification in ruby's internal representation (hidden array of -# hidden [key, val] pairs — a hash here crashes parent_start1). +# namespace through the preload shim (DYLD_INSERT_LIBRARIES/LD_PRELOAD +# at the in-VFS path read from TEBAKO_PRELOAD_SHIM — the driver's flow +# of the env image's layout grant, with a literal fallback for images +# predating the grant) plus the current mounts (TEBAKO_TFS_MOUNTS), +# injected via env_modification in ruby's internal representation +# (hidden array of hidden [key, val] pairs — a hash here crashes +# parent_start1). # Host targets pass through untouched; a covered path the mounts do not # hold answers ENOENT and exec fails honestly. Shell-form spawns and # bare command names (PATH search) do not resolve through the VFS. # The hook is parent-side (before fork), so the materialization work # (malloc/extract) never runs in the async-signal-safe child. +# darwin24 (macos arm64, factory run 31699651270): dyld TERMINATES an +# Apple-restricted binary at exec when the inherited +# DYLD_INSERT_LIBRARIES names a foreign dylib — darwin23 stripped the +# variable instead. Under the driver's process-wide export that killed +# /bin/sh and /usr/bin/cc for every system()/backtick of a packaged +# interpreter. The hook drops the inherited variable per spawn whose +# target is restricted (any shell form; anything resolving into Apple's +# system binary dirs) — a restricted target can never honor the +# insertion on any host, so the scrub loses nothing. Non-restricted +# host targets (a third-party JRE) keep the driver's delivery — spec 22 +# §3.1's array form. # X-Redesign: v2-spawn diff --git a/process.c b/process.c index aff3503..aff3ba4 100644 --- a/process.c +++ b/process.c -@@ -2746,6 +2746,81 @@ +@@ -2746,6 +2746,174 @@ return eargp; } @@ -49,6 +63,84 @@ index aff3503..aff3ba4 100644 + EXPORT_STR(rb_str_new_cstr(val))))); +} + ++#if defined(__APPLE__) ++/* dyld TERMINATES an Apple-restricted binary at exec when ++ DYLD_INSERT_LIBRARIES names a foreign dylib (factory run 31699651270, ++ macos arm64 darwin24: /bin/sh and /usr/bin/cc died under the armed ++ env; darwin23 stripped the variable instead — spec 22 §3.1). Drop the ++ inherited variable per spawn whose target is restricted; the nil pair ++ DELETES the inherited entry (rb_execarg_parent_start1's NIL_P branch, ++ st_delete). */ ++static void ++tfs_env_unset(struct rb_execarg *eargp, const char *key) ++{ ++ VALUE env = eargp->env_modification; ++ if (!RB_TYPE_P(env, T_ARRAY)) { ++ env = hide_obj(rb_ary_new()); ++ eargp->env_modification = env; ++ } ++ rb_ary_push(env, hide_obj(rb_assoc_new(EXPORT_STR(rb_str_new_cstr(key)), ++ Qnil))); ++} ++ ++/* execvp's PATH search mirrored parent-side (empty elements mean "."): ++ the first executable candidate wins; no hit returns prog unchanged — ++ the child's own search then fails the same way. */ ++static const char * ++tfs_resolve_in_path(const char *prog, char *buf, size_t buflen) ++{ ++ const char *path = getenv("PATH"); ++ const char *p, *e; ++ size_t len; ++ ++ if (path == NULL) return prog; ++ for (p = path;; p = e + 1) { ++ e = strchr(p, ':'); ++ len = e != NULL ? (size_t)(e - p) : strlen(p); ++ if (len == 0) { ++ if (access(prog, X_OK) == 0) return prog; ++ } ++ else if (len + strlen(prog) + 2 <= buflen) { ++ memcpy(buf, p, len); ++ buf[len] = '/'; ++ strcpy(buf + len + 1, prog); ++ if (access(buf, X_OK) == 0) return buf; ++ } ++ if (e == NULL) break; ++ } ++ return prog; ++} ++ ++/* The spawn target dyld would kill under the armed env: any shell form ++ (the shell is always a platform binary) and anything resolving into ++ Apple's system binary dirs. A bare name resolves through the PATH ++ mirror above — /usr/bin/cc is restricted even when named "cc". */ ++static int ++tfs_spawn_target_restricted(struct rb_execarg *eargp) ++{ ++ static const char *const restricted_dirs[] = { ++ "/bin/", "/sbin/", "/usr/bin/", "/usr/sbin/", "/usr/libexec/", "/System/" ++ }; ++ char resolved[MAXPATHLEN]; ++ const char *prog; ++ size_t i; ++ ++ if (eargp->use_shell) return 1; ++ prog = RB_TYPE_P(eargp->invoke.cmd.command_abspath, T_STRING) ++ ? RSTRING_PTR(eargp->invoke.cmd.command_abspath) : NULL; ++ if (prog == NULL && RB_TYPE_P(eargp->invoke.cmd.command_name, T_STRING)) ++ prog = RSTRING_PTR(eargp->invoke.cmd.command_name); ++ if (prog == NULL) return 0; ++ if (strchr(prog, '/') == NULL) ++ prog = tfs_resolve_in_path(prog, resolved, sizeof(resolved)); ++ for (i = 0; i < sizeof(restricted_dirs) / sizeof(restricted_dirs[0]); i++) { ++ if (strncmp(prog, restricted_dirs[i], strlen(restricted_dirs[i])) == 0) ++ return 1; ++ } ++ return 0; ++} ++#endif ++ +/* Spawn of a memfs-resident binary: materialize the exec target to the + host cache, point command_abspath at it (the child execve()s + command_abspath; command_name follows for error reporting), and put @@ -60,10 +152,20 @@ index aff3503..aff3ba4 100644 +tfs_spawn_prepare(struct rb_execarg *eargp) +{ + const char *prog; ++ const char *shim; + char *mapped; + char *preload; + char *mounts; + ++#if defined(__APPLE__) ++ /* The scrub first: a restricted target dies under the inherited ++ variable at exec, before the VFS flow below could matter. */ ++ if (tfs_spawn_target_restricted(eargp)) { ++ tfs_env_unset(eargp, "DYLD_INSERT_LIBRARIES"); ++ return; ++ } ++#endif ++ + if (eargp->use_shell || !RB_TYPE_P(eargp->invoke.cmd.command_name, T_STRING)) return; + prog = RSTRING_PTR(eargp->invoke.cmd.command_name); + if (prog == NULL || !tebako_path_is_embedded(prog)) return; @@ -73,18 +175,23 @@ index aff3503..aff3ba4 100644 + eargp->invoke.cmd.command_abspath = rb_str_new_cstr(mapped); + free(mapped); + -+ /* The preload shim rides the runtime's own env image (/lib/tebako/... -+ under the memfs root); its absence only means an older runtime -+ image — the exec proceeds without VFS, answering honestly for -+ paths the host lacks. */ ++ /* The shim's in-VFS path flows from the env image's layout grant ++ (TEBAKO_PRELOAD_SHIM, exported by the driver — the SSOT); the ++ literal is the transition fallback for an image predating the ++ grant. Its absence only means an older runtime image — the exec ++ proceeds without VFS, answering honestly for paths the host ++ lacks. */ ++ shim = getenv("TEBAKO_PRELOAD_SHIM"); +#if defined(__APPLE__) -+ preload = tebako_fs_dlmap2file("/__tfs__/lib/tebako/libtfs_preload.dylib"); ++ if (shim == NULL || *shim == '\0') shim = "/__tfs__/lib/tebako/libtfs_preload.dylib"; ++ preload = tebako_fs_dlmap2file(shim); + if (preload != NULL) { + tfs_env_set(eargp, "DYLD_INSERT_LIBRARIES", preload); + free(preload); + } +#else -+ preload = tebako_fs_dlmap2file("/__tfs__/lib/tebako/libtfs_preload.so"); ++ if (shim == NULL || *shim == '\0') shim = "/__tfs__/lib/tebako/libtfs_preload.so"; ++ preload = tebako_fs_dlmap2file(shim); + if (preload != NULL) { + tfs_env_set(eargp, "LD_PRELOAD", preload); + free(preload); @@ -104,7 +211,7 @@ index aff3503..aff3ba4 100644 static VALUE rb_execarg_init(int argc, const VALUE *orig_argv, int accept_shell, VALUE execarg_obj) { -@@ -2758,6 +2833,9 @@ +@@ -2758,6 +2926,9 @@ prog = rb_exec_getargs(&argc, &argv, accept_shell, &env, &opthash); rb_exec_fillarg(prog, argc, argv, env, opthash, execarg_obj); ALLOCV_END(argv_buf); diff --git a/patches/4.0/process_c_tebako_spawn.patch b/patches/4.0/process_c_tebako_spawn.patch index c4f51a5..4e58a6e 100644 --- a/patches/4.0/process_c_tebako_spawn.patch +++ b/patches/4.0/process_c_tebako_spawn.patch @@ -8,21 +8,35 @@ # materialized to the host cache (tebako_fs_exec_materialize — home-layout mounts whole-tree) and the exec # pair (command_name, command_abspath — the child execve()s # command_abspath) is pointed at the host copy. The child re-enters the -# namespace through the preload shim (DYLD_INSERT_LIBRARIES/LD_PRELOAD) -# plus the current mounts (TEBAKO_TFS_MOUNTS), injected via -# env_modification in ruby's internal representation (hidden array of -# hidden [key, val] pairs — a hash here crashes parent_start1). +# namespace through the preload shim (DYLD_INSERT_LIBRARIES/LD_PRELOAD +# at the in-VFS path read from TEBAKO_PRELOAD_SHIM — the driver's flow +# of the env image's layout grant, with a literal fallback for images +# predating the grant) plus the current mounts (TEBAKO_TFS_MOUNTS), +# injected via env_modification in ruby's internal representation +# (hidden array of hidden [key, val] pairs — a hash here crashes +# parent_start1). # Host targets pass through untouched; a covered path the mounts do not # hold answers ENOENT and exec fails honestly. Shell-form spawns and # bare command names (PATH search) do not resolve through the VFS. # The hook is parent-side (before fork), so the materialization work # (malloc/extract) never runs in the async-signal-safe child. +# darwin24 (macos arm64, factory run 31699651270): dyld TERMINATES an +# Apple-restricted binary at exec when the inherited +# DYLD_INSERT_LIBRARIES names a foreign dylib — darwin23 stripped the +# variable instead. Under the driver's process-wide export that killed +# /bin/sh and /usr/bin/cc for every system()/backtick of a packaged +# interpreter. The hook drops the inherited variable per spawn whose +# target is restricted (any shell form; anything resolving into Apple's +# system binary dirs) — a restricted target can never honor the +# insertion on any host, so the scrub loses nothing. Non-restricted +# host targets (a third-party JRE) keep the driver's delivery — spec 22 +# §3.1's array form. # X-Redesign: v2-spawn diff --git a/process.c b/process.c index aff3503..aff3ba4 100644 --- a/process.c +++ b/process.c -@@ -2746,6 +2746,81 @@ +@@ -2746,6 +2746,174 @@ return eargp; } @@ -49,6 +63,84 @@ index aff3503..aff3ba4 100644 + EXPORT_STR(rb_str_new_cstr(val))))); +} + ++#if defined(__APPLE__) ++/* dyld TERMINATES an Apple-restricted binary at exec when ++ DYLD_INSERT_LIBRARIES names a foreign dylib (factory run 31699651270, ++ macos arm64 darwin24: /bin/sh and /usr/bin/cc died under the armed ++ env; darwin23 stripped the variable instead — spec 22 §3.1). Drop the ++ inherited variable per spawn whose target is restricted; the nil pair ++ DELETES the inherited entry (rb_execarg_parent_start1's NIL_P branch, ++ st_delete). */ ++static void ++tfs_env_unset(struct rb_execarg *eargp, const char *key) ++{ ++ VALUE env = eargp->env_modification; ++ if (!RB_TYPE_P(env, T_ARRAY)) { ++ env = hide_obj(rb_ary_new()); ++ eargp->env_modification = env; ++ } ++ rb_ary_push(env, hide_obj(rb_assoc_new(EXPORT_STR(rb_str_new_cstr(key)), ++ Qnil))); ++} ++ ++/* execvp's PATH search mirrored parent-side (empty elements mean "."): ++ the first executable candidate wins; no hit returns prog unchanged — ++ the child's own search then fails the same way. */ ++static const char * ++tfs_resolve_in_path(const char *prog, char *buf, size_t buflen) ++{ ++ const char *path = getenv("PATH"); ++ const char *p, *e; ++ size_t len; ++ ++ if (path == NULL) return prog; ++ for (p = path;; p = e + 1) { ++ e = strchr(p, ':'); ++ len = e != NULL ? (size_t)(e - p) : strlen(p); ++ if (len == 0) { ++ if (access(prog, X_OK) == 0) return prog; ++ } ++ else if (len + strlen(prog) + 2 <= buflen) { ++ memcpy(buf, p, len); ++ buf[len] = '/'; ++ strcpy(buf + len + 1, prog); ++ if (access(buf, X_OK) == 0) return buf; ++ } ++ if (e == NULL) break; ++ } ++ return prog; ++} ++ ++/* The spawn target dyld would kill under the armed env: any shell form ++ (the shell is always a platform binary) and anything resolving into ++ Apple's system binary dirs. A bare name resolves through the PATH ++ mirror above — /usr/bin/cc is restricted even when named "cc". */ ++static int ++tfs_spawn_target_restricted(struct rb_execarg *eargp) ++{ ++ static const char *const restricted_dirs[] = { ++ "/bin/", "/sbin/", "/usr/bin/", "/usr/sbin/", "/usr/libexec/", "/System/" ++ }; ++ char resolved[MAXPATHLEN]; ++ const char *prog; ++ size_t i; ++ ++ if (eargp->use_shell) return 1; ++ prog = RB_TYPE_P(eargp->invoke.cmd.command_abspath, T_STRING) ++ ? RSTRING_PTR(eargp->invoke.cmd.command_abspath) : NULL; ++ if (prog == NULL && RB_TYPE_P(eargp->invoke.cmd.command_name, T_STRING)) ++ prog = RSTRING_PTR(eargp->invoke.cmd.command_name); ++ if (prog == NULL) return 0; ++ if (strchr(prog, '/') == NULL) ++ prog = tfs_resolve_in_path(prog, resolved, sizeof(resolved)); ++ for (i = 0; i < sizeof(restricted_dirs) / sizeof(restricted_dirs[0]); i++) { ++ if (strncmp(prog, restricted_dirs[i], strlen(restricted_dirs[i])) == 0) ++ return 1; ++ } ++ return 0; ++} ++#endif ++ +/* Spawn of a memfs-resident binary: materialize the exec target to the + host cache, point command_abspath at it (the child execve()s + command_abspath; command_name follows for error reporting), and put @@ -60,10 +152,20 @@ index aff3503..aff3ba4 100644 +tfs_spawn_prepare(struct rb_execarg *eargp) +{ + const char *prog; ++ const char *shim; + char *mapped; + char *preload; + char *mounts; + ++#if defined(__APPLE__) ++ /* The scrub first: a restricted target dies under the inherited ++ variable at exec, before the VFS flow below could matter. */ ++ if (tfs_spawn_target_restricted(eargp)) { ++ tfs_env_unset(eargp, "DYLD_INSERT_LIBRARIES"); ++ return; ++ } ++#endif ++ + if (eargp->use_shell || !RB_TYPE_P(eargp->invoke.cmd.command_name, T_STRING)) return; + prog = RSTRING_PTR(eargp->invoke.cmd.command_name); + if (prog == NULL || !tebako_path_is_embedded(prog)) return; @@ -73,18 +175,23 @@ index aff3503..aff3ba4 100644 + eargp->invoke.cmd.command_abspath = rb_str_new_cstr(mapped); + free(mapped); + -+ /* The preload shim rides the runtime's own env image (/lib/tebako/... -+ under the memfs root); its absence only means an older runtime -+ image — the exec proceeds without VFS, answering honestly for -+ paths the host lacks. */ ++ /* The shim's in-VFS path flows from the env image's layout grant ++ (TEBAKO_PRELOAD_SHIM, exported by the driver — the SSOT); the ++ literal is the transition fallback for an image predating the ++ grant. Its absence only means an older runtime image — the exec ++ proceeds without VFS, answering honestly for paths the host ++ lacks. */ ++ shim = getenv("TEBAKO_PRELOAD_SHIM"); +#if defined(__APPLE__) -+ preload = tebako_fs_dlmap2file("/__tfs__/lib/tebako/libtfs_preload.dylib"); ++ if (shim == NULL || *shim == '\0') shim = "/__tfs__/lib/tebako/libtfs_preload.dylib"; ++ preload = tebako_fs_dlmap2file(shim); + if (preload != NULL) { + tfs_env_set(eargp, "DYLD_INSERT_LIBRARIES", preload); + free(preload); + } +#else -+ preload = tebako_fs_dlmap2file("/__tfs__/lib/tebako/libtfs_preload.so"); ++ if (shim == NULL || *shim == '\0') shim = "/__tfs__/lib/tebako/libtfs_preload.so"; ++ preload = tebako_fs_dlmap2file(shim); + if (preload != NULL) { + tfs_env_set(eargp, "LD_PRELOAD", preload); + free(preload); @@ -104,7 +211,7 @@ index aff3503..aff3ba4 100644 static VALUE rb_execarg_init(int argc, const VALUE *orig_argv, int accept_shell, VALUE execarg_obj) { -@@ -2758,6 +2833,9 @@ +@@ -2758,6 +2926,9 @@ prog = rb_exec_getargs(&argc, &argv, accept_shell, &env, &opthash); rb_exec_fillarg(prog, argc, argv, env, opthash, execarg_obj); ALLOCV_END(argv_buf);