mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
* 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. * 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. * 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.
1733 lines
59 KiB
TypeScript
1733 lines
59 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import {
|
|
open,
|
|
type FileHandle,
|
|
chmod,
|
|
mkdir,
|
|
readFile,
|
|
realpath,
|
|
symlink,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { promisify } from "node:util";
|
|
|
|
import { buildBinary } from "plist";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
|
|
import {
|
|
resolveAppBundleExecutable,
|
|
type AppBundleFileSystem,
|
|
} from "../../../src/application/AppBundleExecutable.js";
|
|
import {
|
|
dosMz,
|
|
elf,
|
|
fat,
|
|
pe,
|
|
thinMach,
|
|
} from "../../../src/domain/binaryTarget.fixture.js";
|
|
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
|
import { hasXmlPlistSignature } from "../../../src/domain/apple/appleArchiveFormat.js";
|
|
import { parseExecutableHeader } from "../../../src/domain/binaryTarget.js";
|
|
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import { ESP8266_REFUSAL } from "../../../src/domain/firmware/espImage.js";
|
|
import {
|
|
ESP32C3_APP_SEGMENTS,
|
|
ESP32C3_BOOTLOADER_SEGMENTS,
|
|
esp8266Image,
|
|
espImage,
|
|
} from "../../fixtures/espImage.js";
|
|
import { espFlashDump } from "../../../src/domain/firmware/espFlash.fixture.js";
|
|
import { javaClassFile } from "../../fixtures/javaClassFile.js";
|
|
import {
|
|
MAX_JVM_CLASS_IDENTITY_BYTES,
|
|
JVM_CLASS_IDENTITY_LIMIT_REASON,
|
|
} from "../../../src/domain/apple/jvmClassFile.js";
|
|
import {
|
|
arArchive,
|
|
CPU,
|
|
fatImage,
|
|
machoImage,
|
|
rpathCommand,
|
|
codeSignatureCommand,
|
|
} from "../../../src/artifacts/apple/MachoImage.fixture.js";
|
|
import {
|
|
FIT_EMBEDDED,
|
|
FIT_EXTERNAL,
|
|
} from "../../../src/artifacts/fit/FitImage.fixture.js";
|
|
import { devicetreeFixture } from "../../../src/firmware/devicetree/Devicetree.fixture.js";
|
|
|
|
it.each([
|
|
[4000, false],
|
|
[9000, false],
|
|
[4000, true],
|
|
[9000, true],
|
|
] as const)(
|
|
"refuses a JVM class with %i unused constant bytes and CPU lookalike %s without claiming FAT identity",
|
|
async (constantBytes, fatCpuLookalike) => {
|
|
const directory = await createTestTempDirectory("rea-class-target-");
|
|
const path = join(directory, "input.class");
|
|
const bytes = javaClassFile(constantBytes, fatCpuLookalike);
|
|
expect(parseExecutableHeader(bytes, "arm64")).toEqual({
|
|
ok: false,
|
|
error: "unsupported binary format",
|
|
});
|
|
await writeFile(path, bytes);
|
|
const resolved = await parseBinaryTarget(path);
|
|
expect(resolved).toMatchObject({
|
|
ok: false,
|
|
error: { reason: "unsupported binary format" },
|
|
});
|
|
if (resolved.ok) throw new Error("JVM class was admitted as executable");
|
|
expect(projectAnalysisError(resolved.error)).toMatchObject({
|
|
code: "target_unavailable",
|
|
category: "unsupported_target",
|
|
details: { reason: "unsupported binary format" },
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([MAX_JVM_CLASS_IDENTITY_BYTES, MAX_JVM_CLASS_IDENTITY_BYTES + 1])(
|
|
"preserves JVM identity uncertainty at the class scan boundary for %i bytes",
|
|
async (size) => {
|
|
const directory = await createTestTempDirectory("rea-class-boundary-");
|
|
const path = join(directory, "input.class");
|
|
const classPrefix = javaClassFile(4000, true);
|
|
classPrefix.writeUInt16BE(1, classPrefix.length - 2);
|
|
const attribute = Buffer.alloc(6);
|
|
attribute.writeUInt16BE(5, 0);
|
|
attribute.writeUInt32BE(size - classPrefix.length - attribute.length, 2);
|
|
const handle = await open(path, "w");
|
|
try {
|
|
await handle.write(Buffer.concat([classPrefix, attribute]));
|
|
await handle.truncate(size);
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
expect(await parseBinaryTarget(path)).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
reason:
|
|
size === MAX_JVM_CLASS_IDENTITY_BYTES
|
|
? "unsupported binary format"
|
|
: JVM_CLASS_IDENTITY_LIMIT_REASON,
|
|
},
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([0x01c0, 0x01c2, 0x01c4, 0xaa64])(
|
|
"retains original PE COFF machine %i through file-backed target resolution",
|
|
async (peMachine) => {
|
|
const directory = await createTestTempDirectory("rea-pe-machine-");
|
|
const path = join(directory, "image.exe");
|
|
const bytes = pe(peMachine === 0xaa64 ? 0xaa64 : 0x01c0);
|
|
bytes.writeUInt16LE(peMachine, bytes.readUInt32LE(0x3c) + 4);
|
|
await writeFile(path, bytes);
|
|
const target = await parseBinaryTarget(path);
|
|
expect(target).toMatchObject({
|
|
ok: true,
|
|
value: {
|
|
format: "pe",
|
|
architecture: peMachine === 0xaa64 ? "arm64" : "arm",
|
|
peMachine,
|
|
},
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([{ cpus: [0x0100000c] }, { cpus: [0x01000007, 0x0100000c] }])(
|
|
"preserves a FAT container's format in the resolved target and Evidence ($cpus)",
|
|
async ({ cpus }) => {
|
|
const directory = await createTestTempDirectory("rea-universal-target-");
|
|
const path = join(directory, "image");
|
|
await writeFile(path, fat(cpus));
|
|
const resolved = await parseBinaryTarget(path, {
|
|
hostArchitecture: "arm64",
|
|
});
|
|
if (!resolved.ok) throw resolved.error;
|
|
expect(resolved.value).toMatchObject({
|
|
kind: "executable",
|
|
format: "mach-o-universal",
|
|
architecture: "arm64",
|
|
});
|
|
const evidence = createEvidence(
|
|
resolved.value,
|
|
{ id: "fixture", name: "Fixture", version: "1" },
|
|
{
|
|
operation: "inspect_macho",
|
|
parameters: {},
|
|
result: null,
|
|
},
|
|
);
|
|
expect(
|
|
parseEvidence(JSON.parse(JSON.stringify(evidence))).subject,
|
|
).toMatchObject({
|
|
format: "mach-o-universal",
|
|
architecture: "arm64",
|
|
});
|
|
},
|
|
);
|
|
|
|
it("reads a sparse FAT slice header beyond the bounded prefix", async () => {
|
|
const directory = await createTestTempDirectory("rea-sparse-fat-target-");
|
|
const path = join(directory, "image");
|
|
const sliceOffset = 64 * 1024;
|
|
const file = await open(path, "w+");
|
|
try {
|
|
const fatHeader = Buffer.alloc(28);
|
|
fatHeader.writeUInt32BE(0xcafebabe, 0);
|
|
fatHeader.writeUInt32BE(1, 4);
|
|
fatHeader.writeUInt32BE(CPU.arm64.type, 8);
|
|
fatHeader.writeUInt32BE(CPU.arm64.subtype, 12);
|
|
fatHeader.writeUInt32BE(sliceOffset, 16);
|
|
fatHeader.writeUInt32BE(32, 20);
|
|
await file.write(fatHeader, 0, fatHeader.length, 0);
|
|
const sliceHeader = machoImage({ cpu: CPU.arm64 }).subarray(0, 12);
|
|
await file.write(sliceHeader, 0, sliceHeader.length, sliceOffset);
|
|
await file.truncate(sliceOffset + 32);
|
|
} finally {
|
|
await file.close();
|
|
}
|
|
expect(
|
|
await parseBinaryTarget(path, { hostArchitecture: "arm64" }),
|
|
).toMatchObject({
|
|
ok: true,
|
|
value: { format: "mach-o-universal", architecture: "arm64" },
|
|
});
|
|
});
|
|
|
|
it("reads the complete bounded 128-entry FAT64 table beyond the prefix", async () => {
|
|
const directory = await createTestTempDirectory("rea-fat64-max-table-");
|
|
const path = join(directory, "image");
|
|
const entryCount = 128;
|
|
const tableEnd = 8 + entryCount * 32;
|
|
const sliceSize = 32;
|
|
const file = await open(path, "w+");
|
|
try {
|
|
const table = Buffer.alloc(tableEnd);
|
|
table.writeUInt32BE(0xcafebabf, 0);
|
|
table.writeUInt32BE(entryCount, 4);
|
|
const thinHeader = machoImage({ cpu: CPU.arm64 }).subarray(0, 12);
|
|
for (let index = 0; index < entryCount; index += 1) {
|
|
const entry = 8 + index * 32;
|
|
const sliceOffset = tableEnd + index * sliceSize;
|
|
table.writeUInt32BE(CPU.arm64.type, entry);
|
|
table.writeUInt32BE(CPU.arm64.subtype, entry + 4);
|
|
table.writeBigUInt64BE(BigInt(sliceOffset), entry + 8);
|
|
table.writeBigUInt64BE(BigInt(sliceSize), entry + 16);
|
|
await file.write(thinHeader, 0, thinHeader.length, sliceOffset);
|
|
}
|
|
await file.write(table, 0, table.length, 0);
|
|
await file.truncate(tableEnd + entryCount * sliceSize);
|
|
} finally {
|
|
await file.close();
|
|
}
|
|
expect(
|
|
await parseBinaryTarget(path, { hostArchitecture: "arm64" }),
|
|
).toMatchObject({
|
|
ok: true,
|
|
value: { format: "mach-o-universal", architecture: "arm64" },
|
|
});
|
|
});
|
|
|
|
it.each([0, 1])(
|
|
"rejects a file-backed FAT CPU mismatch in slice %i before host selection",
|
|
async (wrongIndex) => {
|
|
const directory = await createTestTempDirectory("rea-fat-cpu-mismatch-");
|
|
const path = join(directory, "image");
|
|
const bytes = fatImage([
|
|
{ cpu: CPU.x86_64, bytes: machoImage({ cpu: CPU.x86_64 }) },
|
|
{ cpu: CPU.arm64, bytes: machoImage({ cpu: CPU.arm64 }) },
|
|
]);
|
|
const entry = 8 + wrongIndex * 20;
|
|
new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).setUint32(
|
|
entry,
|
|
wrongIndex === 0 ? CPU.arm64.type : CPU.x86_64.type,
|
|
false,
|
|
);
|
|
await writeFile(path, Buffer.from(bytes));
|
|
expect(
|
|
await parseBinaryTarget(path, { hostArchitecture: "arm64" }),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "BinaryTargetError",
|
|
reason: `FAT architecture ${wrongIndex} CPU type/subtype disagrees with its Mach-O header`,
|
|
constraint: "file_format_rejected",
|
|
},
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{
|
|
name: "archive slices",
|
|
slices: [
|
|
{ cpu: CPU.x86_64, bytes: arArchive() },
|
|
{ cpu: CPU.arm64, bytes: arArchive("second.o") },
|
|
],
|
|
},
|
|
{
|
|
name: "mixed Mach-O and archive slices",
|
|
slices: [
|
|
{ cpu: CPU.x86_64, bytes: machoImage({ cpu: CPU.x86_64 }) },
|
|
{ cpu: CPU.arm64, bytes: arArchive() },
|
|
],
|
|
},
|
|
])("refuses FAT $name before provider selection", async ({ slices }) => {
|
|
const directory = await createTestTempDirectory("rea-fat-archive-target-");
|
|
const path = join(directory, "image");
|
|
await writeFile(path, fatImage(slices));
|
|
expect(
|
|
await parseBinaryTarget(path, { hostArchitecture: "arm64" }),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "BinaryTargetError",
|
|
constraint: "file_format_rejected",
|
|
},
|
|
});
|
|
});
|
|
|
|
describe("binary target I/O: app plist permissions and decoding", () => {
|
|
it.each([
|
|
["EACCES", "permission denied reading app Info.plist"],
|
|
["EPERM", "permission denied reading app Info.plist"],
|
|
])("preserves %s while reading an app plist", async (code, message) => {
|
|
const directory = await createTestTempDirectory("rea-app-permission-");
|
|
const app = join(directory, "Permission.app");
|
|
await mkdir(join(app, "Contents"), { recursive: true });
|
|
await writeFile(join(app, "Contents", "Info.plist"), "placeholder");
|
|
const denied = Object.assign(new Error("denied"), { code });
|
|
const fileSystem: AppBundleFileSystem = {
|
|
readFile: async () => {
|
|
throw denied;
|
|
},
|
|
realpath,
|
|
};
|
|
const result = await resolveAppBundleExecutable(app, fileSystem);
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "BinaryTargetError",
|
|
path: join(app, "Contents", "Info.plist"),
|
|
reason: expect.stringContaining(message),
|
|
cause: denied,
|
|
systemCode: code,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("preserves non-permission plist read failures as read failures", async () => {
|
|
const directory = await createTestTempDirectory("rea-app-read-error-");
|
|
const app = join(directory, "ReadError.app");
|
|
await mkdir(join(app, "Contents"), { recursive: true });
|
|
await writeFile(join(app, "Contents", "Info.plist"), "placeholder");
|
|
const readError = Object.assign(new Error("I/O failure"), { code: "EIO" });
|
|
const result = await resolveAppBundleExecutable(app, {
|
|
readFile: async () => {
|
|
throw readError;
|
|
},
|
|
realpath,
|
|
});
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
path: join(app, "Contents", "Info.plist"),
|
|
reason: expect.stringContaining("could not read app Info.plist"),
|
|
cause: readError,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("does not label binary plist decoder I/O failures as malformed data", async () => {
|
|
const directory = await createTestTempDirectory("rea-app-decode-error-");
|
|
const app = join(directory, "DecodeError.app");
|
|
await mkdir(join(app, "Contents"), { recursive: true });
|
|
await writeFile(join(app, "Contents", "Info.plist"), "bplist00");
|
|
const decodeError = Object.assign(new Error("I/O failure"), {
|
|
code: "EIO",
|
|
});
|
|
const result = await resolveAppBundleExecutable(app, {
|
|
readFile: async () => Buffer.from("bplist00"),
|
|
realpath,
|
|
decodeBinaryPlist: async () => {
|
|
throw decodeError;
|
|
},
|
|
});
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
path: join(app, "Contents", "Info.plist"),
|
|
reason: expect.stringContaining("could not decode app Info.plist"),
|
|
cause: decodeError,
|
|
},
|
|
});
|
|
});
|
|
|
|
it.each(["EACCES", "EPERM", "ENOENT", "ENOTDIR"])(
|
|
"preserves %s while resolving an app executable",
|
|
async (code) => {
|
|
const directory = await createTestTempDirectory("rea-app-permission-");
|
|
const app = join(directory, "Permission.app");
|
|
const programs = join(app, "Contents", "MacOS");
|
|
await mkdir(programs, { recursive: true });
|
|
await writeFile(
|
|
join(app, "Contents", "Info.plist"),
|
|
"<plist><dict><key>CFBundleExecutable</key><string>App</string></dict></plist>",
|
|
);
|
|
const denied = Object.assign(new Error("denied"), { code });
|
|
const fileSystem: AppBundleFileSystem = {
|
|
readFile,
|
|
realpath: async (path) => {
|
|
if (String(path) === join(programs, "App")) throw denied;
|
|
return realpath(path);
|
|
},
|
|
};
|
|
const result = await resolveAppBundleExecutable(app, fileSystem);
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "BinaryTargetError",
|
|
path: join(programs, "App"),
|
|
reason: expect.stringContaining(
|
|
code === "EACCES" || code === "EPERM"
|
|
? "permission denied"
|
|
: "missing",
|
|
),
|
|
cause: denied,
|
|
},
|
|
});
|
|
},
|
|
);
|
|
|
|
it("keeps missing and malformed plist diagnostics distinct", async () => {
|
|
const directory = await createTestTempDirectory("rea-app-diagnostics-");
|
|
const missing = join(directory, "Missing.app");
|
|
await mkdir(join(missing, "Contents"), { recursive: true });
|
|
const missingResult = await resolveAppBundleExecutable(missing);
|
|
expect(missingResult).toMatchObject({
|
|
ok: false,
|
|
error: { reason: expect.stringContaining("missing") },
|
|
});
|
|
|
|
const notDirectory = join(directory, "NotDirectory.app");
|
|
await mkdir(notDirectory, { recursive: true });
|
|
await writeFile(join(notDirectory, "Contents"), "not a directory");
|
|
const notDirectoryResult = await resolveAppBundleExecutable(notDirectory);
|
|
expect(notDirectoryResult).toMatchObject({
|
|
ok: false,
|
|
error: { reason: expect.stringContaining("missing") },
|
|
});
|
|
|
|
const malformed = join(directory, "Malformed.app");
|
|
await mkdir(join(malformed, "Contents", "MacOS"), { recursive: true });
|
|
await writeFile(
|
|
join(malformed, "Contents", "Info.plist"),
|
|
"<plist><dict></dict></plist>",
|
|
);
|
|
const malformedResult = await resolveAppBundleExecutable(malformed);
|
|
expect(malformedResult).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
path: join(malformed, "Contents", "Info.plist"),
|
|
reason: expect.stringContaining("malformed"),
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("binary target I/O: package classification", () => {
|
|
it("classifies ZIP package families and text artifacts without Hopper", async () => {
|
|
const directory = await createTestTempDirectory("rea-artifact-target-");
|
|
const zip = join(directory, "fixture.apk");
|
|
const msix = join(directory, "fixture.msixbundle");
|
|
const appx = join(directory, "fixture.appx");
|
|
const script = join(directory, "bundle.js");
|
|
const emptyZip = Buffer.from([0x50, 0x4b, 0x05, 0x06, 0, 0, 0, 0]);
|
|
await Promise.all([
|
|
writeFile(zip, emptyZip),
|
|
writeFile(msix, emptyZip),
|
|
writeFile(appx, emptyZip),
|
|
]);
|
|
await writeFile(script, "export default 1;\n");
|
|
const archive = await parseBinaryTarget(zip);
|
|
const msixArchive = await parseBinaryTarget(msix);
|
|
const appxArchive = await parseBinaryTarget(appx);
|
|
const javascript = await parseBinaryTarget(script);
|
|
expect(archive.ok && archive.value).toMatchObject({
|
|
kind: "archive",
|
|
format: "apk",
|
|
});
|
|
expect(msixArchive.ok && msixArchive.value).toMatchObject({
|
|
kind: "archive",
|
|
format: "msix",
|
|
});
|
|
expect(appxArchive.ok && appxArchive.value).toMatchObject({
|
|
kind: "archive",
|
|
format: "appx",
|
|
});
|
|
expect(javascript.ok && javascript.value).toMatchObject({
|
|
kind: "artifact",
|
|
format: "javascript",
|
|
});
|
|
});
|
|
|
|
it("classifies a binary plist by its magic under any extension", async () => {
|
|
const directory = await createTestTempDirectory("rea-bplist-target-");
|
|
const path = join(directory, "Model.archive");
|
|
await writeFile(path, buildBinary({ $archiver: "NSKeyedArchiver" }));
|
|
const result = await parseBinaryTarget(path);
|
|
expect(result.ok && result.value).toMatchObject({
|
|
kind: "artifact",
|
|
format: "plist",
|
|
});
|
|
});
|
|
|
|
it("classifies XML plists by content while preserving named formats", async () => {
|
|
const directory = await createTestTempDirectory("rea-xml-plist-target-");
|
|
const xml = Buffer.from(
|
|
'<?xml version="1.0"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict><key>$archiver</key><string>NSKeyedArchiver</string></dict></plist>',
|
|
);
|
|
const archivePath = join(directory, "Model.archive");
|
|
const javascriptPath = join(directory, "Model.js");
|
|
await writeFile(archivePath, xml);
|
|
await writeFile(javascriptPath, xml);
|
|
|
|
const archive = await parseBinaryTarget(archivePath);
|
|
const javascript = await parseBinaryTarget(javascriptPath);
|
|
expect(archive.ok && archive.value).toMatchObject({
|
|
kind: "artifact",
|
|
format: "plist",
|
|
});
|
|
expect(javascript.ok && javascript.value).toMatchObject({
|
|
kind: "artifact",
|
|
format: "javascript",
|
|
});
|
|
});
|
|
|
|
it("continues a bounded XML plist probe beyond the initial file window", async () => {
|
|
const directory = await createTestTempDirectory("rea-xml-long-prolog-");
|
|
const path = join(directory, "Model.archive");
|
|
await writeFile(
|
|
path,
|
|
`<?xml version="1.0"?>${" ".repeat(8192)}<plist><dict/></plist>`,
|
|
);
|
|
|
|
const result = await parseBinaryTarget(path);
|
|
expect(result.ok && result.value).toMatchObject({
|
|
kind: "artifact",
|
|
format: "plist",
|
|
});
|
|
expect(hasXmlPlistSignature(Buffer.from(`<root><plist/></root>`))).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it("classifies Intel HEX and S-records by a valid first record", async () => {
|
|
const directory = await createTestTempDirectory("rea-hex-target-");
|
|
const hex = join(directory, "firmware.hex");
|
|
const text = join(directory, "firmware.txt");
|
|
const srec = join(directory, "firmware.mot");
|
|
const bad = join(directory, "bad.hex");
|
|
await Promise.all([
|
|
writeFile(hex, ":0300300002337A1E\n:00000001FF\n"),
|
|
writeFile(text, ":00000001FF\n"),
|
|
writeFile(srec, "S9030000FC\n"),
|
|
writeFile(bad, ":0300300002337A1F\n:00000001FF\n"),
|
|
]);
|
|
for (const [path, format] of [
|
|
[hex, "intel-hex"],
|
|
[text, "intel-hex"],
|
|
[srec, "srec"],
|
|
] as const)
|
|
expect(await parseBinaryTarget(path)).toMatchObject({
|
|
ok: true,
|
|
value: { kind: "archive", format },
|
|
});
|
|
expect(await parseBinaryTarget(bad)).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "BinaryTargetError" },
|
|
});
|
|
});
|
|
|
|
it("classifies ESP images by bytes and refuses ESP8266 v1 images", async () => {
|
|
const directory = await createTestTempDirectory("rea-esp-target-");
|
|
const app = join(directory, "firmware.ota");
|
|
const truncated = join(directory, "download.bin");
|
|
const esp8266 = join(directory, "esp8266.bin");
|
|
const encrypted = join(directory, "encrypted.bin");
|
|
const image = espImage({ segments: ESP32C3_APP_SEGMENTS });
|
|
await Promise.all([
|
|
writeFile(app, image),
|
|
writeFile(truncated, image.subarray(0, 40)),
|
|
writeFile(esp8266, esp8266Image()),
|
|
writeFile(encrypted, Buffer.alloc(4096, 0x5a)),
|
|
]);
|
|
for (const path of [app, truncated])
|
|
expect(await parseBinaryTarget(path)).toMatchObject({
|
|
ok: true,
|
|
value: { kind: "archive", format: "esp-image" },
|
|
});
|
|
expect(await parseBinaryTarget(esp8266)).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "BinaryTargetError",
|
|
constraint: "file_format_rejected",
|
|
reason: ESP8266_REFUSAL,
|
|
},
|
|
});
|
|
// Flash-encrypted bytes carry no magic; they stay unrecognized, not ESP.
|
|
const unrecognized = await parseBinaryTarget(encrypted);
|
|
expect(unrecognized.ok).toBe(false);
|
|
expect(JSON.stringify(unrecognized)).not.toContain("ESP");
|
|
});
|
|
|
|
it("does not trust a ZIP-family extension without ZIP magic", async () => {
|
|
const directory = await createTestTempDirectory("rea-fake-archive-");
|
|
const path = join(directory, "fake.zip");
|
|
await writeFile(path, "not a zip");
|
|
const result = await parseBinaryTarget(path);
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "BinaryTargetError" },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("binary target I/O: ESP flash layouts", () => {
|
|
it("detects a flash layout before the bootloader it begins with", async () => {
|
|
const directory = await createTestTempDirectory("rea-esp-flash-target-");
|
|
const dump = join(directory, "flash.bin");
|
|
const bootloader = join(directory, "bootloader.bin");
|
|
const image = espImage({ segments: ESP32C3_BOOTLOADER_SEGMENTS });
|
|
await Promise.all([
|
|
writeFile(dump, espFlashDump({ bootloader: { at: 0, bytes: image } })),
|
|
writeFile(bootloader, image),
|
|
]);
|
|
expect(await parseBinaryTarget(dump)).toMatchObject({
|
|
ok: true,
|
|
value: {
|
|
kind: "archive",
|
|
format: "esp-flash-image",
|
|
espFlash: {
|
|
selected: false,
|
|
flashOffset: null,
|
|
partitionTableOffset: null,
|
|
},
|
|
},
|
|
});
|
|
expect(await parseBinaryTarget(bootloader)).toMatchObject({
|
|
ok: true,
|
|
value: { kind: "archive", format: "esp-image" },
|
|
});
|
|
});
|
|
|
|
it("keeps refusing an ESP8266 dump that has an ESP-IDF-style table", async () => {
|
|
const directory = await createTestTempDirectory("rea-esp8266-flash-");
|
|
const dump = join(directory, "esp8266-flash.bin");
|
|
await writeFile(
|
|
dump,
|
|
espFlashDump({ bootloader: { at: 0, bytes: esp8266Image() } }),
|
|
);
|
|
expect(await parseBinaryTarget(dump)).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
constraint: "file_format_rejected",
|
|
reason: ESP8266_REFUSAL,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("reads a selected flash dump with caller inputs and refuses bad pairings", async () => {
|
|
const directory = await createTestTempDirectory("rea-esp-flash-select-");
|
|
const esp32 = espImage({
|
|
segments: ESP32C3_BOOTLOADER_SEGMENTS,
|
|
chipId: 0,
|
|
});
|
|
const partial = join(directory, "from-0x1000.bin");
|
|
const noise = join(directory, "noise.bin");
|
|
await Promise.all([
|
|
writeFile(
|
|
partial,
|
|
espFlashDump({ bootloader: { at: 0x1000, bytes: esp32 } }).subarray(
|
|
0x1000,
|
|
),
|
|
),
|
|
writeFile(noise, Buffer.alloc(0x1_0000, 0x5a)),
|
|
]);
|
|
expect(
|
|
await parseBinaryTarget(partial, {
|
|
formatHint: "esp-flash-image",
|
|
espFlash: { flashOffset: 0x1000 },
|
|
}),
|
|
).toMatchObject({
|
|
ok: true,
|
|
value: {
|
|
kind: "archive",
|
|
format: "esp-flash-image",
|
|
espFlash: {
|
|
selected: true,
|
|
flashOffset: 0x1000,
|
|
partitionTableOffset: null,
|
|
},
|
|
},
|
|
});
|
|
for (const [options, reason] of [
|
|
[
|
|
{ espFlash: { flashOffset: 0x1000 } },
|
|
"flash_offset and partition_table_offset apply only with format=esp-flash-image",
|
|
],
|
|
[
|
|
{
|
|
formatHint: "esp-flash-image",
|
|
espFlash: { partitionTableOffset: 0x8800 },
|
|
},
|
|
"partition_table_offset must be a multiple of 0x1000, as ESP-IDF requires",
|
|
],
|
|
] as const)
|
|
expect(await parseBinaryTarget(partial, options)).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "BinaryTargetError",
|
|
constraint: "load_selection_rejected",
|
|
reason,
|
|
},
|
|
});
|
|
// Ciphertext is not recognized as a layout: unknown, not malformed.
|
|
expect(
|
|
await parseBinaryTarget(noise, { formatHint: "esp-flash-image" }),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
constraint: "file_format_rejected",
|
|
reason: expect.stringContaining(
|
|
"ESP flash image layout is not recognized",
|
|
),
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
it("classifies a FIT by its /images node and leaves a plain DTB rejected", async () => {
|
|
const directory = await createTestTempDirectory("rea-fit-target-");
|
|
const itb = join(directory, "boot.itb");
|
|
const extensionless = join(directory, "boot");
|
|
const dtb = join(directory, "board.dtb");
|
|
await Promise.all([
|
|
writeFile(itb, FIT_EMBEDDED),
|
|
writeFile(extensionless, FIT_EXTERNAL),
|
|
writeFile(dtb, devicetreeFixture()),
|
|
]);
|
|
for (const path of [itb, extensionless])
|
|
expect(await parseBinaryTarget(path)).toMatchObject({
|
|
ok: true,
|
|
value: { kind: "archive", format: "fit" },
|
|
});
|
|
expect(await parseBinaryTarget(dtb)).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "BinaryTargetError",
|
|
constraint: "file_format_rejected",
|
|
},
|
|
});
|
|
});
|
|
|
|
describe("binary target I/O: filesystem and app bundle target resolution", () => {
|
|
it("resolves relative Hopper databases and rejects unknown or unreadable paths", async () => {
|
|
const directory = await createTestTempDirectory("rea-target-");
|
|
await writeFile(join(directory, "sample.hop"), "database");
|
|
await writeFile(join(directory, "text"), "hello");
|
|
const database = await parseBinaryTarget("sample.hop", { cwd: directory });
|
|
expect(database.ok && database.value.sha256).toBe(
|
|
"3549b0028b75d981cdda2e573e9cb49dedc200185876df299f912b79f69dabd8",
|
|
);
|
|
expect((await parseBinaryTarget("text", { cwd: directory })).ok).toBe(
|
|
false,
|
|
);
|
|
expect((await parseBinaryTarget("missing", { cwd: directory })).ok).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
|
|
"reports an unreadable target as a host read denial",
|
|
async () => {
|
|
const directory = await createTestTempDirectory("rea-target-denied-");
|
|
const path = join(directory, "unreadable");
|
|
await writeFile(path, thinMach(0xcffaedfe, 0x0100000c));
|
|
await chmod(path, 0);
|
|
try {
|
|
const result = await parseBinaryTarget(path, {
|
|
cwd: directory,
|
|
hostArchitecture: "arm64",
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.error).toMatchObject({ path, systemCode: "EACCES" });
|
|
expect(projectAnalysisError(result.error)).toMatchObject({
|
|
code: "access_denied",
|
|
details: { path, system_code: "EACCES" },
|
|
});
|
|
} finally {
|
|
await chmod(path, 0o600);
|
|
}
|
|
},
|
|
);
|
|
|
|
it("rejects non-regular targets before reading them", async () => {
|
|
const directory = await createTestTempDirectory("rea-target-");
|
|
expect((await parseBinaryTarget(directory)).ok).toBe(false);
|
|
});
|
|
|
|
it("resolves a macOS app bundle to its declared program file", async () => {
|
|
const directory = await createTestTempDirectory("rea-target-");
|
|
const app = join(directory, "Example App.app");
|
|
const contents = join(app, "Contents");
|
|
const programs = join(contents, "MacOS");
|
|
await mkdir(programs, { recursive: true });
|
|
await writeFile(
|
|
join(contents, "Info.plist"),
|
|
'<?xml version="1.0"?><plist><dict><key>CFBundleExecutable</key><string>Example & Tool</string></dict></plist>',
|
|
);
|
|
await writeFile(
|
|
join(programs, "Example & Tool"),
|
|
thinMach(0xfeedfacf, 0x0100000c),
|
|
);
|
|
const result = await parseBinaryTarget(app, {
|
|
cwd: directory,
|
|
hostArchitecture: "arm64",
|
|
});
|
|
expect(result.ok && result.value).toMatchObject({
|
|
path: await realpath(join(programs, "Example & Tool")),
|
|
format: "mach-o",
|
|
});
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"preserves a literal backslash in a POSIX app executable name",
|
|
async () => {
|
|
const directory = await createTestTempDirectory("rea-target-");
|
|
const app = join(directory, "Back.app");
|
|
const programs = join(app, "Contents", "MacOS");
|
|
const executable = join(programs, "App\\Name");
|
|
await mkdir(programs, { recursive: true });
|
|
await writeFile(
|
|
join(app, "Contents", "Info.plist"),
|
|
"<plist><dict><key>CFBundleExecutable</key><string>App\\Name</string></dict></plist>",
|
|
);
|
|
await writeFile(executable, thinMach(0xfeedfacf, 0x0100000c));
|
|
const result = await parseBinaryTarget(app, {
|
|
cwd: directory,
|
|
hostArchitecture: "arm64",
|
|
});
|
|
expect(result.ok && result.value).toMatchObject({
|
|
path: await realpath(executable),
|
|
format: "mach-o",
|
|
});
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform !== "win32")(
|
|
"rejects a Windows separator in an app executable declaration",
|
|
async () => {
|
|
const directory = await createTestTempDirectory("rea-target-");
|
|
const app = join(directory, "Broken.app");
|
|
await mkdir(join(app, "Contents", "MacOS"), { recursive: true });
|
|
await writeFile(
|
|
join(app, "Contents", "Info.plist"),
|
|
"<plist><dict><key>CFBundleExecutable</key><string>..\\escape</string></dict></plist>",
|
|
);
|
|
const result = await parseBinaryTarget(app, { cwd: directory });
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { reason: "app has an unsafe CFBundleExecutable" },
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["missing plist", undefined],
|
|
["missing executable name", "<plist><dict></dict></plist>"],
|
|
[
|
|
"unsafe executable name",
|
|
"<plist><dict><key>CFBundleExecutable</key><string>../escape</string></dict></plist>",
|
|
],
|
|
[
|
|
"missing program file",
|
|
"<plist><dict><key>CFBundleExecutable</key><string>Missing</string></dict></plist>",
|
|
],
|
|
])("rejects an app bundle with %s", async (_case, plist) => {
|
|
const directory = await createTestTempDirectory("rea-target-");
|
|
const app = join(directory, "Broken.app");
|
|
const contents = join(app, "Contents");
|
|
await mkdir(join(contents, "MacOS"), { recursive: true });
|
|
if (plist !== undefined)
|
|
await writeFile(join(contents, "Info.plist"), plist);
|
|
expect(
|
|
(
|
|
await parseBinaryTarget(app, {
|
|
cwd: directory,
|
|
hostArchitecture: "arm64",
|
|
})
|
|
).ok,
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("app bundle canonical containment", () => {
|
|
it.skipIf(process.platform === "win32")(
|
|
"rejects an app program symlink that leaves the bundle",
|
|
async () => {
|
|
const directory = await createTestTempDirectory("rea-target-");
|
|
const app = join(directory, "Escaping.app");
|
|
const contents = join(app, "Contents");
|
|
const programs = join(contents, "MacOS");
|
|
const outside = join(directory, "outside");
|
|
await mkdir(programs, { recursive: true });
|
|
await writeFile(outside, thinMach(0xfeedfacf, 0x0100000c));
|
|
await writeFile(
|
|
join(contents, "Info.plist"),
|
|
"<plist><dict><key>CFBundleExecutable</key><string>Escaping</string></dict></plist>",
|
|
);
|
|
await symlink(outside, join(programs, "Escaping"));
|
|
expect(
|
|
(
|
|
await parseBinaryTarget(app, {
|
|
cwd: directory,
|
|
hostArchitecture: "arm64",
|
|
})
|
|
).ok,
|
|
).toBe(false);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"rejects a Contents/MacOS directory symlink that leaves the bundle",
|
|
async () => {
|
|
const directory = await createTestTempDirectory("rea-target-");
|
|
const app = join(directory, "Escaping.app");
|
|
const contents = join(app, "Contents");
|
|
const programs = join(contents, "MacOS");
|
|
const outside = join(directory, "outside");
|
|
await mkdir(contents, { recursive: true });
|
|
await mkdir(outside);
|
|
await writeFile(
|
|
join(contents, "Info.plist"),
|
|
"<plist><dict><key>CFBundleExecutable</key><string>Escaping</string></dict></plist>",
|
|
);
|
|
await writeFile(
|
|
join(outside, "Escaping"),
|
|
thinMach(0xfeedfacf, 0x0100000c),
|
|
);
|
|
await symlink(outside, programs, "dir");
|
|
|
|
const result = await parseBinaryTarget(app, {
|
|
cwd: directory,
|
|
hostArchitecture: "arm64",
|
|
});
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
path: app,
|
|
reason: "app program directory leaves Contents/MacOS",
|
|
},
|
|
});
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"allows bundle and program directory aliases within the canonical bundle",
|
|
async () => {
|
|
const directory = await createTestTempDirectory("rea-target-");
|
|
const app = join(directory, "Canonical.app");
|
|
const alias = join(directory, "Alias.app");
|
|
const contents = join(app, "Contents");
|
|
const programs = join(contents, "Programs");
|
|
const executable = join(programs, "Example");
|
|
await mkdir(programs, { recursive: true });
|
|
await writeFile(executable, thinMach(0xfeedfacf, 0x0100000c));
|
|
await writeFile(
|
|
join(contents, "Info.plist"),
|
|
"<plist><dict><key>CFBundleExecutable</key><string>Example</string></dict></plist>",
|
|
);
|
|
await symlink(programs, join(contents, "MacOS"), "dir");
|
|
await symlink(app, alias, "dir");
|
|
const result = await parseBinaryTarget(alias, {
|
|
cwd: directory,
|
|
hostArchitecture: "arm64",
|
|
});
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
value: { path: await realpath(executable), format: "mach-o" },
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("binary target I/O: explicit kinds and executable headers", () => {
|
|
it("honors an explicit database kind without relying on the file suffix", async () => {
|
|
const directory = await createTestTempDirectory("rea-target-");
|
|
await writeFile(join(directory, "saved-analysis"), "database");
|
|
const result = await parseBinaryTarget("saved-analysis", {
|
|
cwd: directory,
|
|
hostArchitecture: "arm64",
|
|
targetKind: "database",
|
|
});
|
|
expect(result.ok && result.value).toMatchObject({
|
|
kind: "database",
|
|
format: "analysis-database",
|
|
});
|
|
});
|
|
|
|
it("reads a PE header beyond the initial probe", async () => {
|
|
const directory = await createTestTempDirectory("rea-target-");
|
|
const path = join(directory, "delayed.exe");
|
|
await writeFile(path, pe(0x8664, 8192));
|
|
const result = await parseBinaryTarget(path, {
|
|
cwd: directory,
|
|
hostArchitecture: "x64",
|
|
});
|
|
expect(result.ok && result.value).toMatchObject({
|
|
format: "pe",
|
|
architecture: "x86_64",
|
|
});
|
|
});
|
|
|
|
it("extracts CodeView RSDS debug metadata from a PE target", async () => {
|
|
const directory = await createTestTempDirectory("rea-target-");
|
|
const path = join(directory, "symbols.exe");
|
|
await writeFile(
|
|
path,
|
|
pe(0x8664, 64, 0x0002, false, {
|
|
type: "RSDS",
|
|
pdbPath: "C:\\build\\test.pdb",
|
|
guid: "12345678-abcd-ef01-2345-6789abcdef01",
|
|
age: 3,
|
|
}),
|
|
);
|
|
const result = await parseBinaryTarget(path, {
|
|
cwd: directory,
|
|
hostArchitecture: "x64",
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value).toMatchObject({
|
|
format: "pe",
|
|
architecture: "x86_64",
|
|
codeView: {
|
|
pdbPath: "C:\\build\\test.pdb",
|
|
pdbFileName: "test.pdb",
|
|
guid: "12345678-abcd-ef01-2345-6789abcdef01",
|
|
age: 3,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("extracts CodeView NB10 debug metadata from a PE target", async () => {
|
|
const directory = await createTestTempDirectory("rea-target-");
|
|
const path = join(directory, "legacy.exe");
|
|
await writeFile(
|
|
path,
|
|
pe(0x014c, 64, 0x0002, false, {
|
|
type: "NB10",
|
|
pdbPath: "D:\\old\\legacy.pdb",
|
|
age: 1,
|
|
}),
|
|
);
|
|
const result = await parseBinaryTarget(path, {
|
|
cwd: directory,
|
|
hostArchitecture: "ia32",
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value).toMatchObject({
|
|
format: "pe",
|
|
architecture: "x86",
|
|
codeView: {
|
|
pdbPath: "D:\\old\\legacy.pdb",
|
|
pdbFileName: "legacy.pdb",
|
|
guid: null,
|
|
age: 1,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("refuses a truncated Mach-O header before any provider sees it", async () => {
|
|
const directory = await createTestTempDirectory("rea-target-");
|
|
const path = join(directory, "truncated");
|
|
await writeFile(path, thinMach(0xcffaedfe, 0x0100000c).subarray(0, 12));
|
|
const result = await parseBinaryTarget(path, {
|
|
cwd: directory,
|
|
hostArchitecture: "arm64",
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
expect(result.ok ? undefined : result.error).toMatchObject({
|
|
path,
|
|
reason:
|
|
"truncated Mach-O header: a 64-bit header needs 32 bytes; the file has 12",
|
|
});
|
|
});
|
|
|
|
it("checks Mach-O load commands against the whole file beyond the probe", async () => {
|
|
const directory = await createTestTempDirectory("rea-target-");
|
|
const path = join(directory, "large-commands");
|
|
const firstCommand = rpathCommand("@loader_path/" + "a".repeat(8192));
|
|
const bytes = Buffer.from(
|
|
machoImage({ commands: [firstCommand, codeSignatureCommand()] }),
|
|
);
|
|
await writeFile(path, bytes);
|
|
const result = await parseBinaryTarget(path, {
|
|
cwd: directory,
|
|
hostArchitecture: "arm64",
|
|
});
|
|
expect(result.ok && result.value).toMatchObject({
|
|
format: "mach-o",
|
|
architecture: "arm64",
|
|
});
|
|
|
|
// The second command's header lies beyond the initial 4096-byte probe.
|
|
// Corrupting it must fail admission even though the prefix is unchanged.
|
|
bytes.writeUInt32LE(0, 32 + firstCommand.length + 4);
|
|
await writeFile(path, bytes);
|
|
const malformed = await parseBinaryTarget(path, {
|
|
cwd: directory,
|
|
hostArchitecture: "arm64",
|
|
});
|
|
expect(malformed).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
path,
|
|
reason: `load command 1 at offset ${firstCommand.length} has invalid cmdsize 0`,
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("app executable filename fidelity", () => {
|
|
it.each([" leading", "trailing ", " both "])(
|
|
"preserves executable filename whitespace in an XML plist: %j",
|
|
async (name) => {
|
|
const directory = await createTestTempDirectory("rea-app-name-");
|
|
const app = join(directory, "Whitespace.app");
|
|
const contents = join(app, "Contents");
|
|
const executable = join(contents, "MacOS", name);
|
|
await mkdir(join(contents, "MacOS"), { recursive: true });
|
|
await writeFile(
|
|
join(contents, "Info.plist"),
|
|
`<plist><dict><key>CFBundleExecutable</key><string>${name}</string></dict></plist>`,
|
|
);
|
|
await writeFile(executable, thinMach(0xfeedfacf, 0x0100000c));
|
|
const result = await parseBinaryTarget(app, {
|
|
cwd: directory,
|
|
hostArchitecture: "arm64",
|
|
});
|
|
expect(result.ok && result.value).toMatchObject({
|
|
path: await realpath(executable),
|
|
format: "mach-o",
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["an escaped entity reference", "a&lt;b", "a<b"],
|
|
["a numeric character reference", "a b", "a b"],
|
|
["a CDATA section", "<![CDATA[a&b]]>", "a&b"],
|
|
])(
|
|
"decodes an XML plist executable name containing %s",
|
|
async (_case, encoded, name) => {
|
|
const directory = await createTestTempDirectory("rea-app-xml-name-");
|
|
const app = join(directory, "Encoded.app");
|
|
const contents = join(app, "Contents");
|
|
const executable = join(contents, "MacOS", name);
|
|
await mkdir(join(contents, "MacOS"), { recursive: true });
|
|
await writeFile(
|
|
join(contents, "Info.plist"),
|
|
`<plist><dict><key>CFBundleExecutable</key><string>${encoded}</string></dict></plist>`,
|
|
);
|
|
await writeFile(executable, thinMach(0xfeedfacf, 0x0100000c));
|
|
const result = await parseBinaryTarget(app, {
|
|
cwd: directory,
|
|
hostArchitecture: "arm64",
|
|
});
|
|
expect(result.ok && result.value).toMatchObject({
|
|
path: await realpath(executable),
|
|
format: "mach-o",
|
|
});
|
|
},
|
|
);
|
|
|
|
it("reads the top-level executable key rather than commented or nested text", async () => {
|
|
const directory = await createTestTempDirectory("rea-app-xml-key-");
|
|
const app = join(directory, "Nested.app");
|
|
const contents = join(app, "Contents");
|
|
const executable = join(contents, "MacOS", "App");
|
|
await mkdir(join(contents, "MacOS"), { recursive: true });
|
|
await writeFile(
|
|
join(contents, "Info.plist"),
|
|
[
|
|
"<plist><dict>",
|
|
"<!-- <key>CFBundleExecutable</key><string>Old</string> -->",
|
|
"<key>NSExtension</key><dict><key>CFBundleExecutable</key><string>Inner</string></dict>",
|
|
"<key>CFBundleExecutable</key><string>App</string>",
|
|
"</dict></plist>",
|
|
].join(""),
|
|
);
|
|
await writeFile(executable, thinMach(0xfeedfacf, 0x0100000c));
|
|
await writeFile(
|
|
join(contents, "MacOS", "Old"),
|
|
thinMach(0xfeedfacf, 0x0100000c),
|
|
);
|
|
await writeFile(
|
|
join(contents, "MacOS", "Inner"),
|
|
thinMach(0xfeedfacf, 0x0100000c),
|
|
);
|
|
const result = await parseBinaryTarget(app, {
|
|
cwd: directory,
|
|
hostArchitecture: "arm64",
|
|
});
|
|
expect(result.ok && result.value).toMatchObject({
|
|
path: await realpath(executable),
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
"<key>__proto__</key><dict><key>CFBundleExecutable</key><string>Forged</string></dict>",
|
|
"<key>__proto__</key><string>x</string>",
|
|
])("opens an app whose XML plist also holds %s", async (entry) => {
|
|
const directory = await createTestTempDirectory("rea-app-xml-proto-");
|
|
const app = join(directory, "Proto.app");
|
|
const contents = join(app, "Contents");
|
|
const executable = join(contents, "MacOS", "App");
|
|
await mkdir(join(contents, "MacOS"), { recursive: true });
|
|
await writeFile(
|
|
join(contents, "Info.plist"),
|
|
`<plist><dict>${entry}<key>CFBundleExecutable</key><string>App</string></dict></plist>`,
|
|
);
|
|
await writeFile(executable, thinMach(0xfeedfacf, 0x0100000c));
|
|
const result = await parseBinaryTarget(app, {
|
|
cwd: directory,
|
|
hostArchitecture: "arm64",
|
|
});
|
|
expect(result.ok && result.value).toMatchObject({
|
|
path: await realpath(executable),
|
|
});
|
|
});
|
|
|
|
it.skipIf(process.platform !== "darwin")(
|
|
"preserves executable filename whitespace from native binary plists",
|
|
async () => {
|
|
const directory = await createTestTempDirectory("rea-app-binary-name-");
|
|
for (const name of ["Ordinary", " App ", "Tab\t"]) {
|
|
const app = join(directory, `${name}.app`);
|
|
const contents = join(app, "Contents");
|
|
const executable = join(contents, "MacOS", name);
|
|
const plist = join(contents, "Info.plist");
|
|
await mkdir(join(contents, "MacOS"), { recursive: true });
|
|
await writeFile(
|
|
plist,
|
|
`<plist><dict><key>CFBundleExecutable</key><string>${name}</string></dict></plist>`,
|
|
);
|
|
await promisify(execFile)("/usr/bin/plutil", [
|
|
"-convert",
|
|
"binary1",
|
|
plist,
|
|
]);
|
|
await writeFile(executable, thinMach(0xfeedfacf, 0x0100000c));
|
|
const result = await parseBinaryTarget(app, {
|
|
cwd: directory,
|
|
hostArchitecture: "arm64",
|
|
});
|
|
expect(result.ok && result.value).toMatchObject({
|
|
path: await realpath(executable),
|
|
format: "mach-o",
|
|
});
|
|
}
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("iOS-style app bundle targets", () => {
|
|
const plist = (name: string) =>
|
|
`<plist><dict><key>CFBundleExecutable</key><string>${name}</string></dict></plist>`;
|
|
|
|
it("resolves a flat bundle's program file beside its Info.plist", async () => {
|
|
const directory = await createTestTempDirectory("rea-target-");
|
|
const app = join(directory, "Flat.app");
|
|
await mkdir(app);
|
|
await writeFile(join(app, "Info.plist"), plist("Flat"));
|
|
await writeFile(join(app, "Flat"), thinMach(0xfeedfacf, 0x0100000c));
|
|
const result = await parseBinaryTarget(app, {
|
|
cwd: directory,
|
|
hostArchitecture: "arm64",
|
|
});
|
|
expect(result.ok && result.value).toMatchObject({
|
|
path: await realpath(join(app, "Flat")),
|
|
bundleInfoPlist: join(await realpath(app), "Info.plist"),
|
|
format: "mach-o",
|
|
});
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"keeps the declaring Info.plist for a symlinked program file",
|
|
async () => {
|
|
const directory = await createTestTempDirectory("rea-target-");
|
|
const app = join(directory, "Linked.app");
|
|
await mkdir(join(app, "bin"), { recursive: true });
|
|
await writeFile(join(app, "Info.plist"), plist("Linked"));
|
|
await writeFile(
|
|
join(app, "bin", "real"),
|
|
thinMach(0xfeedfacf, 0x0100000c),
|
|
);
|
|
await symlink("bin/real", join(app, "Linked"));
|
|
const result = await parseBinaryTarget(app, {
|
|
cwd: directory,
|
|
hostArchitecture: "arm64",
|
|
});
|
|
expect(result.ok && result.value).toMatchObject({
|
|
path: await realpath(join(app, "bin", "real")),
|
|
bundleInfoPlist: join(await realpath(app), "Info.plist"),
|
|
});
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"follows a readable Contents/Info.plist symlink",
|
|
async () => {
|
|
const directory = await createTestTempDirectory("rea-target-");
|
|
const app = join(directory, "Linked.app");
|
|
const contents = join(app, "Contents");
|
|
await mkdir(join(contents, "MacOS"), { recursive: true });
|
|
await writeFile(join(contents, "Real.plist"), plist("Linked"));
|
|
await symlink("Real.plist", join(contents, "Info.plist"));
|
|
await writeFile(
|
|
join(contents, "MacOS", "Linked"),
|
|
thinMach(0xfeedfacf, 0x0100000c),
|
|
);
|
|
const result = await parseBinaryTarget(app, {
|
|
cwd: directory,
|
|
hostArchitecture: "arm64",
|
|
});
|
|
expect(result.ok && result.value).toMatchObject({
|
|
path: await realpath(join(contents, "MacOS", "Linked")),
|
|
});
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
|
|
"reports a wrapper it cannot read as a permission denial",
|
|
async () => {
|
|
const directory = await createTestTempDirectory("rea-target-");
|
|
const app = join(directory, "Locked.app");
|
|
const wrapper = join(app, "Wrapper");
|
|
await mkdir(join(wrapper, "Locked.app"), { recursive: true });
|
|
await chmod(wrapper, 0o000);
|
|
try {
|
|
const result = await parseBinaryTarget(app, {
|
|
cwd: directory,
|
|
hostArchitecture: "arm64",
|
|
});
|
|
if (result.ok) throw new Error("Expected a permission denial");
|
|
expect(result.error.message).toContain("permission denied");
|
|
} finally {
|
|
await chmod(wrapper, 0o755);
|
|
}
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"resolves an iOS-on-Mac wrapper through its single Wrapper bundle",
|
|
async () => {
|
|
const directory = await createTestTempDirectory("rea-target-");
|
|
const app = join(directory, "Phone.app");
|
|
const wrapped = join(app, "Wrapper", "Phone.app");
|
|
await mkdir(wrapped, { recursive: true });
|
|
await writeFile(join(wrapped, "Info.plist"), plist("Phone"));
|
|
await writeFile(join(wrapped, "Phone"), thinMach(0xfeedfacf, 0x0100000c));
|
|
await symlink("Wrapper/Phone.app", join(app, "WrappedBundle"));
|
|
const result = await parseBinaryTarget(app, {
|
|
cwd: directory,
|
|
hostArchitecture: "arm64",
|
|
});
|
|
expect(result.ok && result.value).toMatchObject({
|
|
path: await realpath(join(wrapped, "Phone")),
|
|
format: "mach-o",
|
|
});
|
|
|
|
await mkdir(join(app, "Wrapper", "Second.app"));
|
|
expect(
|
|
(
|
|
await parseBinaryTarget(app, {
|
|
cwd: directory,
|
|
hostArchitecture: "arm64",
|
|
})
|
|
).ok,
|
|
).toBe(false);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"rejects a flat bundle program symlink that leaves the bundle",
|
|
async () => {
|
|
const directory = await createTestTempDirectory("rea-target-");
|
|
const app = join(directory, "Escaping.app");
|
|
const outside = join(directory, "outside");
|
|
await mkdir(app);
|
|
await writeFile(outside, thinMach(0xfeedfacf, 0x0100000c));
|
|
await writeFile(join(app, "Info.plist"), plist("Escaping"));
|
|
await symlink(outside, join(app, "Escaping"));
|
|
const result = await parseBinaryTarget(app, {
|
|
cwd: directory,
|
|
hostArchitecture: "arm64",
|
|
});
|
|
if (result.ok) throw new Error("Expected an escaping program file");
|
|
expect(result.error.message).toContain("leaves the bundle root");
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("binary Info.plist app bundle targets", () => {
|
|
it.each([
|
|
["Mac.app", ["Contents", "MacOS"], ["Contents", "Info.plist"]],
|
|
["Phone.app", [], ["Info.plist"]],
|
|
])(
|
|
"resolves %s from a binary Info.plist on every host",
|
|
async (bundle, programs, plist) => {
|
|
const directory = await createTestTempDirectory("rea-app-bplist-");
|
|
// Windows file names cannot hold a tab or end in a space.
|
|
const names =
|
|
process.platform === "win32"
|
|
? ["Ordinary"]
|
|
: ["Ordinary", " App ", "Tab\t"];
|
|
for (const name of names) {
|
|
const app = join(directory, name, bundle);
|
|
const executable = join(app, ...programs, name);
|
|
await mkdir(join(app, ...programs), { recursive: true });
|
|
await mkdir(join(app, ...plist.slice(0, -1)), { recursive: true });
|
|
await writeFile(
|
|
join(app, ...plist),
|
|
buildBinary({ CFBundleExecutable: name, CFBundleVersion: "1" }),
|
|
);
|
|
await writeFile(executable, thinMach(0xfeedfacf, 0x0100000c));
|
|
const result = await parseBinaryTarget(app, {
|
|
cwd: directory,
|
|
hostArchitecture: "arm64",
|
|
});
|
|
expect(result.ok && result.value).toMatchObject({
|
|
path: await realpath(executable),
|
|
format: "mach-o",
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
[
|
|
"XML",
|
|
"<plist><dict><key>__proto__</key><dict><key>CFBundleExecutable</key><string>Forged</string></dict></dict></plist>",
|
|
],
|
|
[
|
|
"binary",
|
|
buildBinary(JSON.parse('{"__proto__":{"CFBundleExecutable":"Forged"}}')),
|
|
],
|
|
])(
|
|
"ignores CFBundleExecutable nested under __proto__ in %s Info.plist files",
|
|
async (_encoding, contents) => {
|
|
const directory = await createTestTempDirectory("rea-app-proto-only-");
|
|
const app = join(directory, "Proto.app");
|
|
await mkdir(join(app, "Contents", "MacOS"), { recursive: true });
|
|
await writeFile(join(app, "Contents", "Info.plist"), contents);
|
|
await writeFile(
|
|
join(app, "Contents", "MacOS", "Forged"),
|
|
thinMach(0xfeedfacf, 0x0100000c),
|
|
);
|
|
expect(await resolveAppBundleExecutable(app)).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
reason: expect.stringContaining("CFBundleExecutable is missing"),
|
|
},
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("DOS binary target I/O", () => {
|
|
it("retains the declared PE signature when its overlapping layout is unsupported", async () => {
|
|
const directory = await createTestTempDirectory("rea-overlapping-pe-");
|
|
const path = join(directory, "overlap.exe");
|
|
await writeFile(path, pe(0x014c, 4));
|
|
const result = await parseBinaryTarget(path);
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
reason: expect.stringContaining("Unsupported overlapping PE header"),
|
|
diagnostics: {
|
|
unsupported_layout: true,
|
|
mz_windows_header: { state: "declared", offset: 4, signature: "PE" },
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("admits original DOS header padding after a relocation table", async () => {
|
|
const directory = await createTestTempDirectory("rea-dos-header-padding-");
|
|
const path = join(directory, "legacy.exe");
|
|
const bytes = Buffer.alloc(128);
|
|
bytes.write("MZ", 0, "ascii");
|
|
bytes.writeUInt16LE(128, 2);
|
|
bytes.writeUInt16LE(1, 4);
|
|
bytes.writeUInt16LE(2, 6);
|
|
bytes.writeUInt16LE(4, 8);
|
|
bytes.writeUInt16LE(28, 24);
|
|
bytes.write("ABCD", 60, "ascii");
|
|
await writeFile(path, bytes);
|
|
|
|
expect(await parseBinaryTarget(path)).toMatchObject({
|
|
ok: true,
|
|
value: { format: "dos-mz", architecture: "x86", kind: "executable" },
|
|
});
|
|
});
|
|
|
|
it("resolves a PE signature beyond the prefix when DOS padding follows a relocation table", async () => {
|
|
const directory = await createTestTempDirectory(
|
|
"rea-padded-stub-pe-target-",
|
|
);
|
|
const path = join(directory, "padded-stub.dll");
|
|
const offset = 8192;
|
|
const stub = Buffer.alloc(offset);
|
|
stub.write("MZ", 0, "ascii");
|
|
stub.writeUInt16LE(offset % 512, 2);
|
|
stub.writeUInt16LE(Math.ceil(offset / 512), 4);
|
|
stub.writeUInt16LE(2, 6);
|
|
stub.writeUInt16LE(4, 8);
|
|
stub.writeUInt16LE(28, 24);
|
|
stub.writeUInt32LE(offset, 60);
|
|
const peRecord = pe(0x8664, offset, 0x2002).subarray(offset);
|
|
const bytes = Buffer.concat([stub, peRecord]);
|
|
await writeFile(path, bytes);
|
|
|
|
expect(await parseBinaryTarget(path)).toMatchObject({
|
|
ok: true,
|
|
value: {
|
|
format: "pe",
|
|
architecture: "x86_64",
|
|
executableRole: "shared-library",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("validates ambiguous DOS facts after extending the bounded prefix for relocations", async () => {
|
|
const directory = await createTestTempDirectory(
|
|
"rea-ambiguous-mz-relocations-",
|
|
);
|
|
const path = join(directory, "legacy.exe");
|
|
const bytes = Buffer.alloc(8192);
|
|
bytes.write("MZ", 0, "ascii");
|
|
bytes.writeUInt16LE(16, 4);
|
|
bytes.writeUInt16LE(1010, 6);
|
|
bytes.writeUInt16LE(257, 8);
|
|
bytes.writeUInt16LE(64, 24);
|
|
bytes.write("ABCD", 60, "ascii");
|
|
await writeFile(path, bytes);
|
|
|
|
const result = await parseBinaryTarget(path);
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
reason: "MZ Windows new-header candidate is unresolved",
|
|
diagnostics: {
|
|
mz_dos: {
|
|
state: "valid",
|
|
header_bytes: 4112,
|
|
image_bytes: 8192,
|
|
module_bytes: 4080,
|
|
relocation_count: 1010,
|
|
relocation_table_offset: 64,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("resolves a short-header PE signature beyond the bounded prefix", async () => {
|
|
const directory = await createTestTempDirectory(
|
|
"rea-short-stub-pe-target-",
|
|
);
|
|
const path = join(directory, "short-stub.dll");
|
|
const offset = 8192;
|
|
const stub = dosMz(64);
|
|
stub.writeUInt32LE(offset, 60);
|
|
const peRecord = pe(0x8664, offset, 0x2002).subarray(offset);
|
|
const bytes = Buffer.concat([
|
|
stub,
|
|
Buffer.alloc(offset - stub.length),
|
|
peRecord,
|
|
]);
|
|
await writeFile(path, bytes);
|
|
|
|
expect(await parseBinaryTarget(path)).toMatchObject({
|
|
ok: true,
|
|
value: {
|
|
format: "pe",
|
|
architecture: "x86_64",
|
|
executableRole: "shared-library",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("validates a complete DOS file beyond the initial metadata probe", async () => {
|
|
const directory = await createTestTempDirectory("rea-dos-target-");
|
|
const path = join(directory, "legacy.exe");
|
|
const bytes = dosMz(8192);
|
|
await writeFile(path, bytes);
|
|
expect(await parseBinaryTarget(path)).toMatchObject({
|
|
ok: true,
|
|
value: { format: "dos-mz", architecture: "x86", kind: "executable" },
|
|
});
|
|
await writeFile(path, bytes.subarray(0, bytes.length - 1));
|
|
expect(await parseBinaryTarget(path)).toMatchObject({
|
|
ok: false,
|
|
error: { message: "Cannot open artifact: truncated DOS MZ load module" },
|
|
});
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
"EI_VERSION",
|
|
(bytes: Buffer) => {
|
|
bytes[6] = 0;
|
|
},
|
|
],
|
|
[
|
|
"e_version",
|
|
(bytes: Buffer) => {
|
|
bytes.writeUInt32LE(0, 20);
|
|
},
|
|
],
|
|
[
|
|
"e_ehsize",
|
|
(bytes: Buffer) => {
|
|
bytes.writeUInt16LE(51, 40);
|
|
},
|
|
],
|
|
])(
|
|
"rejects an ELF with malformed %s during public target opening",
|
|
async (_name, corrupt) => {
|
|
const directory = await createTestTempDirectory("rea-invalid-elf-header-");
|
|
const path = join(directory, "invalid.elf");
|
|
const bytes = elf(1, 1, 3);
|
|
corrupt(bytes);
|
|
await writeFile(path, bytes);
|
|
expect(await parseBinaryTarget(path)).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "BinaryTargetError",
|
|
constraint: "file_format_rejected",
|
|
},
|
|
});
|
|
},
|
|
);
|
|
|
|
describe("binary target I/O: PE CodeView header ranges", () => {
|
|
it.each(["RSDS", "NB10"] as const)(
|
|
"reads bounded %s header debug records and rejects unfinished names",
|
|
async (type) => {
|
|
const directory = await createTestTempDirectory("rea-target-");
|
|
const path = join(directory, "header-symbols.exe");
|
|
const bytes = pe(0x8664, 64, 0x0002, false, {
|
|
type,
|
|
pdbPath: "header.pdb",
|
|
});
|
|
const optionalHeader = 64 + 24;
|
|
bytes.writeUInt32LE(512, optionalHeader + 60);
|
|
bytes.copy(bytes, 384, 512);
|
|
bytes.writeUInt32LE(384, optionalHeader + 112 + 6 * 8);
|
|
bytes.writeUInt32LE(412, 384 + 20);
|
|
bytes.writeUInt32LE(0, 384 + 24);
|
|
for (const complete of [true, false]) {
|
|
if (!complete) {
|
|
const dataSize = bytes.readUInt32LE(384 + 16);
|
|
bytes[412 + dataSize - 1] = 0x61;
|
|
}
|
|
await writeFile(path, bytes);
|
|
const resolved = await parseBinaryTarget(path, {
|
|
hostArchitecture: "x64",
|
|
});
|
|
const buffered = parseExecutableHeader(bytes, "x64");
|
|
if (!resolved.ok) throw resolved.error;
|
|
if (!buffered.ok) throw new Error(buffered.error);
|
|
for (const value of [resolved.value, buffered.value]) {
|
|
if (complete)
|
|
expect(value).toMatchObject({
|
|
codeView: { pdbPath: "header.pdb" },
|
|
});
|
|
else expect(value).not.toHaveProperty("codeView");
|
|
}
|
|
}
|
|
},
|
|
);
|
|
});
|
|
|
|
it("does not read a relocation table offset when DOS declares zero records", async () => {
|
|
const directory = await createTestTempDirectory("rea-empty-dos-relocations-");
|
|
const path = join(directory, "legacy.exe");
|
|
const bytes = dosMz(8192);
|
|
bytes.writeUInt16LE(257, 8);
|
|
bytes.writeUInt16LE(4104, 24);
|
|
bytes.writeUInt32LE(0, 60);
|
|
await writeFile(path, bytes);
|
|
const probe = await open(path, "r");
|
|
const prototype = Object.getPrototypeOf(probe) as FileHandle;
|
|
await probe.close();
|
|
const reads = vi.spyOn(prototype, "read");
|
|
try {
|
|
expect(await parseBinaryTarget(path)).toMatchObject({
|
|
ok: true,
|
|
value: { format: "dos-mz", architecture: "x86" },
|
|
});
|
|
const lengths = reads.mock.calls.map(
|
|
(args) => (args as readonly unknown[])[2],
|
|
);
|
|
expect(lengths).toContain(4096);
|
|
expect(lengths).not.toContain(4104);
|
|
} finally {
|
|
reads.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("recognizes Windows metadata independently of invalid DOS load declarations", async () => {
|
|
const directory = await createTestTempDirectory("rea-invalid-dos-valid-pe-");
|
|
const path = join(directory, "windows.exe");
|
|
const bytes = pe(0x8664, 8192);
|
|
bytes.writeUInt16LE(0, 4);
|
|
bytes.writeUInt16LE(1010, 6);
|
|
bytes.writeUInt16LE(257, 8);
|
|
bytes.writeUInt16LE(64, 24);
|
|
await writeFile(path, bytes);
|
|
expect(await parseBinaryTarget(path)).toMatchObject({
|
|
ok: true,
|
|
value: { format: "pe", architecture: "x86_64" },
|
|
});
|
|
});
|
|
|
|
it("refuses a declared DOS relocation header that is not present in the file", async () => {
|
|
const directory = await createTestTempDirectory(
|
|
"rea-truncated-dos-relocations-",
|
|
);
|
|
const path = join(directory, "legacy.exe");
|
|
const bytes = dosMz(8192);
|
|
bytes.writeUInt16LE(1010, 6);
|
|
bytes.writeUInt16LE(257, 8);
|
|
bytes.writeUInt16LE(64, 24);
|
|
bytes.writeUInt32LE(0, 60);
|
|
await writeFile(path, bytes.subarray(0, 4096));
|
|
expect(await parseBinaryTarget(path)).toMatchObject({
|
|
ok: false,
|
|
error: { reason: "truncated DOS MZ load module" },
|
|
});
|
|
});
|