Skip to content

nix: repair darwin Mach-O page hashes the store-path rewrite invalidates - #4344

Merged
Andrew Gazelka (andrewgazelka) merged 2 commits into
mainfrom
nondet-4336
Jul 31, 2026
Merged

nix: repair darwin Mach-O page hashes the store-path rewrite invalidates#4344
Andrew Gazelka (andrewgazelka) merged 2 commits into
mainfrom
nondet-4336

Conversation

@andrewgazelka

@andrewgazelka Andrew Gazelka (andrewgazelka) commented Jul 29, 2026

Copy link
Copy Markdown
Member

Repins nix-src to megamerge 5ab172cd (53 patches), adding libstore: Bit-reproducibly fix darwin Mach-O page hashes after rewriting.

What was broken

RewritingSink substitutes scratch-path bytes for final-path bytes after the builder exits. Apple's ld has already ad-hoc-signed every arm64 binary at link time, and the CodeDirectory's SHA-256 page hashes cover exactly the bytes being rewritten — so the output carries a signature that no longer describes its own contents. macOS kills such binaries at first page-in (cs_invalid_page).

The patch recomputes only the mismatched page-hash slots in place, preserving the linker-signed flag, the 4 KiB page size, the identifier and every special slot, so the result is bit-identical to a cold build.

Measured, aarch64-darwin, claude-code

Patched daemon vs the one this repin replaces (--rebuild --keep-failed, comparing the .check wrapper against the registered one):

bytes differing .check signature
unpatched 80 invalid signature (code or signature have been modified)
patched 48 valid on disk / satisfies its Designated Requirement

The locked input builds to the same store path that was tested: 35gmhy2d…-nix-aarch64-apple-darwin-2.34.7+ix.g5ab172cde2d3.h908bb0d8.

What this does not do

It does not make nix build --rebuild pass. All four runs — patched ×2, unpatched ×2 — exit 1. #4336 stays open.

The residual 48 bytes are LC_UUID (16) plus the page-0 CodeDirectory hash covering it. The helper preserves LC_UUID deliberately — that preservation is what makes the repair bit-reproducible — and LC_UUID is ld64's own content hash, computed over content that still held the redirected $out.

Reaching rc=0 needs a second, independent change. Measured combinations:

exit
unpatched, with UUID 1
unpatched, -Wl,-no_uuid 1
patched, with UUID 1
patched, -Wl,-no_uuid 0, twice

Both halves are necessary. The second is a nixpkgs bintools decision that nixpkgs#188347 reversed in 23.11 to restore symbolication, so it is not this PR's to make.

Recomputing LC_UUID inside nix was tried and rejected: four reconstructions of ld64's content-UUID algorithm all failed to reproduce a cold build's value. Pinning nix to an undocumented linker checksum would be worse than the Mach-O parsing upstream already objected to.

Why it is fork-only

Vendored from NixOS/nix#15638 at its pre-force-push revision 883e4331 — the minimal in-process form (376-line darwin-only helper, 16-line #ifdef __APPLE__ call site). The PR later grew a settings surface, a nix __fixup-macho subcommand and substitution-time verification, and was admin-closed 2026-07-05 with "baking in support for MACH-O into Nix is a NACK" and "We should be doing less rewriting - not more". #15638 credits NixOS/nix#14999, our own still-open draft using the non-reproducible codesign -f -s - approach.

Marked upstream = "hold". Nothing was posted upstream: this fork's aiPrsAllowed = false, and the maintainer explicitly asked for less agent-generated volume on that thread.

Two known gaps, both from taking the minimal form, recorded in the fork-packages reason: the Mach-O parse runs in the daemon as root (later #15638 revisions moved it behind a privilege-dropped hook for that reason), and it re-signs any page whose stored hash disagrees with its bytes, so a file already corrupt for an unrelated reason is silently repaired rather than left detectably broken.

Test

passthru.tests.machoRewrite wires the patch's own functional test (tests/functional/macho-rewrite.sh), which asserts the rewritten binary still executes, carries the substituted path, verifies under codesign, and keeps flags=0x20002(adhoc,linker-signed). It expects --check itself to fail, for the LC_UUID reason above. I have not executed the suite — it is wired, not yet run here.

Unverified claim, dropped

The upstream thread cites 437 broken slices across 48 packages in the live channels. I scanned 2610 signed Mach-O objects in the local store (610 bin/*, 2000 *.dylib) and found zero modified; fish-4.8.0/bin/fish and every ffmpeg-headless dylib verify clean. I could not reproduce it, so that number is not carried anywhere in this change.

Refs #4336

Note

Add focused functional test for darwin Mach-O page hash repair after store-path rewrite

  • Adds a new machoRewrite focused functional test in packages/nix/default.nix covering the Mach-O ad-hoc signature page-hash mismatch that occurs after store-path rewriting on darwin.
  • Registers the fix in lib/fork-packages.nix as an intent entry with upstream status hold, documenting the scope and provenance of the vendored patch.
  • The machoRewrite test is included in the standard passthru.tests set for non-cross builds.

Macroscope summarized 6475255.

@github-actions github-actions Bot added the ci/big-change Allow the extended CI wall-time budget for a legitimate broad change label Jul 29, 2026
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Required workers must start within 120 minutes after GitHub marks them ready. Once assigned, each has 120 seconds for setup, then 10800 seconds for validation, then 10 seconds for cleanup. Add the ci/big-change label and then re-run the whole workflow for extended validation; a --failed re-run reuses the budget this comment describes. Lockfile and Rust toolchain changes are labeled automatically.

Extended validation: the ci/big-change label is present.

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Client-side network during CI

phase wall network wall conns failed down up
clone-gate 3.9s 0ms 0 0 0B 0B
required-check 1.9m 0ms 0 0 0B 0B

Client-side connections only (proxy env): eval fetches, gh, git. Daemon-side substitutions and fixed-output builders are not visible here. Run: https://github.com/indexable-inc/index/actions/runs/30665471543

Repin `nix-src` to megamerge 5ab172cd (53 patches), adding `libstore:
Bit-reproducibly fix darwin Mach-O page hashes after rewriting`.

`RewritingSink` substitutes scratch-path bytes for final-path bytes after
the builder exits. Apple's `ld` has already ad-hoc-signed every arm64
binary at link time, and the CodeDirectory's SHA-256 page hashes cover
exactly the bytes being rewritten, so the registered output carries a
signature that no longer describes its contents. macOS kills such binaries
at first page-in (`cs_invalid_page`). The patch recomputes only the
mismatched page-hash slots in place, preserving the `linker-signed` flag,
the 4 KiB page size, the identifier and every special slot, so the result
is bit-identical to a cold build.

Measured on aarch64-darwin against claude-code (index#4336), patched
daemon vs the one this repin replaces:

  unpatched   80 bytes differ   .check: invalid signature (code or
                                signature have been modified)
  patched     48 bytes differ   .check: valid on disk / satisfies its
                                Designated Requirement

What this does NOT do: make `nix build --rebuild` pass. All four runs
(patched x2, unpatched x2) exit 1. The residual 48 bytes are `LC_UUID`
(16) plus the page-0 CodeDirectory hash that covers it. The helper
preserves `LC_UUID` deliberately -- that preservation is what makes the
repair bit-reproducible -- and `LC_UUID` is ld64's own content hash,
computed over content that still held the redirected `$out`. Reaching
rc=0 additionally requires `-Wl,-no_uuid` (measured: rc=0 twice with
both, rc=1 with either alone), which is a nixpkgs bintools decision that
nixpkgs#188347 reversed in 23.11 to restore symbolication. Recomputing
`LC_UUID` inside nix was tried and rejected: four reconstructions of
ld64's content-UUID algorithm all failed to reproduce a cold build's
value, and pinning nix to an undocumented linker checksum would be worse
than the Mach-O parsing upstream already objected to. So #4336 stays
open on that half.

Fork-only by construction. Vendored from NixOS/nix#15638 at its
pre-force-push revision 883e4331 (the minimal in-process form: a 376-line
darwin-only helper and a 16-line `#ifdef __APPLE__` call site); the PR
later grew a settings surface, a `nix __fixup-macho` subcommand and
substitution-time verification, and was admin-closed 2026-07-05 with
"baking in support for MACH-O into Nix is a NACK". #15638 credits
NixOS/nix#14999, our own still-open draft. Marked `upstream = "hold"`:
a human argues it or drops it, per this fork's `aiPrsAllowed = false`.

`passthru.tests.machoRewrite` wires the patch's own functional test
(`tests/functional/macho-rewrite.sh`), which asserts the rewritten binary
still executes, verifies under codesign, and keeps `linker-signed`.

Refs #4336

Assisted-by: Claude <noreply@anthropic.com>
The nix-src comment listed the Mach-O page-hash fix among the patches with
no lib/fork-packages.nix entry, which the entry this branch adds makes false
for that one patch.
@andrewgazelka

Copy link
Copy Markdown
Member Author

Rebased onto main, and the repin half of this PR is gone: main now pins nix-src at ix-patched f200a3a8d492, whose comment already lists "the Mach-O page-hash reproducibility fix" among the patches it carries. Verified the patch is in that rev by title (libstore: Bit-reproducibly fix darwin Mach-O page hashes after rewriting) and that tests/functional/macho-rewrite.sh exists there, so flake.nix and flake.lock were resolved to main and this branch no longer touches either.

What is left is the part main does not have:

  • lib/fork-packages.nix: the intent entry recording the NACK and the measured scope, keyed on the patch title above. Mains own nix-src` comment says these patches "have no lib/fork-packages.nix intent entries and so default to hold", which this fixes for the Mach-O one.
  • packages/nix/default.nix: the machoRewrite focused functional test, added alongside mains buildLogFastExit` rather than replacing it.

A second commit corrects that now-stale sentence in flake.nix. #4336 still stays open on the LC_UUID half, unchanged.

(Comment written by Claude Code, an AI agent.)

@andrewgazelka
Andrew Gazelka (andrewgazelka) merged commit 99f29d4 into main Jul 31, 2026
12 of 14 checks passed
@andrewgazelka
Andrew Gazelka (andrewgazelka) deleted the nondet-4336 branch July 31, 2026 21:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/big-change Allow the extended CI wall-time budget for a legitimate broad change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant