commit 9351e593c35aafabb76580dee163dc5e95ba5c1f
parent dc6ab8fec82ec9d1d6755a131fd9c30f8ca64ea4
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Sat, 9 May 2026 17:42:00 -0700
test/libc: split harness into shared cases + musl/glibc runners
Move test/musl/{cases,Containerfile,extract.sh,run.sh} under
test/libc/{cases,musl}/ and add a sibling test/libc/glibc/ runner
backed by a Debian bookworm sysroot (libc6-dev=2.36-9+deb12u13).
Both runners share test/libc/cases/.
The glibc runner is dynamic-link only: it hands libc.so.6 directly
to cfree ld (the on-disk libc.so is a GROUP linker script we don't
parse), passes -dynamic-linker /lib/ld-linux-aarch64.so.1 to
override cfree's musl default, and pulls libc_nonshared.a alongside
for atexit / __stack_chk_fail_local. Static-glibc isn't a real-world
deployment shape, so the sysroot omits libc.a + the static
-lpthread/-ldl/-lrt stubs.
Both runners now consume cfree's freestanding headers from
rt/include/ via -isystem; glibc's stdio.h/unistd.h reach for
<stddef.h> which neither libc ships. The musl runner's prior
-nostdinc-only setup worked by coincidence (musl headers don't
transitively need stddef.h) — the new ordering makes that explicit.
The glibc runner pins the runtime image to docker.io/arm64v8/debian
:bookworm-slim instead of the multi-arch debian:bookworm-slim, which
both avoids the cached-amd64-manifest trap on arm64 hosts and skips
the registry round-trip that --platform would force on every run.
`make test-glibc` currently surfaces SIGSEGV during ld-linux's reloc
processing for all three cases — real cfree dynamic-link gaps the
musl harness doesn't exercise.
Diffstat:
21 files changed, 746 insertions(+), 355 deletions(-)
diff --git a/doc/DWARF.md b/doc/DWARF.md
@@ -527,7 +527,7 @@ sanity for the wire format itself:
- `readelf --debug-dump=info,line,abbrev,aranges` — reference
rendering; hand-diff once per phase.
-Both run under `test/musl/` style: optional, gated by tool
+Both run under `test/libc/` style: optional, gated by tool
availability (`command -v llvm-dwarfdump`), skipped otherwise. They
are **not** the oracle for any case; the W path is. They exist to
catch wire-format errors that our own consumer would also miss.
diff --git a/doc/DYNLD.md b/doc/DYNLD.md
@@ -41,7 +41,9 @@ machinery as `link_exe`, with:
- DT_R(UN)PATH from `opts->r(un)paths`.
- Exports promoted into `.dynsym` from `opts->exports`.
-Add a harness case under `test/musl/` (or a new `test/link/dyn/`):
+Add a harness case under `test/libc/cases/` (shared by the
+`test/libc/musl/` and `test/libc/glibc/` runners) or a new
+`test/link/dyn/`:
build `libfoo.so` from a single `.c`, link an exe against it, run.
### Phase 8 — TLS GD/IE/LD, IRELATIVE (deferred)
diff --git a/doc/linker-status.md b/doc/linker-status.md
@@ -29,9 +29,11 @@ live in `test/link/` — they are not duplicated in `test/elf/`.
classic ET_EXEC) and **dynamic** (libc.so + Scrt1.o, ET_DYN PIE with
PT_INTERP / PT_DYNAMIC / PLT / .got.plt and BIND_NOW resolution
against the runtime loader). Both variants run the result under
-qemu/podman. Sysroot is produced by `test/musl/Containerfile`
-(Alpine 3.20 + musl 1.2.5-r3). Excluded from the default `make test`
-because it needs podman.
+qemu/podman. Sysroots are produced by `test/libc/musl/Containerfile`
+(Alpine 3.20 + musl 1.2.5-r3) and `test/libc/glibc/Containerfile`
+(Debian bookworm + glibc 2.36); cases are shared under
+`test/libc/cases/`. Excluded from the default `make test` because
+they need podman.
---
diff --git a/test/musl/cases/01_syscall_write.c b/test/libc/cases/01_syscall_write.c
diff --git a/test/musl/cases/01_syscall_write.stdout b/test/libc/cases/01_syscall_write.stdout
diff --git a/test/libc/cases/02_errno_touch.c b/test/libc/cases/02_errno_touch.c
@@ -0,0 +1,14 @@
+/* Tier 2: touch errno. We deliberately call a function that fails
+ * (close(-1) → EBADF) and read errno after. errno's lowering varies
+ * by libc — in musl it's `__thread int errno;` (exercises
+ * R_AARCH64_TLSLE_* and PT_TLS emission); in glibc it's
+ * `(*__errno_location())` (a per-thread function call, no TLS reloc
+ * on the caller side). Either way the test asserts the value. */
+
+#include <errno.h>
+#include <unistd.h>
+
+int main(void) {
+ (void)close(-1);
+ return errno == EBADF ? 0 : 1;
+}
diff --git a/test/libc/cases/03_printf_hello.c b/test/libc/cases/03_printf_hello.c
@@ -0,0 +1,7 @@
+/* Tier 3: full stdio. printf pulls in FILE buffering, locale,
+ * vfprintf, write_iov, errno, lock primitives, the works. If this
+ * runs, cfree ld is meaningfully usable for static-libc programs. */
+
+#include <stdio.h>
+
+int main(void) { return printf("hello, libc\n") < 0 ? 1 : 0; }
diff --git a/test/libc/cases/03_printf_hello.stdout b/test/libc/cases/03_printf_hello.stdout
@@ -0,0 +1 @@
+hello, libc
diff --git a/test/libc/glibc/Containerfile b/test/libc/glibc/Containerfile
@@ -0,0 +1,68 @@
+# test/libc/glibc/Containerfile — produces a glibc aarch64 sysroot tarball
+# on stdout. Pinned to Debian bookworm (glibc 2.36).
+#
+# Usage (driven by test/libc/glibc/extract.sh):
+# podman build --platform linux/arm64 -f Containerfile -t cfree-glibc-sysroot .
+# podman run --rm cfree-glibc-sysroot > sysroot.tar
+#
+# The image's ENTRYPOINT writes a tar of /sysroot to stdout. The extract
+# script unpacks it into build/glibc-sysroot/ on the host.
+#
+# Notes about glibc on Debian (vs. the musl/Alpine variant):
+# - libc is split across libc.so.6 (the dynamic SO) and
+# libc_nonshared.a (a few non-shared callbacks pulled in by every
+# dynamic exe — atexit, __stack_chk_fail_local, etc.). On disk,
+# /usr/lib/aarch64-linux-gnu/libc.so is a *linker script* that
+# GROUPs them. cfree ld doesn't parse linker scripts, so we copy
+# libc.so.6 directly and hand it to the linker by path.
+# - The runtime loader is /lib/ld-linux-aarch64.so.1, distinct from
+# libc (unlike musl, where ld-musl-aarch64.so.1 *is* libc). That
+# means the dynamic-link variant of the harness must pass
+# -dynamic-linker explicitly; cfree ld's default is musl.
+# - Debian uses multi-arch paths: Scrt1.o + crti/crtn live under
+# /usr/lib/aarch64-linux-gnu, the runtime SOs under
+# /lib/aarch64-linux-gnu, and headers under both /usr/include and
+# /usr/include/aarch64-linux-gnu. We flatten everything into
+# /sysroot/{lib,include} with the multi-arch include kept as a
+# subdir so -isystem can pick it up.
+FROM docker.io/arm64v8/debian:bookworm-slim
+
+# libc6-dev: Scrt1.o, crti.o, crtn.o, libc_nonshared.a + glibc headers
+# under /usr/include and /usr/include/aarch64-linux-gnu. We
+# deliberately do NOT stage libc.a or the static -lpthread/-ldl/-lrt
+# archives: the harness only exercises dynamic-link (static-glibc is
+# officially discouraged and isn't a real-world deployment shape).
+# linux-libc-dev: kernel uapi (linux/*, asm/*, asm-generic/*) under
+# /usr/include — used by syscall numbers etc. Pulled in transitively
+# by libc6-dev, listed explicitly for clarity.
+# Note: we deliberately do NOT pull libgcc / compiler-rt — soft-float
+# / TF / 128-bit-int helpers come from our own rt/ build.
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ libc6-dev=2.36-9+deb12u13 \
+ linux-libc-dev \
+ && rm -rf /var/lib/apt/lists/*
+
+# Stage the artifacts the linker needs into one tree so the host extract
+# is a single tar pipe. Multi-arch dirs get flattened to /sysroot/lib/.
+# Symlinks (notably ld-linux-aarch64.so.1) are dereferenced so the
+# extracted tree is self-contained.
+RUN set -eux; \
+ mkdir -p /sysroot/lib /sysroot/include; \
+ cd /usr/lib/aarch64-linux-gnu; \
+ cp Scrt1.o crti.o crtn.o libc_nonshared.a /sysroot/lib/; \
+ cp -L /lib/aarch64-linux-gnu/libc.so.6 /sysroot/lib/libc.so.6; \
+ cp -L /lib/aarch64-linux-gnu/libm.so.6 /sysroot/lib/libm.so.6; \
+ cp -L /lib/ld-linux-aarch64.so.1 /sysroot/lib/ld-linux-aarch64.so.1; \
+ # Common include tree + multi-arch (sys/cdefs.h, bits/*, gnu/stubs-lp64.h)
+ cp -r /usr/include/. /sysroot/include/
+
+# Pin the build for cache reuse and reproducibility audits.
+RUN set -eux; \
+ { \
+ echo "debian bookworm-slim glibc $(dpkg-query -W -f='${Version}' libc6-dev)"; \
+ echo "linux-libc-dev $(dpkg-query -W -f='${Version}' linux-libc-dev)"; \
+ uname -m; \
+ } > /sysroot/PROVENANCE
+
+ENTRYPOINT ["sh", "-c", "tar -C /sysroot -cf - ."]
diff --git a/test/libc/glibc/extract.sh b/test/libc/glibc/extract.sh
@@ -0,0 +1,49 @@
+#!/usr/bin/env bash
+# test/libc/glibc/extract.sh — build the glibc sysroot image
+# (test/libc/glibc/Containerfile) and unpack /sysroot from it into
+# build/glibc-sysroot/. Cached after the first successful run; pass `-f`
+# to force a rebuild.
+#
+# Output layout:
+# build/glibc-sysroot/
+# lib/ crt1.o Scrt1.o crti.o crtn.o libc.a libc_nonshared.a
+# libc.so.6 libm.so.6 ld-linux-aarch64.so.1 ...
+# include/ glibc + linux uapi tree (multi-arch dir kept as
+# include/aarch64-linux-gnu/)
+# PROVENANCE
+set -eu
+
+ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
+SYSROOT="$ROOT/build/glibc-sysroot"
+TAG="cfree-glibc-sysroot"
+FORCE=0
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ -f|--force) FORCE=1; shift ;;
+ *) echo "unknown arg: $1" >&2; exit 2 ;;
+ esac
+done
+
+if [ -f "$SYSROOT/PROVENANCE" ] && [ $FORCE -eq 0 ]; then
+ echo "glibc sysroot already present at $SYSROOT (use -f to rebuild)"
+ exit 0
+fi
+
+if ! command -v podman >/dev/null 2>&1; then
+ echo "extract.sh: podman is required" >&2
+ exit 1
+fi
+
+cd "$ROOT/test/libc/glibc"
+echo "Building $TAG (Debian bookworm aarch64 + libc6-dev)..."
+podman build --platform linux/arm64 -f Containerfile -t "$TAG" . >/dev/null
+
+rm -rf "$SYSROOT"
+mkdir -p "$SYSROOT"
+
+echo "Extracting sysroot to $SYSROOT..."
+podman run --rm --platform linux/arm64 "$TAG" | tar -C "$SYSROOT" -xf -
+
+echo "Done. Provenance:"
+cat "$SYSROOT/PROVENANCE"
diff --git a/test/libc/glibc/run.sh b/test/libc/glibc/run.sh
@@ -0,0 +1,247 @@
+#!/usr/bin/env bash
+# test/libc/glibc/run.sh — drive cfree ld against a real glibc sysroot on
+# aarch64-linux. Dynamic-link only — static-linked glibc is officially
+# discouraged (libc.a relies on dlopen-loaded NSS modules, has its own
+# entire reloc surface area, and isn't a real-world deployment shape),
+# so we don't carry the variant. Each case in test/libc/cases/*.c is
+# exercised once:
+#
+# dynamic — PIE object + libc.so.6, with explicit dynamic linker
+# cfree ld -pie \
+# -dynamic-linker /lib/ld-linux-aarch64.so.1 \
+# -o case.exe \
+# $SYSROOT/lib/Scrt1.o $SYSROOT/lib/crti.o \
+# case.o \
+# $SYSROOT/lib/libc.so.6 $SYSROOT/lib/libc_nonshared.a $CFREE_RT \
+# $SYSROOT/lib/crtn.o
+#
+# Unlike musl, where ld-musl-aarch64.so.1 is the same file as libc,
+# glibc's loader is a separate ELF — cfree ld's default interp is musl,
+# so we override via -dynamic-linker. libc.so.6 carries
+# SONAME=libc.so.6 so DT_NEEDED is correct without a linker-script
+# intermediary (the on-disk libc.so is a GROUP script that cfree ld
+# doesn't parse — we hand the SO directly). libc_nonshared.a
+# contributes the handful of non-shared callbacks every glibc dyn-exe
+# pulls in — atexit, __stack_chk_fail_local, __libc_csu_init/fini on
+# older glibc, etc. — and must follow libc.so.6 in the demand chain.
+#
+# Each case file may carry an `expected` companion (default 0) and an
+# optional `expected_stdout` file checked with substring match.
+#
+# Designed to fail fast and clearly: the *first* failure surface (compile
+# / link / run / output) is the gap to fix next. Run with
+# CFREE_GLIBC_KEEP=1 to leave intermediates in build/glibc/<case>/.
+set -u
+
+ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
+CASES_DIR="$ROOT/test/libc/cases"
+BUILD_DIR="$ROOT/build/glibc"
+SYSROOT="$ROOT/build/glibc-sysroot"
+CFREE="$ROOT/build/cfree"
+CFREE_RT="$ROOT/rt/build/aarch64-linux/libcfree_rt.a"
+
+if [ ! -d "$SYSROOT" ]; then
+ echo "glibc sysroot missing — run test/libc/glibc/extract.sh first" >&2
+ exit 2
+fi
+if [ ! -x "$CFREE" ]; then
+ echo "cfree driver missing at $CFREE — run 'make' first" >&2
+ exit 2
+fi
+if [ ! -f "$CFREE_RT" ]; then
+ echo "cfree rt missing at $CFREE_RT — run 'make rt-aarch64-linux'" >&2
+ exit 2
+fi
+
+mkdir -p "$BUILD_DIR"
+
+color_red() { printf '\033[31m%s\033[0m' "$1"; }
+color_grn() { printf '\033[32m%s\033[0m' "$1"; }
+color_yel() { printf '\033[33m%s\033[0m' "$1"; }
+
+PASS=0; FAIL=0; FAIL_NAMES=()
+
+# Pick a runner. Native arm64 hosts can run aarch64 ELFs directly under
+# podman without binfmt; otherwise we want qemu-aarch64-static.
+arch_raw="$(uname -m 2>/dev/null || true)"
+is_aarch64=0
+{ [ "$arch_raw" = "aarch64" ] || [ "$arch_raw" = "arm64" ]; } && is_aarch64=1
+
+QEMU_BIN="$(command -v qemu-aarch64-static 2>/dev/null || command -v qemu-aarch64 2>/dev/null || true)"
+have_qemu=0; [ -n "$QEMU_BIN" ] && have_qemu=1
+have_podman=0; command -v podman >/dev/null 2>&1 && have_podman=1
+
+# clang must understand --target=aarch64-linux-gnu. Every system path
+# is overridden via --sysroot / -isystem so the host's headers /
+# libraries are not consulted.
+if ! clang --target=aarch64-linux-gnu -c -x c - -o /dev/null < /dev/null 2>/dev/null; then
+ echo "clang does not accept --target=aarch64-linux-gnu" >&2
+ exit 2
+fi
+
+# Dynamic-variant exes need /lib/ld-linux-aarch64.so.1 + libc.so.6 to
+# load. qemu-user resolves them relative to QEMU_LD_PREFIX or -L; the
+# podman fallback uses a debian:bookworm image which ships them at the
+# expected paths.
+QEMU_LD_PREFIX_OVERRIDE="$SYSROOT"
+
+run_aarch64() {
+ local exe="$1" out="$2" err="$3"
+ if [ $have_qemu -eq 1 ]; then
+ # Point qemu-user at our extracted sysroot so the loader
+ # search ("/lib/ld-linux-aarch64.so.1") resolves to the
+ # SYSROOT copy rather than the (possibly-absent) host one.
+ QEMU_LD_PREFIX="$QEMU_LD_PREFIX_OVERRIDE" \
+ "$QEMU_BIN" "$exe" >"$out" 2>"$err"
+ RUN_RC=$?; return
+ fi
+ if [ $have_podman -eq 1 ]; then
+ local dir base
+ dir="$(cd "$(dirname "$exe")" && pwd)"; base="$(basename "$exe")"
+ # Pin the image name to the arm64-specific repo
+ # (docker.io/arm64v8/...) instead of the multi-arch
+ # debian:bookworm-slim. Two reasons:
+ # 1. Avoids the cached-amd64-manifest trap that
+ # debian:bookworm-slim hits on arm64 hosts where an
+ # amd64 pull happened earlier — podman silently uses
+ # the wrong arch and the dyn-exe fails to load.
+ # 2. Avoids passing --platform, which forces podman to
+ # hit the registry on every run to verify the
+ # manifest matches. Pinning the repo + relying on the
+ # local cache keeps subsequent runs offline + fast.
+ # arm64v8/debian:bookworm-slim ships the matching glibc
+ # loader, so the dynamic variant resolves PT_INTERP without
+ # extra mounts.
+ podman run --rm --net=none \
+ -v "$dir":/work:Z -w /work \
+ docker.io/arm64v8/debian:bookworm-slim "./$base" \
+ >"$out" 2>"$err"
+ RUN_RC=$?; return
+ fi
+ RUN_RC=127
+}
+
+run_case() {
+ local src="$1"
+ local name="$(basename "$src" .c)"
+ local work="$BUILD_DIR/$name"
+ local label="$name"
+ mkdir -p "$work"
+
+ local expected=0
+ [ -f "$CASES_DIR/${name}.expected" ] && \
+ expected="$(cat "$CASES_DIR/${name}.expected" | tr -d '[:space:]')"
+
+ local expect_stdout=""
+ if [ -f "$CASES_DIR/${name}.stdout" ]; then
+ expect_stdout="$(cat "$CASES_DIR/${name}.stdout")"
+ fi
+
+ # ---- compile ----
+ # Three -isystem layers, in order of precedence:
+ # rt/include/ — cfree's own freestanding
+ # headers (stddef.h,
+ # stdarg.h, stdint.h, ...).
+ # Compiler-defined; libc
+ # does not ship these.
+ # glibc's unistd.h /
+ # stdio.h #include <stddef.h>
+ # for size_t, so the
+ # freestanding set must be
+ # reachable.
+ # sysroot/include/ — glibc + linux-libc-dev
+ # headers (top-level uapi).
+ # sysroot/include/aarch64-linux-gnu — glibc multi-arch (bits/*,
+ # gnu/stubs-lp64.h, ...);
+ # <features.h> reaches in.
+ # -nostdinc strips the host's default include path so clang's
+ # resource dir is not consulted.
+ local cc_flags=(--target=aarch64-linux-gnu --sysroot="$SYSROOT"
+ -nostdinc
+ -isystem "$ROOT/rt/include"
+ -isystem "$SYSROOT/include"
+ -isystem "$SYSROOT/include/aarch64-linux-gnu"
+ -fPIE -fpic -O0)
+
+ local obj="$work/${name}.o"
+ if ! clang "${cc_flags[@]}" -c "$src" -o "$obj" 2>"$work/cc.err"; then
+ FAIL=$((FAIL+1))
+ FAIL_NAMES+=("$label (compile)")
+ printf ' %s %s\n' "$(color_red FAIL)" "$label (compile)"
+ sed 's/^/ cc| /' "$work/cc.err"
+ return
+ fi
+
+ # ---- link ----
+ # PIE start file, libc.so.6 as the *shared* input (cfree ld
+ # doesn't read the libc.so linker script, so we hand the actual
+ # SO directly), with -dynamic-linker overriding the musl default.
+ # Expects cfree ld to:
+ # - accept ET_DYN ELF objects as input,
+ # - emit PT_INTERP "/lib/ld-linux-aarch64.so.1",
+ # - emit PT_DYNAMIC with DT_NEEDED libc.so.6,
+ # - emit a .dynsym/.dynstr/.gnu.hash + .rela.plt/.got.plt
+ # so the loader can bind imported symbols at runtime.
+ # libc_nonshared.a still links statically; libcfree_rt.a stays —
+ # soft-float TF helpers are static-bound from our side.
+ # crti/crtn are unchanged.
+ local exe="$work/${name}.exe"
+ local link_cmd=("$CFREE" "ld" -pie
+ -dynamic-linker /lib/ld-linux-aarch64.so.1
+ -o "$exe"
+ "$SYSROOT/lib/Scrt1.o" "$SYSROOT/lib/crti.o"
+ "$obj"
+ "$SYSROOT/lib/libc.so.6" "$SYSROOT/lib/libc_nonshared.a"
+ "$CFREE_RT"
+ "$SYSROOT/lib/crtn.o")
+
+ if ! "${link_cmd[@]}" >"$work/link.out" 2>"$work/link.err"; then
+ FAIL=$((FAIL+1))
+ FAIL_NAMES+=("$label (link)")
+ printf ' %s %s\n' "$(color_red FAIL)" "$label (link)"
+ sed 's/^/ ld| /' "$work/link.err" | head -10
+ return
+ fi
+
+ # ---- run ----
+ run_aarch64 "$exe" "$work/run.out" "$work/run.err"
+ if [ "$RUN_RC" -ne "$expected" ]; then
+ FAIL=$((FAIL+1))
+ FAIL_NAMES+=("$label (run rc=$RUN_RC, want $expected)")
+ printf ' %s %s (rc=%s, want %s)\n' "$(color_red FAIL)" "$label" \
+ "$RUN_RC" "$expected"
+ [ -s "$work/run.err" ] && sed 's/^/ err| /' "$work/run.err" | head -5
+ [ -s "$work/run.out" ] && sed 's/^/ out| /' "$work/run.out" | head -5
+ return
+ fi
+
+ if [ -n "$expect_stdout" ]; then
+ if ! grep -qF -- "$expect_stdout" "$work/run.out"; then
+ FAIL=$((FAIL+1))
+ FAIL_NAMES+=("$label (stdout)")
+ printf ' %s %s (stdout mismatch)\n' "$(color_red FAIL)" "$label"
+ printf ' expected substring: %s\n' "$expect_stdout"
+ sed 's/^/ got| /' "$work/run.out" | head -5
+ return
+ fi
+ fi
+
+ PASS=$((PASS+1))
+ printf ' %s %s\n' "$(color_grn PASS)" "$label"
+}
+
+shopt -s nullglob
+
+printf 'Running glibc dynamic-link cases...\n'
+for src in "$CASES_DIR"/*.c; do
+ run_case "$src"
+done
+
+printf '\nResults: %s pass, %s fail\n' "$PASS" "$FAIL"
+
+if [ ${#FAIL_NAMES[@]} -gt 0 ]; then
+ printf '\nFailed:\n'
+ for n in "${FAIL_NAMES[@]}"; do printf ' %s\n' "$n"; done
+ exit 1
+fi
+exit 0
diff --git a/test/libc/musl/Containerfile b/test/libc/musl/Containerfile
@@ -0,0 +1,40 @@
+# test/libc/musl/Containerfile — produces a static musl aarch64 sysroot
+# tarball on stdout. Pinned to Alpine 3.20.10 + musl 1.2.5.
+#
+# Usage (driven by test/libc/musl/extract.sh):
+# podman build --platform linux/arm64 -f Containerfile -t cfree-musl-sysroot .
+# podman run --rm cfree-musl-sysroot > sysroot.tar
+#
+# The image's ENTRYPOINT writes a tar of /sysroot to stdout. The extract
+# script unpacks it into build/musl-sysroot/ on the host.
+FROM docker.io/arm64v8/alpine:3.20.10
+
+# musl-dev: Scrt1.o, crt1.o, crti.o, crtn.o, libc.a + headers under /usr/include.
+# Plus the dynamic linker / libc.so used by the dynamic-link variant of the
+# harness (in musl, /lib/ld-musl-aarch64.so.1 *is* libc — same file).
+# linux-headers: kernel uapi (linux/*, asm/*, asm-generic/*) — used by syscall
+# definitions in the musl headers.
+# Note: we deliberately do NOT pull clang's compiler-rt or libgcc — the
+# soft-float / TF / 128-bit-int helpers (__extenddftf2 etc.) come from
+# our own rt/ build (rt/build/aarch64-linux/libcfree_rt.a).
+RUN apk add --no-cache musl-dev=1.2.5-r3 linux-headers
+
+# Stage the artifacts the linker needs into one tree so the host extract
+# is a single tar pipe.
+RUN mkdir -p /sysroot/lib /sysroot/include \
+ && cp /usr/lib/crt1.o /sysroot/lib/ \
+ && cp /usr/lib/Scrt1.o /sysroot/lib/ \
+ && cp /usr/lib/rcrt1.o /sysroot/lib/ \
+ && cp /usr/lib/crti.o /sysroot/lib/ \
+ && cp /usr/lib/crtn.o /sysroot/lib/ \
+ && cp /usr/lib/libc.a /sysroot/lib/ \
+ && cp /usr/lib/libssp_nonshared.a /sysroot/lib/ \
+ && cp /lib/ld-musl-aarch64.so.1 /sysroot/lib/ \
+ && ln -s ld-musl-aarch64.so.1 /sysroot/lib/libc.so \
+ && cp -r /usr/include/. /sysroot/include/
+
+# Pin the build for cache reuse and reproducibility audits.
+RUN echo "alpine 3.20.10 musl 1.2.5-r3" > /sysroot/PROVENANCE \
+ && uname -m >> /sysroot/PROVENANCE
+
+ENTRYPOINT ["sh", "-c", "tar -C /sysroot -cf - ."]
diff --git a/test/libc/musl/extract.sh b/test/libc/musl/extract.sh
@@ -0,0 +1,46 @@
+#!/usr/bin/env bash
+# test/libc/musl/extract.sh — build the musl sysroot image (test/libc/musl/Containerfile)
+# and unpack /sysroot from it into build/musl-sysroot/. Cached after the first
+# successful run; pass `-f` to force a rebuild.
+#
+# Output layout:
+# build/musl-sysroot/
+# lib/ crt1.o crti.o crtn.o libc.a libssp_nonshared.a
+# include/ musl + linux-headers tree
+# PROVENANCE
+set -eu
+
+ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
+SYSROOT="$ROOT/build/musl-sysroot"
+TAG="cfree-musl-sysroot"
+FORCE=0
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ -f|--force) FORCE=1; shift ;;
+ *) echo "unknown arg: $1" >&2; exit 2 ;;
+ esac
+done
+
+if [ -f "$SYSROOT/PROVENANCE" ] && [ $FORCE -eq 0 ]; then
+ echo "musl sysroot already present at $SYSROOT (use -f to rebuild)"
+ exit 0
+fi
+
+if ! command -v podman >/dev/null 2>&1; then
+ echo "extract.sh: podman is required" >&2
+ exit 1
+fi
+
+cd "$ROOT/test/libc/musl"
+echo "Building $TAG (Alpine aarch64 + musl-dev)..."
+podman build --platform linux/arm64 -f Containerfile -t "$TAG" . >/dev/null
+
+rm -rf "$SYSROOT"
+mkdir -p "$SYSROOT"
+
+echo "Extracting sysroot to $SYSROOT..."
+podman run --rm --platform linux/arm64 "$TAG" | tar -C "$SYSROOT" -xf -
+
+echo "Done. Provenance:"
+cat "$SYSROOT/PROVENANCE"
diff --git a/test/libc/musl/run.sh b/test/libc/musl/run.sh
@@ -0,0 +1,239 @@
+#!/usr/bin/env bash
+# test/libc/musl/run.sh — drive cfree ld against a real musl sysroot on
+# aarch64-linux. Each case in test/libc/cases/*.c is exercised in two
+# variants:
+#
+# static — non-PIC object + libc.a, classic static-exe link
+# cfree ld -static -o case.exe \
+# $SYSROOT/lib/crt1.o $SYSROOT/lib/crti.o \
+# case.o \
+# $SYSROOT/lib/libc.a $CFREE_RT \
+# $SYSROOT/lib/crtn.o
+#
+# dynamic — PIE object + libc.so, expects PT_INTERP /lib/ld-musl-aarch64.so.1
+# cfree ld -pie -o case.exe \
+# $SYSROOT/lib/Scrt1.o $SYSROOT/lib/crti.o \
+# case.o \
+# $SYSROOT/lib/libc.so $CFREE_RT \
+# $SYSROOT/lib/crtn.o
+# (musl ships ld-musl-aarch64.so.1 *as* libc — same file. The
+# harness intentionally has no -dynamic-linker flag yet because
+# cfree ld currently doesn't accept one; this is one of the gaps
+# we expect the dynamic variant to surface.)
+#
+# Each case file may carry an `expected` companion (default 0) and an
+# optional `expected_stdout` file checked with substring match.
+#
+# Designed to fail fast and clearly: the *first* failure surface (compile
+# / link / run / output) is the gap to fix next. Run with
+# CFREE_MUSL_KEEP=1 to leave intermediates in build/musl/<case>/.
+set -u
+
+ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
+CASES_DIR="$ROOT/test/libc/cases"
+BUILD_DIR="$ROOT/build/musl"
+SYSROOT="$ROOT/build/musl-sysroot"
+CFREE="$ROOT/build/cfree"
+CFREE_RT="$ROOT/rt/build/aarch64-linux/libcfree_rt.a"
+
+if [ ! -d "$SYSROOT" ]; then
+ echo "musl sysroot missing — run test/libc/musl/extract.sh first" >&2
+ exit 2
+fi
+if [ ! -x "$CFREE" ]; then
+ echo "cfree driver missing at $CFREE — run 'make' first" >&2
+ exit 2
+fi
+if [ ! -f "$CFREE_RT" ]; then
+ echo "cfree rt missing at $CFREE_RT — run 'make rt-aarch64-linux'" >&2
+ exit 2
+fi
+
+mkdir -p "$BUILD_DIR"
+
+color_red() { printf '\033[31m%s\033[0m' "$1"; }
+color_grn() { printf '\033[32m%s\033[0m' "$1"; }
+color_yel() { printf '\033[33m%s\033[0m' "$1"; }
+
+# Per-variant counters so the dynamic-link surface is visible in its own
+# right rather than being averaged into one total.
+PASS_static=0; FAIL_static=0; FAIL_NAMES_static=()
+PASS_dynamic=0; FAIL_dynamic=0; FAIL_NAMES_dynamic=()
+
+# Pick a runner. Native arm64 hosts can run aarch64 ELFs directly under
+# podman without binfmt; otherwise we want qemu-aarch64-static.
+arch_raw="$(uname -m 2>/dev/null || true)"
+is_aarch64=0
+{ [ "$arch_raw" = "aarch64" ] || [ "$arch_raw" = "arm64" ]; } && is_aarch64=1
+
+QEMU_BIN="$(command -v qemu-aarch64-static 2>/dev/null || command -v qemu-aarch64 2>/dev/null || true)"
+have_qemu=0; [ -n "$QEMU_BIN" ] && have_qemu=1
+have_podman=0; command -v podman >/dev/null 2>&1 && have_podman=1
+
+# clang must understand --target=aarch64-linux-musl. Recent clang ships
+# linux-musl as a target alias of linux-gnu for our purposes (we override
+# every system path via --sysroot).
+if ! clang --target=aarch64-linux-musl -c -x c - -o /dev/null < /dev/null 2>/dev/null; then
+ echo "clang does not accept --target=aarch64-linux-musl" >&2
+ exit 2
+fi
+
+run_aarch64() {
+ local exe="$1" out="$2" err="$3"
+ if [ $have_qemu -eq 1 ]; then
+ "$QEMU_BIN" "$exe" >"$out" 2>"$err"; RUN_RC=$?; return
+ fi
+ if [ $have_podman -eq 1 ]; then
+ local dir base platform_flag=()
+ dir="$(cd "$(dirname "$exe")" && pwd)"; base="$(basename "$exe")"
+ [ $is_aarch64 -eq 0 ] && platform_flag=(--platform linux/arm64)
+ podman run --rm "${platform_flag[@]}" --net=none \
+ -v "$dir":/work:Z -w /work alpine:latest "./$base" >"$out" 2>"$err"
+ RUN_RC=$?; return
+ fi
+ RUN_RC=127
+}
+
+# run_case <variant> <src>
+# variant ∈ {static, dynamic}
+run_case() {
+ local variant="$1" src="$2"
+ local name="$(basename "$src" .c)"
+ local work="$BUILD_DIR/$name/$variant"
+ local label="$name [$variant]"
+ mkdir -p "$work"
+
+ local expected=0
+ [ -f "$CASES_DIR/${name}.expected" ] && \
+ expected="$(cat "$CASES_DIR/${name}.expected" | tr -d '[:space:]')"
+
+ local expect_stdout=""
+ if [ -f "$CASES_DIR/${name}.stdout" ]; then
+ expect_stdout="$(cat "$CASES_DIR/${name}.stdout")"
+ fi
+
+ # ---- compile ----
+ # Two -isystem layers, in order of precedence:
+ # rt/include/ — cfree's own freestanding headers
+ # (stddef.h, stdarg.h, stdint.h, ...).
+ # sysroot/include/ — musl + linux-headers tree.
+ # -nostdinc strips the host's default include path so clang's
+ # resource dir is not consulted.
+ local cc_flags=(--target=aarch64-linux-musl --sysroot="$SYSROOT"
+ -nostdinc
+ -isystem "$ROOT/rt/include"
+ -isystem "$SYSROOT/include"
+ -O0)
+ case "$variant" in
+ static) cc_flags+=(-fno-PIC -fno-pie) ;;
+ dynamic) cc_flags+=(-fPIE -fpic) ;;
+ esac
+
+ local obj="$work/${name}.o"
+ if ! clang "${cc_flags[@]}" -c "$src" -o "$obj" 2>"$work/cc.err"; then
+ eval "FAIL_${variant}=\$((FAIL_${variant}+1))"
+ eval "FAIL_NAMES_${variant}+=(\"\$label (compile)\")"
+ printf ' %s %s\n' "$(color_red FAIL)" "$label (compile)"
+ sed 's/^/ cc| /' "$work/cc.err"
+ return
+ fi
+
+ # ---- link ----
+ local exe="$work/${name}.exe"
+ local link_cmd
+ case "$variant" in
+ static)
+ # Link order mirrors a typical static-musl invocation:
+ # crt1.o crti.o obj libc.a libcfree_rt.a crtn.o
+ # libcfree_rt provides the TF / soft-float builtins
+ # (__addtf3, __extenddftf2 etc.) that musl's libc.a calls
+ # from printf's long-double formatting. Archive ingestion
+ # iterates demand-load to a fixed point so one trailing
+ # libcfree_rt.a is enough.
+ link_cmd=("$CFREE" "ld" -static -o "$exe"
+ "$SYSROOT/lib/crt1.o" "$SYSROOT/lib/crti.o"
+ "$obj"
+ "$SYSROOT/lib/libc.a" "$CFREE_RT"
+ "$SYSROOT/lib/crtn.o")
+ ;;
+ dynamic)
+ # Dynamic-exe link: PIE start file, libc.so as a *shared*
+ # input (not an archive), expects cfree ld to:
+ # - accept ET_DYN ELF objects as input,
+ # - emit PT_INTERP "/lib/ld-musl-aarch64.so.1",
+ # - emit PT_DYNAMIC with DT_NEEDED libc.so,
+ # - emit a .dynsym/.dynstr/.gnu.hash + .rela.plt/.got.plt
+ # so the loader can bind imported symbols at runtime.
+ # libcfree_rt.a stays — soft-float TF helpers are still
+ # static-bound from our side. crti/crtn are unchanged.
+ link_cmd=("$CFREE" "ld" -pie -o "$exe"
+ "$SYSROOT/lib/Scrt1.o" "$SYSROOT/lib/crti.o"
+ "$obj"
+ "$SYSROOT/lib/libc.so" "$CFREE_RT"
+ "$SYSROOT/lib/crtn.o")
+ ;;
+ esac
+
+ if ! "${link_cmd[@]}" >"$work/link.out" 2>"$work/link.err"; then
+ eval "FAIL_${variant}=\$((FAIL_${variant}+1))"
+ eval "FAIL_NAMES_${variant}+=(\"\$label (link)\")"
+ printf ' %s %s\n' "$(color_red FAIL)" "$label (link)"
+ sed 's/^/ ld| /' "$work/link.err" | head -10
+ return
+ fi
+
+ # ---- run ----
+ run_aarch64 "$exe" "$work/run.out" "$work/run.err"
+ if [ "$RUN_RC" -ne "$expected" ]; then
+ eval "FAIL_${variant}=\$((FAIL_${variant}+1))"
+ eval "FAIL_NAMES_${variant}+=(\"\$label (run rc=\$RUN_RC, want \$expected)\")"
+ printf ' %s %s (rc=%s, want %s)\n' "$(color_red FAIL)" "$label" \
+ "$RUN_RC" "$expected"
+ [ -s "$work/run.err" ] && sed 's/^/ err| /' "$work/run.err" | head -5
+ [ -s "$work/run.out" ] && sed 's/^/ out| /' "$work/run.out" | head -5
+ return
+ fi
+
+ if [ -n "$expect_stdout" ]; then
+ if ! grep -qF -- "$expect_stdout" "$work/run.out"; then
+ eval "FAIL_${variant}=\$((FAIL_${variant}+1))"
+ eval "FAIL_NAMES_${variant}+=(\"\$label (stdout)\")"
+ printf ' %s %s (stdout mismatch)\n' "$(color_red FAIL)" "$label"
+ printf ' expected substring: %s\n' "$expect_stdout"
+ sed 's/^/ got| /' "$work/run.out" | head -5
+ return
+ fi
+ fi
+
+ eval "PASS_${variant}=\$((PASS_${variant}+1))"
+ printf ' %s %s\n' "$(color_grn PASS)" "$label"
+}
+
+shopt -s nullglob
+
+printf 'Running musl static-link cases...\n'
+for src in "$CASES_DIR"/*.c; do
+ run_case static "$src"
+done
+
+printf '\nRunning musl dynamic-link cases...\n'
+for src in "$CASES_DIR"/*.c; do
+ run_case dynamic "$src"
+done
+
+printf '\nResults:\n'
+printf ' static : %s pass, %s fail\n' "$PASS_static" "$FAIL_static"
+printf ' dynamic: %s pass, %s fail\n' "$PASS_dynamic" "$FAIL_dynamic"
+
+if [ ${#FAIL_NAMES_static[@]} -gt 0 ]; then
+ printf '\nFailed (static):\n'
+ for n in "${FAIL_NAMES_static[@]}"; do printf ' %s\n' "$n"; done
+fi
+if [ ${#FAIL_NAMES_dynamic[@]} -gt 0 ]; then
+ printf '\nFailed (dynamic):\n'
+ for n in "${FAIL_NAMES_dynamic[@]}"; do printf ' %s\n' "$n"; done
+fi
+
+total_fail=$((FAIL_static + FAIL_dynamic))
+if [ $total_fail -gt 0 ]; then exit 1; fi
+exit 0
diff --git a/test/musl/Containerfile b/test/musl/Containerfile
@@ -1,40 +0,0 @@
-# test/musl/Containerfile — produces a static musl aarch64 sysroot
-# tarball on stdout. Pinned to Alpine 3.20.10 + musl 1.2.5.
-#
-# Usage (driven by test/musl/extract.sh):
-# podman build --platform linux/arm64 -f Containerfile -t cfree-musl-sysroot .
-# podman run --rm cfree-musl-sysroot > sysroot.tar
-#
-# The image's ENTRYPOINT writes a tar of /sysroot to stdout. The extract
-# script unpacks it into build/musl-sysroot/ on the host.
-FROM docker.io/arm64v8/alpine:3.20.10
-
-# musl-dev: Scrt1.o, crt1.o, crti.o, crtn.o, libc.a + headers under /usr/include.
-# Plus the dynamic linker / libc.so used by the dynamic-link variant of the
-# harness (in musl, /lib/ld-musl-aarch64.so.1 *is* libc — same file).
-# linux-headers: kernel uapi (linux/*, asm/*, asm-generic/*) — used by syscall
-# definitions in the musl headers.
-# Note: we deliberately do NOT pull clang's compiler-rt or libgcc — the
-# soft-float / TF / 128-bit-int helpers (__extenddftf2 etc.) come from
-# our own rt/ build (rt/build/aarch64-linux/libcfree_rt.a).
-RUN apk add --no-cache musl-dev=1.2.5-r3 linux-headers
-
-# Stage the artifacts the linker needs into one tree so the host extract
-# is a single tar pipe.
-RUN mkdir -p /sysroot/lib /sysroot/include \
- && cp /usr/lib/crt1.o /sysroot/lib/ \
- && cp /usr/lib/Scrt1.o /sysroot/lib/ \
- && cp /usr/lib/rcrt1.o /sysroot/lib/ \
- && cp /usr/lib/crti.o /sysroot/lib/ \
- && cp /usr/lib/crtn.o /sysroot/lib/ \
- && cp /usr/lib/libc.a /sysroot/lib/ \
- && cp /usr/lib/libssp_nonshared.a /sysroot/lib/ \
- && cp /lib/ld-musl-aarch64.so.1 /sysroot/lib/ \
- && ln -s ld-musl-aarch64.so.1 /sysroot/lib/libc.so \
- && cp -r /usr/include/. /sysroot/include/
-
-# Pin the build for cache reuse and reproducibility audits.
-RUN echo "alpine 3.20.10 musl 1.2.5-r3" > /sysroot/PROVENANCE \
- && uname -m >> /sysroot/PROVENANCE
-
-ENTRYPOINT ["sh", "-c", "tar -C /sysroot -cf - ."]
diff --git a/test/musl/cases/02_errno_touch.c b/test/musl/cases/02_errno_touch.c
@@ -1,12 +0,0 @@
-/* Tier 2: touch errno. errno is `__thread` in musl, so this exercises
- * TLS local-exec relocs (R_AARCH64_TLSLE_*) and PT_TLS emission. We
- * deliberately call a function that fails (close(-1) → EBADF) and read
- * errno after. */
-
-#include <errno.h>
-#include <unistd.h>
-
-int main(void) {
- (void)close(-1);
- return errno == EBADF ? 0 : 1;
-}
diff --git a/test/musl/cases/03_printf_hello.c b/test/musl/cases/03_printf_hello.c
@@ -1,7 +0,0 @@
-/* Tier 3: full stdio. printf pulls in FILE buffering, locale,
- * vfprintf, write_iov, errno, lock primitives, the works. If this
- * runs, cfree ld is meaningfully usable for static musl programs. */
-
-#include <stdio.h>
-
-int main(void) { return printf("hello, musl\n") < 0 ? 1 : 0; }
diff --git a/test/musl/cases/03_printf_hello.stdout b/test/musl/cases/03_printf_hello.stdout
@@ -1 +0,0 @@
-hello, musl
diff --git a/test/musl/extract.sh b/test/musl/extract.sh
@@ -1,46 +0,0 @@
-#!/usr/bin/env bash
-# test/musl/extract.sh — build the musl sysroot image (test/musl/Containerfile)
-# and unpack /sysroot from it into build/musl-sysroot/. Cached after the first
-# successful run; pass `-f` to force a rebuild.
-#
-# Output layout:
-# build/musl-sysroot/
-# lib/ crt1.o crti.o crtn.o libc.a libssp_nonshared.a
-# include/ musl + linux-headers tree
-# PROVENANCE
-set -eu
-
-ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
-SYSROOT="$ROOT/build/musl-sysroot"
-TAG="cfree-musl-sysroot"
-FORCE=0
-
-while [ $# -gt 0 ]; do
- case "$1" in
- -f|--force) FORCE=1; shift ;;
- *) echo "unknown arg: $1" >&2; exit 2 ;;
- esac
-done
-
-if [ -f "$SYSROOT/PROVENANCE" ] && [ $FORCE -eq 0 ]; then
- echo "musl sysroot already present at $SYSROOT (use -f to rebuild)"
- exit 0
-fi
-
-if ! command -v podman >/dev/null 2>&1; then
- echo "extract.sh: podman is required" >&2
- exit 1
-fi
-
-cd "$ROOT/test/musl"
-echo "Building $TAG (Alpine aarch64 + musl-dev)..."
-podman build --platform linux/arm64 -f Containerfile -t "$TAG" . >/dev/null
-
-rm -rf "$SYSROOT"
-mkdir -p "$SYSROOT"
-
-echo "Extracting sysroot to $SYSROOT..."
-podman run --rm --platform linux/arm64 "$TAG" | tar -C "$SYSROOT" -xf -
-
-echo "Done. Provenance:"
-cat "$SYSROOT/PROVENANCE"
diff --git a/test/musl/run.sh b/test/musl/run.sh
@@ -1,230 +0,0 @@
-#!/usr/bin/env bash
-# test/musl/run.sh — drive cfree ld against a real musl sysroot on
-# aarch64-linux. Each case in test/musl/cases/*.c is exercised in two
-# variants:
-#
-# static — non-PIC object + libc.a, classic static-exe link
-# cfree ld -static -o case.exe \
-# $SYSROOT/lib/crt1.o $SYSROOT/lib/crti.o \
-# case.o \
-# $SYSROOT/lib/libc.a $CFREE_RT \
-# $SYSROOT/lib/crtn.o
-#
-# dynamic — PIE object + libc.so, expects PT_INTERP /lib/ld-musl-aarch64.so.1
-# cfree ld -pie -o case.exe \
-# $SYSROOT/lib/Scrt1.o $SYSROOT/lib/crti.o \
-# case.o \
-# $SYSROOT/lib/libc.so $CFREE_RT \
-# $SYSROOT/lib/crtn.o
-# (musl ships ld-musl-aarch64.so.1 *as* libc — same file. The
-# harness intentionally has no -dynamic-linker flag yet because
-# cfree ld currently doesn't accept one; this is one of the gaps
-# we expect the dynamic variant to surface.)
-#
-# Each case file may carry an `expected` companion (default 0) and an
-# optional `expected_stdout` file checked with substring match.
-#
-# Designed to fail fast and clearly: the *first* failure surface (compile
-# / link / run / output) is the gap to fix next. Run with
-# CFREE_MUSL_KEEP=1 to leave intermediates in build/musl/<case>/.
-set -u
-
-ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
-TEST_DIR="$ROOT/test/musl"
-BUILD_DIR="$ROOT/build/musl"
-SYSROOT="$ROOT/build/musl-sysroot"
-CFREE="$ROOT/build/cfree"
-CFREE_RT="$ROOT/rt/build/aarch64-linux/libcfree_rt.a"
-
-if [ ! -d "$SYSROOT" ]; then
- echo "musl sysroot missing — run test/musl/extract.sh first" >&2
- exit 2
-fi
-if [ ! -x "$CFREE" ]; then
- echo "cfree driver missing at $CFREE — run 'make' first" >&2
- exit 2
-fi
-if [ ! -f "$CFREE_RT" ]; then
- echo "cfree rt missing at $CFREE_RT — run 'make rt-aarch64-linux'" >&2
- exit 2
-fi
-
-mkdir -p "$BUILD_DIR"
-
-color_red() { printf '\033[31m%s\033[0m' "$1"; }
-color_grn() { printf '\033[32m%s\033[0m' "$1"; }
-color_yel() { printf '\033[33m%s\033[0m' "$1"; }
-
-# Per-variant counters so the dynamic-link surface is visible in its own
-# right rather than being averaged into one total.
-PASS_static=0; FAIL_static=0; FAIL_NAMES_static=()
-PASS_dynamic=0; FAIL_dynamic=0; FAIL_NAMES_dynamic=()
-
-# Pick a runner. Native arm64 hosts can run aarch64 ELFs directly under
-# podman without binfmt; otherwise we want qemu-aarch64-static.
-arch_raw="$(uname -m 2>/dev/null || true)"
-is_aarch64=0
-{ [ "$arch_raw" = "aarch64" ] || [ "$arch_raw" = "arm64" ]; } && is_aarch64=1
-
-QEMU_BIN="$(command -v qemu-aarch64-static 2>/dev/null || command -v qemu-aarch64 2>/dev/null || true)"
-have_qemu=0; [ -n "$QEMU_BIN" ] && have_qemu=1
-have_podman=0; command -v podman >/dev/null 2>&1 && have_podman=1
-
-# clang must understand --target=aarch64-linux-musl. Recent clang ships
-# linux-musl as a target alias of linux-gnu for our purposes (we override
-# every system path via --sysroot).
-if ! clang --target=aarch64-linux-musl -c -x c - -o /dev/null < /dev/null 2>/dev/null; then
- echo "clang does not accept --target=aarch64-linux-musl" >&2
- exit 2
-fi
-
-run_aarch64() {
- local exe="$1" out="$2" err="$3"
- if [ $have_qemu -eq 1 ]; then
- "$QEMU_BIN" "$exe" >"$out" 2>"$err"; RUN_RC=$?; return
- fi
- if [ $have_podman -eq 1 ]; then
- local dir base platform_flag=()
- dir="$(cd "$(dirname "$exe")" && pwd)"; base="$(basename "$exe")"
- [ $is_aarch64 -eq 0 ] && platform_flag=(--platform linux/arm64)
- podman run --rm "${platform_flag[@]}" --net=none \
- -v "$dir":/work:Z -w /work alpine:latest "./$base" >"$out" 2>"$err"
- RUN_RC=$?; return
- fi
- RUN_RC=127
-}
-
-# run_case <variant> <src>
-# variant ∈ {static, dynamic}
-run_case() {
- local variant="$1" src="$2"
- local name="$(basename "$src" .c)"
- local work="$BUILD_DIR/$name/$variant"
- local label="$name [$variant]"
- mkdir -p "$work"
-
- local expected=0
- [ -f "$TEST_DIR/cases/${name}.expected" ] && \
- expected="$(cat "$TEST_DIR/cases/${name}.expected" | tr -d '[:space:]')"
-
- local expect_stdout=""
- if [ -f "$TEST_DIR/cases/${name}.stdout" ]; then
- expect_stdout="$(cat "$TEST_DIR/cases/${name}.stdout")"
- fi
-
- # ---- compile ----
- local cc_flags=(--target=aarch64-linux-musl --sysroot="$SYSROOT"
- -nostdinc -isystem "$SYSROOT/include" -O0)
- case "$variant" in
- static) cc_flags+=(-fno-PIC -fno-pie) ;;
- dynamic) cc_flags+=(-fPIE -fpic) ;;
- esac
-
- local obj="$work/${name}.o"
- if ! clang "${cc_flags[@]}" -c "$src" -o "$obj" 2>"$work/cc.err"; then
- eval "FAIL_${variant}=\$((FAIL_${variant}+1))"
- eval "FAIL_NAMES_${variant}+=(\"\$label (compile)\")"
- printf ' %s %s\n' "$(color_red FAIL)" "$label (compile)"
- sed 's/^/ cc| /' "$work/cc.err"
- return
- fi
-
- # ---- link ----
- local exe="$work/${name}.exe"
- local link_cmd
- case "$variant" in
- static)
- # Link order mirrors a typical static-musl invocation:
- # crt1.o crti.o obj libc.a libcfree_rt.a crtn.o
- # libcfree_rt provides the TF / soft-float builtins
- # (__addtf3, __extenddftf2 etc.) that musl's libc.a calls
- # from printf's long-double formatting. Archive ingestion
- # iterates demand-load to a fixed point so one trailing
- # libcfree_rt.a is enough.
- link_cmd=("$CFREE" "ld" -static -o "$exe"
- "$SYSROOT/lib/crt1.o" "$SYSROOT/lib/crti.o"
- "$obj"
- "$SYSROOT/lib/libc.a" "$CFREE_RT"
- "$SYSROOT/lib/crtn.o")
- ;;
- dynamic)
- # Dynamic-exe link: PIE start file, libc.so as a *shared*
- # input (not an archive), expects cfree ld to:
- # - accept ET_DYN ELF objects as input,
- # - emit PT_INTERP "/lib/ld-musl-aarch64.so.1",
- # - emit PT_DYNAMIC with DT_NEEDED libc.so,
- # - emit a .dynsym/.dynstr/.gnu.hash + .rela.plt/.got.plt
- # so the loader can bind imported symbols at runtime.
- # libcfree_rt.a stays — soft-float TF helpers are still
- # static-bound from our side. crti/crtn are unchanged.
- link_cmd=("$CFREE" "ld" -pie -o "$exe"
- "$SYSROOT/lib/Scrt1.o" "$SYSROOT/lib/crti.o"
- "$obj"
- "$SYSROOT/lib/libc.so" "$CFREE_RT"
- "$SYSROOT/lib/crtn.o")
- ;;
- esac
-
- if ! "${link_cmd[@]}" >"$work/link.out" 2>"$work/link.err"; then
- eval "FAIL_${variant}=\$((FAIL_${variant}+1))"
- eval "FAIL_NAMES_${variant}+=(\"\$label (link)\")"
- printf ' %s %s\n' "$(color_red FAIL)" "$label (link)"
- sed 's/^/ ld| /' "$work/link.err" | head -10
- return
- fi
-
- # ---- run ----
- run_aarch64 "$exe" "$work/run.out" "$work/run.err"
- if [ "$RUN_RC" -ne "$expected" ]; then
- eval "FAIL_${variant}=\$((FAIL_${variant}+1))"
- eval "FAIL_NAMES_${variant}+=(\"\$label (run rc=\$RUN_RC, want \$expected)\")"
- printf ' %s %s (rc=%s, want %s)\n' "$(color_red FAIL)" "$label" \
- "$RUN_RC" "$expected"
- [ -s "$work/run.err" ] && sed 's/^/ err| /' "$work/run.err" | head -5
- [ -s "$work/run.out" ] && sed 's/^/ out| /' "$work/run.out" | head -5
- return
- fi
-
- if [ -n "$expect_stdout" ]; then
- if ! grep -qF -- "$expect_stdout" "$work/run.out"; then
- eval "FAIL_${variant}=\$((FAIL_${variant}+1))"
- eval "FAIL_NAMES_${variant}+=(\"\$label (stdout)\")"
- printf ' %s %s (stdout mismatch)\n' "$(color_red FAIL)" "$label"
- printf ' expected substring: %s\n' "$expect_stdout"
- sed 's/^/ got| /' "$work/run.out" | head -5
- return
- fi
- fi
-
- eval "PASS_${variant}=\$((PASS_${variant}+1))"
- printf ' %s %s\n' "$(color_grn PASS)" "$label"
-}
-
-shopt -s nullglob
-
-printf 'Running musl static-link cases...\n'
-for src in "$TEST_DIR/cases"/*.c; do
- run_case static "$src"
-done
-
-printf '\nRunning musl dynamic-link cases...\n'
-for src in "$TEST_DIR/cases"/*.c; do
- run_case dynamic "$src"
-done
-
-printf '\nResults:\n'
-printf ' static : %s pass, %s fail\n' "$PASS_static" "$FAIL_static"
-printf ' dynamic: %s pass, %s fail\n' "$PASS_dynamic" "$FAIL_dynamic"
-
-if [ ${#FAIL_NAMES_static[@]} -gt 0 ]; then
- printf '\nFailed (static):\n'
- for n in "${FAIL_NAMES_static[@]}"; do printf ' %s\n' "$n"; done
-fi
-if [ ${#FAIL_NAMES_dynamic[@]} -gt 0 ]; then
- printf '\nFailed (dynamic):\n'
- for n in "${FAIL_NAMES_dynamic[@]}"; do printf ' %s\n' "$n"; done
-fi
-
-total_fail=$((FAIL_static + FAIL_dynamic))
-if [ $total_fail -gt 0 ]; then exit 1; fi
-exit 0
diff --git a/test/test.mk b/test/test.mk
@@ -24,7 +24,7 @@
# against the public cfree.h surface; reuses cfree-roundtrip,
# link-exe-runner, and jit-runner.
-.PHONY: test test-lex test-pp test-pp-err test-elf test-ar test-ar-driver test-link test-cg test-dwarf test-debug test-parse test-parse-err test-musl test-lib-deps
+.PHONY: test test-lex test-pp test-pp-err test-elf test-ar test-ar-driver test-link test-cg test-dwarf test-debug test-parse test-parse-err test-musl test-glibc test-lib-deps
test: test-lex test-pp test-pp-err test-elf test-ar test-ar-driver test-link test-cg test-dwarf test-debug test-parse test-parse-err test-lib-deps
@@ -130,24 +130,36 @@ test-parse: lib $(PARSE_RUNNER) $(ROUNDTRIP_BIN) $(LINK_EXE_RUNNER) $(JIT_RUNNER
test-parse-err: lib $(PARSE_RUNNER)
sh test/parse/run_errors.sh
-# test-musl: end-to-end static + dynamic musl link/run on aarch64.
-# Pulls a pinned musl sysroot (test/musl/extract.sh — podman against
-# Alpine 3.20), builds rt/build/aarch64-linux/libcfree_rt.a for the
-# soft-float / TF builtins, and runs `cfree ld` against the real musl
-# libc.a (static variant) and libc.so (dynamic variant — see
-# doc/DYNLD.md). Excluded from the default `test` target because it
-# needs podman and ~30s on first run; opt-in via `make test-musl`.
+# test-musl / test-glibc: end-to-end static + dynamic libc link/run on
+# aarch64. Each variant pulls its own pinned sysroot (podman, ~30s on
+# first run) and shares the same case files under test/libc/cases/:
#
-# The sysroot is treated as a real prerequisite via its PROVENANCE
+# test-musl — Alpine 3.20 + musl 1.2.5 (test/libc/musl/)
+# test-glibc — Debian bookworm + glibc 2.36 (test/libc/glibc/)
+#
+# Both build rt/build/aarch64-linux/libcfree_rt.a for soft-float / TF
+# builtins, and run `cfree ld` against the real libc.a (static) and
+# libc.so / libc.so.6 (dynamic — see doc/DYNLD.md). Excluded from the
+# default `test` target because they need podman; opt-in via
+# `make test-musl` / `make test-glibc`.
+#
+# Each sysroot is treated as a real prerequisite via its PROVENANCE
# marker so subsequent runs skip extraction and re-extract only when
-# the file is removed (or test/musl/extract.sh -f forces a rebuild).
-MUSL_SYSROOT_MARKER = build/musl-sysroot/PROVENANCE
+# the file is removed (or extract.sh -f forces a rebuild).
+MUSL_SYSROOT_MARKER = build/musl-sysroot/PROVENANCE
+GLIBC_SYSROOT_MARKER = build/glibc-sysroot/PROVENANCE
-$(MUSL_SYSROOT_MARKER): test/musl/extract.sh test/musl/Containerfile
- @bash test/musl/extract.sh
+$(MUSL_SYSROOT_MARKER): test/libc/musl/extract.sh test/libc/musl/Containerfile
+ @bash test/libc/musl/extract.sh
+
+$(GLIBC_SYSROOT_MARKER): test/libc/glibc/extract.sh test/libc/glibc/Containerfile
+ @bash test/libc/glibc/extract.sh
test-musl: bin rt-aarch64-linux $(MUSL_SYSROOT_MARKER)
- @bash test/musl/run.sh
+ @bash test/libc/musl/run.sh
+
+test-glibc: bin rt-aarch64-linux $(GLIBC_SYSROOT_MARKER)
+ @bash test/libc/glibc/run.sh
# Fail if libcfree.a depends on any external symbol not in the allowlist.
# Drift in either direction (new dep, or stale entry) is a failure.