From 75dcf6205f5dea2999d45d2ac7710fc200b86891 Mon Sep 17 00:00:00 2001 From: tebako-ci Date: Tue, 11 Aug 2026 15:22:02 +0800 Subject: [PATCH 1/8] patches: dln_c_loader_interpose (all 5 series) + the spec-22 acceptance harness Spec 22 class L, ELF (linux gnu/musl): the runtime binary's own dlopen/dlerror definitions preempt process-wide (the interpreter, its C extensions, and any library they pull), so every native-library load -- dln_load, ffi, fiddle, C-extension self-loads -- routes a VFS-resident path through tebako_fs_dlmap2file (library + dependency closure to the exec cache) and loads the real file. Host paths pass through; covered-but-not-held answers ENOENT and the raw call serves it (the dln_load precedent). Materialization failures are named errors (library, mount, verdict) through the dlerror channel. dlsym is not interposed (real handles make it unnecessary; a main-binary dlsym cannot resolve its original on musl). macOS is inert in this TU: dyld applies __interpose tuples only from dylibs, never from the main executable (verified), so the macOS interposition is the driver's self-insertion at boot (crates/tebako-driver). Inert where USE_DLN_DLOPEN is undefined (windows). One body serves 3.2-4.0 (home 3.2); 3.1 carries its diverged context. The ci/spec22 harness is the local reproducible acceptance proof (jailed fiddle + C-ext self-dlopen probe). --- ci/include/tebako/fs/c_api.h | 1 + ci/spec22/README.md | 77 ++++++++++ ci/spec22/fixtures/probe.rb | 74 ++++++++++ ci/spec22/fixtures/probe_ext.c | 44 ++++++ ci/spec22/fixtures/vfsdep.c | 9 ++ ci/spec22/fixtures/vfsprobe.c | 12 ++ ci/spec22/run.sh | 175 +++++++++++++++++++++++ patches/3.1/dln_c_loader_interpose.patch | 159 ++++++++++++++++++++ patches/3.1/patch-3.1.yaml | 2 + patches/3.2/dln_c_loader_interpose.patch | 159 ++++++++++++++++++++ patches/3.2/patch-3.2.yaml | 2 + patches/3.3/patch-3.3.yaml | 2 + patches/3.4/patch-3.4.yaml | 2 + patches/4.0/patch-4.0.yaml | 2 + 14 files changed, 720 insertions(+) create mode 100644 ci/spec22/README.md create mode 100644 ci/spec22/fixtures/probe.rb create mode 100644 ci/spec22/fixtures/probe_ext.c create mode 100644 ci/spec22/fixtures/vfsdep.c create mode 100644 ci/spec22/fixtures/vfsprobe.c create mode 100755 ci/spec22/run.sh create mode 100644 patches/3.1/dln_c_loader_interpose.patch create mode 100644 patches/3.2/dln_c_loader_interpose.patch diff --git a/ci/include/tebako/fs/c_api.h b/ci/include/tebako/fs/c_api.h index b2e2fc9..bb7b9da 100644 --- a/ci/include/tebako/fs/c_api.h +++ b/ci/include/tebako/fs/c_api.h @@ -85,6 +85,7 @@ int tebako_get_errno(void); char* tebako_fs_dlmap2file(const char* path); char* tebako_fs_mounts(void); +char* tebako_fs_mount_of(const char* path); #ifdef __cplusplus } diff --git a/ci/spec22/README.md b/ci/spec22/README.md new file mode 100644 index 0000000..83c1058 --- /dev/null +++ b/ci/spec22/README.md @@ -0,0 +1,77 @@ +# ci/spec22 — spec 22 phase 1 acceptance harness (loader interposition, POSIX) + +This directory is the **local, reproducible** acceptance proof for the +`dln_c_loader_interpose` patch family (`patches/*/dln_c_loader_interpose.patch`): +spec 22 class L — the process's `dlopen`/`dlerror` are interposed inside the +runtime binary, so any native-library load of a VFS-resident path is +materialized (with its dependency closure) to the exec cache and loaded for +real, from any caller in the process. No per-gem Ruby adapter is present in +the runtime under test. + +It is deliberately **not** wired into CI: it needs the three repos on one +machine plus a compiled runtime, which the patch-level gates (lint, +compile-smoke) intentionally do not build. Run it by hand; it is idempotent +and fully deterministic from pinned inputs. + +## What it proves (fixtures/probe.rb, jailed) + +| check | assertion | +|---|---| +| `fiddle` | `Fiddle.dlopen("/probe/lib/libvfsprobe.")` — a VFS path — loads and its function returns 42 (dependency `libvfsdep` resolved through the closure walk) | +| `cext-self-dlopen` | a hand-rolled C extension calls its **own** `dlopen` on the VFS path from `Init` (bypassing `dln_load`) and returns 42 | +| `named-error` | `Fiddle.dlopen("/probe/lib")` (a directory) raises `Fiddle::DLError` whose message is the tebako verdict line: `cannot materialize VFS-resident library '/probe/lib' (mount '/'): …` | +| `jail-deny` | `File.read("/etc/passwd")` is denied under `TEBAKO_JAIL=deny;::rw` — interposition did not punch through the jail | + +The probe exits 0 only when all four print `ok`. + +## Inputs and pins + +- **This repo** (any checkout of tamatebako/ruby with the patch wired into + `patches/*/patch-*.yaml`) — supplies the patched source tree via + `tools/apply` + a sha256-pinned mirror, never the release channel. +- **tamatebako/tebako** at a commit carrying `tebako_fs_mount_of` + (`crates/tfs`), staged with `tools/stage_link_unit` (env per + AGENTS.md §13: `DWARFS_RS_VCPKG_ROOT`/`VCPKG_ROOT`/`SQFS_SYS_VCPKG_ROOT`, + `CARGO_NET_GIT_FETCH_WITH_CLI=true`). Default: `../../tebako-wt-spec22-mountof` + (override `TEBAKO_REPO`). +- **tamatebako/tebako-runtime-ruby** (the runtime factory), run from a + detached git worktree at `origin/main` — its checkout is never mutated + and its source pin is never bumped: `tools/build_runtime + --src-mirror file://… --src-release spec22-local-…` consumes the local + mirror. +- **The adapter-less tebako-runtime gem** in a local gem repo (default + `/tmp/tebako-gem-repo`, override `GEM_REPO_DIR`). Build it from the + spec-22 deletion branch of tamatebako/tebako-runtime: + + ```sh + git worktree add ../tebako-runtime-wt-spec22-drop-ff -b feat/drop-class-l-adapters origin/main + cd ../tebako-runtime-wt-spec22-drop-ff + # ffi/fiddle entries removed from POST_REQUIRE_MAP + adapters deleted (the branch) + gem build tebako-runtime.gemspec # with VERSION temporarily "0.8.2.local" + mkdir -p /tmp/tebako-gem-repo/gems && cp tebako-runtime-0.8.2.local.gem /tmp/tebako-gem-repo/gems/ + gem generate_index --directory /tmp/tebako-gem-repo + ``` + + The harness points `GEMRC` at that repo, so the factory's + `gem install tebako-runtime` resolves the adapter-less build — without + publishing anything and without touching `~/.gemrc`. + +## Run + +```sh +export DWARFS_RS_VCPKG_ROOT=/path/to/vcpkg VCPKG_ROOT=/path/to/vcpkg \ + SQFS_SYS_VCPKG_ROOT=/path/to/vcpkg CARGO_NET_GIT_FETCH_WITH_CLI=true +ci/spec22/run.sh 4.0.6 # any version in versions.yml +``` + +Verdict: `SPEC22-ACCEPTANCE-OK ` on success; a named `FAIL +spec22 (…)` line otherwise. Everything transient is under +`/tmp/spec22-scratch-` (`SCRATCH` to override); delete it to +rebuild from scratch, or delete a single artifact to rebuild just that +stage (the link unit is reused across runs by design). + +Platform notes: macOS uses the `__DATA,__interpose` section in the runtime +binary; ELF (linux-gnu/musl) uses the main binary's own `dlopen`/`dlerror` +definitions, 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. diff --git a/ci/spec22/fixtures/probe.rb b/ci/spec22/fixtures/probe.rb new file mode 100644 index 0000000..abdbe86 --- /dev/null +++ b/ci/spec22/fixtures/probe.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +# probe.rb — spec 22 phase 1 acceptance probe (POSIX). Runs as the +# --tebako-entry script of a jailed tebako runtime with the probe payload +# mounted at "/". Asserts, with NO per-gem Ruby adapter present: +# +# fiddle — Fiddle.dlopen of a VFS-resident library materializes +# it (+ its dependency closure) and returns 42. +# cext-self-dlopen — a C extension's OWN dlopen of a VFS-resident path +# (bypassing ruby's dln_load) is routed likewise. +# named-error — a failed materialization (dlopen of a VFS directory) +# raises carrying the tebako verdict line: the library, +# the mount, the verdict (spec 22 §5). +# jail-deny — the TEBAKO_JAIL policy is live: a host read outside +# the allowance is denied (interposition did not punch +# through the jail). +# +# Every check prints one `PROBE ` line; the +# process exits 0 only when all checks are ok. + +LIB = "/probe/lib/libvfsprobe.#{RUBY_PLATFORM.match?(/darwin/) ? "dylib" : "so"}" + +$probe_results = [] + +def probe(name) + value = yield + $probe_results << [name, "ok", value.inspect] +rescue Exception => e # rubocop:disable Lint/RescueException -- the probe must see every failure mode + $probe_results << [name, "fail", "#{e.class}: #{e.message}"] +end + +require "fiddle" + +probe("fiddle") do + handle = Fiddle.dlopen(LIB) + answer = Fiddle::Function.new(handle["probe_answer"], [], Fiddle::TYPE_INT).call + raise "want 42, got #{answer}" unless answer == 42 + + answer +end + +probe("cext-self-dlopen") do + require "/probe/lib/probe_ext" + answer = ProbeExt.answer + raise "want 42, got #{answer}" unless answer == 42 + + answer +end + +probe("named-error") do + begin + Fiddle.dlopen("/probe/lib") # a directory: materialization refuses (EISDIR) + raise "no error raised" + rescue Fiddle::DLError => e + unless e.message.include?("cannot materialize VFS-resident library '/probe/lib'") && + e.message.include?("(mount '/'") + raise "verdict line missing library/mount: #{e.message}" + end + + e.message + end +end + +probe("jail-deny") do + begin + File.read("/etc/passwd") + raise "jail not enforced" + rescue Errno::EPERM, Errno::EACCES => e + e.class + end +end + +$probe_results.each { |name, status, detail| puts "PROBE #{name} #{status} #{detail}" } +exit($probe_results.all? { |_, status, _| status == "ok" } ? 0 : 1) diff --git a/ci/spec22/fixtures/probe_ext.c b/ci/spec22/fixtures/probe_ext.c new file mode 100644 index 0000000..d3d7795 --- /dev/null +++ b/ci/spec22/fixtures/probe_ext.c @@ -0,0 +1,44 @@ +/* probe_ext.c — a ruby C extension that SELF-DLOPENS a VFS-resident + * library from Init (bypassing ruby's dln_load entirely). This is the + * load class the per-gem adapters used to paper over: without the spec-22 + * loader interposition the raw dlopen of "/probe/lib/..." fails on the + * host; with it, the call is routed through the libtfs materialization + * and the real file loads. dlsym runs unmodified against the returned + * real handle (dlsym is deliberately not interposed). */ + +#include +#include +#include + +static VALUE probe_ext_answer(VALUE self); + +void +Init_probe_ext(void) +{ + static int (*answer_fn)(void); + void *handle; + + handle = dlopen(PROBE_LIB_PATH, RTLD_LAZY | RTLD_LOCAL); + if (handle == NULL) { + rb_raise(rb_eLoadError, "probe_ext self-dlopen of %s failed: %s", + PROBE_LIB_PATH, dlerror()); + } + answer_fn = (int (*)(void))dlsym(handle, "probe_answer"); + if (answer_fn == NULL) { + rb_raise(rb_eLoadError, "probe_ext: probe_answer missing in %s", + PROBE_LIB_PATH); + } + { + VALUE mod = rb_define_module("ProbeExt"); + rb_iv_set(mod, "@answer_fn", SIZET2NUM((size_t)answer_fn)); + rb_define_singleton_method(mod, "answer", probe_ext_answer, 0); + } +} + +static VALUE +probe_ext_answer(VALUE self) +{ + VALUE packed = rb_iv_get(self, "@answer_fn"); + int (*answer_fn)(void) = (int (*)(void))NUM2SIZET(packed); + return INT2NUM(answer_fn()); +} diff --git a/ci/spec22/fixtures/vfsdep.c b/ci/spec22/fixtures/vfsdep.c new file mode 100644 index 0000000..e22c8b2 --- /dev/null +++ b/ci/spec22/fixtures/vfsdep.c @@ -0,0 +1,9 @@ +/* vfsdep.c — leaf dependency of libvfsprobe: exists so the spec-22 probe + * exercises the Mach-O/ELF dependency-closure walk of tebako_fs_dlmap2file + * (libvfsprobe links this library; the closure walk must extract BOTH next + * to each other in the exec cache for the real dlopen to resolve it). */ +int +vfsdep_value(void) +{ + return 41; +} diff --git a/ci/spec22/fixtures/vfsprobe.c b/ci/spec22/fixtures/vfsprobe.c new file mode 100644 index 0000000..2250910 --- /dev/null +++ b/ci/spec22/fixtures/vfsprobe.c @@ -0,0 +1,12 @@ +/* vfsprobe.c — the VFS-resident native library the probe loads through + * fiddle AND through the probe C extension's own dlopen. Links libvfsdep + * (recorded as @rpath/libvfsdep.dylib resp. DT_NEEDED libvfsdep.so with + * an @loader_path/$ORIGIN runpath) so the load only succeeds when the + * materialization extracted the dependency closure, not just one file. */ +extern int vfsdep_value(void); + +int +probe_answer(void) +{ + return vfsdep_value() + 1; +} diff --git a/ci/spec22/run.sh b/ci/spec22/run.sh new file mode 100755 index 0000000..e2b6362 --- /dev/null +++ b/ci/spec22/run.sh @@ -0,0 +1,175 @@ +#!/bin/bash +# ci/spec22/run.sh — spec 22 phase 1 (loader interposition, POSIX) +# acceptance harness. Builds, from pinned inputs only: +# +# 1. a patched ruby source tree (this repo's tools/apply) — incl. the +# dln_c_loader_interpose patch, +# 2. a v2 link unit staged from a tamatebako/tebako checkout that +# carries tebako_fs_mount_of, +# 3. a runtime (exe + env image) built by the runtime factory against +# that LOCAL source mirror and link unit, with the adapter-less +# tebako-runtime gem (ffi/fiddle adapters deleted) installed from a +# local gem repo, +# 4. a probe payload image (fixtures/): a VFS-resident native library +# with a dependency, a C extension that self-dlopens it, and an +# entry probe asserting fiddle + self-dlopen + the named dlerror +# verdict + the jail, +# 5. one jailed invocation of the runtime; grep-verdict on the PROBE +# lines. +# +# Idempotent: existing artifacts under $SCRATCH are reused; delete the +# scratch dir (or the specific artifact) to force a rebuild. Everything +# transient lives under $SCRATCH; the repos are never mutated (the +# factory runs from its own git worktree). +# +# Usage: ci/spec22/run.sh [ruby-version] (default 4.0.6) +# +# Required env (the local toolchain roots, see AGENTS.md §13): +# DWARFS_RS_VCPKG_ROOT / VCPKG_ROOT / SQFS_SYS_VCPKG_ROOT — vcpkg root +# CARGO_NET_GIT_FETCH_WITH_CLI=true +# Overridable: +# TEBAKO_REPO (default: /tebako-wt-spec22-mountof — +# the checkout carrying tebako_fs_mount_of) +# FACTORY_REPO (default: /tebako-runtime-ruby) +# GEM_REPO_DIR (default: /tmp/tebako-gem-repo — local gem repo with +# the adapter-less tebako-runtime gem; see README.md) +# SCRATCH (default: /tmp/spec22-scratch-) +# SRC_TAG (default: spec22-local-) + +set -euo pipefail + +VERSION="${1:-4.0.6}" +SELF_DIR="$(cd "$(dirname "$0")" && pwd)" +RUBY_REPO="$(cd "$SELF_DIR/../.." && pwd)" +ECOSYSTEM="$(cd "$RUBY_REPO/.." && pwd)" + +TEBAKO_REPO="${TEBAKO_REPO:-$ECOSYSTEM/tebako-wt-spec22-mountof}" +FACTORY_REPO="${FACTORY_REPO:-$ECOSYSTEM/tebako-runtime-ruby}" +GEM_REPO_DIR="${GEM_REPO_DIR:-/tmp/tebako-gem-repo}" +SCRATCH="${SCRATCH:-/tmp/spec22-scratch-$VERSION}" +SRC_TAG="${SRC_TAG:-spec22-local-$VERSION}" +TFS_CLI="${TFS_CLI:-$ECOSYSTEM/tebako/target/release/tfs}" + +PLATFORM_OS="$(uname -s | tr '[:upper:]' '[:lower:]')" +case "$PLATFORM_OS" in + darwin) LIBEXT=dylib; EXTEXT=bundle; MKIMAGE_FORMAT=dwarfs ;; + linux) LIBEXT=so; EXTEXT=so; MKIMAGE_FORMAT=dwarfs ;; + *) echo "FAIL run.sh: unsupported host $PLATFORM_OS (POSIX only)" >&2; exit 64 ;; +esac + +step() { echo "== spec22 step: $*"; } +die() { echo "FAIL spec22 ($*)" >&2; exit 1; } + +[ -x "$TFS_CLI" ] || die "tfs CLI not at $TFS_CLI (build the tebako workspace release)" +[ -d "$GEM_REPO_DIR/gems" ] || die "adapter-less gem repo missing at $GEM_REPO_DIR (README.md §gem)" + +mkdir -p "$SCRATCH"/{mirror,tmp,home,tebako-home} +export TMPDIR="$SCRATCH/tmp" + +# --- 1. patched source tree + local mirror ------------------------------- +if [ ! -f "$SCRATCH/mirror/tfs-ruby-$VERSION-src.tar.gz" ]; then + step "apply $VERSION + roll local source mirror" + rm -rf "$SCRATCH/roll" + TFS_CACHE_DIR="${TFS_CACHE_DIR:-$RUBY_REPO/.cache/tarballs}" \ + "$RUBY_REPO/tools/apply" "$VERSION" "$SCRATCH/roll" --platform linux-gnu >/dev/null + [ -f "$SCRATCH/roll/tfs-ruby-$VERSION-src/dln.c" ] || die "apply produced no tree" + grep -q "tfs_dlopen_route" "$SCRATCH/roll/tfs-ruby-$VERSION-src/dln.c" \ + || die "rolled tree lacks the loader-interpose block (manifest not wired?)" + ( cd "$SCRATCH/roll" && tar -czf "$SCRATCH/mirror/tfs-ruby-$VERSION-src.tar.gz" "tfs-ruby-$VERSION-src" ) + ( cd "$SCRATCH/mirror" && shasum -a 256 "tfs-ruby-$VERSION-src.tar.gz" > SHA256SUMS ) +fi + +# --- 2. v2 link unit (carries tebako_fs_mount_of) ------------------------- +if [ ! -f "$SCRATCH/link-unit/libtfs.a" ]; then + step "stage link unit from $TEBAKO_REPO" + [ -d "$TEBAKO_REPO/crates/tfs" ] || die "TEBAKO_REPO $TEBAKO_REPO is not the product checkout" + ( cd "$TEBAKO_REPO" && ruby tools/stage_link_unit "$SCRATCH/link-unit" ) +fi + +# --- 3. runtime build against the local mirror ---------------------------- +RUNTIME_PKG="$SCRATCH/runtime-packages/tebako-runtime-local-$VERSION-macos-arm64" +[ "$PLATFORM_OS" = linux ] && RUNTIME_PKG="$SCRATCH/runtime-packages/tebako-runtime-local-$VERSION-linux-$(uname -m)" +FACTORY_WT="$SCRATCH/factory-worktree" +if [ ! -x "$RUNTIME_PKG" ] && [ ! -e "$RUNTIME_PKG/$(basename "$RUNTIME_PKG")" ]; then + step "factory build $VERSION (local mirror, staged link unit, adapter-less gem)" + if [ ! -d "$FACTORY_WT" ]; then + GIT_EDITOR=true git -C "$FACTORY_REPO" worktree add "$FACTORY_WT" origin/main >/dev/null 2>&1 \ + || GIT_EDITOR=true git -C "$FACTORY_REPO" worktree add "$FACTORY_WT" --detach origin/main >/dev/null + ( cd "$FACTORY_WT" && bundle install --quiet ) + fi + cat > "$SCRATCH/gemrc" </dev/null | head -1)")/.." + [ -f "$RB_BUILD_DIR/include/ruby.h" ] || die "ruby build headers not found under $SCRATCH/factory" + mkdir -p "$PROBE_TREE/probe/lib" + cp "$SELF_DIR/fixtures/probe.rb" "$PROBE_TREE/probe/" + PROBE_LIB="/probe/lib/libvfsprobe.$LIBEXT" + if [ "$PLATFORM_OS" = darwin ]; then + clang -dynamiclib -O2 "$SELF_DIR/fixtures/vfsdep.c" \ + -install_name "@rpath/libvfsdep.$LIBEXT" -o "$PROBE_TREE/probe/lib/libvfsdep.$LIBEXT" + 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" + clang -bundle -O2 -undefined dynamic_lookup \ + -I"$RB_BUILD_DIR/include" -I"$RB_BUILD_DIR/.ext/include/arm64-darwin24" \ + -DPROBE_LIB_PATH="\"$PROBE_LIB\"" \ + "$SELF_DIR/fixtures/probe_ext.c" -o "$PROBE_TREE/probe/lib/probe_ext.$EXTEXT" + else + clang -shared -fPIC -O2 "$SELF_DIR/fixtures/vfsdep.c" \ + -Wl,-soname,"libvfsdep.$LIBEXT" -o "$PROBE_TREE/probe/lib/libvfsdep.$LIBEXT" + clang -shared -fPIC -O2 "$SELF_DIR/fixtures/vfsprobe.c" \ + -L"$PROBE_TREE/probe/lib" -lvfsdep -Wl,-rpath,'$ORIGIN' \ + -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 -shared -fPIC -O2 \ + -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" + fi + "$TFS_CLI" mkimage --format "$MKIMAGE_FORMAT" "$PROBE_TREE" --output "$PAYLOAD_IMG" >/dev/null +fi + +# --- 5. the jailed proof ----------------------------------------------------- +step "jailed probe run (TEBAKO_JAIL=deny;\$SCRATCH:\$SCRATCH:rw)" +set +e +out="$(env -i \ + HOME="$SCRATCH/home" \ + TMPDIR="$SCRATCH/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" || die "fiddle leg" +echo "$out" | grep -q "^PROBE cext-self-dlopen ok 42" || die "cext self-dlopen leg" +echo "$out" | grep -q "^PROBE named-error ok " || die "named-error leg" +echo "$out" | grep -q "^PROBE jail-deny ok " || die "jail leg" +[ "$status" -eq 0 ] || die "probe exit $status" +echo "SPEC22-ACCEPTANCE-OK $VERSION ($RUNTIME_EXE)" diff --git a/patches/3.1/dln_c_loader_interpose.patch b/patches/3.1/dln_c_loader_interpose.patch new file mode 100644 index 0000000..d08aa35 --- /dev/null +++ b/patches/3.1/dln_c_loader_interpose.patch @@ -0,0 +1,159 @@ +# dln_c_loader_interpose.patch +# +# Target: dln.c (pass2 (linux gnu/musl)) +# Source: v2 design (docs/spec/22 -- runtime-native interposition, class L) +# +# Interpose the process's loader on ELF: the runtime binary's own +# dlopen/dlerror definitions preempt process-wide (the interpreter, its +# C extensions, and any library they pull), so EVERY native-library load +# in the process -- the interpreter's dln_load, ffi, fiddle, and C +# extensions self-loading -- routes a VFS-resident path through the +# libtfs materialization (tebako_fs_dlmap2file: the library plus its +# Mach-O/ELF dependency closure to the exec cache) and loads the real +# file. Host paths pass through untouched; a covered path the mounts do +# not hold answers ENOENT and the raw call serves it from the host (the +# dln_load precedent). A materialization failure is a named error -- the +# library, the mount, the verdict -- delivered through the dlerror +# channel, never a silent host fallback. dlsym is not interposed: the +# wrapper returns real handles, so dlsym needs no routing (a main-binary +# dlsym cannot resolve its own original on musl -- no dlvsym). Inert on +# macOS (dyld applies __interpose tuples only from dylibs, never from +# the main executable: the macOS interposition is the driver's +# self-insertion at boot) and on windows (its own dln path). +# X-Redesign: v2-loader-interpose +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, ...) + # define USE_DLN_DLOPEN + #endif + ++#if defined(USE_DLN_DLOPEN) && !defined(__APPLE__) ++/* -- Start of tebako patch (dln_c_loader_interpose) -- */ ++/* Loader interposition (spec 22, class L, ELF): the runtime binary's ++ own dlopen/dlerror definitions preempt process-wide (the interpreter, ++ its C extensions, and any library they pull), so EVERY native-library ++ load in the process -- the interpreter's dln_load, ffi, fiddle, and C ++ extensions self-loading -- routes a VFS-resident path through the ++ libtfs materialization (tebako_fs_dlmap2file: the library plus its ++ dependency closure to the exec cache) and loads the real file. Host ++ paths pass through untouched; a covered path the mounts do not hold ++ answers ENOENT and the raw call serves it from the host (the dln_load ++ precedent). A materialization failure is a named error -- the library, ++ the mount, the verdict -- delivered through the dlerror channel, never ++ a silent host fallback. dlsym is not interposed: the wrapper returns ++ real handles, so dlsym needs no routing (and a main-binary dlsym ++ 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. */ ++ ++#include ++#include ++#include ++#include ++#include ++#include /* modern libtfs C API (tebako_fs_*) */ ++ ++/* libtfs c_api extension (requested): tebako_fs_mount_of -- the mount ++ point of the longest-prefix mount covering a path (NULL + ENOENT when ++ uncovered); declared here so this translation unit stays C99-clean ++ meanwhile */ ++char *tebako_fs_mount_of(const char *path); ++ ++typedef void *(*tfs_dlopen_fn)(const char *, int); ++typedef char *(*tfs_dlerror_fn)(void); ++ ++static __thread char tfs_dl_verdict[1024]; ++static __thread int tfs_dl_verdict_pending; ++ ++/* The materialization verdict becomes the dlerror() answer of the ++ failed call, so ffi/fiddle/dln raise carrying this exact line. (The ++ driver's macOS interpose dylib keeps the same verdict wording -- the ++ two shells differ in plumbing, not in the contract.) */ ++static void ++tfs_dl_remember(const char *path, int err) ++{ ++ char *mount = tebako_fs_mount_of(path); ++ snprintf(tfs_dl_verdict, sizeof(tfs_dl_verdict), ++ "tebako: cannot materialize VFS-resident library '%s' (mount '%s'): %s", ++ path, mount != NULL ? mount : "(unknown)", strerror(err)); ++ free(mount); ++ tfs_dl_verdict_pending = 1; ++} ++ ++static void * ++tfs_dlopen_route(const char *path, int mode, tfs_dlopen_fn real_dlopen) ++{ ++ void *handle; ++ char *mapped; ++ int err; ++ ++ tfs_dl_verdict_pending = 0; ++ if (!tebako_path_is_embedded(path)) { ++ return real_dlopen(path, mode); ++ } ++ mapped = tebako_fs_dlmap2file(path); ++ if (mapped != NULL) { ++ handle = real_dlopen(mapped, mode); ++ free(mapped); ++ return handle; ++ } ++ err = errno; ++ if (err == ENOENT) { ++ /* covered but not held: a host path the mount happens to ++ cover -- the raw call serves it from the host */ ++ return real_dlopen(path, mode); ++ } ++ tfs_dl_remember(path, err); ++ errno = err; /* the failed call's own errno, for direct errno readers */ ++ return NULL; ++} ++ ++static char * ++tfs_dlerror_route(tfs_dlerror_fn real_dlerror) ++{ ++ if (tfs_dl_verdict_pending) { ++ tfs_dl_verdict_pending = 0; ++ return tfs_dl_verdict; ++ } ++ return real_dlerror(); ++} ++ ++/* The originals resolve through dlsym(RTLD_NEXT) -- the next object ++ after the main binary in the global scope; dlsym itself is never ++ defined here, so the lookup is not interposed. */ ++static tfs_dlopen_fn ++tfs_real_dlopen(void) ++{ ++ static tfs_dlopen_fn real_fn; ++ if (real_fn == NULL) real_fn = (tfs_dlopen_fn)dlsym(RTLD_NEXT, "dlopen"); ++ return real_fn; ++} ++ ++static tfs_dlerror_fn ++tfs_real_dlerror(void) ++{ ++ static tfs_dlerror_fn real_fn; ++ if (real_fn == NULL) real_fn = (tfs_dlerror_fn)dlsym(RTLD_NEXT, "dlerror"); ++ return real_fn; ++} ++ ++void * ++dlopen(const char *path, int mode) ++{ ++ return tfs_dlopen_route(path, mode, tfs_real_dlopen()); ++} ++ ++char * ++dlerror(void) ++{ ++ return tfs_dlerror_route(tfs_real_dlerror()); ++} ++/* -- End of tebako patch -- */ ++#endif ++ + #if defined(__hp9000s300) || ((defined(__NetBSD__) || defined(__FreeBSD__) || defined(__OpenBSD__)) && !defined(__ELF__)) || defined(NeXT) || defined(MACOSX_DYLD) + # define EXTERNAL_PREFIX "_" + #else diff --git a/patches/3.1/patch-3.1.yaml b/patches/3.1/patch-3.1.yaml index 8d70e0d..628b4c6 100644 --- a/patches/3.1/patch-3.1.yaml +++ b/patches/3.1/patch-3.1.yaml @@ -49,6 +49,8 @@ patches: file: dln_c_dlmap_msys.patch - feature: dln_c_tebako_includes file: dln_c_tebako_includes.patch + - feature: dln_c_loader_interpose + file: dln_c_loader_interpose.patch - feature: file_c_tebako_includes file: file_c_tebako_includes.patch - feature: gem_prelude_tebako_runtime diff --git a/patches/3.2/dln_c_loader_interpose.patch b/patches/3.2/dln_c_loader_interpose.patch new file mode 100644 index 0000000..d667534 --- /dev/null +++ b/patches/3.2/dln_c_loader_interpose.patch @@ -0,0 +1,159 @@ +# dln_c_loader_interpose.patch +# +# Target: dln.c (pass2 (linux gnu/musl)) +# Source: v2 design (docs/spec/22 -- runtime-native interposition, class L) +# +# Interpose the process's loader on ELF: the runtime binary's own +# dlopen/dlerror definitions preempt process-wide (the interpreter, its +# C extensions, and any library they pull), so EVERY native-library load +# in the process -- the interpreter's dln_load, ffi, fiddle, and C +# extensions self-loading -- routes a VFS-resident path through the +# libtfs materialization (tebako_fs_dlmap2file: the library plus its +# Mach-O/ELF dependency closure to the exec cache) and loads the real +# file. Host paths pass through untouched; a covered path the mounts do +# not hold answers ENOENT and the raw call serves it from the host (the +# dln_load precedent). A materialization failure is a named error -- the +# library, the mount, the verdict -- delivered through the dlerror +# channel, never a silent host fallback. dlsym is not interposed: the +# wrapper returns real handles, so dlsym needs no routing (a main-binary +# dlsym cannot resolve its own original on musl -- no dlvsym). Inert on +# macOS (dyld applies __interpose tuples only from dylibs, never from +# the main executable: the macOS interposition is the driver's +# self-insertion at boot) and on windows (its own dln path). +# X-Redesign: v2-loader-interpose +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, ...) + # define USE_DLN_DLOPEN + #endif + ++#if defined(USE_DLN_DLOPEN) && !defined(__APPLE__) ++/* -- Start of tebako patch (dln_c_loader_interpose) -- */ ++/* Loader interposition (spec 22, class L, ELF): the runtime binary's ++ own dlopen/dlerror definitions preempt process-wide (the interpreter, ++ its C extensions, and any library they pull), so EVERY native-library ++ load in the process -- the interpreter's dln_load, ffi, fiddle, and C ++ extensions self-loading -- routes a VFS-resident path through the ++ libtfs materialization (tebako_fs_dlmap2file: the library plus its ++ dependency closure to the exec cache) and loads the real file. Host ++ paths pass through untouched; a covered path the mounts do not hold ++ answers ENOENT and the raw call serves it from the host (the dln_load ++ precedent). A materialization failure is a named error -- the library, ++ the mount, the verdict -- delivered through the dlerror channel, never ++ a silent host fallback. dlsym is not interposed: the wrapper returns ++ real handles, so dlsym needs no routing (and a main-binary dlsym ++ 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. */ ++ ++#include ++#include ++#include ++#include ++#include ++#include /* modern libtfs C API (tebako_fs_*) */ ++ ++/* libtfs c_api extension (requested): tebako_fs_mount_of -- the mount ++ point of the longest-prefix mount covering a path (NULL + ENOENT when ++ uncovered); declared here so this translation unit stays C99-clean ++ meanwhile */ ++char *tebako_fs_mount_of(const char *path); ++ ++typedef void *(*tfs_dlopen_fn)(const char *, int); ++typedef char *(*tfs_dlerror_fn)(void); ++ ++static __thread char tfs_dl_verdict[1024]; ++static __thread int tfs_dl_verdict_pending; ++ ++/* The materialization verdict becomes the dlerror() answer of the ++ failed call, so ffi/fiddle/dln raise carrying this exact line. (The ++ driver's macOS interpose dylib keeps the same verdict wording -- the ++ two shells differ in plumbing, not in the contract.) */ ++static void ++tfs_dl_remember(const char *path, int err) ++{ ++ char *mount = tebako_fs_mount_of(path); ++ snprintf(tfs_dl_verdict, sizeof(tfs_dl_verdict), ++ "tebako: cannot materialize VFS-resident library '%s' (mount '%s'): %s", ++ path, mount != NULL ? mount : "(unknown)", strerror(err)); ++ free(mount); ++ tfs_dl_verdict_pending = 1; ++} ++ ++static void * ++tfs_dlopen_route(const char *path, int mode, tfs_dlopen_fn real_dlopen) ++{ ++ void *handle; ++ char *mapped; ++ int err; ++ ++ tfs_dl_verdict_pending = 0; ++ if (!tebako_path_is_embedded(path)) { ++ return real_dlopen(path, mode); ++ } ++ mapped = tebako_fs_dlmap2file(path); ++ if (mapped != NULL) { ++ handle = real_dlopen(mapped, mode); ++ free(mapped); ++ return handle; ++ } ++ err = errno; ++ if (err == ENOENT) { ++ /* covered but not held: a host path the mount happens to ++ cover -- the raw call serves it from the host */ ++ return real_dlopen(path, mode); ++ } ++ tfs_dl_remember(path, err); ++ errno = err; /* the failed call's own errno, for direct errno readers */ ++ return NULL; ++} ++ ++static char * ++tfs_dlerror_route(tfs_dlerror_fn real_dlerror) ++{ ++ if (tfs_dl_verdict_pending) { ++ tfs_dl_verdict_pending = 0; ++ return tfs_dl_verdict; ++ } ++ return real_dlerror(); ++} ++ ++/* The originals resolve through dlsym(RTLD_NEXT) -- the next object ++ after the main binary in the global scope; dlsym itself is never ++ defined here, so the lookup is not interposed. */ ++static tfs_dlopen_fn ++tfs_real_dlopen(void) ++{ ++ static tfs_dlopen_fn real_fn; ++ if (real_fn == NULL) real_fn = (tfs_dlopen_fn)dlsym(RTLD_NEXT, "dlopen"); ++ return real_fn; ++} ++ ++static tfs_dlerror_fn ++tfs_real_dlerror(void) ++{ ++ static tfs_dlerror_fn real_fn; ++ if (real_fn == NULL) real_fn = (tfs_dlerror_fn)dlsym(RTLD_NEXT, "dlerror"); ++ return real_fn; ++} ++ ++void * ++dlopen(const char *path, int mode) ++{ ++ return tfs_dlopen_route(path, mode, tfs_real_dlopen()); ++} ++ ++char * ++dlerror(void) ++{ ++ return tfs_dlerror_route(tfs_real_dlerror()); ++} ++/* -- End of tebako patch -- */ ++#endif ++ + #if defined(__hp9000s300) || ((defined(__NetBSD__) || defined(__FreeBSD__) || defined(__OpenBSD__)) && !defined(__ELF__)) || defined(NeXT) + # define EXTERNAL_PREFIX "_" + #else diff --git a/patches/3.2/patch-3.2.yaml b/patches/3.2/patch-3.2.yaml index 80f69f4..63db234 100644 --- a/patches/3.2/patch-3.2.yaml +++ b/patches/3.2/patch-3.2.yaml @@ -83,6 +83,8 @@ patches: file: dln_c_dlmap_msys.patch - feature: dln_c_tebako_includes file: dln_c_tebako_includes.patch + - feature: dln_c_loader_interpose + file: dln_c_loader_interpose.patch - feature: file_c_tebako_includes file: file_c_tebako_includes.patch - feature: gem_prelude_tebako_runtime diff --git a/patches/3.3/patch-3.3.yaml b/patches/3.3/patch-3.3.yaml index 641c4dc..fedadbd 100644 --- a/patches/3.3/patch-3.3.yaml +++ b/patches/3.3/patch-3.3.yaml @@ -102,6 +102,8 @@ patches: file: dln_c_dlmap_msys.patch - feature: dln_c_tebako_includes file: dln_c_tebako_includes.patch + - feature: dln_c_loader_interpose + file: ../3.2/dln_c_loader_interpose.patch - feature: file_c_tebako_includes file: file_c_tebako_includes.patch - feature: gem_prelude_tebako_runtime diff --git a/patches/3.4/patch-3.4.yaml b/patches/3.4/patch-3.4.yaml index 87e79a8..c43040c 100644 --- a/patches/3.4/patch-3.4.yaml +++ b/patches/3.4/patch-3.4.yaml @@ -42,6 +42,8 @@ patches: file: dln_c_dlmap_msys.patch - feature: dln_c_tebako_includes file: dln_c_tebako_includes.patch + - feature: dln_c_loader_interpose + file: ../3.2/dln_c_loader_interpose.patch - feature: file_c_tebako_includes file: file_c_tebako_includes.patch - feature: gem_prelude_tebako_runtime diff --git a/patches/4.0/patch-4.0.yaml b/patches/4.0/patch-4.0.yaml index 01d638e..bdf10ed 100644 --- a/patches/4.0/patch-4.0.yaml +++ b/patches/4.0/patch-4.0.yaml @@ -63,6 +63,8 @@ patches: file: dln_c_dlmap_msys.patch - feature: dln_c_tebako_includes file: dln_c_tebako_includes.patch + - feature: dln_c_loader_interpose + file: ../3.2/dln_c_loader_interpose.patch - feature: file_c_tebako_includes file: file_c_tebako_includes.patch - feature: gem_prelude_tebako_runtime From 782a3eacedd7646f7ada6160424df8d1527dd00e Mon Sep 17 00:00:00 2001 From: tebako-ci Date: Tue, 11 Aug 2026 20:31:39 +0800 Subject: [PATCH 2/8] =?UTF-8?q?ci/spec22:=20README=20=E2=80=94=20the=20mac?= =?UTF-8?q?OS=20delivery=20is=20the=20driver's=20self-insertion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The harness README's platform notes still described the pre-discovery design (an __interpose section in the runtime binary). dyld honors tuples only from dylib images (verified empirically, spec 22 'Phase 1 delivery'): the macOS interposition is the driver writing its embedded interpose dylib and re-execing with DYLD_INSERT_LIBRARIES; the ELF delivery is this patch family. --- ci/spec22/README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/ci/spec22/README.md b/ci/spec22/README.md index 83c1058..b0e6d6e 100644 --- a/ci/spec22/README.md +++ b/ci/spec22/README.md @@ -70,8 +70,12 @@ spec22 (…)` line otherwise. Everything transient is under rebuild from scratch, or delete a single artifact to rebuild just that stage (the link unit is reused across runs by design). -Platform notes: macOS uses the `__DATA,__interpose` section in the runtime -binary; ELF (linux-gnu/musl) uses the main binary's own `dlopen`/`dlerror` -definitions, which preempt process-wide (verify on ELF with +Platform notes: on macOS dyld honors `__DATA,__interpose` tuples only +from dylib images (never from the main executable — verified +empirically), so the macOS interposition is the DRIVER's self-insertion +at boot (crates/tebako-driver's embedded interpose dylib + +`DYLD_INSERT_LIBRARIES` + a once-only re-exec); ELF (linux-gnu/musl) +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. From e55af1caf384bbc57e47551a754783d4b6942076 Mon Sep 17 00:00:00 2001 From: tebako-ci Date: Wed, 12 Aug 2026 09:28:07 +0800 Subject: [PATCH 3/8] =?UTF-8?q?ci/spec22:=20run.sh=20=E2=80=94=20fixes=20f?= =?UTF-8?q?rom=20the=20first=20real=20macOS=20runs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - prefer homebrew bison: stock macOS ships 2.3, ruby regenerates parse.c (needs >= 3.0) when a patch nudges the dependency graph — breaks 3.2.x builds - package layout: accept the sibling form (exe IS $RUNTIME_PKG, image $RUNTIME_PKG.tfs beside it) as well as the directory form - probe natives: canonical header root deps/src/_ruby_$VERSION; the blanket find can land on the deps/stash_* installed-headers copy (include/ruby-X/ruby.h — one level deeper), breaking the dirname arithmetic - derive the .ext/include arch dir instead of hardcoding arm64-darwin24 --- ci/spec22/run.sh | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) 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 From d92775e352ffa94c28b43947e3537b0179c3b199 Mon Sep 17 00:00:00 2001 From: tebako-ci Date: Wed, 12 Aug 2026 14:28:35 +0800 Subject: [PATCH 4/8] dln_c_loader_interpose: default-visibility dlopen/dlerror definitions ruby compiles dln.c with -fvisibility=hidden (XCFLAGS); upstream symbols get default visibility from the RUBY_SYMBOL_EXPORT pragmas in dln.h, but the patch's new definitions carried no export marker, so they stayed GLOBAL HIDDEN in dln.o and LOCAL in the linked exe. -Wl,-export-dynamic cannot export a hidden symbol, so the exe-preempt design (spec 22 phase 1, class L, ELF delivery) never engaged: nm -D showed no dlopen/dlerror export (gate exit 66 on the 4.0.6 linux-gnu build). Annotate both definitions __attribute__((visibility("default"))) (the 3.1 copy and the 3.2 body shared by 3.2-4.0 via manifest refs) and document the constraint in the patch comment. macOS is unaffected (its delivery is the driver's self-insertion dylib, not this TU). --- patches/3.1/dln_c_loader_interpose.patch | 13 +++++++++---- patches/3.2/dln_c_loader_interpose.patch | 13 +++++++++---- 2 files changed, 18 insertions(+), 8 deletions(-) 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.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()); From 3acab68db66a8e842461aba2e120e785c33363ca Mon Sep 17 00:00:00 2001 From: tebako-ci Date: Wed, 12 Aug 2026 19:09:30 +0800 Subject: [PATCH 5/8] ci/spec22/elf: the containerized ELF acceptance leg, reproducible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The linux/amd64 twin of run.sh: one docker container (ubuntu-20.04 factory CI image, linux/amd64 under qemu-user on Apple Silicon), one host-side driver (run-elf-leg.sh) — toolchain setup, source roll from this checkout, adapter-less gem repo staging, link unit build with the GNU_UNIQUE gate, factory runtime build with the nm -D dlopen/dlerror export gate, and the same four jailed assertions as the macOS leg. Proven green on 4.0.6 (SPEC22-ACCEPTANCE-OK). The container-local deviations are documented in elf/README.md and scripted where automatable: the distro libjemalloc.a neuter (qemu-user hangs miniruby otherwise; setup-toolchain.sh, idempotent) and the libtfs.a reseal recipe for gcc's GNU_UNIQUE template statics in the vendored rnp closure (gated in build-link-unit.sh; reseal.py + swap-members.py + ar-rebuild.py included; the durable fix is upstream arscope work, flagged in the README). --- ci/spec22/README.md | 15 ++ ci/spec22/elf/README.md | 96 ++++++++++++ .../__pycache__/ar-rebuild.cpython-310.pyc | Bin 0 -> 2444 bytes .../elf/__pycache__/reseal.cpython-310.pyc | Bin 0 -> 4806 bytes .../__pycache__/swap-members.cpython-310.pyc | Bin 0 -> 3538 bytes ci/spec22/elf/ar-rebuild.py | 82 +++++++++++ ci/spec22/elf/build-link-unit.sh | 71 +++++++++ ci/spec22/elf/build-runtime.sh | 59 ++++++++ ci/spec22/elf/env.sh | 48 ++++++ ci/spec22/elf/probe.sh | 77 ++++++++++ ci/spec22/elf/reseal.py | 139 ++++++++++++++++++ ci/spec22/elf/roll-source.sh | 29 ++++ ci/spec22/elf/run-elf-leg.sh | 73 +++++++++ ci/spec22/elf/setup-toolchain.sh | 94 ++++++++++++ ci/spec22/elf/stage-gem-repo.sh | 32 ++++ ci/spec22/elf/swap-members.py | 100 +++++++++++++ 16 files changed, 915 insertions(+) create mode 100644 ci/spec22/elf/README.md create mode 100644 ci/spec22/elf/__pycache__/ar-rebuild.cpython-310.pyc create mode 100644 ci/spec22/elf/__pycache__/reseal.cpython-310.pyc create mode 100644 ci/spec22/elf/__pycache__/swap-members.cpython-310.pyc create mode 100644 ci/spec22/elf/ar-rebuild.py create mode 100755 ci/spec22/elf/build-link-unit.sh create mode 100755 ci/spec22/elf/build-runtime.sh create mode 100755 ci/spec22/elf/env.sh create mode 100755 ci/spec22/elf/probe.sh create mode 100644 ci/spec22/elf/reseal.py create mode 100755 ci/spec22/elf/roll-source.sh create mode 100755 ci/spec22/elf/run-elf-leg.sh create mode 100755 ci/spec22/elf/setup-toolchain.sh create mode 100755 ci/spec22/elf/stage-gem-repo.sh create mode 100644 ci/spec22/elf/swap-members.py 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/__pycache__/ar-rebuild.cpython-310.pyc b/ci/spec22/elf/__pycache__/ar-rebuild.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..14ac3e7f5cdf920f2c4f1b0fc8cf2df548fc25f5 GIT binary patch literal 2444 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 T{h4CKuuSWOWm~U7$o>BTAs4_8 literal 0 HcmV?d00001 diff --git a/ci/spec22/elf/__pycache__/swap-members.cpython-310.pyc b/ci/spec22/elf/__pycache__/swap-members.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b3a083d75f84cc444943c8deffce92a2cc74073d GIT binary patch literal 3538 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 SwV7i%wo`T{oF9W`$NvL8E|}&3 literal 0 HcmV?d00001 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() From 301a2e2e3f22aced8c1731959b58bbb80518b768 Mon Sep 17 00:00:00 2001 From: tebako-ci Date: Wed, 12 Aug 2026 19:09:50 +0800 Subject: [PATCH 6/8] ci/spec22/elf: drop the stray py_compile cache --- .../elf/__pycache__/ar-rebuild.cpython-310.pyc | Bin 2444 -> 0 bytes ci/spec22/elf/__pycache__/reseal.cpython-310.pyc | Bin 4806 -> 0 bytes .../elf/__pycache__/swap-members.cpython-310.pyc | Bin 3538 -> 0 bytes 3 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 ci/spec22/elf/__pycache__/ar-rebuild.cpython-310.pyc delete mode 100644 ci/spec22/elf/__pycache__/reseal.cpython-310.pyc delete mode 100644 ci/spec22/elf/__pycache__/swap-members.cpython-310.pyc diff --git a/ci/spec22/elf/__pycache__/ar-rebuild.cpython-310.pyc b/ci/spec22/elf/__pycache__/ar-rebuild.cpython-310.pyc deleted file mode 100644 index 14ac3e7f5cdf920f2c4f1b0fc8cf2df548fc25f5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2444 zcmZWq&2J;O6(5o_(r7f2?ae0MZQOR6ODbS(Z%`CSU1PC9S_BCa7tIHXOcMxWk&?%j zX0#+_uMG|mtdnDqOWVsLiFC|8=pWHPfyegbOE0-C3T%HID_$=$1Rpt%?~9MWN0yyV zDWzaE1$;Eem9duf9Q zyn(SPLf$;0@;%W!_da21D~-~&h zWQQyB)rFU?L8l9yHK+46b<&)EQ1{--?2LF`CO4(npXFn|H9bxK{n_7=X{mCZmqj*C z#6vB!f!Bji4xGoU z5c0waC4YMV?YG|Ed*j_`>7$ff$gSkP+M}w?@dUEFQcTC$Ky1SQz2w*Xr%5UEQSP?8 z)O4PE*pNunNj9AdUhmuaSgKA$p_5@g7RjJ2G=9`p6n&(!k=Q|0*YmlX7hBoAWVf7Y zH~#gS?@uoP5pQpRGr0O<%p8Q?L|1pQVgMpezM!{pWJgrR?|7V@lSgFaRfPK$;8Zcg zG~~|7kthFQJcEB8tb0ypPI!Yi7qs#_;;TCxr?~- z91d+T&7=}*SW#|&w}@Z2nA{?-kdUmRg=iGr2mKlTW3oZIv`gau`Y*^|LTP}0^>s08 zq1QJh9gocTBJ=5Z?$3BltG>?JTctLSdvMSc$VQ1bofdQxL z7%IQ5IV(#(HPHeH1eaas)cc8DO}gDw?Vs%B@7MPNWUs;wB1bMdyn|eHYTY%{cHEz6 zfB&3z6WSdo_u$>j*bG5L|3W@)xEZXzaCnn<`HCSUW}53BGPde6W_%MGGGwTiwLR%x zvn-cG#XG!1E zll>>rczitBgn`#yPnjGjs0M=rF*xeIXqh@y*3abVSazI>U!oprgjrvTjN67fnCX0s zikWCBgr%jj;Uqf}xUrROiDEVpGSeu%kXmN zHy|T#*Pe#1{^V~Q!VsLwf zyi1Y1@5t{#aLq{EhV`+^-#G@Xa5zKWr{i3Y^8)!}U|h>kovS)c;qIj5v$446V5MAU XeG-Q5UaA`lUk+b!H`WGHw?tBs?UVeFAd8UYN9;(9;)iUF6Au&1k{@ss%n^G4j|A?( z-6JV-4ihJIMpPlk$vBnc88DN^Dbl!gl`5r+R97ZVSfy~M!p!&IfqGa0XaC#Bzq|kb zul<%`rD8Mu&b%Ej|80`7e^cZBpMl29D7ph8S)EBPMTggU`W1DNevP`3e$Bd>eslGl zH0pV2%G{s0%*(>ZqF#`eEPl-Dmb7IF-=Zwb3cj{ik|QfZJ@47uj58_TNlSHf?T}zh zdD?S3wce)lua7=+l-KLHP0wjJn{$h6ekXFeUU$(`+VKPJOV5dxP`pKVC7g4WZiYQ? zPDgIrlXk~nj9R+pIvakpk)k2A)3S^pw}~Z_Ft#aJ!y#yc=#a>TEi$c7_W% z8{t|<+Dq=b=XhN|iok{Kx@R0WkdTbzNqaD%x#WYZ7ivH9!@%uep;MO^u3^M^w)Wif zb{D&MJdOFjb~cnBdFmu>l+6nRESL6Pa5|m!E{<}rzJ}zU8+10SHQTWA#sA=idiO&6``N}36thF{|#@m zz9DmcQ;L2r&P5^?+kAzoZ(W|pB zD92k*DYjxelhbQc%EZX+4#?6dMCO#(r(F3r*kCo<0{F@RZ4gM5x6*GMpxI ze4ZlrqP;a9gb}gDaR#wmGu>T zE&a9+9v=~XBgwlxtR$qgjW)h*?2G$_OqQ~O(> zi=o`C=UTqkk@W%%kT7P-0V%M38V4C6^(2)Zl9SgMs^loTG@ZN5ZGLQjMwp!b%6x{O zK$+qe{sf8%YV$Gk2p{1`_!xhJkBAXo-ZN*^X-scYP#ydzxGCDwKzbLIn5zO^MKNm| z0WK$eMPEd08(|UgSbV~k#lYBQm)P~mz6tj*;jXy|p|Q;oNI7*1PLPLdo3o6ZVD(UH z`W#FjbnlGJ%?*>?T!>`6N22WQ<5w*thW4T~(i5mrB8|W(y^bGskc>6#M0GWb35nrK zSUnBmI5NT0u88js6iPXmYc|JkKYfyfTy~J~Fv_ z37vOyA?l3uUqm7Q(*FRZTqyZ$_C-!e+eQ{qh9QhT>vO0Mszfvyk%0__d;PXRZlRl< z#pWf9+!B$woTEGbHt*+r*3ZWR%Kc}*5aqWS6(Mf%z7-Xu9T&F6vNb5C>OVIBmPZDoFz6VvT{#+y%W@ofcA{U2D}!-4At!N?67iC`AIiC(-Jf&QwI<@iF2BWg z*kDplArJj^2YFhSD02mThut!6vYQ4ZIYg2~Q?aqlQ%OeR5&idw4Gzh}JM44zMVe#o zN;f_16a7(WYDUf?cNOB%?Hp&2c~l-r?*@|jFf=yykevUyU(R?07MSRd$78V(k3*Y$ zo;BCae3@>6S|v-niPjK@^#{jpGtGD^*OdgN(&?RoZA@7NpZyTA9^$)jM8{XHS z?$40-$;a=FK|e42AOC|a>J!NGtIP{C-@^*C{mFP%KCvP&z8g*0vlK=UY*WY;a{WOt%DH2h8 zCgH*N(^GLCxv&{_dl!F7o6*jv+17N|^@G}cCv3VMeX(|zXA4&&0m3<8xO@gO)zg@= z_Z<>z!mp_s8V?i%f$ZH z%=b=vcS2%x-Cn{s9yk#tWipPY@&w4Q=|mNTB2sWnjt;;F%D*t*MF^}4dM;IsJKRaQ zy^!i37P^3mi4k@H6aqXX2$p6f6}mXz_5$xlPhI?wb}>Q3M<*V(%K_JxsD%#*Ni#^u zRPCgsRPD1QB#P)8vDjh2qO-9WupRO!coX5z2!;R(MLXu1>NAOlN~cS)YG%@NU!6iT z;SI9DqN@phL|*saIn*VL{}LtD`WPdP132uDa2r~G+VX-B3-vRUCq) z^UhZ3f}=MGRLS!&L!{1NX2RF%xvTG8y8Nno1t}VRz_O;jP z;iR38*NQN5njT)6_doDKfRI1Jqd6o#7L_G(Rh)`!p#as^k zK+&@%vC~It25c;{Q3?VrfXfVl7QJAt>nS&a@w^*=Ju@`R>bfhQ^cBER_VhhYy?HR` zNCUNc-Ef@%j{>f}7mYwuOKXwe0Yf)Zo>x6eFqNM6bql&}5R?TfrAMQB5)0!04cYrQ zF`{m-UKm;^$!B(}14y^DqNJf-r2PS~AW|K|_0qM}CvtEZYc48jf)h8`WOffU8a`- T{h4CKuuSWOWm~U7$o>BTAs4_8 diff --git a/ci/spec22/elf/__pycache__/swap-members.cpython-310.pyc b/ci/spec22/elf/__pycache__/swap-members.cpython-310.pyc deleted file mode 100644 index b3a083d75f84cc444943c8deffce92a2cc74073d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3538 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 SwV7i%wo`T{oF9W`$NvL8E|}&3 From 6cd0da0631a68509234ba33fe7688b3e2b302416 Mon Sep 17 00:00:00 2001 From: tebako-ci Date: Thu, 13 Aug 2026 07:29:56 +0800 Subject: [PATCH 7/8] =?UTF-8?q?feat(spawn-hook):=20read=20TEBAKO=5FPRELOAD?= =?UTF-8?q?=5FSHIM=20before=20the=20literal=20(spec=2022=20=C2=A73=20SSOT)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tfs_spawn_prepare now resolves the preload shim's in-VFS path from TEBAKO_PRELOAD_SHIM (the driver's flow of the env image's layout.yaml preload_shim grant) and falls back to the literal /__tfs__/lib/tebako/libtfs_preload.{so,dylib} for images predating the grant. The env var is authoritative; the literal keeps older runtime images working through the transition. Same transform applied to patches/{3.1,3.2,3.3,3.4,4.0} (byte-identical hook across versions); hunk headers recomputed arithmetically, verified by line recount. Apply-fixture suite: 110 examples, 0 failures. --- patches/3.1/process_c_tebako_spawn.patch | 33 +++++++++++++++--------- patches/3.2/process_c_tebako_spawn.patch | 33 +++++++++++++++--------- patches/3.3/process_c_tebako_spawn.patch | 33 +++++++++++++++--------- patches/3.4/process_c_tebako_spawn.patch | 33 +++++++++++++++--------- patches/4.0/process_c_tebako_spawn.patch | 33 +++++++++++++++--------- 5 files changed, 105 insertions(+), 60 deletions(-) diff --git a/patches/3.1/process_c_tebako_spawn.patch b/patches/3.1/process_c_tebako_spawn.patch index c7945da..7ff358a 100644 --- a/patches/3.1/process_c_tebako_spawn.patch +++ b/patches/3.1/process_c_tebako_spawn.patch @@ -8,10 +8,13 @@ # materialized to the host cache (tebako_fs_dlmap2file) 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. @@ -22,7 +25,7 @@ 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,87 @@ return eargp; } @@ -60,6 +63,7 @@ index aff3503..aff3ba4 100644 +tfs_spawn_prepare(struct rb_execarg *eargp) +{ + const char *prog; ++ const char *shim; + char *mapped; + char *preload; + char *mounts; @@ -73,18 +77,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 +113,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 +2839,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/process_c_tebako_spawn.patch b/patches/3.2/process_c_tebako_spawn.patch index c7945da..7ff358a 100644 --- a/patches/3.2/process_c_tebako_spawn.patch +++ b/patches/3.2/process_c_tebako_spawn.patch @@ -8,10 +8,13 @@ # materialized to the host cache (tebako_fs_dlmap2file) 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. @@ -22,7 +25,7 @@ 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,87 @@ return eargp; } @@ -60,6 +63,7 @@ index aff3503..aff3ba4 100644 +tfs_spawn_prepare(struct rb_execarg *eargp) +{ + const char *prog; ++ const char *shim; + char *mapped; + char *preload; + char *mounts; @@ -73,18 +77,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 +113,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 +2839,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 c7945da..7ff358a 100644 --- a/patches/3.3/process_c_tebako_spawn.patch +++ b/patches/3.3/process_c_tebako_spawn.patch @@ -8,10 +8,13 @@ # materialized to the host cache (tebako_fs_dlmap2file) 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. @@ -22,7 +25,7 @@ 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,87 @@ return eargp; } @@ -60,6 +63,7 @@ index aff3503..aff3ba4 100644 +tfs_spawn_prepare(struct rb_execarg *eargp) +{ + const char *prog; ++ const char *shim; + char *mapped; + char *preload; + char *mounts; @@ -73,18 +77,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 +113,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 +2839,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 c7945da..7ff358a 100644 --- a/patches/3.4/process_c_tebako_spawn.patch +++ b/patches/3.4/process_c_tebako_spawn.patch @@ -8,10 +8,13 @@ # materialized to the host cache (tebako_fs_dlmap2file) 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. @@ -22,7 +25,7 @@ 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,87 @@ return eargp; } @@ -60,6 +63,7 @@ index aff3503..aff3ba4 100644 +tfs_spawn_prepare(struct rb_execarg *eargp) +{ + const char *prog; ++ const char *shim; + char *mapped; + char *preload; + char *mounts; @@ -73,18 +77,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 +113,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 +2839,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 c7945da..7ff358a 100644 --- a/patches/4.0/process_c_tebako_spawn.patch +++ b/patches/4.0/process_c_tebako_spawn.patch @@ -8,10 +8,13 @@ # materialized to the host cache (tebako_fs_dlmap2file) 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. @@ -22,7 +25,7 @@ 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,87 @@ return eargp; } @@ -60,6 +63,7 @@ index aff3503..aff3ba4 100644 +tfs_spawn_prepare(struct rb_execarg *eargp) +{ + const char *prog; ++ const char *shim; + char *mapped; + char *preload; + char *mounts; @@ -73,18 +77,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 +113,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 +2839,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); From 882d4cab1a9360fdfc848556c7906efd9713d73d Mon Sep 17 00:00:00 2001 From: tebako-ci Date: Thu, 13 Aug 2026 21:22:59 +0800 Subject: [PATCH 8/8] spawn hook: scrub the inherited DYLD_INSERT_LIBRARIES for restricted targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Factory run 31699651270 (macos arm64, darwin24) failed the shell-string class-E example with EMPTY output and killed the probe's diagnostic cc: dyld TERMINATES an Apple-restricted binary at exec when the inherited DYLD_INSERT_LIBRARIES names a foreign dylib. darwin23 stripped the variable instead (the behavior spec 22 §3.1 pinned), so under the driver's process-wide export every system()/backtick of a packaged interpreter killed /bin/sh on that host. The hook now drops the inherited variable per spawn whose target is restricted: any shell form (the shell is always a platform binary) and any target resolving into Apple's system binary dirs (/bin, /sbin, /usr/bin, /usr/sbin, /usr/libexec, /System), with an execvp-equivalent parent-side PATH walk for bare names. The nil env pair deletes the inherited entry (rb_execarg_parent_start1's NIL_P branch, st_delete — verified against ruby_4_0 process.c). A restricted target can never honor the insertion on any host, so the scrub loses nothing; a non-restricted host target (a third-party JRE) keeps the delivery — spec 22 §3.1's array form stays green. Patch verified: applies cleanly against ruby_4_0 process.c (patch --dry-run); the PATH-walk + prefix logic passes a 12-case standalone oracle (sh/cc resolve restricted, temurin's hostedtoolcache path and /usr/local stay delivered). All five version patches kept byte-identical. --- patches/3.1/process_c_tebako_spawn.patch | 102 ++++++++++++++++++++++- patches/3.2/process_c_tebako_spawn.patch | 102 ++++++++++++++++++++++- patches/3.3/process_c_tebako_spawn.patch | 102 ++++++++++++++++++++++- patches/3.4/process_c_tebako_spawn.patch | 102 ++++++++++++++++++++++- patches/4.0/process_c_tebako_spawn.patch | 102 ++++++++++++++++++++++- 5 files changed, 500 insertions(+), 10 deletions(-) diff --git a/patches/3.1/process_c_tebako_spawn.patch b/patches/3.1/process_c_tebako_spawn.patch index 7ff358a..35f8b73 100644 --- a/patches/3.1/process_c_tebako_spawn.patch +++ b/patches/3.1/process_c_tebako_spawn.patch @@ -20,12 +20,23 @@ # 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,87 @@ +@@ -2746,6 +2746,174 @@ return eargp; } @@ -52,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 @@ -68,6 +157,15 @@ index aff3503..aff3ba4 100644 + 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; @@ -113,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 +2839,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/process_c_tebako_spawn.patch b/patches/3.2/process_c_tebako_spawn.patch index 7ff358a..35f8b73 100644 --- a/patches/3.2/process_c_tebako_spawn.patch +++ b/patches/3.2/process_c_tebako_spawn.patch @@ -20,12 +20,23 @@ # 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,87 @@ +@@ -2746,6 +2746,174 @@ return eargp; } @@ -52,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 @@ -68,6 +157,15 @@ index aff3503..aff3ba4 100644 + 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; @@ -113,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 +2839,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 7ff358a..35f8b73 100644 --- a/patches/3.3/process_c_tebako_spawn.patch +++ b/patches/3.3/process_c_tebako_spawn.patch @@ -20,12 +20,23 @@ # 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,87 @@ +@@ -2746,6 +2746,174 @@ return eargp; } @@ -52,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 @@ -68,6 +157,15 @@ index aff3503..aff3ba4 100644 + 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; @@ -113,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 +2839,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 7ff358a..35f8b73 100644 --- a/patches/3.4/process_c_tebako_spawn.patch +++ b/patches/3.4/process_c_tebako_spawn.patch @@ -20,12 +20,23 @@ # 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,87 @@ +@@ -2746,6 +2746,174 @@ return eargp; } @@ -52,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 @@ -68,6 +157,15 @@ index aff3503..aff3ba4 100644 + 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; @@ -113,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 +2839,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 7ff358a..35f8b73 100644 --- a/patches/4.0/process_c_tebako_spawn.patch +++ b/patches/4.0/process_c_tebako_spawn.patch @@ -20,12 +20,23 @@ # 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,87 @@ +@@ -2746,6 +2746,174 @@ return eargp; } @@ -52,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 @@ -68,6 +157,15 @@ index aff3503..aff3ba4 100644 + 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; @@ -113,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 +2839,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);