feat(tar-xz): true streaming for Node extract()/list() — O(largest entry)#113
Conversation
Adds the streaming-XZ pipeline foundation that subsequent blocks in this story will build on. No callers yet — extract.ts and list.ts still use the buffered helpers; their migration is Block 3 / Block 4. - streamXz(input: TarInputNode): AsyncIterable<Uint8Array> Pipes any TarInputNode through createUnxz() and yields decompressed chunks via the Transform's native Symbol.asyncIterator. No internal Buffer.concat, no Readable.from() wrap (per spec §12.5). - @deprecated tags on collectAllChunks / decompressXz / runWritable. These remain functional until Blocks 3+4 migrate their callers. - 9 unit tests in packages/tar-xz/test/xz-helpers.spec.ts (flat layout, matching coverage.spec.ts / node-api.spec.ts / tar-format.spec.ts). Covers: byte-equality, multi-chunk yield, all four input forms (Uint8Array / Buffer / Readable / AsyncIterable), error propagation on corrupt input, memory-shape no-accumulation proof, deprecation tags. Memory test correctly distinguishes XZ preset-6 dictionary (~8 MB working memory, inherent) from accumulation (would grow with archive size). Spec doc TAR-XZ-STREAMING-2026-04-28.md captured in this commit (adversarial + llm-spec hardened, 5 design decisions locked, §10/§11 review ledgers filled). 108 / 108 tar-xz tests pass; 27 / 27 nxz tests pass; tsc + lint clean.
Blocks 2+3+4 of TAR-XZ-STREAMING-2026-04-28. Replaces the buffered TarUnpack/TarList Writable classes with a coroutine-style AsyncGenerator parser that yields entries while the XZ stream is still being consumed. - tar-parser.ts: new ParseEvent discriminated union + parseTar(source, mode) AsyncGenerator. State machine: HEADER / CONTENT / SKIP / PADDING. Reuses parseNextHeader() unchanged. Removes all four `v8 ignore` blocks that were marking the chunk-split / PAX-split paths (lines 86-89, 148-153, 169-174 — now hot paths exercised by 8 new unit tests in test/tar-parser-stream.spec.ts). Adds MAX_PAX_HEADER_BYTES = 1 MB DoS guard (per spec A-07/A-08); exceeds → throws Error with code 'TAR_PARSER_INVARIANT' (per D-5/L-S-02). - extract.ts: Writable class replaced by lean async generator with a lookahead-buffer pattern that handles cooperative coroutine flow without losing parse events. makeTarEntryWithData rewired around a pull-callback; bytes()/text() memoize via a shared cachedBytes field (D-3). +5 new tests cover S-08 (consumer skips entry.data), memoization, single-use data after bytes(), and S-08b (consumer-break silent stop). - list.ts: Writable class replaced by 4-line generator wrapping parseTar(xzStream, 'list'). Mode 'list' never yields chunk events so memory stays O(BLOCK_SIZE). - xz-helpers.ts: deleted the deprecated collectAllChunks / decompressXz / runWritable. Zero callers verified via search_structural before removal. Spec drift flagged + accepted: D-5's "TAR_PARSER_INVARIANT always re-throws even on consumer-break" cannot be implemented literally because Biome's noUnsafeFinally rule prohibits `throw` in `finally`. After consumer-break (parser.return()) the iterator is dead; no subsequent .next() can observe the corrupted state — surfacing the invariant via finally would only produce an unhandled rejection. Resolution: finally swallows cleanup errors on consumer-break, consistent with D-2 silent-stop semantics. Documented for opus review. Test counts post-blocks-2-3-4: tar-xz 120, root 489, nxz 27 — all green. tsc clean, lint clean (rtk proxy biome).
Block 5 of TAR-XZ-STREAMING-2026-04-28. Adds the regression-lock that PR #113 needs to ship — proves the streaming refactor preserves the 18 TOCTOU vectors closed by PR #108 and meets the O(largest entry) memory target. - vitest.config.ts: pool='forks' + execArgv=['--expose-gc'] for the global.gc handle the memory tests need (Vitest 4 moved execArgv to top-level test config, not poolOptions). - package.json: new test:memory script. - test/security.spec.ts (new, 22 tests): consolidated TOCTOU coverage for V1/R6-1 (leaf symlink), F-001 (traversal), F-002/R3-1 (TOCTOU ancestor + ENOENT walk), S3 (per entry-type), R4-2 (hardlink strip), R5-1 (hardlink symlink source), S2 (hardlink escape), V6a/V6b (NUL/empty name), V12 (setuid mask), V2/V3 (O_NOFOLLOW POSIX — skipped with note on win32). Plus S-14 (Win32 policy doc test) and S-15a/S-15b (PAX bomb DoS — both safe outcomes verified: truncated bomb hits "Unexpected end" before guard, > 1 MB actual hits the TAR_PARSER_INVARIANT). - test/memory-shape.spec.ts (new, 4 tests): in-loop high-water sampling per spec §12.3. extract 1 × 50 MB ≤ 116 MB peak; list 100 × 1 MB metadata ≤ 16 MB peak; extract 5 × 10 MB ≤ 36 MB peak. Tests SKIP cleanly when global.gc is unavailable. - file.ts: ~17 lines of @security TSDoc on extractFile distinguishing POSIX (fd-based, minimal window) from Windows (by-path, wallclock window scales with entry size in streaming mode). Replaces the prior "OUT OF SCOPE" comment. - README.md: streaming claim updated (now O(largest entry) as of v6.1.0); new "Security model" subsection (25 lines) documenting the POSIX/Windows split and recommending exclusive directories on Windows until Win32 fd-based extraction lands (separate TODO). Quality gates: build=0 tsc=0 lint=0 security-test=0 memory-test=0 full-test=0. 661 total tests pass across all packages (33 spec files).
There was a problem hiding this comment.
Pull request overview
This PR delivers true streaming for the Node.js tar-xz extract() and list() APIs, replacing the previous buffer-then-parse implementation so memory usage scales with the largest single entry rather than the full archive.
Changes:
- Introduces a streaming XZ decompression helper (
streamXz) and a streaming TAR parser coroutine (parseTar). - Rewrites Node
extract()/list()to consumeparseTarevents and yield entries lazily, with new security + memory-shape regression tests. - Updates Vitest config/scripts and documentation (README/spec/changeset) to reflect the streaming model and its security implications.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/tar-xz/src/node/xz-helpers.ts | Adds streamXz() streaming decompression pipeline. |
| packages/tar-xz/src/node/tar-parser.ts | Adds parseTar() async-generator TAR parser + PAX DoS guard. |
| packages/tar-xz/src/node/extract.ts | Rewrites extract() to be truly streaming; adds memoizing helpers. |
| packages/tar-xz/src/node/list.ts | Rewrites list() to stream metadata without buffering content. |
| packages/tar-xz/vitest.config.ts | Forces forks pool + --expose-gc for memory-shape tests. |
| packages/tar-xz/test/*.spec.ts | Adds/extends streaming, security, and memory-shape tests. |
| packages/tar-xz/src/node/file.ts | Updates security documentation (POSIX vs Windows TOCTOU model). |
| packages/tar-xz/README.md | Documents new streaming + security model. |
| docs/plans/TAR-XZ-STREAMING-2026-04-28.md | Adds design/spec document for the refactor. |
| packages/tar-xz/package.json | Adds test:memory script. |
| .changeset/tar-xz-streaming.md | Declares minor release + behavior notes. |
| TODO.md | Tracks story progress + Windows follow-up. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /** Pull next chunk from source and append to state.buffer. */ | ||
| const iter = source[Symbol.asyncIterator](); | ||
|
|
||
| async function pullChunk(): Promise<boolean> { | ||
| const { value, done } = await iter.next(); | ||
| if (done) return false; | ||
| const chunk = value as Uint8Array; | ||
| if (state.buffer.length + chunk.length > MAX_PAX_HEADER_BYTES && phase === 'HEADER') { | ||
| const err = new Error(`PAX header exceeds maximum size (${MAX_PAX_HEADER_BYTES / 1024} KB)`); | ||
| (err as Error & { code: string }).code = 'TAR_PARSER_INVARIANT'; | ||
| throw err; | ||
| } | ||
| state.buffer = Buffer.concat([state.buffer, Buffer.from(chunk)]); | ||
| return true; | ||
| } |
There was a problem hiding this comment.
parseTar() manually pulls from source[Symbol.asyncIterator]() but never calls iter.return() on early termination (e.g. when the consumer breaks out of extract()/list() and parser.return() is invoked). Adding a try/finally around the main loop to invoke await iter.return?.() would ensure upstream generators/streams (like streamXz()) are properly closed and don’t keep running in the background.
| return { | ||
| ...entry, | ||
| data: dataGen, | ||
| async bytes(): Promise<Uint8Array> { | ||
| if (cachedBytes !== null) return cachedBytes; | ||
| const chunks: Uint8Array[] = []; | ||
| for await (const c of dataGen) chunks.push(c); | ||
| cachedBytes = concatChunks(chunks); | ||
| return cachedBytes; | ||
| }, | ||
| async text(encoding?: string): Promise<string> { | ||
| const bytes = await this.bytes(); | ||
| return new TextDecoder(encoding ?? 'utf-8').decode(bytes); | ||
| }, |
There was a problem hiding this comment.
bytes() currently reads from the same single-use dataGen as entry.data. If a caller iterates entry.data (even partially) and then calls bytes()/text(), the helpers will return only the remaining bytes (or an empty array) while still claiming to “collect all chunks”. Consider either (1) throwing a clear error if data iteration has started before bytes()/text() is called, or (2) implementing a tee/memoization strategy that guarantees bytes() always returns the full content.
| }, | ||
| async text(encoding?: string): Promise<string> { | ||
| const bytes = await this.bytes(); | ||
| return new TextDecoder(encoding ?? 'utf-8').decode(bytes); |
There was a problem hiding this comment.
text(encoding?) switched from Buffer.toString(BufferEncoding) semantics to TextDecoder. In Node, TextDecoder supports a narrower set of encoding labels than BufferEncoding (and different aliases), so callers passing encodings like base64, hex, etc. will now throw. If the intent is to preserve the previous Node behavior, decode via Buffer.from(bytes).toString(encoding as BufferEncoding) (or explicitly document/restrict supported encodings).
| return new TextDecoder(encoding ?? 'utf-8').decode(bytes); | |
| return Buffer.from(bytes).toString((encoding ?? 'utf-8') as BufferEncoding); |
| export default defineConfig({ | ||
| test: { | ||
| // Use forks pool so that --expose-gc (required by memory-shape tests) is | ||
| // available. Memory-shape tests feature-detect gc at runtime and skip if | ||
| // missing, so this flag is non-breaking on CI runners that do not pass it. | ||
| // The forks pool also gives each test file a clean V8 heap. | ||
| // | ||
| // Note: Vitest 4 moved pool-level execArgv to top-level test.execArgv. | ||
| // The previous poolOptions.forks.execArgv is now just test.execArgv. | ||
| pool: 'forks', | ||
| execArgv: ['--expose-gc'], | ||
| include: ['test/**/*.spec.ts'], |
There was a problem hiding this comment.
Switching the entire tar-xz test suite to pool: 'forks' can significantly increase test runtime and process overhead vs threads, even though only the memory-shape tests need --expose-gc. Consider using a separate Vitest config (or a dedicated vitest --config ... script) for memory-shape tests so regular pnpm test can keep the default pool.
| export default defineConfig({ | |
| test: { | |
| // Use forks pool so that --expose-gc (required by memory-shape tests) is | |
| // available. Memory-shape tests feature-detect gc at runtime and skip if | |
| // missing, so this flag is non-breaking on CI runners that do not pass it. | |
| // The forks pool also gives each test file a clean V8 heap. | |
| // | |
| // Note: Vitest 4 moved pool-level execArgv to top-level test.execArgv. | |
| // The previous poolOptions.forks.execArgv is now just test.execArgv. | |
| pool: 'forks', | |
| execArgv: ['--expose-gc'], | |
| include: ['test/**/*.spec.ts'], | |
| const memoryShapeInclude = ['test/**/*memory-shape*.spec.ts']; | |
| const runMemoryShapeTests = process.env.VITEST_MEMORY_SHAPE === '1'; | |
| export default defineConfig({ | |
| test: { | |
| // Only memory-shape tests require --expose-gc. Keep the regular suite on | |
| // Vitest's default pool, and opt into forks/execArgv only for a dedicated | |
| // memory-shape run (for example: VITEST_MEMORY_SHAPE=1 vitest). | |
| ...(runMemoryShapeTests | |
| ? { | |
| pool: 'forks' as const, | |
| execArgv: ['--expose-gc'], | |
| include: memoryShapeInclude, | |
| } | |
| : { | |
| include: ['test/**/*.spec.ts'], | |
| exclude: memoryShapeInclude, | |
| }), |
| // T-05: Memory shape — in-loop high-water mark sampling (spec §12.3 pattern) | ||
| // | ||
| // Uses a 5 MB synthetic decompressed tar and asserts that peak memory delta | ||
| // stays below 10× the typical XZ output chunk size (64 KB = 655 360 bytes). |
There was a problem hiding this comment.
Comment has an incorrect 64 KB conversion: 64 KB is 65,536 bytes, not 655,360. Since this comment is used to justify the memory threshold, fixing the number will avoid confusion.
| // stays below 10× the typical XZ output chunk size (64 KB = 655 360 bytes). | |
| // stays below 10× the typical XZ output chunk size (64 KB = 65,536 bytes). |
| // Feed the source into the Transform via pipeline() so that errors from the | ||
| // input side (e.g. truncated readable) propagate and are not silently swallowed. | ||
| // We do NOT await pipelinePromise here — iteration drives consumption below. | ||
| const pipelinePromise = pipeline(Readable.from(source), unxzStream); | ||
|
|
||
| // Iterate the Transform's own Symbol.asyncIterator directly (spec §12.5: | ||
| // prefer this over Readable.from(transform) which adds another buffering layer). | ||
| // Node's Transform implements Symbol.asyncIterator natively since Node 10. | ||
| return (async function* () { | ||
| try { | ||
| for await (const chunk of unxzStream) { | ||
| const buf = chunk as Buffer; | ||
| yield new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); | ||
| } | ||
| // Surface any pipeline error (e.g. corrupt XZ, premature end of source). | ||
| await pipelinePromise; | ||
| } catch (err) { | ||
| // Ensure the pipeline promise is always settled to avoid unhandled rejections. | ||
| await pipelinePromise.catch(() => undefined); | ||
| throw err; | ||
| } |
There was a problem hiding this comment.
streamXz() starts a pipeline() but doesn’t reliably clean it up when the consumer stops iterating early (e.g. extract()/list() consumer breaks). If the async generator is closed before pipelinePromise is awaited, the pipeline can keep running/hang, and a later pipeline rejection can become an unhandled promise rejection. Add try/finally cleanup that aborts/destroys the streams (or uses an AbortController/signal) and always awaits/handles pipelinePromise in finally so early-return is safe.
|
|
||
| - **Unified API** — `create`, `extract`, `list` work identically in Node.js and browsers | ||
| - **Stream-shaped API** — all functions return `AsyncIterable<…>`; stream-shaped inputs accepted. Note: current Node `extract()`/`list()` implementations buffer internally — true streaming is a planned optimization | ||
| - **Stream-shaped API** — all functions return `AsyncIterable<…>`; stream-shaped inputs accepted. Node `extract()`/`list()` are now O(largest entry) streaming as of v6.1.0 — the previous buffered behavior was the v6.0.0 "planned optimization" delivery |
There was a problem hiding this comment.
README wording here is internally inconsistent: it says Node extract()/list() are streaming as of v6.1.0, but then refers to the previous buffered behavior as the v6.0.0 "planned optimization" delivery. Consider rephrasing to clearly state that v6.1.0 delivers the planned optimization and that v6.0.0 buffered internally.
| - **Stream-shaped API** — all functions return `AsyncIterable<…>`; stream-shaped inputs accepted. Node `extract()`/`list()` are now O(largest entry) streaming as of v6.1.0 — the previous buffered behavior was the v6.0.0 "planned optimization" delivery | |
| - **Stream-shaped API** — all functions return `AsyncIterable<…>`; stream-shaped inputs accepted. In Node, `extract()`/`list()` are O(largest entry) streaming as of v6.1.0, which delivered the planned optimization; in v6.0.0 they still buffered internally |
| These were always part of the streaming contract advertised by v6.0.0 but only become observable now: | ||
|
|
||
| 1. **`entry.data` chunk count and size** now reflect the XZ decompressor's output cadence (typically 64 KB blocks). Previously: exactly one chunk per entry containing the full file. Code that assumed "one chunk per entry" or relied on `Buffer.concat` of all chunks should iterate normally — `entry.bytes()` and `entry.text()` still collect the whole file. | ||
| 2. **`entry.bytes()` and `entry.text()` memoize.** Calling `bytes()` twice now returns the same buffer reference (efficient) instead of re-collecting. Memory promise preserved: at most one entry's bytes are held at a time. | ||
| 3. **`entry.data` is single-use.** Iterating it twice now yields nothing on the second pass (was: same single chunk both times). If you need to re-read, call `bytes()` first and keep the reference. | ||
| 4. **Consumer-break with corrupt downstream is now silent.** If you `for await ... break` and the archive is corrupt past the break point, no error surfaces. Previously the buffered model could surface the error after the loop. Errors during forward iteration still throw normally — only post-break errors are suppressed (matches `tar-stream` and JS AsyncGenerator convention). |
There was a problem hiding this comment.
This changeset’s description of the pre-v6.1.0 behavior seems inaccurate: the old Node implementation buffered and processed the whole archive before yielding any entries, so "errors after consumer break" wouldn’t have been observable in the same way. Consider tightening the wording to avoid claiming a specific old runtime behavior that didn’t occur with the fully-buffered pipeline.
| These were always part of the streaming contract advertised by v6.0.0 but only become observable now: | |
| 1. **`entry.data` chunk count and size** now reflect the XZ decompressor's output cadence (typically 64 KB blocks). Previously: exactly one chunk per entry containing the full file. Code that assumed "one chunk per entry" or relied on `Buffer.concat` of all chunks should iterate normally — `entry.bytes()` and `entry.text()` still collect the whole file. | |
| 2. **`entry.bytes()` and `entry.text()` memoize.** Calling `bytes()` twice now returns the same buffer reference (efficient) instead of re-collecting. Memory promise preserved: at most one entry's bytes are held at a time. | |
| 3. **`entry.data` is single-use.** Iterating it twice now yields nothing on the second pass (was: same single chunk both times). If you need to re-read, call `bytes()` first and keep the reference. | |
| 4. **Consumer-break with corrupt downstream is now silent.** If you `for await ... break` and the archive is corrupt past the break point, no error surfaces. Previously the buffered model could surface the error after the loop. Errors during forward iteration still throw normally — only post-break errors are suppressed (matches `tar-stream` and JS AsyncGenerator convention). | |
| These are newly observable consequences of the true-streaming implementation: | |
| 1. **`entry.data` chunk count and size** now reflect the XZ decompressor's output cadence (typically 64 KB blocks). Previously: exactly one chunk per entry containing the full file. Code that assumed "one chunk per entry" or relied on `Buffer.concat` of all chunks should iterate normally — `entry.bytes()` and `entry.text()` still collect the whole file. | |
| 2. **`entry.bytes()` and `entry.text()` memoize.** Calling `bytes()` twice now returns the same buffer reference (efficient) instead of re-collecting. Memory promise preserved: at most one entry's bytes are held at a time. | |
| 3. **`entry.data` is single-use.** Iterating it twice now yields nothing on the second pass (was: same single chunk both times). If you need to re-read, call `bytes()` first and keep the reference. | |
| 4. **Consumer-break with corrupt downstream is now silent.** With true streaming, if you `for await ... break` and the archive is corrupt past the break point, no error surfaces because that later data is never consumed. This differs from the old fully buffered pipeline, which did not expose this same post-break behavior. Errors during forward iteration still throw normally — only corruption beyond the break point is suppressed (matching `tar-stream` and JS AsyncGenerator convention). |
| ## §12 Locked Design Decisions (2026-04-28) | ||
|
|
||
| User-validated decisions BEFORE adversarial review (each via separate AskUserQuestion with | ||
| concrete chiffrage): | ||
|
|
There was a problem hiding this comment.
The spec document contains a duplicated section header/content: ## §12 Locked Design Decisions (2026-04-28) appears twice back-to-back. Removing the duplicate will prevent confusion when linking to section anchors and during future edits.
| ## §12 Locked Design Decisions (2026-04-28) | |
| User-validated decisions BEFORE adversarial review (each via separate AskUserQuestion with | |
| concrete chiffrage): |
Round-1 findings from opus + Copilot post-restart consolidated review
(3M + 8L). Closes resource leaks, restores BufferEncoding contract on
text(), and bundles 8 polish items to converge in one round.
M-class (correctness):
- streamXz()/parseTar() now propagate cleanup on early termination.
streamXz wraps the pipeline in finally{ unxzStream.destroy(); await
pipelinePromise.catch() } — destroying the Transform on consumer-break
suppresses the resulting pipeline rejection. parseTar wraps its main
loop in try/finally{ await iter.return?.() } so upstream cleanup
propagates back through the chain. No more pending unhandled
rejections after `for await ... break` (F-1).
- entry.bytes() now throws explicitly when entry.data has been iterated
first: "entry.data already iterated; bytes() cannot recover full
content; consume one or call bytes() first." Internal flag set on the
first .next() of the data wrapper. Clean contract; surprising silent
partial caches eliminated. +2 tests (F-2).
- entry.text(encoding?) reverted to Buffer.from(...).toString(encoding)
semantics. The TextDecoder rewrite shipped in Block 3 was a breaking
change vs the previous Buffer.toString() contract — base64, hex,
latin1 etc. now work again. Tests cover utf8 default + base64 + hex
(F-3).
L-class (polish, doc, layout):
- Concurrent entry.data iteration guard (boolean dataGenInFlight) —
spec §2.4 forbids it; runtime now enforces (F-4).
- Stray-chunk silent fallthrough in extract() outer loop now throws
err.code='TAR_PARSER_INVARIANT' instead of consuming-and-continuing
(F-5).
- memory-shape Test 1 raised to preset:6 to match the 16 MB slack
budget rationale (XZ preset-6 dictionary ~8 MB). Threshold unchanged
(F-6).
- New packages/tar-xz/vitest.memory.config.ts (pool: 'forks',
--expose-gc) keeps the default vitest config on threads. test:memory
script updated to use the dedicated config. Default `pnpm test`
runtime no longer regressed by forks pool (F-7).
- xz-helpers.spec.ts:146 "64 KB = 655,360" → "64 KB = 65,536" math fix
(F-8).
- README streaming claim reworded: "v6.0.0 introduced the stream-first
API contract; v6.1.0 delivers the planned optimization that fulfills
it." (F-9).
- Changeset bullet 4 softened: removed "previously the buffered model
could surface the error" overclaim; replaced with accurate JS
AsyncGenerator convention reference (F-10).
- docs/plans/TAR-XZ-STREAMING-2026-04-28.md duplicate `## §12 Locked
Design Decisions` header collapsed to one (F-11).
Tests: 150+3-skip pass; memory 3+1-skip pass. tsc + lint + build green.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Feed the source into the Transform via pipeline() so that errors from the | ||
| // input side (e.g. truncated readable) propagate and are not silently swallowed. | ||
| // We do NOT await pipelinePromise here — iteration drives consumption below. | ||
| const pipelinePromise = pipeline(Readable.from(source), unxzStream); | ||
|
|
There was a problem hiding this comment.
pipelinePromise is created outside the returned async generator, so the pipeline starts as soon as streamXz() is called (even if the caller never iterates). That can eagerly consume the input Readable, do work without backpressure from the consumer, and if the pipeline rejects before iteration begins it can produce an unhandled rejection. Consider creating/starting the pipeline inside the async generator on first iteration (or attaching an immediate .catch() handler at creation time) so the stream stays lazy and errors are always observed by the iterator.
| const chunks: Uint8Array[] = []; | ||
| for await (const c of dataGen) chunks.push(c); | ||
| cachedBytes = concatChunks(chunks); |
There was a problem hiding this comment.
bytes() currently buffers all chunks into an array and then concatenates them into a new Uint8Array, which doubles copying and peak memory (chunks array + final buffer) for large entries. Since the entry size is known (entry.size), consider allocating the output once and filling it as chunks arrive to keep memory overhead closer to 1× entry size and reduce CPU.
| const chunks: Uint8Array[] = []; | |
| for await (const c of dataGen) chunks.push(c); | |
| cachedBytes = concatChunks(chunks); | |
| const out = new Uint8Array(entry.size); | |
| let offset = 0; | |
| for await (const c of dataGen) { | |
| out.set(c, offset); | |
| offset += c.byteLength; | |
| } | |
| cachedBytes = offset === out.byteLength ? out : out.subarray(0, offset); |
Round-2 Copilot findings on PR #113 (1M + 1L): - streamXz() now creates the createUnxz() Transform and the pipeline() call INSIDE the async generator body. Pipeline starts only on the first .next() — truly lazy. Consumers that call streamXz(input) but never iterate (e.g. early break before any read) trigger zero I/O and zero resources. T-07 proves it via a counting Readable mock (readCount=0 after 20ms when not iterated). The cleanup `finally{ unxzStream.destroy(); await pipelinePromise.catch(...) }` is reached on every termination path (CR2-1). - bytes() in makeTarEntryWithData() now allocates `new Uint8Array( entry.size)` once and `set()`s each chunk at running offset. Halves peak memory for large entries vs the old chunks-array + concat. entry.size===0 short-circuits to Uint8Array(0). Defensive overrun guard throws Error with code='TAR_PARSER_INVARIANT' if offset+chunk would exceed entry.size — mathematically unreachable given parseTar's Math.min(bytesRemaining,…) but locks the invariant (CR2-2). Pre-push opus senior review (§2.10 round-3 gate): SAFE-TO-PUSH — lazy semantics confirmed, cleanup paths sound, T-07 race-free with positive control. Zero new findings. 151 tar-xz tests pass + 489 full suite + 3 memory-shape pass. tsc + lint + build all green.
CI build failed on `packages/tar-xz` with TS2741: TypeScript 6's `lib.esnext` adds `[Symbol.asyncDispose]` to `AsyncGenerator`, and the fix-round-1 dataWrapper proxy didn't implement it. The wrapper only needs to satisfy `AsyncIterable<Uint8Array>` — that is the public type on `TarEntryWithData.data`. `AsyncGenerator` was over-typed. - Restructured the wrapper into a pure AsyncIterable whose `[Symbol.asyncIterator]()` sets `dataIterStarted = true` and returns the underlying `dataGen` directly. No more next/return/throw on the literal — strict mode rejected those without explicit method types, and they were redundant once the wrapper just delegates. - Surface is strictly smaller and matches the public type contract exactly. No behavior change: bytes()-after-iter still throws because the flag is still set on first `[Symbol.asyncIterator]()` call (which fires the moment the consumer starts a `for await`). All gates green: tar-xz build, tsc, vitest (151 + 3 skipped), root build, full suite, biome lint.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * In `'list'` mode the generator emits only `kind:'entry'` events; content | ||
| * bytes are consumed internally without being yielded. | ||
| * | ||
| * The generator ALWAYS emits a final `kind:'end'` event before returning. |
There was a problem hiding this comment.
The JSDoc says parseTar “ALWAYS emits a final kind:'end' event before returning”, but the generator can also return early when the consumer stops iteration (generator.return()), in which case no 'end' is yielded. Suggest rewording to “emits 'end' on successful end-of-archive detection” (and mention consumer-abort explicitly) to avoid misleading downstream consumers/tests.
| * The generator ALWAYS emits a final `kind:'end'` event before returning. | |
| * On successful end-of-archive detection, the generator emits a final | |
| * `kind:'end'` event before returning. If the consumer stops iteration early | |
| * (for example via `break` from `for await...of` or `generator.return()`), | |
| * no final `kind:'end'` event is guaranteed. |
| export default defineConfig({ | ||
| test: { | ||
| pool: 'forks', | ||
| execArgv: ['--expose-gc'], |
There was a problem hiding this comment.
vitest.memory.config.ts sets test.execArgv, but if Vitest doesn’t actually apply this option to the forks pool, globalThis.gc will be missing and the memory-shape suite will silently take the “SKIPPED” path, making pnpm test:memory a no-op. Add a sanity check (fail the run if gc is absent) and/or configure --expose-gc using the supported forks-pool mechanism (e.g. test.poolOptions.forks.execArgv) so the memory gate reliably executes.
| execArgv: ['--expose-gc'], | |
| poolOptions: { | |
| forks: { | |
| execArgv: ['--expose-gc'], | |
| }, | |
| }, |
…fail-closed (#114) * chore: lifecycle hygiene — promote shipped plans, refresh TODO - docs/plans/TAR-XZ-STREAMING-2026-04-28.md: draft → canonical (PR #113 squash 06a9937 shipped 2026-04-29; all 5 blocks delivered, opus adversarial + LLM consensus + 4 Copilot rounds passed) - docs/plans/native-binding-migration.md: draft → canonical (research conclusion: no migration; document is the deliverable) - TODO.md: remove stale duplicate of #113 streaming entry from Pending-MEDIUM (was a copy of the now-shipped item), refresh Quick Reference table, move Win32 follow-up from Pending to In Progress (story WIN32-TOCTOU-2026-04-29 starts here) * fix(tar-xz): close Win32 symlink-swap TOCTOU with JS-pure 'wx'+retry fail-closed Replace the by-path Win32 fallback in extractFile with a handle-based path using O_EXCL ('wx') atomic create plus an unlink-then-retry pattern for legitimate overwrite. fd-based chmod/utimes follow the file descriptor, not the path. If a symlink is injected during the unlink+retry race, the second 'wx' open fails with EEXIST and a security error is thrown citing the entry name — no bytes are written through the symlink. Recon invalidated the original "match node-tar with CreateFileW" framing: node-tar is pure JS and explicitly does NOT protect Windows either (their PR #456 is Unix-only, get-write-flag.ts gates O_NOFOLLOW on !isWindows). Adopting JS-pure 'wx'+retry exceeds the ecosystem norm without expanding the native addon surface. Adversarial pass folded one M finding (reparse-point coverage gap) into SECURITY.md with full IO_REPARSE_TAG_SYMLINK / MOUNT_POINT / CLOUD_FILES coverage table. Vectors 2-5 (hardlinks, case-insensitive NTFS, ADS, vi.stubGlobal reachability) confirmed handled or non-issues; vector 5 simplified the implementation by cutting the platform-detection seam. Closes the TOCTOU window widened by PR #113 streaming refactor (was ~ms post-Buffer.concat, became seconds-minutes per entry size during streaming write — realistic exploit window for co-tenants). - packages/tar-xz/src/node/file.ts: Win32 branch rewritten ('wx' + unlink+retry, fd-based chmod/utimes, threat-model JSDoc citing W1-W4); unused chmod/utimes named imports trimmed - packages/tar-xz/test/security-win32.spec.ts: 4 BDD scenarios (atomic-create, legit-overwrite, race-detected fail-closed, pre-existing-symlink-rejected-upstream regression lock); vi.mock('node:fs/promises') + vi.spyOn for race injection - packages/tar-xz/SECURITY.md: §"Windows symlink-swap TOCTOU" with reparse-tag coverage table, residual race documentation, user mitigations - packages/tar-xz/README.md: link to SECURITY.md§Win32 - .changeset: patch-level entry - docs/plans/WIN32-TOCTOU-2026-04-29.md: spec (12 sections, threat model, BDD, adversarial ledger; will be promoted to canonical at /finalize) All tar-xz tests: 155 pass / 0 fail / 3 pre-existing platform skips. Lint: 0 errors. Type-check: 0 errors. * fix(tar-xz): apply Copilot round-1 findings on Win32 TOCTOU PR Three M-class findings + two L-class fixes from Copilot review; one finding (C-4) dismissed as a hallucination after content verification (changeset is on-topic, not WASM-related). - file.ts:358 — wrap unlink() in try/catch ENOENT (M-3): when target disappears between failed first open() and our unlink, ignore ENOENT so the retry open('wx') can succeed. Preserves fail-closed for reparse-point injection (retry still rejects any non-empty path). - file.ts:364 — reword security error (M-2): replace "symlink-swap race detected … a symlink was injected" with "target still exists on retry … possible symlink/junction injection or concurrent creation at the target path between unlink and open". The new wording does not overclaim a specific reparse-point type and accepts benign concurrent creators as a possibility, while still failing closed. - security-win32.spec.ts — cross-platform symlink target (M-1): pre-create attacker-target.txt under tmp; symlink points there instead of /dev/null. Add `it.skipIf(!canCreateSymlinks)` gate with a beforeAll probe so Win32 runners without Developer Mode skip symlink-creation paths instead of failing with EPERM. Also fixes Scenario 4's /dev/null target (same portability issue, same fix). - SECURITY.md / README.md — version-agnostic phrasing (L-1, L-2): replace three "v6.1.1" mentions with "this hardening" / "now closed". Removes documentation drift risk if the release ends up as 6.2.0 instead of 6.1.1. - WIN32-TOCTOU-2026-04-29.md — spec drift fix: §7 Observable Success block now cites the new error wording and the cross-platform attacker target. C-4 dismissed: Copilot claimed the changeset references "WASM decoder memlimit"; verified false — `.changeset/win32-toctou- hardening.md` only discusses Win32 TOCTOU. Tests: 155 pass / 0 fail / 3 pre-existing skips. Lint: 0 errors. Type-check: 0 errors. * fix(tar-xz): apply Copilot round-2 findings on Win32 TOCTOU PR One M finding + one L finding from Copilot round 2; the third (PR description WASM mismatch) was an orchestrator slip that has already been corrected via gh pr edit (stale /tmp/pr-body.md from PR #111 was used at PR creation time — apologies to Copilot, which had correctly flagged it both rounds). - security-win32.spec.ts (C2-1, M): vi.stubGlobal('process', spread) drops non-enumerable Node.js APIs (EventEmitter, etc), risking test runner instability. Switch to Object.create(process) + Object.defineProperty('platform', 'win32') so only platform is shadowed and the rest of the process API stays intact. Helper stubWin32Platform() extracted with JSDoc rationale. - .changeset/win32-toctou-hardening.md (C2-2, L): example error text drifted from the new wording (round 2 changed the source but the changeset still cited the old "symlink-swap race detected" message). Replaced with a generic 2-line snippet decoupled from exact error wording. Tests: 155 pass / 0 fail / 3 pre-existing skips (identical to pre-fix). Lint: 0 errors. Type-check: 0 errors. Trend: round 1 = 6 findings (3 M + 2 L + 1 misclassified), round 2 = 3 findings (1 M + 2 L), round 3 expected = 0 (healthy down-trend). * fix(tar-xz): apply Copilot round-3 finding on Win32 TOCTOU PR One M finding from Copilot round 3 — broken skipIf gate. The `it.skipIf(process.platform === 'win32' && !canCreateSymlinks)` gate added in round 2 was subtly broken: Vitest evaluates `skipIf` conditions at test-registration time, but `canCreateSymlinks` is only updated later inside `beforeAll`. Result: the gate always sees `true` on Win32, the test runs even without Developer Mode, and `fsp.symlink` throws EPERM during the race-injection mock, making the test fail with the wrong error. Replaced with a static `it.skipIf(process.platform === 'win32')` on the 2 tests that need symlink creation (race-detected and pre-existing-symlink-rejected scenarios). Removed the orphaned `canCreateSymlinks` variable and `beforeAll` probe block. Coverage tradeoff: - Linux/macOS (~90% CI): all 4 tests run via process.platform stub. Security contract (extract → 'wx' → unlink+retry → security error) fully validated. - Windows: skips the 2 symlink-injection tests cleanly. The other 2 tests (atomic-create-success, legit-overwrite-via-unlink-retry) exercise the real-NTFS Win32 path without needing symlink privilege. Tests: 155 pass / 0 fail / 3 pre-existing skips. Lint: 0 errors. Type-check: 0 errors. Trend: round 1 = 6 findings, round 2 = 3, round 3 = 1, round 4 expected = 0. Convergence. * fix(tar-xz): apply Copilot round-3 findings on Win32 TOCTOU PR Three findings: 1 regression we introduced + 1 test gap + 1 doc. - file.ts (C4-1, M, regression): wrap handle.chmod/utimes on Win32 branch in narrow try/catch with /* best-effort */ comment. The master pre-PR Win32 path treated chmod/utimes as best-effort (some Win32 filesystems — FAT32, cloud-mounted shares — reject these with EPERM even when the write succeeded). Our PR inadvertently made them fatal, breaking those workflows. Now matches master semantics. handle.write errors still propagate (data integrity). - security-win32.spec.ts (C4-2, M, observable proof): the race- detected test verified the symlink remained the attacker's, but did NOT assert attackerTarget content was unchanged. If the extractor accidentally followed the symlink and wrote through it we wouldn't have caught it. Added the byte-equality assert on attackerTarget after the throw — true observable-proof regression gate. attackerTarget was already pre-populated with 'attacker content' from round 2. - .changeset/win32-toctou-hardening.md (C4-3, L): empty typescript code fence (round 3 emptied the block but left the fence) replaced with a 3-line minimal example using extractFile + rejects.toThrow. Tests: 155 pass / 0 fail / 3 pre-existing skips. Lint: 0 errors. Type-check: 0 errors. Trend: round 1=6, round 2=3, round 3=1, round 4=3 (2 real Ms in our own prior code), round 5=3 fixed → expected 0 remaining. * chore(tar-xz): apply Copilot round-4 L-only nits on Win32 TOCTOU PR Five cosmetic findings — no behavior change. - docs/plans/native-binding-migration.md: bump doc-meta updated date 2026-03-01 → 2026-04-29 to match this PR's housekeeping commit (status was promoted to canonical but updated stayed stale). - docs/plans/WIN32-TOCTOU-2026-04-29.md: remove machine-specific '/mnt/wsl/...' Project line — was non-portable. - packages/tar-xz/test/security-win32.spec.ts: remove orphan JSDoc block detached from any declaration; correct "proxy" comment near vi.mock (the factory does shallow spread, not Proxy); align scenario comment regex with the actual assertion text (/symlink-swap race detected/ → /target still exists on retry/, drift from round-2 wording change). Tests: 155 pass / 0 fail / 3 pre-existing skips. Lint: 0 errors. Type-check: 0 errors. Pushed with NO-COPILOT-REVIEW marker — premium quota saved per workflow §2.10 termination rule (a) for L-only round on a ready-to-merge PR. Trend: round 1=6, round 2=3, round 3=1, round 4=3 (2 real Ms in our prior code), round 5=3 fixed, round 6=5 L addressed. Total findings folded across 6 rounds: 21. PR ready for merge.
The Changesets - Version PR workflow has been failing on `node-liblzma` because the root package isn't included in pnpm-workspace.yaml's `packages: ['packages/*']` glob. The `native-memlimit.md` changeset referenced `node-liblzma` and triggered "Found changeset for package not in the workspace" errors on every master push. Architectural reality: this monorepo uses release.yml (release-it + conventional commits) as the canonical release path. Changesets artifacts are decorative — never consumed by the actual release flow. Their content is already captured in CHANGELOG.md via release-it's conventional-commit scan. Cleanup: - .changeset/native-memlimit.md (PR #112, node-liblzma not in workspace, blocked the workflow) - .changeset/wasm-memlimit-option.md (PR #111, same reason) - .changeset/validate-changesets-flow.md (test file from changesets adoption — no longer needed) - .changeset/tar-xz-streaming.md (PR #113, shipped via tar-xz@6.1.0 release-it 2026-04-29) - .changeset/win32-toctou-hardening.md (PR #114, shipped via same tar-xz@6.1.0 release) Keep .changeset/config.json + README.md for the directory's identity. Future PRs can still add changesets if the changesets workflow is later re-enabled as a primary path. Unblocks: changesets.yml workflow on master pushes.
…(63→1) (#115) * chore: gitignore Claude Code transient state files Adds `.claude-recovery.md` and `.workflow-state.json` to .gitignore. Both are per-session orchestration artifacts — should never be committed. The `/finalize` step deletes `.workflow-state.json` on successful workflow completion; without gitignore, an accidental `git add -A` (anti-pattern per CLAUDE.md) leaks them. * refactor: biome auto-fix sweep — useTemplate, optional chains, FIXABLE Phase 2 of REFACTOR-BIOME-2026-04-29: applied `biome check --write` across the workspace. Strictly behavior-preserving auto-fixes only. Categories addressed: - useTemplate: 2 string-concat → template literal sites - noNonNullAssertion (FIXABLE): 5 `gc!()` calls → optional chain `gc?.()` - noUnusedImports: removed orphan import in security.spec.ts - noUnusedVariables: removed orphan local in xz-helpers.spec.ts Files: 7 changed, +24/-29. Verification (post-fix): - pnpm test: 671 pass / 0 fail / 3 skip (identical to baseline) - pnpm type-check: 0 errors x3 packages (root + tar-xz + nxz-cli) - rtk proxy biome lint: 63 → 40 warnings (-23, -36.5%) Remaining 40 warnings (Phases 3-5): - 23 noNonNullAssertion (manual replacements via type narrowing) - 12 noExcessiveCognitiveComplexity (extract-method refactors) - 2 noImportCycles (intentional lzma↔pool, will be biome-ignore'd) - 1 useForOf (manual: index-aware loop) * refactor: kill noNonNullAssertion + noImportCycles + useForOf manually Phase 3+4 of REFACTOR-BIOME-2026-04-29: manual replacements for warnings biome --write cannot fix automatically. Categories addressed: - 23 noNonNullAssertion sites — manual narrowing per site: - errors.ts:176 — undefined-check + early fallback (no `messages[errno]!`) - pool.ts:166 — undefined-check on shift() with invariant comment - tar/checksum.ts:32,92 — `?? 0` for fixed-size header reads + early-break narrows byte type - tar/format.ts:100 — undefined-break in parseOctal loop - tar/format.ts:168 — `?? 0` for header[OFFSETS.typeflag] - nxz.ts:114 — destructure presetMatch[1] with explicit fallback - nxz.ts:710,713,717,755,756,759 — explicit undefined guards in findCommonParent / resolveArchiveCwd; preserves existing process.cwd() fallback semantics - nxz.ts:736,995 — `?? ''` for files[0] indexing - test/coverage.spec.ts:917,965,1045 — `?? 0` in checksum loops - test/security.spec.ts:456,492,541 — `?? 0` in checksum loops - test/wasm/decompress-memlimit.test.ts:141 — explicit `if (result === undefined) throw` (Vitest toBeDefined doesn't narrow TS types per js-typescript GOTCHAS) - examples/browser/main.ts:24 — getElementById guarded - 2 noImportCycles (lzma↔pool re-export): biome-ignore comments citing intentional ESM-resolved-at-runtime pattern from MEMORY.md. - 1 useForOf (format.ts:125 isEmptyBlock): for-of over Uint8Array yields number directly, simultaneously kills the warning AND the block[i]! bypass it covered up. Files: 10 changed, +53/-28. Verification: - pnpm test: 671 pass / 0 fail / 3 skip (identical to baseline) - pnpm type-check: 0 errors x3 packages - rtk proxy biome lint: 40 → 14 warnings (-26) Remaining 14 (Phase 5 + out of scope): - 12 noExcessiveCognitiveComplexity (extract-method refactors next) - 2 suppressions/unused (pre-existing, out of refactor scope) * refactor: extract-method on test cognitive-complexity sites Phase 5a of REFACTOR-BIOME-2026-04-29: kill 7 noExcessiveCognitiveComplexity warnings in test files. Pure helper extraction; test names + ordering unchanged. Helpers extracted (kept inside the spec file, not promoted to a shared util): - `buildEntryBlocks(entry, blocks)` — per-entry tar block construction loop body. Used by `buildTar` in all 3 spec files. - `buildGlobalPaxBlock(attrs, blocks)` — global PAX construction branch (tar-parser-stream.spec.ts only). - `recalculateChecksum(header)` — inline checksum loop pattern shared across multiple `V6b`/`V12` it blocks. After extraction, top-level `buildTar` reduces to a 3-line dispatch (global PAX vs regular entries). Cognitive complexity scores drop below biome threshold. The 7 it() bodies that previously inlined the checksum recalculation now call `recalculateChecksum(header)` — identical math, less noise. Bonus cleanup: tar-parser-stream.spec.ts had a `// biome-ignore lint/complexity/noExcessiveCognitiveComplexity` suppression on the old `buildTar`; refactor makes it unnecessary, suppression removed. Files: 3 changed, +192/-175. Verification: - pnpm test: 671 pass / 0 fail / 3 skip (identical to baseline) - pnpm --filter tar-xz type-check: 0 errors - biome warnings (3 test files): 7 → 0 - biome warnings (full project): 14 → 6 Remaining 6 warnings (Phase 5b + out of scope): - 5 noExcessiveCognitiveComplexity in src files: nxz.ts:777, browser/extract.ts:28, node/extract.ts:198, node/file.ts:180, tar/format.ts:227 (Phase 5b — high-risk territory: just-hardened Win32 TOCTOU code + fresh streaming refactor + core tar format). - 1 pre-existing noEmptyBlockStatements biome-ignore in a test file unrelated to this sweep (out of refactor scope). * refactor: extract-method on lower-risk src complexity sites Phase 5b-1 of REFACTOR-BIOME-2026-04-29: drops 3 noExcessiveCognitiveComplexity warnings via pure extract-method on the lower-risk source files. Helpers extracted (private, same-file scope): - `packages/tar-xz/src/browser/extract.ts` parseTar: - `parsePaxHeaderBlock(data, offset)` → `{offset, paxAttrs}` for the PAX_HEADER branch (parse + advance offset) - `extractEntryContent(data, entry, offset)` → `{offset, contentData}` for the content-slice + 512-byte padding advance Pure helpers, no captured mutable state. Loop semantics preserved. - `packages/tar-xz/src/tar/format.ts` createHeader: - `splitLongName(fullName)` → `{prefix, name}` for the legacy 100-byte field validation + 155/100 prefix/name split. Error message "File name too long for TAR format: ${fullName}" preserved byte-for-byte (consumers may pattern-match). - `packages/nxz/src/nxz.ts` collectArchiveFiles: - `buildEntryPath(entry, dirAbsPath, dirRelative)` → relative path construction for archive entries. Lazy `import('node:fs')` retained inline. Files: 3 changed, +74/-25. Verification: - pnpm test: 671 pass / 0 fail / 3 skip (identical to baseline) - pnpm type-check: 0 errors x3 packages - rtk proxy biome lint: 6 → 3 warnings (-3) Remaining 3 warnings: - 2 noExcessiveCognitiveComplexity in node/file.ts (extractFile, PR #114 hardened) and node/extract.ts (streaming, PR #113) → Phase 5b-2 with pre-push opus senior-review - 1 noNonNullAssertion suppression in test/node-api.spec.ts:249 (pre-existing, unrelated to this sweep) * refactor: extract-method on security-sensitive src complexity sites Phase 5b-2 of REFACTOR-BIOME-2026-04-29: dropped the last 2 noExcessiveCognitiveComplexity warnings via pure extract-method on the highest-risk src files (PR #114 Win32 TOCTOU + PR #113 streaming). ZERO behavior change; all security/streaming contracts preserved verbatim. `packages/tar-xz/src/node/file.ts` extractFile (HIGH RISK — recently hardened by PR #114): 7 private helpers extracted, original extractFile becomes a thin dispatcher: - `ensureSafeLinkname(linkname, opts, name)` — leaf+ancestor symlink validation for hardlink/symlink targets - `extractSymlinkEntry(target, entry, opts)` — SYMLINK branch - `extractHardlinkEntry(target, entry, opts)` — HARDLINK branch - `openFileExclusive(target, fileMode, entryName)` — Win32 'wx' + EEXIST→unlink+retry→security-throw fail-closed gate. Security error message preserved BYTE-IDENTICAL: "Security error: target still exists on retry for '${entryName}' — possible symlink/junction injection or concurrent creation at the target path between unlink and open". The unlink ENOENT pass-through and second-EEXIST throw remain in the same try/catch nesting. - `writeFileEntryPosix(target, entry, fileMode, mtime)` — POSIX path with O_NOFOLLOW + strict chmod/utimes propagation - `writeFileEntryWin32(handle, entry, fileMode, mtime)` — Win32 fd ops with best-effort chmod/utimes wrap (FAT32/cloud share semantics) - `writeFileEntry(target, entry, fileMode, mtime)` — platform dispatcher (POSIX vs Win32) `packages/tar-xz/src/node/extract.ts` extract async generator: 4 top-level helpers extracted; lookaheadRef ref-object pattern replaces inline closure mutation: - `nextParseEvent(parser, lookaheadRef)` — pulls next event with lookahead consumption - `drainEntryChunks(parser, lookaheadRef)` — drains chunk events until next entry boundary; preserves D-2 swallow policy - `drainSkippedEntry(parser, lookaheadRef)` — drains chunks for a filtered-out entry without yielding - `createEntryDataPull(parser, lookaheadRef)` — replaces makeDataGen + dataGenInFlight closure; preserves D-5 TAR_PARSER_INVARIANT detection on concurrent iteration Removed dead biome-ignore comment from extract.ts (no longer needed post-extraction). Replaced `filter && !filter(strippedEntry)` with `filter?.(strippedEntry) === false` — equivalent semantics, lower nesting cost. Files: 2 changed, +417/-250 net (mostly helper extraction). Verification (BLOCKING — all green): - pnpm test: 671 pass / 0 fail / 3 skip (identical to baseline) - pnpm type-check: 0 errors x3 packages - packages/tar-xz/test/security-win32.spec.ts: 4/4 ✅ - packages/tar-xz/test/security.spec.ts: 22/22 ✅ - rtk proxy biome lint: 3 → 1 warnings (-2 cognitive-complexity) Final state across the full REFACTOR-BIOME-2026-04-29 (Phases 1-5b-2): - 63 → 1 biome warnings (-62 = -98.4%) - 1 remaining = pre-existing `suppressions/unused` in test/node-api.spec.ts:249 (unrelated to this sweep, not addressed) - 671 tests pass throughout, 0 regression - 0 TS errors x3 packages throughout - 8 commits on branch fix/biome-warnings-sweep * chore(todo): update biome warnings entry — 63→1 after refactor * docs: refresh WIN32-TOCTOU spec post-refactor + log F-002 follow-up Pre-push opus senior-review on REFACTOR-BIOME-2026-04-29 surfaced two LOW-severity informational findings: - F-001: spec § 1 cited file.ts:328-346 (line refs valid at PR #114 merge); post-refactor the fail-closed path lives in private helpers openFileExclusive + writeFileEntryWin32 + writeFileEntry. Spec amended to keep both the historical reference (audit trail) AND the current helper names. Contract semantics unchanged. - F-002: pre-existing latent HARDLINK validation gap. resolve(cwd, strippedLinkname) throws TypeError on undefined linkname when strip=0. Filed in TODO as defensive-hardening item; not introduced by this refactor. Both findings non-blocking for the refactor PR. * refactor: fix filter check semantics — preserve all-falsy short-circuit Round-1 Copilot review on PR #115 surfaced one M-class finding: Phase 5b-2 replaced the original `filter && !filter(strippedEntry)` with `filter?.(strippedEntry) === false`. The strict `=== false` check skipped entries ONLY when the predicate returned literal `false`; the original `&&` short-circuit skipped on ANY falsy return (false / 0 / '' / null / undefined). Real behavior drift, not equivalent semantics — and divergent from the browser-side `extract()` which kept `filter && !filter(...)`. Fix: revert to `filter && !filter(...)` semantics, but extract the conditional into a private helper `isExcludedByFilter(entry, filter)` so the inline `&&` short-circuit doesn't push extract()'s cognitive complexity score back over biome's 15 threshold. The helper preserves the exact branching of the original idiom: - filter undefined → returns false (don't skip) - filter returns false → returns true (skip) - filter returns 0 / '' / null / undefined → returns true (skip) - filter returns truthy → returns false (don't skip) Verification: - pnpm --filter tar-xz test: 155 pass / 0 fail / 3 skip - pnpm --filter tar-xz type-check: 0 errors - rtk proxy biome lint extract.ts: 0 warnings (cognitive complexity back under threshold thanks to the helper) * refactor: address Copilot round-2 findings on PR #115 Three findings folded: - **C2-1 (M)**: `createEntryDataPull` threw the D-5 invariant error ("concurrent entry.data iteration is not supported") as a plain Error without `code = 'TAR_PARSER_INVARIANT'`. The stray-chunk invariant elsewhere in the module DOES set `code`, and `drainSkippedEntry` filters errors by that exact code to decide swallow vs re-throw. Without the code attribute, a concurrent- iteration error inside a skipped entry would be silently swallowed, breaking the D-5 contract. Pre-existing latent bug surfaced by the Phase 5b-2 extraction. Fixed by setting `err.code = 'TAR_PARSER_INVARIANT'` on the dataGenInFlight error. - **C2-2 (L)**: `ensureSafeLinkname` JSDoc said "empty or undefined linkname fields" but `TarEntry.linkname` is typed as required `string` (parser returns `''` for empty fields). Removed the dead `entry.linkname !== undefined` check (always true given the type) and clarified the docstring. Behavior preserved: empty linkname on SYMLINK/HARDLINK still rejected by `ensureSafeName`'s empty-string guard. - **C2-3 (L)**: F-002 TODO entry I added in the previous round claimed `extractHardlinkEntry` could pass `undefined` to `resolve`. Mischaracterized — `linkname` is required string, parser sets `''` for empty fields, and `ensureSafeLinkname` rejects '' upstream. Replaced the entry with an HTML comment explaining why it was dropped (preserves audit trail). Verification: - pnpm --filter tar-xz test: 155 pass / 0 fail / 3 skip - pnpm --filter tar-xz type-check: 0 errors - rtk proxy biome lint: 1 warning (pre-existing unrelated suppressions/unused — not introduced by this PR) * refactor: fail-fast on empty files in resolveTarOutput Round-3 Copilot finding (M): Phase 3+4's `files[0] ?? ''` replacement in `resolveTarOutput()` swallowed the original `basename(undefined)` TypeError that signaled an invariant violation (empty input files array when no output path provided). With `?? ''`, an empty array silently produces an output named `.tar.xz` (just the suffix) — worse than a clear error. Fix: explicit `firstFile === undefined` check + descriptive throw, preserving the invariant-violation fail-fast semantics. The narrowing also avoids re-introducing the `noNonNullAssertion` warning that Phase 3+4 fought to eliminate. The other `files[0] ?? ''` site at line 1020 (in the dispatcher) is gated by an upstream `if (options.files.length === 0) process.exit` guard at line 1013 — the `??` there is unreachable defensive code, documented in the comment. Left unchanged. Verification: - pnpm --filter nxz-cli test: 27/27 pass - pnpm --filter nxz-cli type-check: 0 errors - rtk proxy biome lint nxz.ts: 0 warnings * refactor: null-safe filter check in isExcludedByFilter Round-4 Copilot finding (M): the helper used `filter !== undefined` to gate the predicate call. The original `filter && !filter(entry)` short-circuited on ANY falsy filter (false / 0 / '' / null / undefined); `!== undefined` lets `null` through, which would crash at `filter(null)` runtime. Browser-side `extract()` correctly uses `filter && ...`. Fix: `typeof filter === 'function' && !filter(entry)` — null-safe, matches browser-side semantics, preserves the documented contract. Note on macOS CI failure on the previous push: the smoke test crashed with `Error: async hook stack has become corrupted (actual: 6939, expected: 6939)` — a known Node.js v22 internal AsyncHooks race during pnpm install postinstall, NOT a refactor regression. Stack trace is 100% inside Node native code (uv__work_done → AfterStat → InternalCallbackScope::Close → AsyncHooks::pop). This push will re-trigger CI; if macOS passes on retry, confirmed flaky. Verification: - pnpm --filter tar-xz test: 155/0/3 - pnpm --filter tar-xz type-check: 0 errors - rtk proxy biome lint extract.ts: 0 warnings * refactor: fail-fast on invariant breach in pool + nxz dispatcher Round-5 Copilot findings (2 M, same fail-fast vs fail-silent class as round-3 C3-1): - **C5-1 (M)** `src/pool.ts:168` — `processQueue()` guards `this.queue.length === 0` immediately above the `shift()` call, so `shift()` returning undefined indicates an INVARIANT VIOLATION (re-entrancy, future refactor, etc.). The previous Phase 3+4 replacement of `!` with `if (item === undefined) return` silenced this breach, leading to queued tasks stalling silently. Fixed: throw `Invariant violation: queue was non-empty but shift() returned undefined`. - **C5-2 (M)** `packages/nxz/src/nxz.ts:1029` — same pattern. After the stdin guard at line 1013 exits on `files.length === 0`, the next reference `options.files[0] ?? ''` (added in Phase 3+4) silenced an invariant breach. Fixed: explicit `if (firstFile === undefined) throw new Error('Internal error: expected at least one input file after stdin handling')`. Mirrors the C3-1 fix in resolveTarOutput from round-3. Both findings predate this refactor in spirit (master had `!` non-null assertions that hid the same invariant assumption); Phase 3+4 replaced the `!` with silent fallbacks. Round-3+5 walked the project back to fail-fast for those specific sites. Verification: - pnpm test: full suite green (155 tar-xz / 27 nxz / 489 root = 671/0/3) - pnpm type-check: 0 errors x3 packages - rtk proxy biome lint: 1 warning (pre-existing unrelated suppressions/unused) * refactor: round-6 comment-precision cleanups (L-only) Three L-class comment-accuracy findings from Copilot round-6: - pool.ts:169 — round-5 commit cited `processQueue.length > 0` but that's the function arity (=0), not the queue. Reworded to reference the actual `this.queue.length === 0` early-return guard above. - file.ts:158 — `extractSymlinkEntry` comment said "We do NOT validate entry.linkname here" but `ensureSafeLinkname()` runs upstream now (since Phase 5b-2). Reworded to clarify what's validated upstream (syntactic: non-empty + no NUL + not dot placeholder) vs what's intentionally NOT enforced here (cwd- containment of the symlink target). - extract.ts:193 — round-2 commit's rationale for setting `err.code = 'TAR_PARSER_INVARIANT'` cited `drainSkippedEntry` filtering, but the makeDataGen() branch is effectively unreachable in production (`makeTarEntryWithData` calls dataPull() exactly once and never exposes makeDataGen). Reworded to describe what the guard actually catches (double makeDataGen() invocation) and why the `code` attribute is set (consistency with other invariant errors in the module). Pure comment-precision; zero behavior change. Push uses `# NO-COPILOT-REVIEW` per workflow §2.10 termination rule (a) for L-only on a refactor PR — saves Copilot premium quota; opus SAFE-TO-PUSH already cleared the actual code. Verification: - pnpm test: 671/0/3 (full suite) - pnpm type-check: 0 errors x3 packages - rtk proxy biome lint: 1 warning (pre-existing unrelated)
Summary
Closes the TAR-XZ-STREAMING-2026-04-28 story by delivering the "planned optimization" advertised in
tar-xzv6.0.0's README. Nodeextract()andlist()now stream — memory stays O(largest single entry) instead of O(archive). For a 1 GB.tar.xzcontaining 100 MB files, peak heap drops from ~1 GB to ~100 MB.Implementation (5 vertical-slice blocks)
streamXz()foundationxz-helpers.tsparseTar()AsyncGenerator coroutinetar-parser.tsextract()rewrite + memoizing helpersextract.tslist()rewritelist.tstest/security.spec.ts(new),test/memory-shape.spec.ts(new),vitest.config.ts,file.ts(TSDoc),README.mdTotal: 661 tests passing across all packages (was ~620 pre-PR).
tscclean, lint clean (viartk proxy biomeworkaround), build clean.Spec hardening
The
docs/plans/TAR-XZ-STREAMING-2026-04-28.mdspec went through:/adversarial(5 perspectives) — 13 findings (3S + 6M + 4L); 3S resolved via user-validated decisions A-01 through A-03; 6M+4L folded as spec amendments./llm --specconsensus (Codex + Copilot; Gemini errored on quota) — 9 additional findings; 2 critical S folded as memory-measurement strategy refinement (in-loop high-water sampling) anderr.code = 'TAR_PARSER_INVARIANT'Node-convention idiom (the spec's "reuseTarFormatError" was unimplementable — that class did not exist).data, D-4 implementation invariants, D-5 invariant error discrimination.Security
test/security.spec.ts(22 tests, vector-labeled).MAX_PAX_HEADER_BYTES = 1 MB— crafted archives declaring giant PAX headers throwTAR_PARSER_INVARIANTinstead of accumulating O(N²).O_NOFOLLOW— TOCTOU window unchanged from buffered model.file.ts@securityblock + newSecurity modelREADME subsection. Win32 fd-based extraction tracked as a separate MEDIUM follow-up TODO.Behavior changes (non-breaking but observable — see changeset)
The new
entry.datacadence,bytes()/text()memoization, single-usedata, and consumer-break silent-stop are all consistent with the v6.0.0 stream-first contract. Anyone with reasonable reliance on the old buffered behavior should open an issue — we'll ship 7.0.0 with a deprecation cycle if needed.Test plan
pnpm build(TypeScript + WASM) — exit 0pnpm exec tsc --noEmit— exit 0rtk proxy biome lint packages/tar-xz/src/ packages/tar-xz/test/— exit 0 (warnings unrelated to PR)pnpm test— 661/661 passingpnpm test:memory --filter @tar-xz— 4/4 passing with--expose-gcavailableFollow-ups (filed in TODO.md)
[Win32] handle-based extraction (CreateFileW + FILE_FLAG_OPEN_REPARSE_POINT)— close TOCTOU on Windows after this ships.Story metadata
feat/tar-xz-streamingdocs/plans/TAR-XZ-STREAMING-2026-04-28.mdtar-xzminor → 6.1.0