From 42f095d5d205cb5cac54b97305a3953c4342f4e2 Mon Sep 17 00:00:00 2001 From: Luke Craig Date: Sun, 7 Jun 2026 18:34:52 -0400 Subject: [PATCH 1/4] Stage the last tool in the manifest (fix dropped strace) The manifest is built with concatStringsSep "\n", so it has no trailing newline. The staging loop's `while IFS='|' read ...` reads the final record into the variables but then exits before the loop body runs, silently dropping whichever tool sorts last. Tools are emitted in attribute-name order (gdbserver, ltrace, python, strace), so strace was missing from every arch bundle even though it built and appeared in the manifest. Add the standard `|| [ -n "$tool_name" ]` guard so the last record is staged. Confirmed the armel bundle now contains gdbserver, ltrace, python, and strace. --- src/mk-arch-bundle.nix | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/mk-arch-bundle.nix b/src/mk-arch-bundle.nix index 56cd786..88648a8 100644 --- a/src/mk-arch-bundle.nix +++ b/src/mk-arch-bundle.nix @@ -204,7 +204,11 @@ pkgs.runCommand "penguin-tools-${penguinArch}" fi } - while IFS='|' read -r tool_name tool_mode tool_kind tool_path tool_link_target; do + # The "|| [ -n ... ]" keeps the last record even though the manifest has no + # trailing newline (writeText joins lines with concatStringsSep); without it + # `read` would consume the final tool into the variables but exit the loop + # before staging it, silently dropping whichever tool sorts last. + while IFS='|' read -r tool_name tool_mode tool_kind tool_path tool_link_target || [ -n "$tool_name" ]; do [ -n "$tool_name" ] || continue case "$tool_mode:$tool_kind" in copy:binary) From ed208584351e8ae8f97ddeccc32b5b1eadf2cf19 Mon Sep 17 00:00:00 2001 From: Luke Craig Date: Sun, 7 Jun 2026 18:58:39 -0400 Subject: [PATCH 2/4] Emit legacy arch/dylib dir names as compat aliases penguin still references the older dylib/arch directory names (arm64, loongarch, ppc64, ppc64el) in its sysroots staging and guest binary rpaths, while penguin-tools uses the canonical names (aarch64, loongarch64, powerpc64, powerpc64le). Add these as compatNames so each bundle emits both, letting penguin-tools fully replace the dylibs that hyperfs used to provide without any rename dance on the penguin side. Verified the arm64 bundle emits igloo_static/arm64 -> aarch64 and igloo_static/dylibs/arm64 -> aarch64. --- src/archs.nix | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/archs.nix b/src/archs.nix index c682c32..3c33a92 100644 --- a/src/archs.nix +++ b/src/archs.nix @@ -16,6 +16,8 @@ arm64 = { penguinName = "aarch64"; + # "arm64" is the legacy dylib/arch dir name the rest of penguin still uses. + compatNames = [ "arm64" ]; crossSystem = { config = "aarch64-linux-musl"; }; @@ -57,6 +59,8 @@ ppc64 = { penguinName = "powerpc64"; + # "ppc64" is the legacy dylib/arch dir name the rest of penguin still uses. + compatNames = [ "ppc64" ]; crossSystem = { config = "powerpc64-linux-musl"; gcc.abi = "elfv2"; @@ -65,7 +69,8 @@ ppc64el = { penguinName = "powerpc64le"; - compatNames = [ "powerpc64el" ]; + # "ppc64el" is the legacy dylib/arch dir name the rest of penguin uses. + compatNames = [ "powerpc64el" "ppc64el" ]; crossSystem = { config = "powerpc64le-linux-musl"; }; @@ -80,6 +85,8 @@ loongarch = { penguinName = "loongarch64"; + # "loongarch" is the legacy dylib/arch dir name the rest of penguin uses. + compatNames = [ "loongarch" ]; crossSystem = { config = "loongarch64-linux-musl"; }; From ffb2a4a9fbc9266c3b68fd6f0518d8daf4a4b59f Mon Sep 17 00:00:00 2001 From: Luke Craig Date: Sun, 7 Jun 2026 20:26:43 -0400 Subject: [PATCH 3/4] Alias bundled deps under the names binaries reference copy_dependency resolved each shared library and the interpreter with readlink -f and staged the file under its *real* name (libc.so, libstdc++.so.6.0.34, libdw-0.194.so). But binaries reference the interpreter as ld-musl-.so.1 and their NEEDED entries use sonames (libstdc++.so.6, libdw.so.1, ...). musl's loader looks up those literal names in the runpath, so as staged none of the tools could even launch: the interpreter ld-musl-.so.1 was absent, as were several sonames. Stage the real file as before, but also create a relative symlink under the referenced name (interpreter name / soname) whenever it differs. This runs even when the real file was already copied for another consumer, since one file is reached under multiple names (e.g. ld-musl-.so.1 and libc.so both map to libc.so). Audited the aarch64, armel, powerpc64, and powerpc64le bundles: every ELF (tools, dylibs, and python's lib-dynload) now has a resolvable interpreter and a complete NEEDED closure. --- src/mk-arch-bundle.nix | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/src/mk-arch-bundle.nix b/src/mk-arch-bundle.nix index 88648a8..05150d3 100644 --- a/src/mk-arch-bundle.nix +++ b/src/mk-arch-bundle.nix @@ -113,25 +113,41 @@ pkgs.runCommand "penguin-tools-${penguinArch}" copy_dependency() { local source_path local source_real + local ref_name + local real_name local dest source_path="$1" source_real="$(readlink -f "$source_path")" + # The name the consumer references (a soname like libstdc++.so.6, or the + # interpreter ld-musl-.so.1) is usually a symlink to a differently + # named real file (libstdc++.so.6.0.34, libc.so). We copy the real file + # but must also expose it under the referenced name, or the musl loader -- + # which looks up the literal NEEDED/interp string -- can't find it. + ref_name="$(basename "$source_path")" + real_name="$(basename "$source_real")" - if [ -n "''${copied_dependencies[$source_real]:-}" ]; then - return 0 - fi - copied_dependencies[$source_real]=1 + dest="$dylib_dir/$real_name" + + if [ -z "''${copied_dependencies[$source_real]:-}" ]; then + copied_dependencies[$source_real]=1 - dest="$dylib_dir/$(basename "$source_real")" - cp -L "$source_real" "$dest" - chmod u+w "$dest" + cp -L "$source_real" "$dest" + chmod u+w "$dest" - if is_elf "$dest"; then - normalize_elf "$dest" "$source_real" + if is_elf "$dest"; then + normalize_elf "$dest" "$source_real" + fi + + chmod 0555 "$dest" || true fi - chmod 0555 "$dest" || true + # Always (re)create the reference-name alias, even when the real file was + # already copied for another consumer -- the same file may be reached + # under several names (e.g. ld-musl-.so.1 and libc.so). + if [ "$ref_name" != "$real_name" ]; then + ln -sfn "$real_name" "$dylib_dir/$ref_name" + fi } stage_binary() { From f735a1782540a33f95bb20d00f59b6dfa043a74a Mon Sep 17 00:00:00 2001 From: Luke Craig Date: Sun, 7 Jun 2026 20:28:57 -0400 Subject: [PATCH 4/4] Enforce runtime link closure in validate_tree Add a check that every staged ELF (tool binaries, dylibs, python's lib-dynload) can be loaded on the guest from this tree alone: its interpreter and every NEEDED soname must resolve inside dylibs/. This is exactly the class of breakage the preceding dep-aliasing fix addressed (missing ld-musl-.so.1 / sonames), which the existing /nix/store + dangling-symlink checks did not catch. Baking it into the per-arch bundle build makes CI enforce it across the whole matrix and prevents regressions. Verified the armel bundle still builds clean. --- src/mk-arch-bundle.nix | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/mk-arch-bundle.nix b/src/mk-arch-bundle.nix index 05150d3..08d7f0e 100644 --- a/src/mk-arch-bundle.nix +++ b/src/mk-arch-bundle.nix @@ -215,6 +215,27 @@ pkgs.runCommand "penguin-tools-${penguinArch}" bad=1 fi + # Every staged ELF must be runnable on the guest from this tree alone: its + # interpreter and every NEEDED soname must resolve inside dylibs/. + # The musl loader looks up those literal names, so a missing alias means + # the binary silently fails to launch at runtime (not caught above). + local elf interp needed + while IFS= read -r elf; do + is_elf "$elf" || continue + interp="$(patchelf --print-interpreter "$elf" 2>/dev/null || true)" + if [ -n "$interp" ] && [ ! -e "$dylib_dir/$(basename "$interp")" ]; then + echo "Missing interpreter $(basename "$interp") for $elf" >&2 + bad=1 + fi + while IFS= read -r needed; do + [ -n "$needed" ] || continue + if [ ! -e "$dylib_dir/$needed" ]; then + echo "Missing NEEDED $needed for $elf" >&2 + bad=1 + fi + done < <(patchelf --print-needed "$elf" 2>/dev/null || true) + done < <(find "$arch_dir" "$dylib_dir" -type f) + if [ "$bad" -ne 0 ]; then exit 1 fi