feat(coreutils): reproducible tar, gzip and zstd - #442
Closed
raphaelvigee wants to merge 4 commits into
Closed
raphaelvigee wants to merge 4 commits into
raphaelvigee wants to merge 4 commits into
Conversation
raphaelvigee
commented
Aug 29, 2026
Member
raphaelvigee
force-pushed
the
raphaelvigee/coreutils-archives
branch
from
August 29, 2026 22:44
04df5d1 to
1f8ae6f
Compare
raphaelvigee
force-pushed
the
raphaelvigee/coreutils-find-grep
branch
from
September 3, 2026 16:26
e0fc361 to
731246b
Compare
raphaelvigee
force-pushed
the
raphaelvigee/coreutils-archives
branch
from
September 3, 2026 16:26
1f8ae6f to
5fe0785
Compare
A heph target runs its recipe with a sandbox PATH of the host's directories, so `cp` means GNU coreutils on Linux and a BSD userland on macOS. The sandbox isolates files; it does nothing about the two hosts disagreeing. `install -D` does not exist on macOS at all, `wc` pads its output so `[ "$(wc -l < f)" = 3 ]` is Linux-only, and `sort` collates by locale — which silently changes build *outputs*, not just exit codes. A build system whose contract is "same inputs, same outputs" cannot leave the tools that produce those outputs undeclared and host-defined. So heph ships its own. `crates/coreutils` compiles 40 MIT-licensed uutils/coreutils applets into the binary and reaches them by re-exec — `heph __coreutils <applet>`, or a symlink named after the applet (argv[0] dispatch, busybox style). Dispatch is the first thing in `main()`, before logging, clap, the self-update check or any runtime: a build may invoke `cp` thousands of times and each one is a fresh process. It runs ahead of the `__supervisor` and `__runner-exec` branches, so it is tested against those argv shapes — eating one would kill the sidecar with a broken pipe rather than an error anyone could read. The shims are one directory under the heph home, materialized once per (toolbox version, binary path) and contributed to `hexecrunner`'s `PathPolicy` as a tier directly behind the target's own tools. A recipe that provisions its own `sed` still gets that `sed` — the builtins only displace the host's. Per-sandbox cost is one extra PATH entry: nothing written per target, nothing staged, nothing to tear down. `plugin-exec` takes a `CoreutilsShims` closure and a version rather than depending on `crates/coreutils`. The driver needs a directory to put on PATH and a number to hash, not knowledge of what an applet is — and most of the workspace links `plugin-exec`, so the dependency would have dragged forty utility crates into `engine`, `e2e` and `plugingo-e2e` builds and test binaries (`cargo tree -p engine | grep -c uu_`: 45 before, 0 after). The closure also keeps materialization lazy, so a `heph query` never touches the filesystem for it. `coreutils: true` with no supplied shims is a hard error, not a shrug: running against the host's utilities while the cache key claims heph's is the silently-wrong-build case. Off by default (`coreutils: true` on the exec/bash driver). Turning it on changes what every recipe's `cp` resolves to, and it moves every exec target's cache key. Cache correctness. The utilities are on a target's PATH without being declared, and nothing can tell which of them a shell command will invoke without parsing it, so `COREUTILS_VERSION` goes into the def hash whole or not at all — bumping it invalidates every exec target in every workspace, which is release-gated, not routine. Nothing is hashed while the toolbox is off, so a workspace that never opts in keeps today's keys. Cost, measured on this tree (aarch64-apple-darwin, rustc 1.96, the real release profile): +7.55 MiB stripped, +19.3%. Trimming does not help — dropping the eight lowest-value applets saves 1.37 MiB of that, because the cost is a shared uucore+clap floor, not the applet count. Startup is unchanged: `heph --version` already costs ~6 ms, essentially all of it before `main`, and the applets add nothing to it. Verified end to end on darwin/arm64: with the toolbox on, a bash target resolves `cp` to `.heph3/coreutils/v1-<hash>/bin/cp` and reports `cp (uutils coreutils) 0.10.0`; with it off the same target gets `/bin/cp`, which rejects `--version`. `heph tool coreutils list | which <name> | run <name> …` is the diagnostic surface — shadowing `cp` silently is exactly the kind of magic that produces an unanswerable bug report. Not in this change: sed, grep, find, xargs, tar, gzip and the template renderer; removing the host directories from the sandbox PATH; and making the toolbox the default. Design and measurements in docs/COREUTILS.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0181d7hhbYWXT42Z1KQPM29Q
Filling in a config file is what `sed -i` and `envsubst` get used for, and
both put the substitution in a shell — where it depends on the host's
`sed`, on quoting, and on whatever else is in scope. The builtin coreutils
fix the first of those; this removes the shell from the job entirely.
`template(name, src, out, vars)` renders a declared template file into a
declared output, in-process. `src` is a hashed input, so editing the
template rebuilds everything downstream.
Two properties are load-bearing rather than incidental:
A template cannot read an undeclared file. The minijinja environment is
built with no loader, so `{% include %}` and `{% import %}` have nothing
to resolve against. A template that could read an undeclared file would
be a hole in the sandbox, not a feature.
An undefined variable is an error that names itself. Undefined behaviour
is strict, and the variables a template references are checked against
`vars` *before* rendering — because minijinja's own message is "undefined
value (in template:1)", which says something is missing without saying
what. Instead: "template uses variables that `vars` does not supply:
prot, tls. Supplied: features, host, port". The check compares the root
of a dotted path, since minijinja reports `{{ cfg.port }}` as the
undeclared name `cfg.port` and comparing the whole path would reject
every template that reads a field; loop-bound names are not reported.
`vars` are held in a `BTreeMap` before hashing. They arrive as a
`HashMap`, whose iteration order is randomized per process, so folding
them in that order would give the same target a different def hash on
every run and never hit cache. `TEMPLATE_FORMAT_VERSION` is in the key
too, for the reason the exec driver hashes its own format version: the
rendered bytes are a function of the renderer as well as the template.
`src` must produce exactly one file. Several have no defensible answer —
picking the first would depend on walk order — so it is refused with the
count, and none names the address that was supposed to produce it.
Verified end to end: a `template` target renders `listen = 0.0.0.0:8080`
from a globbed `.j2` source and a downstream bash target reads it as
`$SRC`; a typo'd variable fails the target naming `prot` and listing what
was supplied.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0181d7hhbYWXT42Z1KQPM29Q
Three of the largest remaining divergences. `grep -P` does not exist on
macOS; `find`'s `-printf`, `-regextype`, `-delete` and `-newermt` are all
GNU-only and BSD `find` requires an explicit path; and `xargs -r` is
GNU-only while the no-input default is inverted between the two.
`find` and `xargs` are adapters over uutils/findutils. Its entry points
take `&[&str]`, so a non-UTF-8 argument is refused by name rather than
lossily converted: GNU `find` accepts arbitrary bytes in a path, this
cannot, and searching a *different* path than the one asked for is worse
than saying so.
`grep` is a POSIX front-end over `grep-searcher`/`grep-regex`, the engine
ripgrep uses, covering -EFivnclLqwxrhHs, -m, -e, -f and `--`. Two
deliberate departures from GNU:
- No -P. The `regex` crate has no backreferences or lookaround by
design, so -P fails with that explanation rather than with "invalid
option" — a pattern needing it has to be rewritten, not retried.
- Never colourised. Output goes into build logs and gets parsed; a
`--color=auto` that guessed from a tty would make a recipe's
behaviour depend on how it was invoked.
The argument parser is hand-rolled rather than clap because `grep -e -v
file` must treat `-v` as the pattern, which a declarative parser fights.
Line numbers are always counted and only printed under -n: the `UTF8`
sink asks every match for its line number and fails with "line numbers
not enabled" if the searcher was not tracking them — found by running it.
Not planned: `diff`/`cmp`. The `diffutils` crate exposes its algorithms
but keeps its CLI in a private `main`, so wiring it up would mean
reimplementing its argument parsing for the lowest-value pair in the set.
Verified against the host's GNU tools: `find . -name '*.txt'` and
`grep -c`/`grep -v` produce byte-identical output, `find -printf` works
(GNU-only), and `find | xargs` pipelines. `heph tool coreutils list` no
longer errors when piped into `head`.
COREUTILS_VERSION 1 -> 2: the applet set changed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0181d7hhbYWXT42Z1KQPM29Q
The applets where the divergence is not a flag but the bytes. GNU tar and bsdtar disagree about whether --transform, --sort, --owner and --mtime exist at all; gzip writes the source filename and its mtime into the header unless told not to; zstd is installed by default on neither host. Archiving the same tree twice, on two machines, should produce the same bytes. With the host tools it does not — gzipping identical content a second later gives a different file, which was reproduced while writing this. So the reproducible settings are the defaults, not flags anyone has to remember: entries sorted by path, uid/gid 0 with empty owner *names*, mode normalised to the executable bit (the rest is umask), mtime from SOURCE_DATE_EPOCH or 0, and no gzip header name or timestamp. There is deliberately no flag to turn any of that off. A recipe that wants a non-reproducible archive is a recipe with a bug. Compression is detected from the magic bytes rather than the file name, so a `.tar` that is actually gzipped extracts instead of failing confusingly. Verified from the CLI: two identical trees tar to the same sha256, and identical content gzips to the same sha256 — where the host's gzip produced two different files for the same input. COREUTILS_VERSION 2 -> 3: the applet set changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0181d7hhbYWXT42Z1KQPM29Q
raphaelvigee
force-pushed
the
raphaelvigee/coreutils-find-grep
branch
from
September 3, 2026 16:56
731246b to
a90f972
Compare
raphaelvigee
force-pushed
the
raphaelvigee/coreutils-archives
branch
from
September 3, 2026 16:56
5fe0785 to
596509f
Compare
raphaelvigee
force-pushed
the
raphaelvigee/coreutils-find-grep
branch
from
September 3, 2026 17:30
a90f972 to
2f656af
Compare
Base automatically changed from
raphaelvigee/coreutils-find-grep
to
raphaelvigee/coreutils-template-driver
September 3, 2026 17:30
Member
Author
|
Superseded by consolidation. The applet halves of this PR (#442 tar/gzip/zstd, #443 sed, #444's
No content was dropped — the restacked tree is byte-identical to the eight-commit version, and every layer builds, lints and passes its unit tests on its own. Fewer layers also matters now that stacked PRs get no CI unless labelled (#449). |
This was referenced Sep 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.