Skip to content

nushell: make reproducible (mangling v0 + deterministic build-time entropy) - #292

Merged
bryan-minimal merged 3 commits into
mainfrom
bryan/nushell-lto
Jun 27, 2026
Merged

nushell: make reproducible (mangling v0 + deterministic build-time entropy)#292
bryan-minimal merged 3 commits into
mainfrom
bryan/nushell-lto

Conversation

@bryan-minimal

@bryan-minimal bryan-minimal commented Jun 23, 2026

Copy link
Copy Markdown
Member

Problem

The rebuild-world audit caught that #254's Rust recipe (-C codegen-units=1 + CONST_RANDOM_SEED=0) left nu non-reproducible — ~11.7% of bytes differing across two builds. (difftastic/hex-patch from the same PR were fine; nushell had deeper issues that were never double-build-verified.)

Root cause — three layers

Layer Symptom Fix
ThinLTO (Cargo.toml lto = "thin") parallel LTO backend non-deterministic CARGO_PROFILE_RELEASE_LTO=off (env beats Cargo.toml)
Legacy mangling hash a few core/alloc generics got unstable …17h<hash>E names; rustc emits codegen items in symbol-name order → ~10% size-preserving .text/.rela.dyn reorder -C symbol-mangling-version=v0 (structural, no hash)
Proc-macro HashMap (last 0.02%) pest/pest_consume generate the parser by iterating std::HashMap; its per-process-random seed varies the Rule discriminants / match-arm order build-to-build, and std has no knob to pin its hasher LD_PRELOAD shim pinning getrandom/getentropy so build-time HashMaps iterate deterministically

Why this matters beyond nushell

Layers 2 and 3 are general Rust-reproducibility mechanisms, not nushell hacks:

  • -C symbol-mangling-version=v0 deterministically fixes rustc mangling for any crate.
  • The entropy shim fixes the whole class of proc-macro / build.rs HashMap-ordered codegen non-determinism (the reason otherwise-clean Rust packages still flake). It's the entropy analogue of what SOURCE_DATE_EPOCH does for time.

(Follow-ups: lift the shim into a shared mechanism / the sandbox, and add both to the documented recipe.)

Verification

Two from-scratch forced rebuilds (--rebuild --no-fetch, aarch64) → byte-identical: repro-check diff reports REPRODUCIBLE. Diagnosed end-to-end with repro-check's new symbol-divergence analysis (gominimal/minimal-repro#22).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Chores
    • Optimized build configuration for improved reproducibility and deterministic outputs in the Nushell package build process.

…tropy)

The rebuild-world audit caught that #254's recipe (codegen-units=1 +
CONST_RANDOM_SEED) left `nu` non-reproducible. Root-caused in three layers:

1. ThinLTO (Cargo.toml `lto = "thin"`) — its parallel backend is
   non-deterministic; override the profile to disable LTO.
2. Legacy symbol-mangling hash — a few core/alloc generics got unstable
   `…17h<hash>E` names; since rustc emits codegen items in symbol-name order,
   that cascaded into a ~10% .text/.rela.dyn reorder. Fix: -C symbol-mangling-version=v0.
3. Proc-macro HashMap (the last 0.02%) — pest/pest_consume generate the parser
   by iterating std::HashMap, whose per-process-random seed varies the generated
   Rule discriminants / match-arm order build-to-build, and std has no knob to
   pin its hasher. Fix: an LD_PRELOAD shim pinning getrandom/getentropy so every
   build-time HashMap iterates deterministically.

Layers 2 and 3 are GENERAL Rust-reproducibility mechanisms, not nushell-specific
— they fix this whole class of proc-macro/codegen non-determinism. Verified
byte-identical across two from-scratch forced rebuilds (repro-check diff,
--rebuild --no-fetch, aarch64): REPRODUCIBLE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@bryan-minimal, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 59 minutes and 58 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a82508cf-fc29-4233-b5e9-397bcc7dbd78

📥 Commits

Reviewing files that changed from the base of the PR and between 7131e3b and b9744e4.

📒 Files selected for processing (1)
  • packages/nushell/build.sh
📝 Walkthrough

Walkthrough

packages/nushell/build.sh gains four determinism controls: RUSTFLAGS now includes -C symbol-mangling-version=v0; Cargo release profile overrides disable ThinLTO and force codegen-units=1; a small C shim overriding getrandom/getentropy to return zeroes is compiled into /tmp/libdetrand.so and injected via LD_PRELOAD for the cargo build --release invocation, then unset immediately after.

Changes

Nushell reproducible build hardening

Layer / File(s) Summary
RUSTFLAGS and Cargo profile overrides
packages/nushell/build.sh
RUSTFLAGS extended with -C symbol-mangling-version=v0 and CONST_RANDOM_SEED=0 kept pinned; CARGO_PROFILE_RELEASE_LTO=off and CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1 added to override defaults from nushell's Cargo.toml.
getrandom/getentropy shim and cargo build invocation
packages/nushell/build.sh
A C source file overriding getrandom and getentropy to return zeroes is written and compiled into /tmp/libdetrand.so; LD_PRELOAD is set to that library before cargo build --release and unset immediately after.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • gominimal/pkgs#264: Modifies a build.sh script with the same pattern of forcing codegen-units=1 and controlling build-time randomness for Rust reproducibility.

Suggested reviewers

  • msample
  • twitchyliquid64

Poem

🐰 A rabbit compiles with zeroes and glee,
No random bytes shall escape, you see!
LD_PRELOAD holds the entropy at bay,
Symbol mangling v0 leads the way.
Deterministic builds, hop hop hooray! 🎉

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the main changes: making nushell reproducible through symbol-mangling v0 and deterministic build-time entropy controls.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bryan/nushell-lto

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/nushell/build.sh (1)

45-48: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: scope LD_PRELOAD to the single cargo build invocation.

Exporting LD_PRELOAD globally and unsetting it afterward leaves a window where any command inserted between the export and unset would unintentionally inherit the shim. Scoping it inline removes that risk and drops the separate unset.

♻️ Proposed change
 gcc -shared -fPIC -O2 -o /tmp/libdetrand.so /tmp/detrand.c
-export LD_PRELOAD=/tmp/libdetrand.so
 
-cargo build --release
-unset LD_PRELOAD
+LD_PRELOAD=/tmp/libdetrand.so cargo build --release
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/nushell/build.sh` around lines 45 - 48, The LD_PRELOAD variable is
being exported globally and then unset afterward, which creates a window where
unintended commands could inherit it. Instead of using separate export and unset
commands around the cargo build --release invocation, scope the LD_PRELOAD
variable directly to that single command by setting it inline as an environment
variable prefix, which automatically limits its scope to just that invocation
and eliminates the need for the separate unset statement.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/nushell/build.sh`:
- Around line 24-45: The LD_PRELOAD shim approach using /tmp/detrand.c with
getrandom and getentropy function overrides will not work because Rust's
standard library makes direct SYS_getrandom syscalls rather than calling the
libc symbols, making the shim non-functional. Remove the entire block that
creates /tmp/detrand.c, compiles it to /tmp/libdetrand.so, and exports
LD_PRELOAD, then replace it with an alternative determinism approach that
actually works with Rust's direct syscall behavior (such as environment-based
seed pinning if Rust supports it, or other build-time reproducibility techniques
that don't rely on symbol interception).

---

Nitpick comments:
In `@packages/nushell/build.sh`:
- Around line 45-48: The LD_PRELOAD variable is being exported globally and then
unset afterward, which creates a window where unintended commands could inherit
it. Instead of using separate export and unset commands around the cargo build
--release invocation, scope the LD_PRELOAD variable directly to that single
command by setting it inline as an environment variable prefix, which
automatically limits its scope to just that invocation and eliminates the need
for the separate unset statement.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 927a5b60-e2c9-405a-b605-70d70ca0f0b6

📥 Commits

Reviewing files that changed from the base of the PR and between 60b5fd6 and 7131e3b.

📒 Files selected for processing (1)
  • packages/nushell/build.sh

Comment thread packages/nushell/build.sh Outdated
Comment on lines +24 to +45
# Determinism shim (fixes a CLASS of Rust non-repro): build-time code — here the
# pest/pest_consume proc-macros generating the parser — seeds std::HashMap from
# getrandom(), whose per-process-random seed makes their CODE-GENERATION order vary
# build-to-build (different Rule discriminants/match-arm order -> different .text).
# std exposes no knob to pin its hasher, so pin the build's entropy itself: an
# LD_PRELOAD that makes getrandom/getentropy deterministic, so every build-time
# HashMap iterates stably. Applies to rustc + all proc-macros for this build only.
cat > /tmp/detrand.c <<'CEOF'
#include <stddef.h>
#include <sys/types.h>
ssize_t getrandom(void *buf, size_t len, unsigned int flags) {
(void)flags;
for (size_t i = 0; i < len; i++) ((unsigned char *)buf)[i] = 0;
return (ssize_t)len;
}
int getentropy(void *buf, size_t len) {
for (size_t i = 0; i < len; i++) ((unsigned char *)buf)[i] = 0;
return 0;
}
CEOF
gcc -shared -fPIC -O2 -o /tmp/libdetrand.so /tmp/detrand.c
export LD_PRELOAD=/tmp/libdetrand.so

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

Does the Rust standard library HashMap RandomState seed via the libc getrandom symbol (interceptable by LD_PRELOAD) or via a direct SYS_getrandom syscall on Linux?

💡 Result:

On Linux, the Rust standard library (std) does not call the libc getrandom symbol; it performs a direct syscall for getrandom [1][2]. When seeding the HashMap RandomState, Rust's internal implementation directly invokes the getrandom system call (often using the libc::syscall wrapper, though it bypasses the dynamic linker's symbol resolution for the getrandom function itself) [1][2]. Because it does not link against or call the exported libc getrandom symbol, this operation is not interceptable by standard LD_PRELOAD techniques targeting the libc getrandom function [1][2]. The implementation details involve: 1. Direct Syscall: The standard library uses the syscall interface (typically via syscall(SYS_getrandom,...)) to interact with the kernel [1][2]. 2. Fallback Logic: If the getrandom syscall is unavailable or fails under certain conditions (such as unsupported flags like GRND_INSECURE on older kernels), the implementation falls back to reading from /dev/urandom [1][2]. 3. Flag Usage: For HashMap keys, the implementation prefers non-blocking behavior, utilizing flags such as GRND_INSECURE (if supported by the kernel) or GRND_NONBLOCK to ensure the application does not block during seed generation [1][2]. Because the call is made directly to the kernel, LD_PRELOAD cannot intercept it in the same way it would intercept a call to a library-provided wrapper function like the one found in glibc [1][2].

Citations:


The getrandom LD_PRELOAD shim will not intercept Rust's HashMap seeding on Linux.

Rust's standard library performs a direct SYS_getrandom syscall rather than calling the libc getrandom symbol, making it non-interceptable by LD_PRELOAD. This means the determinism fix will silently fail with no build error, leaving reproducibility broken. The build falls back to /dev/urandom if the syscall is unavailable, but the shim cannot affect either path.

Remove or replace this approach with a method that actually works with Rust's direct syscall (e.g., environment-based seed pinning if Rust supports it, or rebuilding with a patched Rust toolchain).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/nushell/build.sh` around lines 24 - 45, The LD_PRELOAD shim approach
using /tmp/detrand.c with getrandom and getentropy function overrides will not
work because Rust's standard library makes direct SYS_getrandom syscalls rather
than calling the libc symbols, making the shim non-functional. Remove the entire
block that creates /tmp/detrand.c, compiles it to /tmp/libdetrand.so, and
exports LD_PRELOAD, then replace it with an alternative determinism approach
that actually works with Rust's direct syscall behavior (such as
environment-based seed pinning if Rust supports it, or other build-time
reproducibility techniques that don't rely on symbol interception).

Per CodeRabbit on #292 — set the determinism shim inline on the single cargo
invocation instead of export-then-unset, removing the window where another
command could inherit it. Re-verified byte-identical (repro-check diff,
--rebuild --no-fetch, aarch64): REPRODUCIBLE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@bryan-minimal

Copy link
Copy Markdown
Member Author

Thanks @coderabbitai — addressed both.

Inline LD_PRELOAD (nit): done in 3272436 — the shim is now scoped to the single cargo build invocation, no export/unset window.

On "Rust makes raw SYS_getrandom syscalls, so the shim won't work": I tested this rather than reasoning about it. On our glibc Rust toolchain the shim does take effect — Rust std resolves getrandom through the (LD_PRELOAD-interceptable) libc weak symbol here, not a raw syscall. The package builds byte-identical across two from-scratch forced rebuilds (repro-check diff, --rebuild --no-fetch, aarch64 → REPRODUCIBLE), re-confirmed 3× including with the inline change. Remove the shim and the same build diverges (~0.02%, in pest/pest_consume's HashMap-ordered codegen), so it's demonstrably doing the work.

The concern is valid in general — a musl target, or a Rust that bypasses libc, would defeat LD_PRELOAD — which is exactly why we gate on the build-twice diff rather than assume either way.

@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@bryan-minimal Thanks — that makes sense. The inline LD_PRELOAD scoping change looks good, and I appreciate the empirical validation here.

Given your glibc toolchain results and the repeated rebuild checks, I’m satisfied this is effective in this environment. Good call to treat the build-twice diff as the real gate, especially since the LD_PRELOAD approach could behave differently on musl or with a Rust runtime that bypasses libc.

(•ᴗ•)⸝🐇


✏️ Learnings added
Learnt from: bryan-minimal
URL: https://github.com/gominimal/pkgs/pull/292

Timestamp: 2026-06-26T21:46:05.558Z
Learning: In `packages/nushell/build.sh` in the `gominimal/pkgs` repository, the `LD_PRELOAD` determinism shim is effective on the current glibc-based Rust toolchain because Rust std resolves `getrandom` through an `LD_PRELOAD`-interceptable libc weak symbol in this environment, rather than issuing an uninterceptable raw syscall.

Learnt from: bryan-minimal
URL: https://github.com/gominimal/pkgs/pull/292

Timestamp: 2026-06-26T21:46:05.558Z
Learning: For the `packages/nushell/build.sh` reproducibility fix in the `gominimal/pkgs` repository, correctness of the `LD_PRELOAD` shim should be judged by repeated build-twice reproducibility checks rather than assumption: with the shim, nushell produced byte-identical outputs across repeated from-scratch rebuilds on aarch64, while removing it reintroduced about 0.02% divergence from `pest`/`pest_consume` HashMap-ordered proc-macro code generation.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@bryan-minimal
bryan-minimal added this pull request to the merge queue Jun 27, 2026
Merged via the queue into main with commit 474bcf3 Jun 27, 2026
5 checks passed
@bryan-minimal
bryan-minimal deleted the bryan/nushell-lto branch June 27, 2026 02:15
bryan-minimal added a commit that referenced this pull request Jun 29, 2026
The fix is verified byte-identical locally; nushell #292 (same approach) is green.
No code change — empty commit to re-run the build executor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@bryan-minimal

Copy link
Copy Markdown
Member Author

Part of the reproducibility epic: gominimal/inbox#253

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