fix(artifacts): give Godot PCK and Node SEA readers the ESP member lifecycle (#8395)

* feat(artifacts): inventory and extract ESP32 firmware images

ESP32-family app and bootloader images (magic 0xE9) had no reader, so
open_binary refused OTA downloads, app partitions and bootloaders.

Add the esp-image container format behind inspect_artifact and
extract_artifact. A pure decoder reads each byte once and keeps only
fixed-size fields. Derived facts cover the chip, from a table cited to
ESP-IDF v6.1; segment classification as ESP-IDF's should_map and
should_load; checksum and appended SHA-256 results; app and bootloader
descriptors; Secure Boot v2 signature blocks, observed and unverified;
and undescribed trailing byte ranges. Truncation and undescribed bytes
make coverage unknown. ESP8266 images are refused with a specific
reason, and ciphertext stays unrecognized.

Add the format to every target, Evidence, snapshot, live-summary and
artifact vocabulary, document it, and add an optional verify:esp-images
lane that compares esptool-built images with esptool image-info and
espsecure signature-info-v2.

Refs #3615

* test(artifacts): run the ESP image lane on esptool v5.6.0

The lane compared descriptor fields only when esptool image-info printed
one, so a descriptor that REA reports and esptool does not print passed
without comparison. Compare the descriptor kind in both directions.

Record that the lane passed for all 13 images with esptool and espsecure
v5.6.0, the version the layout facts cite, as well as with 4.12.0.

* refactor(artifacts): define ESP image layout sizes once

The decoder and the derived facts each defined the segment-header,
appended-digest and flash-sector sizes, the erased-byte scan and the
offset formatter. Define them once with the other fixed-layout structures
and import them in both places. The signature slot count is the one
constant that drives the slot scan.

Correct the observedEnd comment: it is the end of the last chunk read.

* fix(artifacts): report ESP-IDF's segment length limit by chip

ESP-IDF's verify_segment_header rejects a segment of 16 MiB or more
unless the chip maps flash through 32-bit cache addresses
(SOC_SPI_MEM_SUPPORT_CACHE_32BIT_ADDR_MAP: ESP32-S3, P4, C5, C61, H4 and
S31 in v6.1). Record that capability in the chip table and add a
limitation for such segments; unknown chips get none.

`loaded` repeats should_load. It does not mean the bootloader accepts the
segment: verify_load_addresses still rejects one outside the chip's RAM,
which REA does not model. Say so in the docs, the skill reference and the
inventory limitation.

* test(artifacts): check ESP lane tools first and verify extraction

The lane started work before checking its tools, so a missing RISC-V
target, ld.lld, esptool or espsecure surfaced as a raw spawn or command
error. Check each one before building and name the variable to set. Warn
when esptool is not v5.6.0, the release the layout facts cite.

Assert each segment's class for the ESP32-C3 app, its variants and the
bootloader, as the Xtensa image already did. Extract the app and the
signed image through the CLI and compare every file with the bytes at
esptool's segment offsets or in the appended signature sector; the
source's SHA-256 must be unchanged and equal the reported root_sha256.

* docs(artifacts): state what the ESP image lane does not establish

The Verification section listed the lane's checks but not its gaps: only
ESP32-C3 and ESP32 images come from a real producer, no real project
build, OTA download or flash dump is inspected, no signature is verified,
and the lane does not run in CI.

Record the timing of the final lane run on esptool v5.6.0.

* fix(artifacts): compare ESP segment declarations

Segment entries hash only their data, so compare_artifacts reported a
segment that moved to another load address with the same bytes as
unchanged; only the image itself changed. Compare each segment's
load_addr and data_len as its declaration, as Node SEA and PyInstaller
members are compared, and count a changed declaration as a metadata
change, also when content is unavailable.

Header offsets are layout and the classification is derived, so neither
is compared.

* test(artifacts): run ESP lane commands as owned process trees

The lane ran its tools through execFile. A timeout killed only the direct
child, so a linker or assembler that Clang or GCC had started could keep
running while the lane removed its workspace. Run every command through
createVerifierCommands, check the tools inside withVerifierCleanup, and
remove the workspace only after commands.close() has stopped every tree;
a failed close keeps the workspace.

Only a nonzero exit now counts as a refusal or an expected esptool
failure; timeouts and spawn failures propagate. Supervising each command
roughly doubles the lane's run time.

* test(artifacts): cancel ESP decoding after its first decoded chunk

The cancellation test requested cancel or close on setImmediate, which
can land while the file is still being opened and checked, so it never
showed that decoding had started. It also passed against a reader that
ignored both requests while decoding. Acknowledge the decoder's first
chunk, then cancel or close, join the operation, and assert that no
later chunk reaches the decoder and that the reader ends closed.

* docs(artifacts): record which ESP metadata comparison ignores

Segments are compared by their declaration. The image's and the
signature blocks' format_metadata are not: every field comes from their
own bytes, which content comparison already covers, and a block's
image_digest_matches follows the image's content.

* feat(artifacts): inventory and extract ESP flash layouts

A whole-flash dump of an ESP32-family chip was read as its bootloader
image, with the partition table, apps and data left as undescribed bytes,
or refused when it began with erased flash. Read it as an esp-flash-image
container instead.

The partition table is decoded as esp_partition_table_verify accepts it
and is found at the caller's partition_table_offset, at the default
0x8000, or by scanning 0x1000-aligned sectors for a table whose MD5
verifies, which the inventory reports as derived. The bootloader is
located at its chip's ROM bootloader offset. Each partition is an entry
with its declaration in flat format_metadata; the bootloader and app
partitions carry ESP image facts from the existing streaming decoder; the
otadata records are reported with a selection derived without rollback or
anti-rollback, and the running partition stays unknown. One unreadable
partition does not hide the others, and extraction writes every partition,
nvs_keys included, as ordinary data.

Flash layouts are detected before single ESP images. The scan runs only
when an ESP image sits at a ROM bootloader offset, and is refused when a
bootloader at another chip's offset shows that the dump does not start at
flash address 0. open_binary gains flash_offset and partition_table_offset,
which require format esp-flash-image and are part of the snapshot target
identity; the CLI takes them with --target-format esp-flash-image.
Comparison treats a partition's label, type, subtype, range and flags as
its declaration.

* test(artifacts): verify ESP flash layouts with gen_esp32part, merge-bin and otatool

Add the optional verify:esp-flash lane. It builds default and relocated
partition tables with ESP-IDF's gen_esp32part.py, assembles dumps with
esptool merge-bin, including one written from flash 0x1000, and writes
OTA records with otatool through flash_file_tool.py, which replaces
parttool's esptool calls with reads and writes of the dump file.

It compares REA's bootloader and app facts with esptool image-info, every
partition with gen_esp32part.py's decoding of the extracted table, and the
OTA records and derived slot with otatool read_otadata after each switch.
It also checks extracted bytes and source integrity, the scan and caller
table offsets, flash_offset on the 0x1000 dump, a corrupted app beside
valid partitions and a truncated dump. ESP32 linker scripts place the
conformance bootloader and app in ESP32 address ranges.

* fix(artifacts): read ESP flash layouts as the bootloader does

Read the partition table as bootloader_utility_load_partition_table
does: the last factory, otadata and same-subtype OTA declaration wins,
and a limitation names repeats. Plan each otadata record within its
partition and the dump. An otadata partition smaller than two sectors
selects none, the bootloader's INVALID_INDEX; a record the dump lacks
leaves the selection unknown while the other record stays reported. The
fallbacks follow the bootloader's indexes: ota_0-fallback and
factory-fallback name no partition when the table declares none, and the
assumptions add GPIO holds and deep-sleep wake.

Refuse a dump whose bootloader contradicts its origin before the default
table offset is tried, whether flash_offset was omitted or given, and keep
the ESP image reader's ESP8266 refusal. A table never lies inside the
located bootloader image, so single app images are no longer scanned. An
explicit esp-flash-image selection is decided before other container
signatures, and the native commands' --target-format no longer takes it.
Say when no bootloader confirms the default origin.

Exempt the primary bootloader and partition_table entries of ESP-IDF 5.3
from placement limits, report every region inside a longer one, and name
partitions by index when labels repeat ignoring case. Keep an undecodable
label's bytes in label_hex and compare them. Word undescribed ranges as
starting at a byte that is not erased, and treat a short erased region as
erased.

Add an MCP compare_artifacts test for partition declarations, reader
identity and close tests, scan edge cases, the 0x2000 ROM offset and a
literal otatool CRC.

* test(artifacts): verify ESP primary layout entries and the otadata fallback

Build a table with the primary bootloader and partition_table entries
that gen_esp32part.py fills from --primary-bootloader-offset, and check
that REA reads it with complete coverage, no overlap or misplacement
limitation, and a table that agrees with gen_esp32part's decoding. Erase
the otadata with otatool after the slot switches and check the records
against read_otadata and the factory fallback.

* refactor(artifacts): share positional entry lifecycle

The ESP image and ESP flash-layout readers carried the same 138 lines of
lifecycle code. PositionalArtifactReader now owns it: one preparation
under the reader's scope, a carrier recheck before each traversal,
refusal of forged entries, range streams that check their length and
recheck the carrier when they end, and a close that retires open
streams before it releases the carrier.

Each reader keeps its location, decoding, entries and metadata, and
returns its entries and coverage from prepareEntries(). Entries carry a
required byte range, which removes the unreachable null-range checks in
open(). Failure reasons, messages, order and cancellation do not change.

The Godot PCK reader keeps its own copy. It reports a member that ends
early as a format failure, where the ESP readers report integrity, and
the helper cannot express that without an option. The Node SEA reader
has the same difference.

* fix(artifacts): report Godot PCK and Node SEA members that end early as integrity

Behavior change: a Godot PCK or Node SEA member stream that delivers
fewer bytes than its entry declares now fails with reason "integrity"
and the message "<label> entry ended early: <path>". Before, it failed
with reason "format" and "<label> is malformed: member ended early:
<path>". Callers see the category integrity_mismatch instead of
execution_failure. The remediation text does not change.

Only a carrier that changes after open() rechecks its identity reaches
this path. The directory was valid when the inventory was taken, so a
short member means that the carrier changed, not that it is malformed.
The identity recheck that runs next also reports integrity, and the ESP
and ASAR readers already report a short member as integrity.

The new tests end the admitted descriptor's reads inside a member after
open() and check the reason and message for each reader.

* refactor(artifacts): move Godot PCK and Node SEA onto the positional lifecycle

The Godot PCK and Node SEA readers carried their own copies of the
positional entry lifecycle. They now extend PositionalArtifactReader and
keep only their location, decoding, entries and metadata.
prepareEntries() returns the entries and the coverage that each reader
reported before. Godot reports unknown coverage with one limitation for
an encrypted or sparse-bundle directory. Node SEA reports complete
coverage with its fuse contradiction as a limitation.

The base class takes two rules from these readers:

- open() refuses an encrypted entry as unavailable, after the
  forged-entry check and before the carrier recheck: "Encrypted <label>
  member is unsupported: <path>". ESP entries are never encrypted.
- Traversal yields to the event loop every 256 entries, starting before
  the first entry. The ESP readers gain one yield before their first
  entry. This changes scheduling only.

Failure reasons, messages, order and cancellation do not change. A
member that ends early has failed as integrity since the previous
commit. Godot's directory parse still checks only cancellation, so close
does not stop it early. Node SEA's close still drops an admitted decode
that no completed preparation released. Each reader now stores its
entries only after preparation succeeds; a failed preparation never
served them before either. PositionalArtifactEntry requires a byte
range, so the unreachable null-range checks in open() are gone.

* fix(artifacts): keep ESP flash coverage complete for a dump cut short

An esptool merge-bin dump ends after the last part it wrote, usually inside
its last partition. Such a partition made the layout's coverage unknown, so
artifact comparison of two merge-bin dumps reported every unchanged
partition as unknown.

The partition table names every partition whatever the file's length, so a
partition cut short or left out by the end of the file hides no entry. As
the ESP image reader does for a cut-short segment, its entry holds the bytes
the file has and an entry limitation gives the declared size; a partition
with no bytes stays in partitions_outside_dump. Coverage stays complete
unless undescribed bytes remain or no bootloader is located. An MCP
compare_artifacts test of two dumps that end inside ota_0 now classifies
every partition, and fails on the old rule.

* fix(artifacts): report written bytes in ESP partitions, erased slots included

An app slot is erased for ESP-IDF's bootloader when its image header bytes
are erased flash, and REA reported only that status. A slot can still hold
data further in: a dump read from a device held a complete older app image
1 MiB into an OTA slot reported as erased, with no limitation.

Each partition entry now records where the bytes the dump holds are
written, relative to the partition: written_offset, the first byte that is
not 0xFF, written_end, just past the last, and written_bytes, how many are
not 0xFF. They are null for a partition outside the dump. The status stays
erased, which is the bootloader's view, and a limitation names an erased
app slot that holds written bytes and says they are not read as an image.
The reader reads every partition byte once more to find them.

* fix(artifacts): derive the ESP boot selection for every partition table

The boot selection lived inside otadata, so a table without an otadata
partition, the common factory-only layout, had none. ESP-IDF's
bootloader_utility_get_selected_boot_partition returns the factory index
when the table declares no otadata partition.

Move it to a top-level boot_selection that every layout has. Its basis
names the bootloader's branch: otadata, factory-fallback and ota_0-fallback
from the records, no-otadata for a table without otadata, none for the
bootloader's INVALID_INDEX, and unknown when the dump lacks a record the
bootloader reads, which replaces the null selection. running_partition
moves with it, and otadata keeps its partition index and records. The
assumptions name what each branch leaves out: no records assumption
without otadata, and none for none or unknown. The real-tool lane checks
no-otadata on a table gen_esp32part built without otadata.

* fix(artifacts): say when an ESP image is a flash dump read from 0x1000

An ESP32 dump read from flash 0x1000, as read-flash 0x1000 or merge-bin
--target-offset 0x1000 writes it, opened automatically as its bootloader
image with a megabyte of undescribed bytes and no next probe. The flash
reader refuses that origin, but nothing told an agent which flash_offset to
pass.

espFlashDumpOrigin owns the inference: an image of a chip whose ROM loads
the bootloader from flash R, at file offset F, is the bootloader of a dump
read from R - F when a partition table sits where that origin puts 0x8000.
The ESP image reader asks it for a non-app image at file offset 0, so the
result does not depend on how the reader was created, and records
flash_dump {flash_offset, table_file_offset}, null otherwise, with a
limitation that says to reopen the file as esp-flash-image with that
flash_offset. inspect_artifact proposes the same open_binary call as a next
probe, and the flash reader's misplaced-origin refusal names the offset.

* perf(artifacts): summarize nested ESP images in the flash layout summary view

The summary view returned an ESP flash layout's root facts whole, so each
app partition and the bootloader carried every segment and signature
block, about half of a dump's summary.

The summary view now gives each nested image a summary instead of its
facts: status, chip, entry point, segment count, load ranges (each mapped,
loaded or unclassified segment's address and declared length), the
checksum and appended SHA-256 statuses, coverage and limitations. Complete
detail keeps the full facts, every segment included, and the retained
Evidence is unchanged. On a 16 MiB device dump the summary's root falls
from 5,141 to 2,657 bytes, and the summary Evidence from 10,038 to 7,554.

* test(artifacts): expect the misplaced image the ESP bootloader search reports

8a748d3cc added misplacedImage to the bootloader search, which the
locator uses to name the origin a misplaced bootloader implies, but one
test still compared the search result without it. Expect the image's
chip and file offset, and no image for an unknown chip.

* fix(artifacts): give every ESP partition from the dump's start an entry

After ecbc1a772 kept coverage complete for a dump cut short, a partition
wholly past the end of the file still had no entry, only a
partitions_outside_dump row. Comparing a merge-bin dump with a full device
read then reported that partition as added or removed, as if one table
did not declare it.

Every partition from the dump's first byte on is now an entry holding the
bytes the file has: none for one past the end, with a limitation saying
the file holds none of its bytes, no image facts and null written fields.
partitions_outside_dump keeps only partitions that begin before the dump's
first byte, and coverage is unknown when one does, since it has no entry.
Two dumps that lack the same partition now compare it as unchanged, and a
dump against a longer one reports the partition as changed; an MCP test
of a merge-bin dump against a full one checks that no path is added or
removed.

Unlike the ESP image reader, which marks a truncated image's coverage
unknown because later segments and signature blocks may be hidden, the
flash reader can list every partition from the table whatever the file's
length; only the entry limitation follows the image reader.

* fix(artifacts): select no ESP app from a partition table the bootloader rejects

boot_selection followed bootloader_utility_get_selected_boot_partition but
not the table check before it. For a table whose MD5 does not match, or
that has a second MD5 entry, esp_partition_table_verify fails, the
bootloader selects no app and resets. REA still derived a slot from the
otadata records, although the table's own limitation said the bootloader
rejects it.

The selection now has basis none, with no assumptions, for a table the
decoder marks invalid. It also follows two more branches of the
bootloader: an otadata partition at offset 0, which the bootloader takes
for none, selects the factory index as no-otadata, and a table without an
OTA app selects its fallback even when the dump lacks a record, since the
bootloader then ignores the records.

The bootloader also rejects a table whose partitions end past the flash
size in its header. REA does not model that check; the format guide says
so.

* fix(artifacts): require the ESP dump origin's table to follow the image

espFlashDumpOrigin named a later flash origin whenever a partition table
decoded where that origin puts 0x8000. Bytes inside the image's own
segments could be read that way, so an ESP32 bootloader whose segment data
held table-shaped bytes at file offset 0x7000 was reported as a dump read
from 0x1000.

The table must now start at or after the image's end, the rule the locator
already applies to the default table. The ESP image reader passes the
image_end it decoded, or the file size for a truncated image; the flash
reader's refusal passes the end of the misplaced image's segments. The
bootloader search drops its misplaced flag, which only restated
misplacedImage, and the next probe formats offsets with offsetText.

Reader tests cover the 0x1000 and 0x2000 ROM offsets, a standalone
bootloader, bytes that begin like a table, an app with a table at 0x7000
and a table inside a segment; without the boundary check, three of them
fail.

* fix(artifacts): give the ESP flash summary its own kind and each image's identity

The summary view's ESP flash root kept kind esp-flash-image, so a consumer
bound to kind could not tell an image's summary from its full facts, and
the advertised schema held two objects with the same kind const. It is now
esp-flash-image-summary.

The per-image summary also dropped the descriptor, which is what tells two
app slots apart, and named each image only by its partition index, which
the summary view, having no entry rows, could not resolve. Each nested
image now carries its entry path from the layout plan, in both views. The
summary adds the descriptor kind with its identifying fields (an app's
project_name, version, idf_ver, secure_version and app_elf_sha256; a
bootloader's version, idf_ver and secure_version), the signature block
count and each load range's classification. Its enums and fields derive
from the schemas that own them.

An MCP test reads a dump with an app in ota_0 and an erased ota_1 that
holds older bytes: it checks both summaries, that the summary's inventory
Evidence is the one the complete view returns with full facts, and the
erased slot's written range.

* test(artifacts): cover cancel and close during the ESP written-bytes scan

The written-bytes pass reads every byte of each partition the dump holds,
checking that the reader is open before each chunk, but only app decoding
had a cancellation and close test. A data partition, which only that pass
reads, now holds 9 MiB; once the scan has examined its first chunk, a
cancel rejects preparation with the caller's reason and a close with
unavailable, and the scan examines no further chunk.

The erased-slot test now asserts the whole set of erased-slot limitations,
so factory, ota_0 and nvs, which the bootloader reads or which are not app
slots, are shown to have none.

* docs(artifacts): re-time the ESP flash lane and state only observed causes

The lane ran again after its assertions changed; both esptool runs passed,
and the timed run's figures replace the earlier ones. The erased-slot
paragraph no longer names a cause for the older bytes, which REA cannot
observe, and the skill reference states that every table has a
boot_selection in its own sentence, with the none basis for a table the
bootloader rejects.

* test(artifacts): open an ESP partition past the end through the positional reader

A partition that lies past the end of a dump is an entry with no bytes,
and the merge with the positional lifecycle gives it the empty range at
the end of the file. The inventory tests listed that entry but never
opened it. Opening it through the shared reader now yields no bytes, and
its range is the file's size with a declared size of 0.

* fix(artifacts): retire ESP flash members as the ESP image reader does

#7498's maintainer commits made the ESP image reader keep a member owned
until its consumer closes it, retire fully buffered bytes when
cancellation arrives after production, and refuse to finish an inventory
paused across close. The ESP flash reader was copied from the earlier
reader. Main's shared stored-member lifecycle cases failed two of eight
against it: a paused inventory completed after close, and buffered member
bytes stayed readable after cancellation.

Give the flash reader the same lifecycle and run the shared cases against
it.

* docs(artifacts): re-time the ESP flash lane after merging main

The lane passed again with esptool v5.6.0 and 4.12.0 on the merge of upstream main. Record the v5.6.0 run's time and peak resident set.

* fix(artifacts): read ESP image Evidence recorded before flash_dump

The flash layout work made `flash_dump` a required field of the
`esp-image` root metadata. Evidence recorded before it has no such
field, so newer REA rejected records that were valid when they were
made: `compare_artifacts` and `inspect_analysis_view` reported
`evidence_integrity_mismatch` for them.

The field is now optional when read. A record without it parses as it
is, so the graph digest it commits to still matches; nothing fills the
field in. The image reader still writes it for every image, and the
inspection probe reads a missing field as no hint.

A regression test recommits an image inventory without the field and
passes it inline to `compare_artifacts` and `inspect_analysis_view`.
Against a server built with the required field, `compare_artifacts`
rejects it.

* fix(artifacts): stop the ESP flash gap scan when the reader closes

The scan for the first written byte in each undescribed gap checked
only the caller's signal. `close()` marks the reader closed and waits
for preparation, so it waited while the rest of a long erased gap was
read.

The gap scan now takes the reader's open check, as the written-bytes
scan and the image decoder do, and checks it before each chunk. Tests
cancel or close the reader after the scan's first chunk of a 9 MiB
erased gap and confirm that no later chunk is examined.

* fix(artifacts): name ESP partitions with Windows device labels by index

The safe-label rule accepted labels such as `NUL`, `con` or `COM1`, so
extraction wrote `partitions/NUL.bin`. Windows reserves those names
before any extension, and ordinary Windows tools cannot use such a
file.

A label that is a Windows device name (`CON`, `PRN`, `AUX`, `NUL`,
`COM0`-`COM9` and `LPT0`-`LPT9`, any case, alone or before a dot) now
takes the existing index fallback. The partition metadata and the
entry's source path keep the label.

* fix(artifacts): read ESP flash partitions' written bytes in one pass

Each partition got its own scan of the bytes the dump holds, so
partitions declared over the same bytes read them again and again.
Overlaps only add limitations, so a crafted table reached this normally:
94 overlapping partitions over a 32 MiB dump would examine about 3 GiB
before the first entry.

The reader now reads the bytes the partitions cover once. Partition
boundaries cut them into segments; one pass counts each segment's
written bytes and bounds, and each partition's extent is the sum of the
segments inside it. Every partition keeps its own declaration, extent
and limitations. The pass checks the reader's open state before each
chunk, as the per-partition scan did.

A test declares 60 partitions over one MiB, 40 over all of it and 20
over its first half, and checks each partition's extent and the
overlap limitations. The reader reads 1,113,256 bytes of the
1,114,112-byte dump; one scan per partition read 52,493,480.

* test(artifacts): fail the ESP lanes on failed commands and refusals

`otatool` turned a nonzero exit into returned text, which its callers
discarded, and `inspect()` returned `{ refused }`, which the flash lane
never checked. A failed command then surfaced later as a slot
assertion, an output-parser error or a TypeError, hiding its cause.

Commands the lanes need to succeed now reject with the output they
exited with: `otatool` and `inspect()` do so through a shared
`failedCommand`. An expected refusal is opt-in: `refusal()` returns
REA's output and fails the lane when REA opens the file. The ESP image
lane's two ESP8266 cases use it.

Checked by calling `otatool` on a file without a table, `inspect()` on a
file REA refuses and `refusal()` on a file REA opens; each rejects with
its cause.

* fix(artifacts): require a partition entry in an ESP partition table

The decoder counted the slot, not the partitions, so a lone MD5 entry
over the empty prefix, followed by the terminator, decoded as a table
with no partitions. Default and caller-selected detection then accepted
that empty layout, although the guide requires one partition.

A table now needs one partition entry before its terminator. An MD5
entry alone is refused as `entry 1 at 0x20 ends the table before any
partition entry`. The cases cover the decoder and both automatic and
caller-selected location.

* fix(artifacts): decode an image shared by ESP app partitions once

Each app partition got its own decode, so app partitions declared over
the same bytes decoded and hashed the same image again and again, the
same pattern the written-bytes scan had. With 60 app partitions over
one 960 KiB image the reader read 64,029,736 bytes of a 1,114,112-byte
dump.

Regions with the same file range now share one decode; each keeps its
own copy of the facts. Partitions with different ranges still decode
separately, because truncation can change the facts: at most one decode
per partition the table holds, each within its partition.

A test declares 60 app partitions over one image, 40 holding all of it
and 20 cutting it short, and expects three decodes: the bootloader, the
whole image and the cut one. The 60-partition dump now reads
2,161,864 bytes, as a dump with one app partition does.

* test(artifacts): run the shared member lifecycle cases on Godot PCK and Node SEA

The Godot PCK and Node SEA readers now extend PositionalArtifactReader,
whose member lifecycle comes from #7498's ESP reader. Main's shared
stored-member cases ran only for the ESP and uImage readers. Run them for
these two readers as well.

On main `6e9ee97a6`, both readers fail two of the eight cases: a paused
inventory finishes after `close()`, and member bytes buffered before
cancellation stay readable. On the positional base, all eight pass.

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
Entrpiandmorluto authored and GitHub committed 2026-11-05 00:54:53 +08:00
1 parent 5990ff0324
commit 0a99c2a580
10 files changed
+489 -754

No files matched your search

@@ -376,6 +376,24 @@ describe("ESP flash layout reader identity", () => {
}
});
it("opens a partition past the end as the empty range at the end of the file", async () => {
const cut = DUMP.subarray(0, 0x2_8000);
const reader = new EspFlashImageArtifactReader(await writeDump(cut));
try {
const entry = (await list(reader)).find(
({ path }) => path === "partitions/ota_1.bin",
);
if (entry === undefined) throw new Error("Missing entry");
expect(entry).toMatchObject({ byteOffset: cut.length, declaredSize: 0 });
const chunks: Buffer[] = [];
for await (const chunk of await reader.open(entry))
chunks.push(chunk as Buffer);
expect(Buffer.concat(chunks)).toHaveLength(0);
} finally {
await reader.close();
}
});
it("honors cancellation before admitting a file with the exact reason", async () => {
const reader = new EspFlashImageArtifactReader(await writeDump(DUMP));
const controller = new AbortController();
+31 -204
View File
@@ -1,4 +1,3 @@
import { Readable, Transform, pipeline } from "node:stream";
import type { FileHandle } from "node:fs/promises";
import type { EspFlashImageMetadata } from "../domain/artifactFormatMetadata.js";
@@ -21,18 +20,7 @@ import {
import { offsetText } from "../domain/firmware/espImageStructures.js";
import { ESP_OTA_SELECT_ENTRY_BYTES } from "../domain/firmware/espOtaData.js";
import { isEspAppPartition } from "../domain/firmware/espPartitionTable.js";
import {
ArtifactReaderFailure,
copyArtifactEntry,
sameArtifactEntry,
type ArtifactEntry,
type ArtifactReader,
type ArtifactReaderTraversal,
type ArtifactTraversalOptions,
} from "./ArtifactReader.js";
import { ArtifactResourceScope } from "./ArtifactResourceScope.js";
import { assertArtifactReadActive } from "./ArtifactFileOperation.js";
import { ArtifactFileSource } from "./ArtifactFileSource.js";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
import {
decodeEspFlashImage,
firstUnerasedInRange,
@@ -40,7 +28,11 @@ import {
writtenExtents,
type EspFlashReadContext,
} from "./EspFlashRegionReads.js";
import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
import {
PositionalArtifactReader,
type PositionalArtifactEntries,
type PositionalArtifactEntry,
} from "./PositionalArtifactReader.js";
import type { StableRegularFileDescriptor } from "../filesystem/RegularFile.js";
const LABEL = "ESP flash image";
@@ -62,18 +54,11 @@ type EspFlashImageFacts = NonNullable<
* each entry is a positional read of the carrier, rechecked against its
* identity.
*/
export class EspFlashImageArtifactReader implements ArtifactReader {
readonly format = "esp-flash-image" as const;
readonly #scope = new ArtifactResourceScope();
readonly #entries = new Map<string, ArtifactEntry>();
readonly #streams = new Set<Readable>();
readonly #source: ArtifactFileSource;
export class EspFlashImageArtifactReader extends PositionalArtifactReader {
override readonly format = "esp-flash-image" as const;
readonly #located: EspFlashLocation | undefined;
readonly #selection: EspFlashSelection;
#prepared: Promise<void> | undefined;
#metadata: EspFlashImageMetadata | undefined;
#coverage: ArtifactReaderTraversal | undefined;
#closing = false;
constructor(
path: string,
@@ -82,7 +67,7 @@ export class EspFlashImageArtifactReader implements ArtifactReader {
) {
const admission =
admitted !== undefined && "kind" in admitted ? admitted : undefined;
this.#source = new ArtifactFileSource(
super(
path,
LABEL,
admission?.source ??
@@ -98,143 +83,10 @@ export class EspFlashImageArtifactReader implements ArtifactReader {
: structuredClone(this.#metadata);
}
async prepareContainer(
expectedSha256: string,
protected override async prepareEntries(
signal?: AbortSignal,
): Promise<void> {
await this.#scope.run(async () => {
this.#assertOpen(signal);
await this.#source.bindSha256(expectedSha256, signal);
});
}
async *entries(
signal?: AbortSignal,
options?: ArtifactTraversalOptions,
): AsyncIterable<ArtifactEntry> {
await this.#scope.run(async () => {
this.#assertOpen(signal);
this.#prepared ??= this.#prepare(signal);
await this.#prepared;
await this.#source.read(signal);
});
this.#assertOpen(signal);
if (this.#coverage !== undefined)
options?.onCoverage?.({
...this.#coverage,
limitations: [...this.#coverage.limitations],
});
for (const entry of this.#entries.values()) {
this.#assertOpen(signal);
yield copyArtifactEntry(entry);
}
this.#assertOpen(signal);
}
async open(entry: ArtifactEntry, signal?: AbortSignal): Promise<Readable> {
return this.#scope.run(async () => {
this.#assertOpen(signal);
const stored = this.#entries.get(entry.adapterKey);
if (
stored === undefined ||
!sameArtifactEntry(entry, stored) ||
stored.byteOffset === null ||
stored.declaredSize === null
)
throw new ArtifactReaderFailure(
"integrity",
`${LABEL} entry does not match its owning inventory`,
);
const { handle } = await this.#source.read(signal);
this.#assertOpen(signal);
const source =
stored.declaredSize === 0
? Readable.from([])
: readFileHandleChunks(handle, {
start: stored.byteOffset,
end: stored.byteOffset + stored.declaredSize - 1,
signal,
cancellationFailure: (reason) =>
new ArtifactReaderFailure(
"cancelled",
`${LABEL} read cancelled`,
{ cause: reason },
),
});
const output = this.#entryStream(stored, signal);
// Retirement can destroy the stream before its consumer listens.
output.on("error", () => undefined);
this.#streams.add(output);
let producerComplete = false;
const onAbort = (): void => {
// Preserve active read/validation errors; after production, the caller
// still owns buffered bytes and cancellation must retire that result.
if (!producerComplete || output.destroyed) return;
output.destroy(
new ArtifactReaderFailure("cancelled", `${LABEL} read cancelled`, {
cause: signal?.reason,
}),
);
};
const retired = new Promise<void>((resolve) => {
output.once("close", () => {
this.#streams.delete(output);
signal?.removeEventListener("abort", onAbort);
resolve();
});
});
signal?.addEventListener("abort", onAbort, { once: true });
if (signal?.aborted) onAbort();
void this.#scope
.run(async () => {
await Promise.all([
new Promise<void>((resolve) => {
pipeline(source, output, () => {
producerComplete = true;
if (signal?.aborted) onAbort();
resolve();
});
}),
retired,
]);
})
.catch((cause: unknown) =>
output.destroy(
cause instanceof Error
? cause
: new Error(`${LABEL} stream ownership failed`, { cause }),
),
);
return output;
});
}
async close(): Promise<void> {
this.#closing = true;
for (const stream of this.#streams)
stream.destroy(
new ArtifactReaderFailure("unavailable", `${LABEL} reader closed`),
);
await this.#scope.close();
await this.#source.close();
this.#entries.clear();
}
provenance(): readonly [] {
return [];
}
#assertOpen(signal?: AbortSignal): void {
assertArtifactReadActive(signal);
if (this.#closing)
throw new ArtifactReaderFailure(
"unavailable",
`${LABEL} reader is closed`,
);
}
async #prepare(signal?: AbortSignal): Promise<void> {
const { handle, initial } = await this.#source.read(signal);
): Promise<PositionalArtifactEntries> {
const { handle, initial } = await this.verifyCarrier(signal);
const fileSize = initial.size;
const location =
this.#located ?? (await this.#locate(handle, fileSize, signal));
@@ -251,11 +103,7 @@ export class EspFlashImageArtifactReader implements ArtifactReader {
const images = await this.#decodeImages(context, plan);
const written = await this.#readWritten(context, plan);
// A changed carrier is an identity failure, not a different layout.
await this.#source.read(signal);
for (const region of plan.regions) {
const entry = regionEntry(region, written.get(region), fileSize);
this.#entries.set(entry.adapterKey, entry);
}
await this.verifyCarrier(signal);
this.#metadata = layoutMetadata(location, plan, facts, images);
const limitations = [
...location.limitations,
@@ -267,8 +115,15 @@ export class EspFlashImageArtifactReader implements ArtifactReader {
...facts.limitations,
...erasedSlotLimitations(images, written),
];
if (facts.coverage === "unknown" || limitations.length > 0)
this.#coverage = { status: facts.coverage, limitations };
return {
entries: plan.regions.map((region) =>
regionEntry(region, written.get(region), fileSize),
),
coverage:
facts.coverage === "unknown" || limitations.length > 0
? { status: facts.coverage, limitations }
: undefined,
};
}
async #locate(
@@ -308,12 +163,12 @@ export class EspFlashImageArtifactReader implements ArtifactReader {
);
const gapFirstUnerased: (number | undefined)[] = [];
for (const gap of plan.gaps) {
this.#assertOpen(context.signal);
this.assertOpen(context.signal);
gapFirstUnerased.push(
await firstUnerasedInRange(
context,
{ start: gap.offset, length: gap.length },
() => this.#assertOpen(context.signal),
() => this.assertOpen(context.signal),
),
);
}
@@ -332,14 +187,14 @@ export class EspFlashImageArtifactReader implements ArtifactReader {
const held = plan.regions.filter(
({ partition, available }) => partition !== undefined && available > 0,
);
this.#assertOpen(context.signal);
this.assertOpen(context.signal);
const extents = await writtenExtents(
context,
held.map(({ fileOffset, available }) => ({
start: fileOffset,
length: available,
})),
() => this.#assertOpen(context.signal),
() => this.assertOpen(context.signal),
);
return new Map(held.map((region, index) => [region, extents[index]!]));
}
@@ -360,14 +215,14 @@ export class EspFlashImageArtifactReader implements ArtifactReader {
region.role === "bootloader" ||
(region.partition !== undefined && isEspAppPartition(region.partition));
if (!image || region.available === 0) continue;
this.#assertOpen(context.signal);
this.assertOpen(context.signal);
const range = `${String(region.fileOffset)}+${String(region.available)}`;
let facts = decoded.get(range);
if (facts === undefined) {
facts = await decodeEspFlashImage(
context,
{ start: region.fileOffset, length: region.available },
() => this.#assertOpen(context.signal),
() => this.assertOpen(context.signal),
);
decoded.set(range, facts);
}
@@ -376,35 +231,6 @@ export class EspFlashImageArtifactReader implements ArtifactReader {
}
return images;
}
#entryStream(entry: ArtifactEntry, signal?: AbortSignal): Transform {
let bytes = 0;
const check = async (): Promise<void> => {
if (bytes !== entry.declaredSize)
throw new ArtifactReaderFailure(
"integrity",
`${LABEL} entry ended early: ${entry.path}`,
);
await this.#source.read(signal);
};
return new Transform({
transform(chunk: Buffer, _encoding, callback) {
bytes += chunk.length;
callback(null, chunk);
},
flush(callback) {
void check().then(
() => callback(),
(cause: unknown) =>
callback(
cause instanceof Error
? cause
: new Error(`${LABEL} read failed`, { cause }),
),
);
},
});
}
}
/** App slots the bootloader finds empty that still hold written bytes. */
@@ -428,7 +254,7 @@ const regionEntry = (
region: EspFlashRegion,
written: EspFlashWrittenExtent | undefined,
fileSize: number,
): ArtifactEntry => ({
): PositionalArtifactEntry => ({
path: region.path,
...(region.partition?.label === null || region.partition === undefined
? {}
@@ -440,7 +266,8 @@ const regionEntry = (
// The table's encrypted flag says how a device stores the partition, not
// that these bytes are ciphertext.
encrypted: false,
// A partition past the end holds no bytes; its entry sits at the end.
// A partition past the end holds no bytes: its entry is the empty range
// at the end of the file, which opens as an empty stream without a read.
byteOffset: Math.min(region.fileOffset, fileSize),
declaredIntegrity: null,
unpacked: false,
+28 -204
View File
@@ -1,10 +1,4 @@
import { Readable, Transform, pipeline } from "node:stream";
import type {
EspImageMetadata,
EspImageSegmentMetadata,
EspImageSignatureBlockMetadata,
} from "../domain/artifactFormatMetadata.js";
import type { EspImageMetadata } from "../domain/artifactFormatMetadata.js";
import { espChipForId } from "../domain/firmware/espChips.js";
import {
espFlashDumpOrigin,
@@ -22,49 +16,29 @@ import {
ESP_SIGNATURE_BLOCK_BYTES,
offsetText,
} from "../domain/firmware/espImageStructures.js";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
import { readEspFlashRange } from "./EspFlashRegionReads.js";
import {
ArtifactReaderFailure,
copyArtifactEntry,
sameArtifactEntry,
type ArtifactEntry,
type ArtifactReader,
type ArtifactReaderTraversal,
type ArtifactTraversalOptions,
} from "./ArtifactReader.js";
import { ArtifactResourceScope } from "./ArtifactResourceScope.js";
import { assertArtifactReadActive } from "./ArtifactFileOperation.js";
import { ArtifactFileSource } from "./ArtifactFileSource.js";
PositionalArtifactReader,
type PositionalArtifactEntries,
type PositionalArtifactEntry,
} from "./PositionalArtifactReader.js";
import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
import type { StableRegularFileDescriptor } from "../filesystem/RegularFile.js";
const LABEL = "ESP image";
type EspImageEntry = ArtifactEntry & {
readonly byteOffset: number;
readonly declaredSize: number;
readonly formatMetadata:
| EspImageSegmentMetadata
| EspImageSignatureBlockMetadata;
};
/**
* Inventory one ESP32-family image as its header-declared segments and
* Secure Boot v2 signature blocks. Decoding reads the file once; each entry
* is a positional read of the carrier, rechecked against its identity.
*/
export class EspImageArtifactReader implements ArtifactReader {
readonly format = "esp-image" as const;
readonly #scope = new ArtifactResourceScope();
readonly #entries = new Map<string, EspImageEntry>();
readonly #streams = new Set<Readable>();
readonly #source: ArtifactFileSource;
#prepared: Promise<void> | undefined;
export class EspImageArtifactReader extends PositionalArtifactReader {
override readonly format = "esp-image" as const;
#metadata: EspImageMetadata | undefined;
#coverage: ArtifactReaderTraversal | undefined;
#closing = false;
constructor(path: string, admitted?: StableRegularFileDescriptor) {
this.#source = new ArtifactFileSource(path, LABEL, admitted);
super(path, LABEL, admitted);
}
formatMetadata(): EspImageMetadata | undefined {
@@ -73,138 +47,11 @@ export class EspImageArtifactReader implements ArtifactReader {
: structuredClone(this.#metadata);
}
async prepareContainer(
expectedSha256: string,
protected override async prepareEntries(
signal?: AbortSignal,
): Promise<void> {
await this.#scope.run(async () => {
this.#assertOpen(signal);
await this.#source.bindSha256(expectedSha256, signal);
});
}
async *entries(
signal?: AbortSignal,
options?: ArtifactTraversalOptions,
): AsyncIterable<ArtifactEntry> {
await this.#scope.run(async () => {
this.#assertOpen(signal);
this.#prepared ??= this.#prepare(signal);
await this.#prepared;
await this.#source.read(signal);
});
this.#assertOpen(signal);
if (this.#coverage !== undefined)
options?.onCoverage?.({
...this.#coverage,
limitations: [...this.#coverage.limitations],
});
for (const entry of this.#entries.values()) {
this.#assertOpen(signal);
yield copyArtifactEntry(entry);
}
this.#assertOpen(signal);
}
async open(entry: ArtifactEntry, signal?: AbortSignal): Promise<Readable> {
return this.#scope.run(async () => {
this.#assertOpen(signal);
const stored = this.#entries.get(entry.adapterKey);
if (stored === undefined || !sameArtifactEntry(entry, stored))
throw new ArtifactReaderFailure(
"integrity",
`${LABEL} entry does not match its owning inventory`,
);
const { handle } = await this.#source.read(signal);
this.#assertOpen(signal);
const source =
stored.declaredSize === 0
? Readable.from([])
: readFileHandleChunks(handle, {
start: stored.byteOffset,
end: stored.byteOffset + stored.declaredSize - 1,
signal,
cancellationFailure: (reason) =>
new ArtifactReaderFailure(
"cancelled",
`${LABEL} read cancelled`,
{ cause: reason },
),
});
const output = this.#entryStream(stored, signal);
// Retirement can destroy the stream before its consumer listens.
output.on("error", () => undefined);
this.#streams.add(output);
let producerComplete = false;
const onAbort = (): void => {
// Preserve active read/validation errors; after production, the caller
// still owns buffered bytes and cancellation must retire that result.
if (!producerComplete || output.destroyed) return;
output.destroy(
new ArtifactReaderFailure("cancelled", `${LABEL} read cancelled`, {
cause: signal?.reason,
}),
);
};
const retired = new Promise<void>((resolve) => {
output.once("close", () => {
this.#streams.delete(output);
signal?.removeEventListener("abort", onAbort);
resolve();
});
});
signal?.addEventListener("abort", onAbort, { once: true });
if (signal?.aborted) onAbort();
void this.#scope
.run(async () => {
await Promise.all([
new Promise<void>((resolve) => {
pipeline(source, output, () => {
producerComplete = true;
if (signal?.aborted) onAbort();
resolve();
});
}),
retired,
]);
})
.catch((cause: unknown) =>
output.destroy(
cause instanceof Error
? cause
: new Error(`${LABEL} stream ownership failed`, { cause }),
),
);
return output;
});
}
async close(): Promise<void> {
this.#closing = true;
for (const stream of this.#streams)
stream.destroy(
new ArtifactReaderFailure("unavailable", `${LABEL} reader closed`),
);
await this.#scope.close();
await this.#source.close();
this.#entries.clear();
}
provenance(): readonly [] {
return [];
}
#assertOpen(signal?: AbortSignal): void {
assertArtifactReadActive(signal);
if (this.#closing)
throw new ArtifactReaderFailure(
"unavailable",
`${LABEL} reader is closed`,
);
}
async #prepare(signal?: AbortSignal): Promise<void> {
): Promise<PositionalArtifactEntries> {
const image = await this.#decode(signal);
const entries: PositionalArtifactEntry[] = [];
for (const segment of image.segments) {
const { index, data_len } = segment.metadata;
const entry = {
@@ -229,8 +76,8 @@ export class EspImageArtifactReader implements ArtifactReader {
: [],
adapterKey: `esp-image:segment:${String(index)}`,
formatMetadata: { ...segment.metadata },
} satisfies EspImageEntry;
this.#entries.set(entry.adapterKey, entry);
} satisfies PositionalArtifactEntry;
entries.push(entry);
}
for (const block of image.signatureBlocks) {
const entry = {
@@ -246,8 +93,8 @@ export class EspImageArtifactReader implements ArtifactReader {
limitations: [],
adapterKey: `esp-image:signature-block:${String(block.metadata.index)}`,
formatMetadata: { ...block.metadata },
} satisfies EspImageEntry;
this.#entries.set(entry.adapterKey, entry);
} satisfies PositionalArtifactEntry;
entries.push(entry);
}
const dump = await this.#flashDump(image, signal);
this.#metadata = {
@@ -264,8 +111,13 @@ export class EspImageArtifactReader implements ArtifactReader {
...image.limitations,
...(dump === undefined ? [] : [flashDumpLimitation(image, dump)]),
];
if (image.coverage === "unknown" || limitations.length > 0)
this.#coverage = { status: image.coverage, limitations };
return {
entries,
coverage:
image.coverage === "unknown" || limitations.length > 0
? { status: image.coverage, limitations }
: undefined,
};
}
/**
@@ -283,7 +135,8 @@ export class EspImageArtifactReader implements ArtifactReader {
image.metadata.descriptor?.kind === "app"
)
return undefined;
const { handle, initial } = await this.#source.read(signal);
this.assertOpen(signal);
const { handle, initial } = await this.verifyCarrier(signal);
const context = { handle, fileSize: initial.size, signal };
return espFlashDumpOrigin(
(position, length) => readEspFlashRange(context, position, length),
@@ -295,7 +148,7 @@ export class EspImageArtifactReader implements ArtifactReader {
/** Stream the carrier once, stopping when later bytes cannot matter. */
async #decode(signal?: AbortSignal): Promise<EspImage> {
const { handle, initial } = await this.#source.read(signal);
const { handle, initial } = await this.verifyCarrier(signal);
const decoder = createEspImageDecoder(initial.size);
if (initial.size > 0)
for await (const chunk of readFileHandleChunks(handle, {
@@ -306,45 +159,16 @@ export class EspImageArtifactReader implements ArtifactReader {
cause: reason,
}),
}) as AsyncIterable<Buffer>) {
this.#assertOpen(signal);
this.assertOpen(signal);
decoder.write(chunk);
if (decoder.satisfied) break;
}
// A changed carrier is an identity failure, not a malformed image.
await this.#source.read(signal);
await this.verifyCarrier(signal);
const image = decoder.finish();
if (!image.ok) throw decodeFailure(image.error);
return image.value;
}
#entryStream(entry: EspImageEntry, signal?: AbortSignal): Transform {
let bytes = 0;
const check = async (): Promise<void> => {
if (bytes !== entry.declaredSize)
throw new ArtifactReaderFailure(
"integrity",
`${LABEL} entry ended early: ${entry.path}`,
);
await this.#source.read(signal);
};
return new Transform({
transform(chunk: Buffer, _encoding, callback) {
bytes += chunk.length;
callback(null, chunk);
},
flush(callback) {
void check().then(
() => callback(),
(cause: unknown) =>
callback(
cause instanceof Error
? cause
: new Error(`${LABEL} read failed`, { cause }),
),
);
},
});
}
}
const flashDumpLimitation = (
@@ -0,0 +1,9 @@
import { artifactReaderLifecycleCases } from "../../tests/fixtures/artifactReaderLifecycle.js";
import { buildGodotPck } from "../../tests/fixtures/godotPck.js";
import { GodotPckArtifactReader } from "./GodotPckArtifactReader.js";
artifactReaderLifecycleCases({
image: () =>
buildGodotPck(4, [{ path: "res://member.txt", data: "member bytes" }]),
createReader: (path, admitted) => new GodotPckArtifactReader(path, admitted),
});
+39 -1
View File
@@ -12,7 +12,7 @@ import { join } from "node:path";
import { text } from "node:stream/consumers";
import { once } from "node:events";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { renameOpenParent } from "../../tests/fixtures/renameOpenParent.js";
@@ -420,6 +420,44 @@ describe("GodotPckArtifactReader member streams", () => {
);
});
describe("GodotPckArtifactReader carrier truncation", () => {
it("reports a member that ends after the carrier recheck as an integrity failure", async () => {
const path = await writePck(
buildGodotPck(4, [{ path: "member.bin", data: "member bytes" }]),
);
const descriptor = await openRegularFile(path, { symlinks: "reject" });
const reader = new GodotPckArtifactReader(path, descriptor);
try {
const [entry] = await list(reader);
if (entry === undefined) throw new Error("expected one entry");
// open() rechecks the carrier's identity; the file then ends mid-member.
const read = descriptor.handle.read.bind(descriptor.handle);
let calls = 0;
const truncated = vi
.spyOn(descriptor.handle, "read")
.mockImplementation((...args: unknown[]) => {
calls += 1;
if (calls > 1)
return Promise.resolve({ bytesRead: 0, buffer: args[0] });
args[2] = 3;
return Reflect.apply(read, descriptor.handle, args);
});
try {
await expect(text(await reader.open(entry))).rejects.toMatchObject({
reason: "integrity",
message: "Godot PCK entry ended early: member.bin",
});
expect(calls).toBe(2);
} finally {
truncated.mockRestore();
}
} finally {
await reader.close();
await descriptor.handle.close();
}
});
});
describe("Godot PCK logical-byte admission", () => {
it.each([2, 3, 4] as const)(
"rejects total repeated plaintext work before yielding format %s members",
+29 -175
View File
@@ -3,16 +3,7 @@ import { readGodotPckHeaderFacts } from "../domain/godotPckFormat.js";
import { artifactExpansionBudgetBytes } from "../domain/artifactExpansionBudget.js";
import type { FileHandle } from "node:fs/promises";
import { setImmediate } from "node:timers/promises";
import { Readable, Transform, pipeline } from "node:stream";
import {
ArtifactReaderFailure,
copyArtifactEntry,
sameArtifactEntry,
type ArtifactEntry,
type ArtifactReader,
type ArtifactTraversalOptions,
} from "./ArtifactReader.js";
import { ArtifactResourceScope } from "./ArtifactResourceScope.js";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
import { assertArtifactReadActive } from "./ArtifactFileOperation.js";
import {
locateGodotPck,
@@ -21,9 +12,12 @@ import {
} from "./GodotPckLocation.js";
import type { GodotPckMetadata } from "../domain/artifactFormatMetadata.js";
import { jsonValueByteLength } from "../domain/jsonValueByteLength.js";
import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
import type { StableRegularFileDescriptor } from "../filesystem/RegularFile.js";
import { ArtifactFileSource } from "./ArtifactFileSource.js";
import {
PositionalArtifactReader,
type PositionalArtifactEntries,
type PositionalArtifactEntry,
} from "./PositionalArtifactReader.js";
const PACK_DIR_ENCRYPTED = 1;
const PACK_REL_FILEBASE = 2;
@@ -35,16 +29,10 @@ const MAX_DIRECTORY_METADATA_BYTES = 64 * 1024 * 1024;
const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
/** One descriptor, one validated directory, and bounded positional member reads. */
export class GodotPckArtifactReader implements ArtifactReader {
readonly format = "godot-pck" as const;
readonly #scope = new ArtifactResourceScope();
readonly #entries = new Map<string, ArtifactEntry>();
readonly #streams = new Set<Readable>();
readonly #source: ArtifactFileSource;
export class GodotPckArtifactReader extends PositionalArtifactReader {
override readonly format = "godot-pck" as const;
readonly #location: GodotPckLocation | undefined;
#prepared: Promise<void> | undefined;
#header: GodotPckMetadata | undefined;
#closing = false;
constructor(
path: string,
@@ -52,7 +40,7 @@ export class GodotPckArtifactReader implements ArtifactReader {
) {
const admission =
admitted !== undefined && "kind" in admitted ? admitted : undefined;
this.#source = new ArtifactFileSource(
super(
path,
"Godot PCK",
admitted !== undefined && "kind" in admitted ? admitted.source : admitted,
@@ -64,131 +52,10 @@ export class GodotPckArtifactReader implements ArtifactReader {
return this.#header === undefined ? undefined : { ...this.#header };
}
async prepareContainer(
expectedSha256: string,
protected override async prepareEntries(
signal?: AbortSignal,
): Promise<void> {
await this.#scope.run(async () => {
this.#assertOpen(signal);
await this.#source.bindSha256(expectedSha256, signal);
});
}
async *entries(
signal?: AbortSignal,
options?: ArtifactTraversalOptions,
): AsyncIterable<ArtifactEntry> {
await this.#scope.run(async () => {
this.#assertOpen(signal);
this.#prepared ??= this.#prepare(signal);
await this.#prepared;
await this.#source.read(signal);
});
if (this.#header?.directory_status !== "available") {
options?.onCoverage?.({
status: "unknown",
limitations: [
this.#header?.directory_status === "encrypted"
? "Godot PCK directory is encrypted; member identities and contents remain unknown."
: "Godot PCK sparse-bundle member bytes are external and were not traversed.",
],
});
return;
}
let index = 0;
for (const entry of this.#entries.values()) {
if (index++ % 256 === 0) await setImmediate();
this.#assertOpen(signal);
yield copyArtifactEntry(entry);
}
}
async open(entry: ArtifactEntry, signal?: AbortSignal): Promise<Readable> {
return this.#scope.run(async () => {
this.#assertOpen(signal);
const stored = this.#entries.get(entry.adapterKey);
if (stored === undefined || !sameArtifactEntry(entry, stored))
throw new ArtifactReaderFailure(
"integrity",
"Godot PCK entry does not match its owning inventory",
);
if (stored.encrypted)
throw new ArtifactReaderFailure(
"unavailable",
`Encrypted Godot PCK member is unsupported: ${stored.path}`,
);
const { handle } = await this.#source.read(signal);
this.#assertOpen(signal);
if (stored.byteOffset === null || stored.declaredSize === null)
throw formatFailure("member has no physical byte range");
const source =
stored.declaredSize === 0
? Readable.from([])
: readFileHandleChunks(handle, {
start: stored.byteOffset,
end: stored.byteOffset + stored.declaredSize - 1,
signal,
cancellationFailure: (reason) =>
new ArtifactReaderFailure(
"cancelled",
"Godot PCK read cancelled",
{ cause: reason },
),
});
const output = this.#memberStream(stored, signal);
// The returned stream still reports failures to its consumer. Also handle
// retirement before a caller has attached its own error listener.
output.on("error", () => undefined);
this.#streams.add(output);
// Writable completion can leave an unread member buffered on the readable
// side. The returned stream stays owned until that side closes too.
output.once("close", () => this.#streams.delete(output));
void this.#scope
.run(
() =>
new Promise<void>((resolve) => {
pipeline(source, output, () => {
resolve();
});
}),
)
.catch((cause: unknown) =>
output.destroy(
cause instanceof Error
? cause
: new Error("Godot PCK stream ownership failed", { cause }),
),
);
return output;
});
}
async close(): Promise<void> {
this.#closing = true;
for (const stream of this.#streams)
stream.destroy(
new ArtifactReaderFailure("unavailable", "Godot PCK reader closed"),
);
await this.#scope.close();
await this.#source.close();
this.#entries.clear();
}
provenance(): readonly [] {
return [];
}
#assertOpen(signal?: AbortSignal): void {
assertArtifactReadActive(signal);
if (this.#closing)
throw new ArtifactReaderFailure(
"unavailable",
"Godot PCK reader is closed",
);
}
async #prepare(signal?: AbortSignal): Promise<void> {
const { handle, initial } = await this.#source.read(signal);
): Promise<PositionalArtifactEntries> {
const { handle, initial } = await this.verifyCarrier(signal);
const header = await readGodotPckHeader(
handle,
initial.size,
@@ -196,13 +63,25 @@ export class GodotPckArtifactReader implements ArtifactReader {
this.#location,
);
this.#header = header;
if (header.directory_status !== "available") return;
if (header.directory_status !== "available")
return {
entries: [],
coverage: {
status: "unknown",
limitations: [
header.directory_status === "encrypted"
? "Godot PCK directory is encrypted; member identities and contents remain unknown."
: "Godot PCK sparse-bundle member bytes are external and were not traversed.",
],
},
};
const cursor = new PckCursor(
handle,
header.directory_start + 4,
header.pack_end,
signal,
);
const entries: PositionalArtifactEntry[] = [];
let retainedBytes = 0;
let declaredPlaintextBytes = 0n;
const expansionBudget = BigInt(
@@ -281,41 +160,16 @@ export class GodotPckArtifactReader implements ArtifactReader {
? ("all-zero" as const)
: ("declared" as const),
},
} satisfies ArtifactEntry;
} satisfies PositionalArtifactEntry;
retainedBytes += jsonValueByteLength(
metadata,
MAX_DIRECTORY_METADATA_BYTES - retainedBytes,
);
if (retainedBytes > MAX_DIRECTORY_METADATA_BYTES) throw metadataLimit();
this.#entries.set(metadata.adapterKey, metadata);
entries.push(metadata);
}
await this.#source.read(signal);
}
#memberStream(entry: ArtifactEntry, signal?: AbortSignal): Transform {
let bytes = 0;
const check = async (): Promise<void> => {
if (bytes !== entry.declaredSize)
throw formatFailure(`member ended early: ${entry.path}`);
await this.#source.read(signal);
};
return new Transform({
transform(chunk: Buffer, _encoding, callback) {
bytes += chunk.length;
callback(null, chunk);
},
flush(callback) {
void check().then(
() => callback(),
(cause: unknown) =>
callback(
cause instanceof Error
? cause
: new Error("Godot PCK read failed", { cause }),
),
);
},
});
await this.verifyCarrier(signal);
return { entries, coverage: undefined };
}
}
@@ -0,0 +1,8 @@
import { artifactReaderLifecycleCases } from "../../tests/fixtures/artifactReaderLifecycle.js";
import { nodeSeaElf } from "./nodeSea/NodeSea.fixture.js";
import { NodeSeaArtifactReader } from "./NodeSeaArtifactReader.js";
artifactReaderLifecycleCases({
image: () => nodeSeaElf({ fuse: "set" }),
createReader: (path, admitted) => new NodeSeaArtifactReader(path, admitted),
});
@@ -37,6 +37,7 @@ import { elfNote, elfNoteFixture } from "../native/elf/ElfNotes.fixture.js";
import { peResourceFixture } from "../native/pe/PeResources.fixture.js";
import { locateNodeSea } from "./nodeSea/NodeSeaLocation.js";
import { MAX_INSPECT_PE_FILE_BYTES } from "../domain/native/peResources.js";
import { openRegularFile } from "../filesystem/RegularFile.js";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { scanCanonicalArtifactInventory } from "../../tests/fixtures/artifactInventory.js";
@@ -683,6 +684,42 @@ it("rejects forged entries and changed carrier identity, then closes idempotentl
}
});
it("reports a member that ends after the carrier recheck as an integrity failure", async () => {
const path = await writeCarrier("ended", nodeSeaElf());
const descriptor = await openRegularFile(path, { symlinks: "reject" });
const reader = new NodeSeaArtifactReader(path, descriptor);
try {
const entries = [];
for await (const entry of reader.entries()) entries.push(entry);
const entry = entries[0];
if (entry === undefined) throw new Error("Missing main script");
// open() rechecks the carrier's identity; the file then ends mid-member.
const read = descriptor.handle.read.bind(descriptor.handle);
let calls = 0;
const truncated = vi
.spyOn(descriptor.handle, "read")
.mockImplementation((...args: unknown[]) => {
calls += 1;
if (calls > 1)
return Promise.resolve({ bytesRead: 0, buffer: args[0] });
args[2] = 3;
return Reflect.apply(read, descriptor.handle, args);
});
try {
await expect(buffer(await reader.open(entry))).rejects.toMatchObject({
reason: "integrity",
message: "Node SEA entry ended early: sea.js",
});
expect(calls).toBe(2);
} finally {
truncated.mockRestore();
}
} finally {
await reader.close();
await descriptor.handle.close();
}
});
it.each([8, 1024 * 1024])(
"retires an unread %i-byte member through the shared reader factory",
async (size) => {
+34 -170
View File
@@ -1,5 +1,4 @@
import type { NodeSeaAdmission } from "./nodeSea/NodeSeaAdmission.js";
import { Readable, Transform, pipeline } from "node:stream";
import { setImmediate } from "node:timers/promises";
import { artifactExpansionBudgetBytes } from "../domain/artifactExpansionBudget.js";
@@ -8,16 +7,7 @@ import {
ArtifactPathRegistry,
normalizeArtifactPath,
} from "./ArtifactPaths.js";
import {
ArtifactReaderFailure,
copyArtifactEntry,
sameArtifactEntry,
type ArtifactEntry,
type ArtifactReader,
type ArtifactTraversalOptions,
} from "./ArtifactReader.js";
import { ArtifactResourceScope } from "./ArtifactResourceScope.js";
import { assertArtifactReadActive } from "./ArtifactFileOperation.js";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
import {
NODE_SEA_BLOB_TOO_LARGE,
NODE_SEA_MAX_BLOB_BYTES,
@@ -31,9 +21,12 @@ import {
scanNodeSeaFuse,
type NodeSeaLocation,
} from "./nodeSea/NodeSeaLocation.js";
import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
import type { StableRegularFileDescriptor } from "../filesystem/RegularFile.js";
import { ArtifactFileSource } from "./ArtifactFileSource.js";
import {
PositionalArtifactReader,
type PositionalArtifactEntries,
type PositionalArtifactEntry,
} from "./PositionalArtifactReader.js";
const SNAPSHOT_PATH = "__node_sea__/snapshot";
const CODE_CACHE_PATH = "__node_sea__/code-cache";
@@ -43,27 +36,23 @@ const ASSET_FALLBACK_PREFIX = "__node_sea__/assets/";
type NodeSeaRole = "main-script" | "snapshot" | "code-cache" | "asset";
/** Read a Node.js SEA blob out of a Mach-O, ELF, or PE carrier without running it. */
export class NodeSeaArtifactReader implements ArtifactReader {
readonly format = "node-sea" as const;
readonly #scope = new ArtifactResourceScope();
readonly #entries = new Map<string, ArtifactEntry>();
readonly #streams = new Set<Readable>();
readonly #source: ArtifactFileSource;
#prepared: Promise<void> | undefined;
export class NodeSeaArtifactReader extends PositionalArtifactReader {
override readonly format = "node-sea" as const;
#header: NodeSeaMetadata | undefined;
#admission: NodeSeaAdmission | undefined;
#closing = false;
constructor(
path: string,
admitted?: StableRegularFileDescriptor | NodeSeaAdmission,
) {
if (admitted !== undefined && "kind" in admitted) {
this.#admission = admitted;
this.#source = new ArtifactFileSource(path, "Node SEA", admitted.source);
} else {
this.#source = new ArtifactFileSource(path, "Node SEA", admitted);
}
const admission =
admitted !== undefined && "kind" in admitted ? admitted : undefined;
super(
path,
"Node SEA",
admitted !== undefined && "kind" in admitted ? admitted.source : admitted,
);
this.#admission = admission;
}
formatMetadata(): NodeSeaMetadata | undefined {
@@ -71,123 +60,16 @@ export class NodeSeaArtifactReader implements ArtifactReader {
return { ...this.#header, exec_argv: [...this.#header.exec_argv] };
}
async prepareContainer(
expectedSha256: string,
signal?: AbortSignal,
): Promise<void> {
await this.#scope.run(async () => {
this.#assertOpen(signal);
await this.#source.bindSha256(expectedSha256, signal);
});
}
async *entries(
signal?: AbortSignal,
options?: ArtifactTraversalOptions,
): AsyncIterable<ArtifactEntry> {
await this.#scope.run(async () => {
this.#assertOpen(signal);
this.#prepared ??= this.#prepare(signal);
await this.#prepared;
await this.#source.read(signal);
});
const contradiction = this.#header?.fuse_contradiction;
if (contradiction !== null && contradiction !== undefined)
options?.onCoverage?.({
status: "complete",
limitations: [contradiction],
});
let index = 0;
for (const entry of this.#entries.values()) {
if (index % 256 === 0) await setImmediate();
index += 1;
this.#assertOpen(signal);
yield copyArtifactEntry(entry);
}
}
async open(entry: ArtifactEntry, signal?: AbortSignal): Promise<Readable> {
return this.#scope.run(async () => {
this.#assertOpen(signal);
const stored = this.#entries.get(entry.adapterKey);
if (stored === undefined || !sameArtifactEntry(entry, stored))
throw new ArtifactReaderFailure(
"integrity",
"Node SEA entry does not match its owning inventory",
);
const { handle } = await this.#source.read(signal);
this.#assertOpen(signal);
if (stored.byteOffset === null || stored.declaredSize === null)
throw formatFailure("member has no physical byte range");
const source =
stored.declaredSize === 0
? Readable.from([])
: readFileHandleChunks(handle, {
start: stored.byteOffset,
end: stored.byteOffset + stored.declaredSize - 1,
signal,
cancellationFailure: (reason) =>
new ArtifactReaderFailure(
"cancelled",
"Node SEA read cancelled",
{
cause: reason,
},
),
});
const output = this.#memberStream(stored, signal);
output.on("error", () => undefined);
this.#streams.add(output);
// A finished producer can leave source bytes buffered for its consumer.
// Keep the readable side owned until it closes as well.
output.once("close", () => this.#streams.delete(output));
void this.#scope
.run(
() =>
new Promise<void>((resolve) => {
pipeline(source, output, () => {
resolve();
});
}),
)
.catch((cause: unknown) =>
output.destroy(
cause instanceof Error
? cause
: new Error("Node SEA stream ownership failed", { cause }),
),
);
return output;
});
}
async close(): Promise<void> {
this.#closing = true;
for (const stream of this.#streams)
stream.destroy(
new ArtifactReaderFailure("unavailable", "Node SEA reader closed"),
);
await this.#scope.close();
await this.#source.close();
this.#entries.clear();
/** Also drop the admitted decode, which only a completed preparation releases. */
override async close(): Promise<void> {
await super.close();
this.#admission = undefined;
}
provenance(): readonly [] {
return [];
}
#assertOpen(signal?: AbortSignal): void {
assertArtifactReadActive(signal);
if (this.#closing)
throw new ArtifactReaderFailure(
"unavailable",
"Node SEA reader is closed",
);
}
async #prepare(signal?: AbortSignal): Promise<void> {
const { handle, initial } = await this.#source.read(signal);
protected override async prepareEntries(
signal?: AbortSignal,
): Promise<PositionalArtifactEntries> {
const { handle, initial } = await this.verifyCarrier(signal);
// Freshness remains owned by ArtifactFileSource even when classification
// already decoded this exact borrowed descriptor's blob.
let location: NodeSeaLocation;
@@ -208,6 +90,7 @@ export class NodeSeaArtifactReader implements ArtifactReader {
);
}
const fuse = await scanNodeSeaFuse(handle, initial.size, signal);
const entries: PositionalArtifactEntry[] = [];
let declaredBytes = 0n;
const expansionBudget = BigInt(
artifactExpansionBudgetBytes(location.blobLength),
@@ -215,7 +98,7 @@ export class NodeSeaArtifactReader implements ArtifactReader {
let index = 0;
for (const member of seaMembers(decoded)) {
if (index % 256 === 0) await setImmediate();
this.#assertOpen(signal);
this.assertOpen(signal);
declaredBytes += BigInt(member.range.length);
if (declaredBytes > expansionBudget)
throw new ArtifactReaderFailure(
@@ -245,8 +128,8 @@ export class NodeSeaArtifactReader implements ArtifactReader {
index,
},
sourcePath: member.sourcePath,
} satisfies ArtifactEntry;
this.#entries.set(entry.adapterKey, entry);
} satisfies PositionalArtifactEntry;
entries.push(entry);
index += 1;
}
this.#header = {
@@ -268,34 +151,15 @@ export class NodeSeaArtifactReader implements ArtifactReader {
fuse_sentinel: fuse.fuseSentinel,
fuse_contradiction: fuse.fuseContradiction,
};
await this.#source.read(signal);
await this.verifyCarrier(signal);
this.#admission = undefined;
}
#memberStream(entry: ArtifactEntry, signal?: AbortSignal): Transform {
let bytes = 0;
const check = async (): Promise<void> => {
if (bytes !== entry.declaredSize)
throw formatFailure(`member ended early: ${entry.path}`);
await this.#source.read(signal);
return {
entries,
coverage:
fuse.fuseContradiction === null
? undefined
: { status: "complete", limitations: [fuse.fuseContradiction] },
};
return new Transform({
transform(chunk: Buffer, _encoding, callback) {
bytes += chunk.length;
callback(null, chunk);
},
flush(callback) {
void check().then(
() => callback(),
(cause: unknown) =>
callback(
cause instanceof Error
? cause
: new Error("Node SEA read failed", { cause }),
),
);
},
});
}
}
+256
View File
@@ -0,0 +1,256 @@
import { Readable, Transform, pipeline } from "node:stream";
import { setImmediate } from "node:timers/promises";
import {
ArtifactReaderFailure,
copyArtifactEntry,
sameArtifactEntry,
type ArtifactEntry,
type ArtifactReader,
type ArtifactReaderTraversal,
type ArtifactTraversalOptions,
} from "./ArtifactReader.js";
import { ArtifactResourceScope } from "./ArtifactResourceScope.js";
import { assertArtifactReadActive } from "./ArtifactFileOperation.js";
import { ArtifactFileSource } from "./ArtifactFileSource.js";
import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
import type { StableRegularFileDescriptor } from "../filesystem/RegularFile.js";
/** An entry whose bytes are one range of the carrier. */
export type PositionalArtifactEntry = ArtifactEntry & {
readonly byteOffset: number;
readonly declaredSize: number;
};
/** The entries one preparation established and the coverage to report. */
export interface PositionalArtifactEntries {
readonly entries: readonly PositionalArtifactEntry[];
/** Undefined when coverage is complete without limitations. */
readonly coverage: ArtifactReaderTraversal | undefined;
}
/**
* Serve entries that are byte ranges of one carrier file. Preparation runs
* once; every traversal, open and completed entry stream rechecks the
* carrier's identity, and close retires open streams before the carrier.
* Encrypted entries are listed but never opened.
*/
export abstract class PositionalArtifactReader implements ArtifactReader {
abstract readonly format: ArtifactReader["format"];
readonly #scope = new ArtifactResourceScope();
readonly #entries = new Map<string, PositionalArtifactEntry>();
readonly #streams = new Set<Readable>();
readonly #label: string;
readonly #source: ArtifactFileSource;
#prepared: Promise<void> | undefined;
#coverage: ArtifactReaderTraversal | undefined;
#closing = false;
constructor(
path: string,
label: string,
admitted?: StableRegularFileDescriptor,
) {
this.#label = label;
this.#source = new ArtifactFileSource(path, label, admitted);
}
/** Read the carrier once and describe its entries. */
protected abstract prepareEntries(
signal?: AbortSignal,
): Promise<PositionalArtifactEntries>;
async prepareContainer(
expectedSha256: string,
signal?: AbortSignal,
): Promise<void> {
await this.#scope.run(async () => {
this.assertOpen(signal);
await this.#source.bindSha256(expectedSha256, signal);
});
}
async *entries(
signal?: AbortSignal,
options?: ArtifactTraversalOptions,
): AsyncIterable<ArtifactEntry> {
await this.#scope.run(async () => {
this.assertOpen(signal);
this.#prepared ??= this.#prepare(signal);
await this.#prepared;
await this.#source.read(signal);
});
this.assertOpen(signal);
if (this.#coverage !== undefined)
options?.onCoverage?.({
...this.#coverage,
limitations: [...this.#coverage.limitations],
});
let index = 0;
for (const entry of this.#entries.values()) {
// Yield between batches so a large inventory does not hold the loop.
if (index++ % 256 === 0) await setImmediate();
this.assertOpen(signal);
yield copyArtifactEntry(entry);
}
this.assertOpen(signal);
}
async open(entry: ArtifactEntry, signal?: AbortSignal): Promise<Readable> {
return this.#scope.run(async () => {
this.assertOpen(signal);
const stored = this.#entries.get(entry.adapterKey);
if (stored === undefined || !sameArtifactEntry(entry, stored))
throw new ArtifactReaderFailure(
"integrity",
`${this.#label} entry does not match its owning inventory`,
);
if (stored.encrypted)
throw new ArtifactReaderFailure(
"unavailable",
`Encrypted ${this.#label} member is unsupported: ${stored.path}`,
);
const { handle } = await this.#source.read(signal);
this.assertOpen(signal);
const source =
stored.declaredSize === 0
? Readable.from([])
: readFileHandleChunks(handle, {
start: stored.byteOffset,
end: stored.byteOffset + stored.declaredSize - 1,
signal,
cancellationFailure: (reason) =>
new ArtifactReaderFailure(
"cancelled",
`${this.#label} read cancelled`,
{ cause: reason },
),
});
const output = this.#entryStream(stored, signal);
// Retirement can destroy the stream before its consumer listens.
output.on("error", () => undefined);
this.#streams.add(output);
let producerComplete = false;
const onAbort = (): void => {
// Preserve active read/validation errors; after production, the caller
// still owns buffered bytes and cancellation must retire that result.
if (!producerComplete || output.destroyed) return;
output.destroy(
new ArtifactReaderFailure(
"cancelled",
`${this.#label} read cancelled`,
{ cause: signal?.reason },
),
);
};
// Writable completion can leave unread bytes buffered on the readable
// side, so the stream stays owned until that side closes too.
const retired = new Promise<void>((resolve) => {
output.once("close", () => {
this.#streams.delete(output);
signal?.removeEventListener("abort", onAbort);
resolve();
});
});
signal?.addEventListener("abort", onAbort, { once: true });
if (signal?.aborted) onAbort();
void this.#scope
.run(async () => {
await Promise.all([
new Promise<void>((resolve) => {
pipeline(source, output, () => {
producerComplete = true;
if (signal?.aborted) onAbort();
resolve();
});
}),
retired,
]);
})
.catch((cause: unknown) =>
output.destroy(
cause instanceof Error
? cause
: new Error(`${this.#label} stream ownership failed`, {
cause,
}),
),
);
return output;
});
}
async close(): Promise<void> {
this.#closing = true;
for (const stream of this.#streams)
stream.destroy(
new ArtifactReaderFailure(
"unavailable",
`${this.#label} reader closed`,
),
);
await this.#scope.close();
await this.#source.close();
this.#entries.clear();
}
provenance(): readonly [] {
return [];
}
/** Recheck the carrier's identity and borrow its descriptor. */
protected verifyCarrier(
signal?: AbortSignal,
): Promise<StableRegularFileDescriptor> {
return this.#source.read(signal);
}
/** Refuse work once the caller cancels or close begins. */
protected assertOpen(signal?: AbortSignal): void {
assertArtifactReadActive(signal);
if (this.#closing)
throw new ArtifactReaderFailure(
"unavailable",
`${this.#label} reader is closed`,
);
}
async #prepare(signal?: AbortSignal): Promise<void> {
const { entries, coverage } = await this.prepareEntries(signal);
for (const entry of entries) this.#entries.set(entry.adapterKey, entry);
this.#coverage = coverage;
}
#entryStream(
entry: PositionalArtifactEntry,
signal?: AbortSignal,
): Transform {
const label = this.#label;
let bytes = 0;
const check = async (): Promise<void> => {
if (bytes !== entry.declaredSize)
throw new ArtifactReaderFailure(
"integrity",
`${label} entry ended early: ${entry.path}`,
);
await this.#source.read(signal);
};
return new Transform({
transform(chunk: Buffer, _encoding, callback) {
bytes += chunk.length;
callback(null, chunk);
},
flush(callback) {
void check().then(
() => callback(),
(cause: unknown) =>
callback(
cause instanceof Error
? cause
: new Error(`${label} read failed`, { cause }),
),
);
},
});
}
}