Commit03d7fb9dRecorded31 Jul 2026Repositorycore-channel

Refuse a compile with no explicit target; add the musl loader package

Message

P1.3, channel half.

THE EXPLICIT-TARGET GUARD. toolchain/bin/zig was a symlink to the zig binary; it is now a guard that refuses zig cc / c++ / build-* / translate-c with no explicit -target and only then execs the real binary. Zig does not fail when it has nothing to detect -- it guesses, silently. Measured on this toolchain: bare zig cc bakes the interpreter /gnu/store/<hash>-glibc-2.41/lib/ld-linux-x86-64.so.2 into every executable it links, which is not a declared input and does not exist inside the sandbox. The musl wrappers exec the guard too, so it sits on the hot path of every compile rather than in a corner where it could rot unnoticed.

The guard asserts its own instrument before interpreting anything: a missing or dangling zig exits 70 naming a broken toolchain, never 78 blaming the caller and never a silent 127. It is a defect detector on the invocation, not a security boundary -- a build reaching past $PATH for the real binary is not stopped by it, and the structural backstop is the post-link ELF check.

Six-case matrix, both directions, in ~/Ops/workspaces/sigil-packaging-stage1-p13/evidence/zig-guard-matrix.log: bare cc -> 78 with no output file; -target -> 0 with a real ELF; the musl wrapper -> 0 and statically linked; zig version / zig ar pass through; broken toolchain -> 70; bare c++ -> 78.

THE MUSL LOADER PACKAGE. core/musl builds musl 1.2.5 -- the version zig 0.16.0 bundles, read from lib/libc/musl/src/internal/version.h in the pinned toolchain, so the loader and the libc executables link against are the same release rather than merely compatible. Most packages will never need it: x86_64-linux-musl-cc links static by default and GNU hello has no INTERP segment at all. It exists so the DYNAMIC case is expressible instead of blocked, with the loader arriving as a declared input rather than ambient.

It carries one channel patch. musl installs lib/ld-musl-x86_64.so.1 as an ABSOLUTE symlink into its own /sigil/store prefix, which resolves only inside the sandbox -- and the bind is assembled on the host, before the chroot. The patch makes the symlink relative to its own directory so it resolves from both sides. Measured: applies cleanly under the builder's own patch -p1 -N -i, and the built item's loader resolves to a real 728 KB ELF shared object on the host. Without it the builder now refuses at assembly time and names the dangling symlink.

Changed
 channel.sgl                                  |  2 ++
 src/core/packages/libc.sgl                   | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/patches/musl-relative-ldso-symlink.patch | 24 ++++++++++++++++++++++++
 toolchain/setup.sh                           | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 4 files changed, 178 insertions(+), 1 deletion(-)
Diff
channel.sglmodified
@@ -1,6 +1,7 @@
1
(import (core packages wasm)
2
(core packages demo)
3
(core packages compression)
+4
(core packages libc)
5
(core packages terminal)
6
(core packages xml)
7
(core packages databases)
@@ -13,6 +14,7 @@
14
(channel-binding name: "core/binaryen" value: binaryen)
15
(channel-binding name: "core/hello" value: hello)
16
(channel-binding name: "core/zlib" value: zlib)
+17
(channel-binding name: "core/musl" value: musl)
18
(channel-binding name: "core/ncurses" value: ncurses)
19
(channel-binding name: "core/libxml2" value: libxml2)
20
(channel-binding name: "core/sqlite" value: sqlite)
src/core/packages/libc.sgladded
@@ -0,0 +1,77 @@
+1
;;; The C runtime, as a package.
+2
;;;
+3
;;; WHY A LOADER PACKAGE EXISTS AT ALL (P1.3).
+4
;;;
+5
;;; Inside the build sandbox a dynamic loader is never ambient. It appears at
+6
;;; its conventional absolute path (`/lib/ld-musl-x86_64.so.1`) only because
+7
;;; some DECLARED dependency ships it in its store item, and nothing else in
+8
;;; the namespace can supply one. That is deliberate: a loader resolved from
+9
;;; whatever the host happened to have is a machine-specific input that no
+10
;;; derivation record mentions, and the spike measured what it costs when the
+11
;;; guess lands badly -- a segfaulting `temacs`, with no error anywhere.
+12
;;;
+13
;;; So a package that produces a DYNAMICALLY linked musl executable must be
+14
;;; able to say where its loader comes from. This is that package. Depend on
+15
;;; it and the loader is bound; do not, and a dynamic executable fails at exec
+16
;;; with ENOENT rather than silently running against something unrecorded.
+17
;;;
+18
;;; MOST PACKAGES DO NOT NEED IT. `x86_64-linux-musl-cc` links STATIC by
+19
;;; default (measured: GNU hello built through it has no INTERP segment at all
+20
;;; and runs), so the whole channel builds without a loader today. This
+21
;;; package is what makes the dynamic case EXPRESSIBLE rather than blocked.
+22
;;;
+23
;;; THE VERSION PIN IS MEASURED, NOT CHOSEN. zig 0.16.0's bundled musl is
+24
;;; 1.2.5 -- `lib/libc/musl/src/internal/version.h` in the pinned toolchain
+25
;;; reads `#define VERSION "1.2.5"`. Pinning the same release keeps the
+26
;;; loader and the libc that executables were LINKED against identical rather
+27
;;; than merely compatible. If the toolchain is re-pinned, re-read that file
+28
;;; and move this with it.
+29
(define-library (core packages libc)
+30
(import (sigil core)
+31
(core build-environments)
+32
(sigil env package)
+33
(sigil build external))
+34
(export musl)
+35
(begin
+36
(define musl
+37
(package
+38
name: "musl"
+39
version: "1.2.5"
+40
source: (external-source
+41
location: "https://musl.libc.org/releases/musl-1.2.5.tar.gz"
+42
sha256: "a9a118bbe84d8764da0ea0d28b3ab3fae8477fc7e4085d90102b8596fc7c75e4")
+43
buildenv: core-buildenv
+44
phases: (list
+45
;; WHY THIS PATCH EXISTS. musl installs
+46
;; `lib/ld-musl-x86_64.so.1` as a symlink to
+47
;; `$(libdir)/libc.so` -- an ABSOLUTE path, which here is
+48
;; `/sigil/store/<item>/lib/libc.so` and exists only inside
+49
;; the sandbox. The sandbox assembly runs on the HOST,
+50
;; before the chroot, so it cannot resolve that symlink and
+51
;; cannot bind the loader.
+52
;;
+53
;; Measured on musl 1.2.5's own Makefile rule at line 213.
+54
;; Without the patch the builder now REFUSES at assembly time
+55
;; and names the dangling symlink; before P1.3 it skipped the
+56
;; bind silently and the failure surfaced as an ENOENT at
+57
;; exec, nowhere near the cause.
+58
;;
+59
;; The patch makes the symlink RELATIVE to its own directory,
+60
;; so it resolves identically from inside and outside.
+61
(external-phase
+62
kind: 'patch
+63
patches: (list
+64
(channel-patch
+65
path: "patches/musl-relative-ldso-symlink.patch"
+66
sha256: "94a0cb858be6713634145d1cf0ddd7ab31f9367e915f3a276b311de9f10d59e9")))
+67
;; `--syslibdir` is what decides where `ld-musl-x86_64.so.1`
+68
;; lands. Its default is `/lib`, which would be an absolute
+69
;; host path OUTSIDE this package's own prefix -- the install
+70
;; would escape DESTDIR exactly the way ncurses' terminfo
+71
;; database does without its `ticdir` pin.
+72
(external-phase
+73
kind: 'configure
+74
args: (list "--prefix=${out}"
+75
"--syslibdir=${out}/lib"))
+76
(external-phase kind: 'make)
+77
(external-phase kind: 'install))))))
src/patches/musl-relative-ldso-symlink.patchadded
@@ -0,0 +1,24 @@
+1
Make the dynamic-loader symlink RELATIVE to its own directory.
+2
+3
musl installs lib/ld-musl-x86_64.so.1 as a symlink to $(libdir)/libc.so, an
+4
ABSOLUTE path. In this channel $(libdir) is the package's own store prefix,
+5
/sigil/store/<item>/lib, which exists only inside the build sandbox. The
+6
sandbox assembly runs on the HOST, before the chroot, so it must be able to
+7
resolve the loader from outside: an absolute self-referential symlink is
+8
dangling there and cannot be bind-mounted.
+9
+10
A relative symlink names the sibling file and therefore resolves identically
+11
from both sides. Nothing else changes; the installed loader still points at
+12
the same libc.so.
+13
+14
--- a/Makefile
+15
+++ b/Makefile
+16
@@ -210,7 +210,7 @@
+17
$(INSTALL) -D -m 644 $< $@
+18
+19
$(DESTDIR)$(LDSO_PATHNAME): $(DESTDIR)$(libdir)/libc.so
+20
- $(INSTALL) -D -l $(libdir)/libc.so $@ || true
+21
+ $(INSTALL) -D -l libc.so $@ || true
+22
+23
install-libs: $(ALL_LIBS:lib/%=$(DESTDIR)$(libdir)/%) $(if $(SHARED_LIBS),$(DESTDIR)$(LDSO_PATHNAME),)
+24
toolchain/setup.shmodified
@@ -10,7 +10,81 @@ actual=$(sha256sum "$archive" | awk '{print $1}')
10
rm -rf "$root/zig-x86_64-linux-0.16.0" "$root/bin"
11
tar -C "$root" -xf "$archive"
12
mkdir -p "$root/bin"
13
ln -s ../zig-x86_64-linux-0.16.0/zig "$root/bin/zig"
+13
+14
# ---------------------------------------------------------------------------
+15
# THE EXPLICIT-TARGET GUARD (P1.3)
+16
#
+17
# `bin/zig` used to be a symlink to the zig binary. It is now a guard that
+18
# REFUSES a compile or a link with no explicit `-target`, and only then execs
+19
# the real binary. The rule it enforces is normative and thrice-measured:
+20
# targets are always explicit inside the build sandbox; loaders are inputs, not
+21
# ambient. Any "native" invocation inside the sandbox is a bug BY DEFINITION.
+22
#
+23
# WHY A REFUSAL AND NOT A DEFAULT. Zig does not fail when it has nothing to
+24
# detect -- it GUESSES, and the guess is silent. Measured 2026-07-31 on this
+25
# toolchain: bare `zig cc` bakes the interpreter
+26
# `/gnu/store/<hash>-glibc-2.41/lib/ld-linux-x86-64.so.2` into every executable
+27
# it links. That is a host-specific, guix-generation-specific absolute path
+28
# which is not a declared input, so inside the namespace it does not exist and
+29
# GNU hello dies at AC_PROG_CC. The spike measured the same guess landing
+30
# somewhere far worse: a SEGFAULTING `temacs`, with no error at all.
+31
#
+32
# WHAT THIS GUARD IS AND IS NOT. It is a defect detector on the invocation, so
+33
# the diagnostic names the cause instead of surfacing as an ENOENT or a
+34
# segfault hours later. It is NOT a security boundary: a build that reaches
+35
# past $PATH for `../zig-x86_64-linux-0.16.0/zig` is not stopped by it. The
+36
# structural backstop for "however the condition arrives" is the post-link
+37
# artifact check (test/integration/elf-dynsym-collapse-gate.sh in the
+38
# monorepo), which reads the produced ELF rather than the command line.
+39
#
+40
# The guard sits on the HOT path: the musl wrappers below exec it too, so every
+41
# compile in every build exercises it. A guard nothing routes through is the
+42
# vacuous-gate family one step earlier.
+43
cat > "$root/bin/zig" <<'EOF'
+44
#!/bin/sh
+45
set -eu
+46
here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+47
real="$here/../zig-x86_64-linux-0.16.0/zig"
+48
+49
# ASSERT THE INSTRUMENT BEFORE INTERPRETING ANYTHING. `-x` resolves symlinks,
+50
# so it is false for a dangling one. Without this check a missing or dangling
+51
# zig would exec-fail with 127, which reads like a compiler error rather than
+52
# like a broken toolchain -- the exact conflation ("the world cannot do this"
+53
# vs "I could not ask") that silently degraded every build once already.
+54
[ -x "$real" ] || {
+55
echo "zig-guard: the zig binary is missing or unusable at $real." >&2
+56
echo "zig-guard: this is a BROKEN TOOLCHAIN, not a compile failure." >&2
+57
echo "zig-guard: re-run toolchain/setup.sh." >&2
+58
exit 70
+59
}
+60
+61
# `case` rather than `[ ... ] && exec`: under `set -e` a failing `[` is the
+62
# status of the whole AND-OR list, so the guard would exit 1 on the FIRST
+63
# non-matching argument and never reach the refusal. Removing the construct
+64
# beats escaping it correctly.
+65
case "${1:-}" in
+66
cc|c++|build-exe|build-lib|build-obj|translate-c)
+67
for arg in "$@"; do
+68
case "$arg" in
+69
-target|--target=*) exec "$real" "$@" ;;
+70
esac
+71
done
+72
echo "zig-guard: REFUSING 'zig $1' with no explicit -target." >&2
+73
echo "zig-guard:" >&2
+74
echo "zig-guard: Inside the build sandbox there is nothing for zig's native" >&2
+75
echo "zig-guard: detection to probe, so it does not fail -- it guesses, and" >&2
+76
echo "zig-guard: bakes a host /gnu/store glibc loader into the output. That" >&2
+77
echo "zig-guard: loader is not a declared input and does not exist in the" >&2
+78
echo "zig-guard: namespace, so the result fails at exec or segfaults." >&2
+79
echo "zig-guard:" >&2
+80
echo "zig-guard: Use the wrappers -- x86_64-linux-musl-cc / -ar / -ranlib --" >&2
+81
echo "zig-guard: or pass -target explicitly." >&2
+82
exit 78
+83
;;
+84
esac
+85
exec "$real" "$@"
+86
EOF
+87
chmod +x "$root/bin/zig"
88
cat > "$root/bin/x86_64-linux-musl-cc" <<'EOF'
89
#!/bin/sh
90
set -eu