Skip to content

glibc: the relocation neither relocated nor left the file intact — and the seven rules - #522

Merged
Sunrisepeak merged 6 commits into
mainfrom
fix/glibc-relocation
Aug 6, 2026
Merged

glibc: the relocation neither relocated nor left the file intact — and the seven rules#522
Sunrisepeak merged 6 commits into
mainfrom
fix/glibc-relocation

Conversation

@Sunrisepeak

Copy link
Copy Markdown
Member

Chain: openxlings/libxpkg#35mcpplibs/mcpp-index#164openxlings/xlings#488this.

The ldd we ship does not survive bash -n

glibc's tarball is a prebuilt, so it carries the absolute paths of the machine that built it — a machine still on the .xlings_data home layout xlings abandoned long ago, so those paths cannot exist anywhere. The recipe knew this and had a rewrite for it. Measured on the payload on disk, that rewrite was wrong three ways at once and reported success every time.

It named six files. Five files hold build paths; four of the five are on that list and were processed wrongly, and the fifth was never opened.

Its pattern ran leftward through quotes and variable names. ([^%s)]+)/<marker>/lib swallowed RTLDLIST=" along with the path:

# what upstream ships
RTLDLIST="/p/lib/ld-linux.so.2 /p/lib64/ld-linux-x86-64.so.2 /p/libx32/ld-linux-x32.so.2"

# what we ship
./ld-linux.so.2 .64/ld-linux-x86-64.so.2 .x32/ld-linux-x32.so.2"

bash -n on ~/.xlings/data/xpkgs/xim-x-glibc/2.39/bin/ldd fails at line 38. 2.44 too. It has been shipping like that.

Its tail was anchored at /lib, so TEXTDOMAINDIR=<build>/share/locale in the same file was untouched — the build path stayed in the artifact, which was the only thing the code existed to remove. (/libx32 matching /lib plus a leftover x32 is where .x32/ came from.)

And it wrote if it had substituted anything and called that success, so "still has build paths" and "we corrupted the file" both produced exactly the output of a clean run: nothing.

Now delegated to elfpatch.relocate_build_paths (libxpkg 0.0.51), which enumerates the payload, anchors on a whole absolute path token, and asserts the result.

Verified end to end against a slice of a real home

before after
files with build paths 5 0
bash -n bin/ldd fails at line 38 passes
libc.so GROUP ./libc.so.6 names real files
ldd <payload binary> does not run prints resolved addresses

The capability is probed with type(), not truthiness — an unknown field on a module proxy is truthy on every client, a trap this repo has hit twice. On an older xlings the recipe rewrites only the linker scripts, where the greedy pattern had nothing to swallow, and says plainly what is left undone. It no longer corrupts bin/: an ldd naming a directory that does not exist is strictly better than an ldd bash cannot parse.

The seven rules, each with a criterion that can be run

They existed only in xlings design documents, which is where rules go to be agreed with and not applied. Normative text now, in the spec recipes are written against.

R3's criterion, for instance: if a fix ADDS a path rather than REMOVING one it is a workaround — libxpkg 0.0.49 fails that test and 0.0.50 passes it, and neither needed an opinion to decide. Every rule is anchored to a defect that shipped: deps_exports for R1, libxpkg 0.0.49 for R3, this glibc relocation for R4, elfpatch._find_tool for R6, the nvidia dependency table for R7.

Two additions:

  • Contract text may not say "if X is absent, fall back to Y". That one sentence authorises every reader to implement its own Y, so the number of answerers grows with the number of readers.
  • A subos.env declaration of a variable that can load code into a process is a privileged operation and must say why RPATH cannot serve the same need. There is exactly one honest answer, and 2026.8.6.1: three places a view answered a question the payload owns, and the subos layer's one live version xlings#488's gate verification narrowed it: the object that calls dlopen is the host's own file, which we cannot patch.

Audited the existing spec text against the new rule — the mirror "fallback" wording is a retry over byte-identical sources, and binding defaulting to the declaring package is applied by the writer. Neither is a violation.

Tests

pytest tests/ -m static — 743 passed.

…nted

Sixteen recipes pinned `xim:glibc@2.39`. The pin was not conservatism, it was
the only safe option: with more than one glibc installed, "which version is
this dependency" had several independent answers, and a package could end up
with its interpreter from one payload and its libc from another — a fault
before `main` reporting an undefined GLIBC_PRIVATE symbol.

xlings 2026.8.5.3 and libxpkg 0.0.50 make that unconstructible, so a floor is
safe now, and a floor is what these recipes actually mean: glibc is backward
compatible, so `>=2.39` reads "at least this", and a pin refuses a newer
runtime that would serve perfectly well. That refusal is what kept
`xim:glibc@2.44` from being usable by anything at all.

Verified before pushing rather than after, because this exact change was
attempted once and CI found four failures in it. All four are gone, run
through `.github/scripts/posix-test.sh` with the fixed client:

    glibc (uninstall)   openssl (uninstall)   bun (install)   llvm (install)
    → failures: 0

`docs/V2/xpackage-spec.md` gains `resolved_deps`: what the resolver decided,
available to install() and config(), total rather than only-if-declared. It
spells out the two shapes a recipe must NOT write — a hand-built payload path,
and a {lib64, lib} probe — because both were real code, and both are how a
recipe becomes a second answerer to a question that has one.

Design: xlings/.agents/docs/2026-08-05-dependency-resolution-single-source.md
xlings 2026.8.5.3 refuses to finish an install that produced a split, so this
should never fire. That is the reason to have it: a check that never fires is
the one that turns "we believe this cannot happen" into something CI states,
and it costs one string compare per binary.

The failure it guards against passes every other check here. The package
installs, its shims appear, its uninstall is clean — and it faults before
`main` on a user's machine with `undefined symbol: __pointer_chk_guard,
version GLIBC_PRIVATE`, naming neither package nor version.

Linux and patchelf only. macOS has no INTERP to pair, and without patchelf the
check is skipped rather than failed: an unverifiable run is not a bad one.
… subos shell

The vendor library is the host's file and cannot carry an RPATH of ours,
so its dependencies are gathered into lib/xlings-deps and that directory
is declared on LD_LIBRARY_PATH. glibc's libraries were in there.

LD_LIBRARY_PATH is inherited by every child, and most children in a subos
are host binaries under the host loader. ld.so and libc.so.6 are two
halves of one build talking over GLIBC_PRIVATE, so `xlings subos use`
returned a /bin/bash that died of SIGSEGV before printing a character --
on a host whose glibc was the same upstream version as ours, merely a
different build.

libc.so.6 was never usable for its stated purpose either: the vendor is
dlopen'd into a running process whose libc is long since bound, and an
already-loaded SONAME is not searched for.

Measured one library at a time rather than reasoned about, because the
first attempt (drop the whole glibc row) broke NVIDIA:

  nothing from glibc      -> NVIDIA device gone from EGL enumeration
  libc.so.6 only          -> device gone AND bash segfaults
  libpthread/librt/libdl  -> RTX 4080/PCIe/SSE2, bash fine

Those three are the vendor's DT_NEEDED minus libc, and since glibc 2.34
they are compatibility stubs -- 27, 13 and 9 defined symbols, their
implementations moved into libc.so.6. libm has 1203 symbols and nothing
here asks for it.
The tarball is a prebuilt, so it carries the absolute paths of the machine
that built it -- a machine still using the `.xlings_data` home layout xlings
abandoned long ago, so those paths cannot exist anywhere. The recipe knew
this and had a rewrite for it. Measured on the payload on disk, that rewrite
was wrong three ways at once, and reported success every time.

  * It named six files. Five files hold build paths; four of the five ARE on
    that list and were processed wrongly, and the fifth was never opened.

  * Its pattern `([^%s)]+)/<marker>/lib` runs leftward through anything that
    is not whitespace or `)`. On `bin/ldd` it swallowed `RTLDLIST="` with the
    path, leaving

        ./ld-linux.so.2 .64/ld-linux-x86-64.so.2 .x32/ld-linux-x32.so.2"

    The ldd we ship does not survive `bash -n` -- 2.39 and 2.44 alike. It has
    been shipping like that.

  * Its tail was anchored at `/lib`, so `TEXTDOMAINDIR=<build>/share/locale`
    in the same file was untouched: the build path stayed in the artifact,
    which was the only thing the code existed to remove. And `/libx32` matched
    `/lib` plus a leftover `x32` -- that is where `.x32/` came from.

Now delegated to elfpatch.relocate_build_paths (libxpkg 0.0.51), which
enumerates the payload, anchors on a whole absolute path token, and then
ASSERTS the result: no build path may remain, and every rewritten shell
script must still parse.

Verified end to end against a slice of a real home. Before: five files with
build paths, `bash -n bin/ldd` fails at line 38. After: zero (the only
remaining hit is the word `.xlings_data` inside this comment), `bash -n`
passes, `libc.so`'s GROUP names real files, and `ldd` prints resolved
addresses for a payload binary instead of not running at all.

The capability is probed with type(), not truthiness -- an unknown field on a
module proxy is truthy on every client, a trap this repo has hit twice. On an
older xlings the recipe rewrites only the linker scripts, where the greedy
pattern had nothing to swallow, and says plainly that the bin/ scripts keep
their build paths and that `xlings self update` fixes them. It no longer
corrupts them: an `ldd` naming a directory that does not exist is strictly
better than an `ldd` bash cannot parse.
They existed only in xlings design documents, which is where rules go to be
agreed with and not applied. Normative text now, in the spec recipes are
written against.

Each carries an executable criterion, because a rule judged by reading is a
rule that drifts. R3's, for instance: if a fix ADDS a path rather than
REMOVING one it is a workaround -- libxpkg 0.0.49 fails that test and 0.0.50
passes it, and neither needed an opinion to decide.

Every rule is anchored to a defect that shipped: deps_exports for R1,
libxpkg 0.0.49 for R3, glibc's path relocation for R4, elfpatch._find_tool for
R6, the nvidia dependency table for R7.

Two additions to the rules themselves:

  * contract text may not say "if X is absent, fall back to Y". That one
    sentence authorises every reader to implement its own Y, so the number of
    answerers grows with the number of readers. Say "X must be present" and
    make the writer guarantee it, or say an absent X is an error.

  * a subos.env declaration of a variable that can load CODE into a process is
    a privileged operation and must say why RPATH cannot serve the same need.
    There is essentially one honest answer -- a library that dlopen's its own
    siblings by bare SONAME, which no RPATH mechanism reaches. Variables that
    cause DATA to be found are ordinary; PATH is a third category and belongs
    to R6.

Audited the existing text against the new rule: the mirror "fallback" wording
is a retry over byte-identical sources, and `binding` defaulting to the
declaring package is applied by the writer. Neither is a violation.
`--prefix` is compiled into ld.so as its default library search path and into
glibc's own binaries as their INTERP. It has been the BUILD MACHINE's path --
`/home/xlings/.xlings_data/...`, a home layout xlings abandoned years ago -- so
every artifact leaked the builder's disk layout, and the next person to read it
could not tell a deliberately dead path from a stale one.

What was never wrong is that it points nowhere. For a relocatable package the
build prefix can never equal the install path, so a default search that finds
nothing is structural: everything must come from DT_RPATH, which is rule 2
stated as a property of the artifact rather than as an intention. An unpatched
binary then fails LOUDLY at execve with ENOENT instead of quietly picking up
the host's loader and mispairing GLIBC_PRIVATE.

So: `/nonexistent/xlings-use-rpath-not-default-search`. It says which kind of
dead path it is without a document, and `/nonexistent` has distribution
precedent (Debian gives it to system users) so nobody creates one by accident.

The recipe now relocates BOTH markers. A published tarball still carries the
old one and the next build will carry the new one; dropping the old the day
the pipeline changes would leave every existing release unrelocated with
nothing to say so -- and adding only the old one would do the same to the next
build. A marker that is not present rewrites nothing and asserts nothing
remains, which is the right outcome for a payload from the other pipeline
rather than an error.

Takes effect on the next glibc build. No existing tarball changes.
@Sunrisepeak
Sunrisepeak merged commit 7245e7b into main Aug 6, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants