Skip to content

pack: store tarball entry names byte for byte - #38715

Open
robobun wants to merge 2 commits into
mainfrom
farm/abea7c51/pack-raw-entry-names
Open

pack: store tarball entry names byte for byte#38715
robobun wants to merge 2 commits into
mainfrom
farm/abea7c51/pack-raw-entry-names

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • bun pm pack (and bun publish, which packs the same way) writes a file whose name is not valid UTF-8 into the tarball under a different name: each offending byte becomes U+FFFD. Two files such as x_\xe9 and x_\xff (legal names on Linux) both end up as package/x_\xef\xbf\xbd, so extracting or installing the tarball keeps only one of them. The command exits 0 and reports Total files: 3.
  • Debug builds instead panic on the same tree: assertion 'left == right' failed: ZStr::from_buf: missing NUL at buf[len].
  • Cause: add_archive_entry in src/runtime/cli/pack_command.rs built the member name with write!(print_buf, "{}{}\x00", BStr(..), BStr(filename)). BStr's Display is lossy (it substitutes U+FFFD, 3 bytes, for each invalid byte), but the length passed to ZStr::from_buf was computed from the original filename. In release builds libarchive reads the C string up to its real NUL and stores the substituted name; in debug builds the NUL check at the computed length fails.
  • The gzip compression level (--gzip-level) a few lines earlier in pack() was built the same way. Its release behavior was already correct (libarchive rejects any non-numeric value), but a non-UTF-8 value tripped the same debug assertion.

Fix

  • Append package/, the filename bytes, and the NUL to print_buf directly and take the buffer as written with ZStr::from_slice_with_nul, so the bytes libarchive receives are the directory entry's bytes. Same change for the compression level.
  • libarchive then does the right thing on its own: the ustar header carries the raw name and, because the name cannot be converted to UTF-8, the pax header gets hdrcharset=BINARY with the raw path. GNU tar extracts both files under their original names.
  • Test: test/cli/install/bun-pack.test.ts, "filenames that are not valid UTF-8 are stored byte for byte" (Linux only; APFS rejects such names and Windows names are UTF-16). It gunzips the tarball and reads the names out of the ustar header and the pax path record itself, because readTarball from bun:internal-for-testing decodes names to JS strings, which maps both the correct and the substituted bytes onto U+FFFD.
    • Fails on the released bun (x_\xe9 is stored as x_\xef\xbf\xbd), passes with this change.
    • The rest of bun-pack.test.ts (77 tests, including the --gzip-level ones) passes with the change.

Background

  • ZStr is bun's borrowed NUL-terminated byte string. ZStr::from_buf(buf, len) trusts the caller that buf[len] == 0 (debug-asserted only); ZStr::from_slice_with_nul(buf) takes a slice whose last byte is the NUL, so the length can only come from what was actually written.
  • bstr::BStr is a [u8] wrapper whose Display impl prints bytes as text, replacing invalid UTF-8 with U+FFFD. That is what the packed ... x_\u{fffd} progress lines want, and what the tarball must not get.
  • pax is the tar flavor bun pm pack writes (archive_write_set_format_pax_restricted). For a name libarchive cannot represent as UTF-8 it adds an extended header with hdrcharset=BINARY, telling extractors the path record holds raw bytes.
Reproduction
d=$(mktemp -d); cd $d
printf '{"name":"s4","version":"1.0.0"}' > package.json
printf ONE > "$(printf 'x_\xe9')"
printf TWO > "$(printf 'x_\xff')"
bun pm pack
tar tvzf s4-1.0.0.tgz

Before (tar tvzf, raw header bytes):

package/package.json
package/x_\357\277\275     (was x_\351)
package/x_\357\277\275     (was x_\377)

After:

b'x' b'package/PaxHeader/x_\xe9'  b'21 hdrcharset=BINARY\n20 path=package/x_\xe9\n'
b'0' b'package/x_\xe9'            b'ONE'
b'x' b'package/PaxHeader/x_\xff'  b'21 hdrcharset=BINARY\n20 path=package/x_\xff\n'
b'0' b'package/x_\xff'            b'TWO'

no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/cli/install/bun-pack.test.ts

add_archive_entry built the member pathname by formatting the filename
through bstr's Display, which replaces every byte sequence that is not
valid UTF-8 with U+FFFD, while the length handed to ZStr::from_buf was
computed from the original filename. On Linux, where such filenames are
legal, two files whose names differed only in such a byte were written
to the tarball under the same substituted name, so one of them was lost
on extract; debug builds hit the from_buf NUL assertion instead.

Append the raw bytes and take the NUL-terminated buffer as written. The
gzip compression level a few lines up went through the same pattern and
gets the same treatment.
@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on bun 1.4.0-canary.1 (two files x_\xe9 / x_\xff both packed as package/x_\xef\xbf\xbd, one lost on extract) and as the ZStr::from_buf debug assertion on a debug build of main. Fix and test are in; the diff is unchanged since the first push.

CI: every lane that has run is green (builds 96741 and 97120, 177 jobs each; the remaining entries in the annotations are retried flakes in unrelated tests). The only lane still outstanding is darwin 14 aarch64, which is queued behind a long backlog on that agent pool; in build 96741 its one failure was test/js/third_party/grpc-js/test-tonic.test.ts, which this change does not touch and which has been reported separately. Ready for review.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:51 AM PT - Aug 15th, 2026

@robobun, your commit d6441241b86dc6c6ec4ef88c740303a8aed75fe3 passed in Build #97120! 🎉


🧪   To try this PR locally:

bunx bun-pr 38715

That installs a local version of the PR into your bun-38715 executable, so you can run:

bun-38715 --bun

@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a756c926-dbc4-4b6d-b710-ebdeb4fdd959

📥 Commits

Reviewing files that changed from the base of the PR and between 2d3b1ee and 4a3b7b5.

📒 Files selected for processing (2)
  • src/runtime/cli/pack_command.rs
  • test/cli/install/bun-pack.test.ts

Walkthrough

The pack command now builds NUL-terminated values from raw bytes. Linux tests parse tarballs and verify that invalid UTF-8 filename bytes remain unchanged.

Changes

Archive byte preservation

Layer / File(s) Summary
Raw byte path construction
src/runtime/cli/pack_command.rs
Compression settings and archive entry paths use direct byte-buffer appends with NUL terminators.
Archive byte validation
test/cli/install/bun-pack.test.ts
A raw tarball parser and Linux-only test verify invalid UTF-8 filename bytes and file contents.

Possibly related PRs

  • oven-sh/bun#37669: Related archive and install robustness work, including extraction handling and archive tests.

Suggested reviewers: jarred-sumner

Merge Risk: ⚪ Minimal · up to 4a3b7

This change preserves non-UTF-8 filenames byte for byte in packed tarballs, preventing filename collisions and debug-build failures. No actionable merge-blocking risk remains after normal checks and review.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely states the primary change: preserving tarball entry names byte for byte.
Description check ✅ Passed The description explains the problem, fix, affected behavior, test coverage, and verification results, although it does not use the template headings.
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.

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

@claude claude 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.

LGTM — replaces lossy BStr Display formatting with direct byte appends so tarball entry names carry the on-disk bytes.

Checked that print_buf is empty on entry to both changed sites (fresh at 2466, cleared at 2507/3209), that ZStr::from_slice_with_nul matches the pushed trailing NUL, and that filename.as_bytes() excludes the NUL. Grepped the file for other write!-via-BStrZStr::from_buf sites — none remain; the other from_buf calls are byte-slice arithmetic, not formatted writes. The Windows set_pathname_utf8 path is unchanged for valid UTF-8 (BStr Display was already identity there). The new Linux-only test reads raw ustar/pax bytes to sidestep readTarball's own lossy decode, and asserts both files land under distinct byte-exact names.

Extended reasoning...

Overview

Two-site change in src/runtime/cli/pack_command.rs: the tarball entry pathname and the gzip compression-level string are now built by extend_from_slice + push(0) + ZStr::from_slice_with_nul instead of write!(buf, "{}\x00", BStr(..)) + ZStr::from_buf(buf, computed_len). The old path was lossy (BStr's Display substitutes U+FFFD for invalid UTF-8, three bytes per one) and the length passed to from_buf was computed from the original bytes, so debug builds asserted and release builds stored the substituted name — colliding distinct non-UTF-8 filenames into one member. A Linux-only test in bun-pack.test.ts gunzips the archive and reads the ustar name field and pax path record byte-for-byte via latin1 decoding.

Security risks

None. This narrows behavior toward byte fidelity for a name that was already being written to the archive; it does not change what files are selected for packing, and libarchive already handles the pax hdrcharset=BINARY emission for non-UTF-8 names.

Level of scrutiny

Low-to-medium. The Rust change is ~10 lines net, mechanical, and strictly less transformation than before. I verified the shared-buffer invariant (print_buf is empty on entry at both sites — declared at 2466, cleared at 2507 and 3209), confirmed from_slice_with_nul's contract in bun_core/util.rs, and checked that the remaining ZStr::from_buf calls in this file don't share the lossy-format pattern. For valid UTF-8 (the overwhelming common case, and the only case on Windows where set_pathname_utf8 is used), BStr Display was already byte-identical, so this is a no-op there.

Other factors

The test is well-constructed: it can't use readTarball (which would map both correct and buggy output onto U+FFFD), so it hand-parses 512-byte ustar blocks and pax records via latin1 to preserve one-char-per-byte, and asserts a Set per member so the ustar and pax spellings must agree. The skipIf(!isLinux) gate is justified in a comment (APFS rejects such names; Windows names are UTF-16). The PR description shows before/after tar tvzf output demonstrating the fix, and reports the rest of the 77-test file still passes.

@claude claude 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.

LGTM — swaps lossy write!(BStr(...)) formatting for direct byte appends into print_buf, so libarchive receives the directory entry's exact bytes.

What was reviewed:

  • Confirmed print_buf is empty at both changed sites (fresh Vec before the compression-level use; .clear() at 2507 and 3209 bracket every add_archive_entry call), so from_slice_with_nul sees only the just-written bytes.
  • Checked the rest of pack_command.rs for the same write!(BStr) + from_buf(computed_len) pattern — the two changed sites were the only ones; remaining BStr::new uses are display/error output where U+FFFD substitution is intended.
  • On Windows the path still goes through set_pathname_utf8; Windows filenames are UTF-16-derived so filename.as_bytes() is already valid UTF-8 there — no behavior change.
  • Test reads the ustar header and pax path record byte-for-byte via latin1 (avoiding readTarball's string decode that would collapse both correct and buggy output onto U+FFFD), and is Linux-gated with a stated reason.
Extended reasoning...

Overview

Two small edits in src/runtime/cli/pack_command.rs (the gzip compression-level filter option and the per-entry pathname in add_archive_entry) replace write!(print_buf, "{}...\x00", BStr(...)) + ZStr::from_buf(buf, computed_len) with extend_from_slice + push(0) + ZStr::from_slice_with_nul. A Linux-only test in test/cli/install/bun-pack.test.ts gunzips the produced tarball and asserts the raw bytes of both the ustar name field and the pax path record match the on-disk filename bytes.

The old code had two coupled defects: BStr's Display replaces each invalid-UTF-8 byte with U+FFFD (3 bytes), so (a) the length passed to from_buf no longer pointed at the NUL (debug assertion), and (b) in release builds distinct filenames collapsed to the same tar member. The new code writes the bytes verbatim and derives the NUL position from the buffer itself, eliminating both.

Security risks

None identified. The change narrows behavior toward byte-for-byte fidelity of filenames already on disk; it does not introduce new parsing of untrusted input, and libarchive's pax writer already handles non-UTF-8 names via hdrcharset=BINARY. Interior NULs are impossible in POSIX filenames, so from_slice_with_nul's trailing-NUL contract holds.

Level of scrutiny

Low-to-medium. This is a ~10-line mechanical fix in a CLI packing path, using an existing in-tree helper (ZStr::from_slice_with_nul, whose doc comment describes exactly this call shape). The invariant it relies on — print_buf is empty on entry — is locally verifiable: print_buf is created empty, cleared at line 2507 after the compression-level use, and cleared at line 3209 after each entry write; no other writes occur between clears. I grepped for sibling sites combining lossy BStr formatting with a separately-computed length passed to from_buf and found none — the remaining BStr::new calls are all in Output::err_generic/log formatting where lossy display is correct, and remaining from_buf calls copy bytes directly into a fixed buffer with the NUL written at the same computed offset.

Other factors

The test is careful: it can't use readTarball (which would decode both the correct and the buggy bytes to the same JS string), so it hand-parses the 512-byte ustar blocks and pax extended-header records with latin1 decoding, sorts by content for determinism, and asserts exact Set equality on names. The skipIf(!isLinux) is justified in a comment (APFS rejects such names; Windows names are UTF-16). No CODEOWNERS cover these files. CI shows a red build, but given the scope (two byte-append sites + one Linux-only test) and that the full bun-pack.test.ts suite is reported passing, any failure there is worth checking against main before attributing it to this change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants