bundle: a damaged executable refuses instead of starting the REPL (#882) - #899
Merged
Conversation
A bundle is the one artifact of this project designed to be COPIED and
DOWNLOADED, so truncation is its normal failure mode — and a truncated
bundle was indistinguishable from a plain interpreter. read_trailer found
no magic at EOF, selfexec returned 0, main() carried on with no script
argument, opened the REPL, and exited 0:
./myapp && echo "deployed" # printed "deployed" for a corrupt binary
That contradicts LANGUAGE_CONTRACT.md's "never report success on
failure", and the tape layer next door already refuses every damaged
input with exit 3 (#411).
Nothing at EOF can separate a truncated bundle from a plain interpreter
once the trailer is gone, so the archive gained a 24-byte head magic at
its START (fmt 1 -> 2), which survives truncation. Startup refuses with
exit 3 on three states that all used to exit 0:
- head present, trailer missing or unreadable (truncated/damaged)
- valid trailer whose named offset has no head
- trailer naming a format this binary cannot read (previously printed
a message and then fell through to the REPL anyway)
Two implementation notes, both load-bearing:
The head magic is stored XOR-obfuscated in the runtime so its plaintext
never appears in the image's rodata. Without that, every plain
`eigenscript` start would find this very constant inside itself and
declare itself a damaged bundle — the fix would have bricked the
interpreter. tests/test_bundle.sh asserts the plain binary still runs a
script AND still opens the REPL, because that failure mode is total.
Finding the head without a trailer needs a linear scan, so it runs ONLY
when startup would otherwise open the REPL (no script argument).
`eigenscript script.eigs` — the hot path, and every one of the suite's
thousands of invocations — pays nothing. The documented consequence is
that a damaged bundle invoked WITH arguments still behaves as an
interpreter; the shipped form (`./myapp`) is the case that matters.
Existing test 6 (entry-level tear) now tears past the head magic so it
keeps exercising the entry-header misparse it was written for rather
than becoming a head mismatch.
Suite 3792/3792 release, 3790/3790 ASan+UBSan with detect_leaks=1, leak
tally 0. Bundle section: 13/13.
Closes #882
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Updates the --bundle executable format and startup detection so that corrupted/truncated bundles refuse to run (exit 3) instead of silently starting the REPL and exiting 0, aligning bundle behavior with the project’s “never report success on failure” contract.
Changes:
- Bumped bundle archive format to v2 and added a 24-byte head magic at the start of the appended archive to detect truncated bundles.
- Updated bundle self-exec logic to refuse on missing/invalid trailer when head magic is present, and to refuse on format mismatch instead of falling through to the REPL.
- Expanded bundle test coverage and documented the new format + damaged-bundle decision table; added changelog entry.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| src/bundle.c | Adds fmt=2 head-magic marker + refusal paths (exit 3) for damaged/truncated bundles; validates head at trailer-named offset. |
| tests/test_bundle.sh | Extends the bundle test suite to cover truncation, in-place trailer corruption, and no-false-positive checks for the plain interpreter. |
| docs/BUNDLE.md | Documents fmt=2 layout, damaged-bundle refusal behavior, and the REPL-path-only scan constraint. |
| CHANGELOG.md | Records the behavioral fix and format bump for corrupted bundles (#882). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+75
to
+78
| static void bundle_head_magic(unsigned char out[BUNDLE_HEAD_LEN]) { | ||
| for (size_t i = 0; i < BUNDLE_HEAD_LEN; i++) | ||
| out[i] = (unsigned char)(BUNDLE_HEAD_OBF[i] ^ 0x5A); | ||
| } |
| } | ||
| } | ||
|
|
||
| fseek(f, (long)(off + BUNDLE_HEAD_LEN), SEEK_SET); |
|
|
||
| A torn archive (misparsing entry headers) refuses with exit 3 rather | ||
| than running garbage. There are no per-entry checksums — a flipped bit | ||
| inside one file's *data* is out of scope for fmt 1; the attached tape |
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.
A bundle is the one artifact of this project designed to be copied and downloaded, so truncation is its normal failure mode. And a truncated bundle was indistinguishable from a plain interpreter: no magic at EOF →
selfexecreturned 0 →main()carried on with no script argument → REPL → exit 0.That contradicts the contract's "never report success on failure", while the tape layer next door already refuses every damaged input with exit 3 (#411).
Why this needed a format change, not a check
Nothing at EOF can separate a truncated bundle from a plain interpreter once the trailer is gone — that's the whole difficulty. So the archive gained a 24-byte head magic at its start (fmt 1 → 2), which survives truncation and is the only thing that can answer "was this ever a bundle?".
That last row wasn't in the issue — the fmt-mismatch path printed "re-bundle on this version" and then returned 0, dropping into the REPL at exit 0 anyway.
Two implementation notes, both load-bearing
The head magic is stored XOR-obfuscated in the runtime. Its plaintext must not appear in the image's rodata — otherwise every plain
eigenscriptstart finds this very constant inside itself and declares itself a damaged bundle. That would have bricked the interpreter, sotests/test_bundle.shnow asserts the plain binary both runs a script and opens the REPL.The scan is gated to the REPL path. Finding a head without a trailer needs a linear scan, so it runs only when startup would otherwise open the REPL (no script argument).
eigenscript script.eigs— the hot path, and every one of the suite's thousands of invocations — pays nothing. The documented consequence: a damaged bundle invoked with arguments still behaves as an interpreter. The shipped form (./myapp) is the case that matters and is covered.Verification
tests/test_bundle.shgoes 8 → 13 checks. Existing test 6 (entry-level tear) now tears past the head magic, so it keeps exercising the entry-header misparse it was written for rather than degenerating into a head mismatch.detect_leaks=1: 3790/3790, leak tally 0docs/BUNDLE.mddocuments the fmt-2 layout, the decision table, and both implementation constraints.Closes #882
🤖 Generated with Claude Code