mirror of
https://github.com/morluto/rea.git
synced 2026-11-05 22:36:08 +00:00
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 reports8a748d3ccadded 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 Afterecbc1a772kept 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:
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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 }),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user