diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md index 2b0def8e4..852894f4a 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -159,6 +159,16 @@ REA. Symlinks and encrypted entries are inventory facts, not extractable files. The result reports the materialized directory; callers do not choose the destination. No separate permission grant is needed. +For classic iNES cartridges, `open_binary` recognizes the byte signature without +an executable architecture. `inspect_artifact` retains header facts and each +PRG/CHR file unit's offset, length and SHA-256; its nested inventory's +`ines.reconstruction` verifies the actual ordered region bytes against the +whole-file digest. Extraction includes the original header, optional trainer +and uninterpreted trailing bytes. These file units are not mapper/CPU bank +maps. NES 2.0, UNIF, dirty legacy headers and truncated payloads are explicitly +refused. No emulator or 6502 analysis is performed. See the +[iNES guide](https://github.com/morluto/rea/blob/main/docs/ines-containers.md). + Native DMG traversal automatically uses a read-only mount on supported macOS hosts, with an owned temporary mount directory and cleanup. It needs no approval flag. Unsupported hosts or mount failures remain explicit limitations; do not diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 7006478bb..90298ebef 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -42,6 +42,7 @@ export default defineConfig({ { text: "Android applications", link: "/android-analysis" }, { text: "Apple applications", link: "/apple-application-analysis" }, { text: "Firmware", link: "/firmware-analysis" }, + { text: "iNES containers", link: "/ines-containers" }, { text: "Reconstruction readiness", link: "/reconstruction-readiness", diff --git a/docs/ines-containers.md b/docs/ines-containers.md new file mode 100644 index 000000000..da218e131 --- /dev/null +++ b/docs/ines-containers.md @@ -0,0 +1,66 @@ +# Classic iNES container inspection + +REA recognizes classic iNES from the `NES` + `0x1a` byte signature, independently +of the file extension. This read-only container inspection uses portable +Node.js file reads; it needs neither Ghidra nor an emulator. + +```bash +rea inspect-artifact ./cartridge.nes --json +rea extract-artifact ./cartridge.nes --json +``` + +Through MCP, call `open_binary` with the local path, then `inspect_artifact`. +Use `extract_artifact` to materialize the regions in the fresh directory named +by its result. The caller owns that successful extraction directory. + +## Header and file regions + +Inspection returns `container-header` and `reconstruction` observations inline. +The single inventory Evidence under `substeps[].evidence.normalized_result` +contains the complete graph and an `ines` object: + +- `ines.header`: original 16-byte `header_hex`, PRG/CHR file-unit counts, mapper, + raw flags 6/7, trainer and battery flags, and the declared mirroring mode. +- `occurrences[]`: each region's path, `source_location.offset` and + `source_location.length` in the original container. Follow `artifact_id` to + the node's observed SHA-256 and byte count. +- `ines.reconstruction`: ordered region paths, concatenated byte count, + SHA-256 and `verified: true`. REA streams the actual region bytes in physical + order and compares their concatenation with the original whole-file digest. + No reconstructed file is written during inspection. + +Regions are `header.bin`, optional `trainer.bin` (512 bytes), +`prg/000.bin` onward (16 KiB each), `chr/000.bin` onward (8 KiB each), and +optional `trailing.bin`. Every input byte is retained, including unused header +fields and uninterpreted trailing data. Zero CHR-ROM units create no CHR region; +REA does not fabricate CHR-RAM contents. + +After extraction, concatenate the materialized files in +`ines.reconstruction.region_order` from the inspection inventory. Their bytes +reproduce the input exactly. Graph path order is lexical and is not physical +file order; use the explicit region order or source offsets. + +## Supported boundary + +This increment supports classic iNES with a complete 16-byte header, nonzero +PRG count, complete declared trainer/PRG/CHR payload, and zero reserved bytes +12–15. Dirty legacy headers are refused because their upper mapper bits are +ambiguous; REA does not silently repair them. NES 2.0 and UNIF signatures, +unsupported header variants and truncated payloads are explicitly refused. +An arbitrary file named `.nes` is not admitted as iNES. + +The PRG/CHR units describe file storage, not mapper bank sizes or CPU windows. +The mapper number and flags are header declarations, not proof of cartridge +hardware or runtime behavior. Bank switching, 6502/65816 disassembly, emulator +sessions and interpretation of console-specific trailing data remain outside +this lane. Raw-binary Ghidra analysis is a separate workflow. + +Source-owned byte fixtures verify original offsets and hashes, extraction and +byte-exact reconstruction, CLI/stdio MCP parity, malformed/unsupported inputs, +failed target-switch recovery, cancellation and source identity. These checks +do not establish emulator behavior or compatibility with every historical ROM. +The workflow has been executed on native Windows x64; Linux and macOS execution +remain unverified for this increment. + +Format references: [iNES](https://www.nesdev.org/wiki/INES) and +[NES 2.0](https://www.nesdev.org/wiki/NES_2.0). diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index bced0278a..f26d56c2b 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -1,5 +1,9 @@ import { constants, type Stats } from "node:fs"; import { createHash } from "node:crypto"; +import { + nesContainerFormat, + parseInesLayout, +} from "../domain/inesContainer.js"; import { access, lstat, @@ -276,19 +280,30 @@ const detectArtifactFormat = async ( > => { throwIfTargetResolutionCancelled(signal); const lower = path.toLowerCase(); - const magic = Buffer.alloc(8); + const magic = Buffer.alloc(16); const observed = await handle.read(magic, 0, magic.length, 0); throwIfTargetResolutionCancelled(signal); if ( - observed.bytesRead === 8 && - magic.equals(Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0])) + observed.bytesRead >= 8 && + magic.subarray(0, 8).equals(Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0])) ) throw new BinaryTargetError( path, "WebAssembly v1 artifacts are not supported by the native opening route", { constraint: "wasm_requires_static_inspection" }, ); - if (hasZipSignature(magic.subarray(0, observed.bytesRead))) { + const prefix = magic.subarray(0, observed.bytesRead); + const nesFormat = nesContainerFormat(prefix); + if (nesFormat !== undefined) { + const parsed = parseInesLayout(prefix, (await handle.stat()).size); + throwIfTargetResolutionCancelled(signal); + if (!parsed.ok) + throw new BinaryTargetError(path, parsed.error, { + constraint: "file_format_rejected", + }); + return "ines"; + } + if (hasZipSignature(prefix)) { // The HarmonyOS App Pack suffix `.app` is a root-input concept; a nested // ZIP member ending in `.app` keeps the generic zip classification. if (lower.endsWith(".app")) return "app-pack"; @@ -339,7 +354,7 @@ const isArchiveFormat = ( | "raw-binary" >, ): format is Extract["format"] => - isZipPackageFormat(format) || ["asar", "dmg", "pkg"].includes(format); + isZipPackageFormat(format) || ["asar", "ines", "dmg", "pkg"].includes(format); const sha256Handle = async ( handle: FileHandle, diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts index c95cec6d5..9978c6da9 100644 --- a/src/artifacts/ArtifactProvider.ts +++ b/src/artifacts/ArtifactProvider.ts @@ -32,7 +32,7 @@ import { } from "../domain/analysisErrorCore.js"; import { ArtifactOperationError } from "../domain/artifactOperationError.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; -import type { JsonValue } from "../domain/jsonValue.js"; +import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import { interfaceBuilderLimitsSchema } from "../domain/apple/interfaceBuilderGraph.js"; import { err, ok, type Result } from "../domain/result.js"; import { ArtifactReaderFailure } from "./ArtifactReader.js"; @@ -349,7 +349,7 @@ class ArtifactClient implements AnalysisClient { const inventoryEvidence = createEvidence(subject, ARTIFACT_GRAPH_PROVIDER, { operation: "inventory_artifact", parameters: inventoryParameters, - result: inventory, + result: jsonValueSchema.parse(inventory), rawResult: null, limitations: inventory.limitations, // The nested inventory already names every occurrence's logical path. diff --git a/src/artifacts/ArtifactReader.ts b/src/artifacts/ArtifactReader.ts index f072dd35c..238122921 100644 --- a/src/artifacts/ArtifactReader.ts +++ b/src/artifacts/ArtifactReader.ts @@ -92,7 +92,7 @@ export const sameArtifactEntry = ( /** Read-only adapter over one directory, archive, or virtual container. */ export interface ArtifactReader { - readonly format: "directory" | ZipPackageFormat | "asar" | "file"; + readonly format: "directory" | ZipPackageFormat | "asar" | "ines" | "file"; entries(signal?: AbortSignal): AsyncIterable; open(entry: ArtifactEntry, signal?: AbortSignal): Promise; provenance(): readonly ArtifactCommand[]; diff --git a/src/artifacts/InesArtifactReader.ts b/src/artifacts/InesArtifactReader.ts new file mode 100644 index 000000000..91790ef85 --- /dev/null +++ b/src/artifacts/InesArtifactReader.ts @@ -0,0 +1,192 @@ +import { createHash } from "node:crypto"; +import { lstat } from "node:fs/promises"; +import { Readable } from "node:stream"; +import { + parseInesLayout, + type InesInventory, + type InesLayout, +} from "../domain/inesContainer.js"; +import type { ArtifactCommand } from "../domain/artifactGraph.js"; +import { + sameRegularFileState, + type StableRegularFileDescriptor, +} from "../filesystem/RegularFile.js"; +import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js"; +import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; +import { + ArtifactReaderFailure, + copyArtifactEntry, + sameArtifactEntry, + type ArtifactEntry, + type ArtifactReader, +} from "./ArtifactReader.js"; +import { abortIfNeeded } from "./ArtifactHash.js"; +import { streamChunkToBuffer } from "./StreamBytes.js"; + +export const INES_LIMITATION = + "Classic iNES file regions are observed; mapper execution, CPU bank mappings, CHR-RAM contents, 6502 analysis and emulator behavior are not observed. Trailing bytes are retained without interpretation. NES 2.0 and UNIF are unsupported."; + +/** Read positional regions from a descriptor retained by the artifact resource scope. */ +export class InesArtifactReader implements ArtifactReader { + readonly format = "ines" as const; + readonly #entries = new Map(); + #layout: InesLayout | undefined; + #closed = false; + + constructor( + private readonly path: string, + private readonly source: StableRegularFileDescriptor, + private readonly ownedSource?: OwnedFileHandle, + ) {} + + async *entries(signal?: AbortSignal): AsyncIterable { + abortIfNeeded(signal); + await this.#verifySource(); + const prefix = Buffer.alloc(16); + const { bytesRead } = await this.source.handle.read(prefix, 0, 16, 0); + abortIfNeeded(signal); + const parsed = parseInesLayout( + prefix.subarray(0, bytesRead), + this.source.initial.size, + ); + if (!parsed.ok) throw new ArtifactReaderFailure("format", parsed.error); + this.#layout = parsed.value; + await this.#verifySource(); + this.#entries.clear(); + for (const region of parsed.value.regions) { + abortIfNeeded(signal); + const entry: ArtifactEntry = { + path: region.path, + kind: "file", + declaredSize: region.length, + compressedSize: null, + executable: false, + encrypted: false, + byteOffset: region.offset, + declaredSha256: null, + unpacked: false, + limitations: [], + adapterKey: region.path, + }; + this.#entries.set(entry.adapterKey, copyArtifactEntry(entry)); + yield entry; + } + } + + async open(entry: ArtifactEntry, signal?: AbortSignal): Promise { + abortIfNeeded(signal); + const produced = this.#entries.get(entry.adapterKey); + if (produced === undefined || !sameArtifactEntry(entry, produced)) + throw new ArtifactReaderFailure( + "integrity", + "iNES region does not match this reader's inventory", + ); + await this.#verifySource(); + const offset = produced.byteOffset; + const size = produced.declaredSize; + if (offset === null || size === null || size <= 0) + throw new ArtifactReaderFailure("integrity", "Invalid iNES region range"); + const stream = readFileHandleChunks(this.source.handle, { + start: offset, + end: offset + size - 1, + signal, + }); + const verify = () => this.#verifySource(); + return Readable.from( + (async function* () { + let bytes = 0; + try { + for await (const raw of stream) { + abortIfNeeded(signal); + const chunk = streamChunkToBuffer(raw); + bytes += chunk.length; + yield chunk; + } + if (bytes !== size) + throw new ArtifactReaderFailure( + "integrity", + "Truncated iNES region", + ); + await verify(); + } catch (cause: unknown) { + abortIfNeeded(signal); + if (cause instanceof ArtifactReaderFailure) throw cause; + throw new ArtifactReaderFailure("io", "Could not read iNES region", { + cause, + }); + } + })(), + { objectMode: false }, + ); + } + + /** Hash actual ordered region bytes; per-region digests alone cannot prove concatenation. */ + async verifyReconstruction( + expectedSha256: string, + signal?: AbortSignal, + ): Promise { + const layout = this.#layout; + if (layout === undefined || this.#entries.size !== layout.regions.length) + throw new ArtifactReaderFailure("integrity", "Incomplete iNES inventory"); + const hash = createHash("sha256"); + let bytes = 0; + for (const region of layout.regions) { + if (region.offset !== bytes) + throw new ArtifactReaderFailure( + "integrity", + "Noncontiguous iNES regions", + ); + const entry = this.#entries.get(region.path); + if (entry === undefined) + throw new ArtifactReaderFailure("integrity", "Missing iNES region"); + for await (const raw of await this.open(entry, signal)) { + const chunk = streamChunkToBuffer(raw); + hash.update(chunk); + bytes += chunk.length; + } + } + const sha256 = hash.digest("hex"); + if (bytes !== this.source.initial.size || sha256 !== expectedSha256) + throw new ArtifactReaderFailure( + "integrity", + "iNES reconstruction differs from the source", + ); + return { + header: layout.header, + reconstruction: { + region_order: layout.regions.map(({ path }) => path), + bytes, + sha256, + verified: true, + }, + }; + } + + provenance(): readonly ArtifactCommand[] { + return []; + } + + close(): Promise { + // Inventory borrows its admitting scope's descriptor. Extraction owns its + // separately opened descriptor and retains this reader if close fails. + this.#closed = true; + return this.ownedSource?.close() ?? Promise.resolve(); + } + + async #verifySource(): Promise { + if (this.#closed) + throw new ArtifactReaderFailure("unavailable", "iNES reader is closed"); + const [opened, selected] = await Promise.all([ + this.source.handle.stat(), + lstat(this.path), + ]); + if ( + !sameRegularFileState(this.source.initial, opened) || + !sameRegularFileState(this.source.initial, selected) + ) + throw new ArtifactReaderFailure( + "integrity", + "iNES source changed during reading", + ); + } +} diff --git a/src/artifacts/extraction/ArtifactExtraction.ts b/src/artifacts/extraction/ArtifactExtraction.ts index 6306a08bf..42f434abe 100644 --- a/src/artifacts/extraction/ArtifactExtraction.ts +++ b/src/artifacts/extraction/ArtifactExtraction.ts @@ -3,6 +3,8 @@ import { lstat, realpath } from "node:fs/promises"; import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"; import { digestCanonicalValue } from "../../domain/canonicalDigest.js"; import { AsarArtifactReader } from "../AsarArtifactReader.js"; +import { InesArtifactReader } from "../InesArtifactReader.js"; +import { openRootArtifact } from "../inventory/hashStableRootArtifact.js"; import { ArtifactPathRegistry, normalizeArtifactPath, @@ -421,6 +423,7 @@ const requireExtractableFormat = async ( if ( !directory && format !== "asar" && + format !== "ines" && !isMachOFormat(format) && !isZipFormat(format) ) @@ -436,13 +439,38 @@ const createReader = async ( path: string, input: Pick< ArtifactExtractionInput, - "inputPath" | "inputFormat" | "environment" + "inputPath" | "inputFormat" | "environment" | "resourceScope" >, ): Promise => { const format = input.inputFormat; if (await requireExtractableFormat(path, input)) return new DirectoryArtifactReader(path); if (format === "asar") return new AsarArtifactReader(path); + if (format === "ines") { + const owner = await openRootArtifact(path, input.resourceScope); + try { + return new InesArtifactReader( + path, + { + handle: owner.handle, + initial: await owner.handle.stat(), + }, + owner, + ); + } catch (cause: unknown) { + const attempt = await input.resourceScope.release({ + kind: "file-handle", + handle: owner, + resource: `iNES source ${path}`, + }); + if (attempt.kind === "failed") + throw ArtifactReaderFailure.withCleanup( + cause, + ArtifactReaderFailure.cleanupObservation(attempt.cause, path), + ); + throw cause; + } + } if (isZipFormat(format)) return new ZipArtifactReader(path, format); return new MachOSliceArtifactReader(path, input.environment); }; diff --git a/src/artifacts/inventory/ArtifactGraphConstruction.ts b/src/artifacts/inventory/ArtifactGraphConstruction.ts index 8466d7cd1..0d8590819 100644 --- a/src/artifacts/inventory/ArtifactGraphConstruction.ts +++ b/src/artifacts/inventory/ArtifactGraphConstruction.ts @@ -19,6 +19,10 @@ import { type ZipPackageFormat, } from "../../domain/zipPackageFormat.js"; import { mzWindowsHeaderOffset, parseDosMzHeader } from "../../domain/dosMz.js"; +import { + nesContainerFormat, + parseInesLayout, +} from "../../domain/inesContainer.js"; /** Mutable internal occurrence used until root-bound IDs are known. */ export interface MutableOccurrence { @@ -219,7 +223,7 @@ export const rootOccurrenceFor = ( artifact_kind: metadata.format === "directory" || isZipPackageFormat(metadata.format) || - ["asar", "dmg", "pkg"].includes(metadata.format) + ["asar", "ines", "dmg", "pkg"].includes(metadata.format) ? "container" : artifactRoleForFormat(metadata.path, metadata.format).kind, artifact_format: metadata.format, @@ -419,6 +423,7 @@ const artifactRoleForFormat = ( format: zipPackageFormatForPath(path) ?? format, }; if (format === "plist") return { kind: "plist", format }; + if (format === "ines") return { kind: "container", format }; return { kind: ["native-addon", "dynamic-library"].includes(byPath.kind) ? byPath.kind @@ -432,6 +437,13 @@ export const classifyArtifactBytes = ( prefix: Buffer, fileSize = prefix.length, ): ArtifactNode["format"] => { + // A header-only region has the signature but not the declared cartridge + // extent. Keep it as a file instead of inventing another container. + if ( + nesContainerFormat(prefix) === "ines" && + parseInesLayout(prefix, fileSize).ok + ) + return "ines"; if (hasZipSignature(prefix)) return "zip"; if (prefix.subarray(0, 8).equals(Buffer.from("bplist00"))) return "plist"; if (prefix.length >= 4) { diff --git a/src/artifacts/inventory/classify.ts b/src/artifacts/inventory/classify.ts index 9dd050c8e..255157450 100644 --- a/src/artifacts/inventory/classify.ts +++ b/src/artifacts/inventory/classify.ts @@ -5,6 +5,10 @@ import type { StableRegularFileDescriptor } from "../../filesystem/RegularFile.j import type { OwnedFileHandle } from "../../filesystem/OwnedFileHandle.js"; import { classifyArtifactContent } from "./ArtifactGraphConstruction.js"; +import { + nesContainerFormat, + parseInesLayout, +} from "../../domain/inesContainer.js"; import { ARTIFACT_CLASSIFICATION_PREFIX_BYTES } from "../ArtifactHash.js"; import type { ArtifactOccurrence } from "../../domain/artifactGraph.js"; import { @@ -71,7 +75,7 @@ export const classifyAndHashRoot = async ( }); }; -/** Classify a root and retain its admitted ZIP descriptor for child inventory. */ +/** Classify a root and retain its admitted container descriptor for child inventory. */ export const classifyAndHashRootForInventory = async ( path: string, directory: boolean, @@ -112,7 +116,9 @@ export const classifyAndHashRootForInventory = async ( options.signal, ); const retainSource = - isZipPackageFormat(format) || format === "mach-o-universal"; + isZipPackageFormat(format) || + format === "mach-o-universal" || + format === "ines"; outcome = { kind: "completed", value: { @@ -148,6 +154,11 @@ const classifyRootFormat = async ( const magic = Buffer.alloc(ARTIFACT_CLASSIFICATION_PREFIX_BYTES); const observed = await handle.read(magic, 0, magic.length, 0); const prefix = magic.subarray(0, observed.bytesRead); + if (nesContainerFormat(prefix) !== undefined) { + const parsed = parseInesLayout(prefix, (await handle.stat()).size); + if (!parsed.ok) throw new ArtifactReaderFailure("format", parsed.error); + return "ines"; + } const lower = path.toLowerCase(); if (hasZipSignature(prefix)) return zipRootPackageFormat(lower); if ( diff --git a/src/artifacts/inventory/reader.ts b/src/artifacts/inventory/reader.ts index 8a171b566..092fdd9aa 100644 --- a/src/artifacts/inventory/reader.ts +++ b/src/artifacts/inventory/reader.ts @@ -1,4 +1,6 @@ import { AsarArtifactReader } from "../AsarArtifactReader.js"; +import { InesArtifactReader, INES_LIMITATION } from "../InesArtifactReader.js"; +import { ArtifactReaderFailure } from "../ArtifactReader.js"; import type { ArtifactReader } from "../ArtifactReader.js"; import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js"; import { MachOSliceArtifactReader } from "../MachOSliceArtifactReader.js"; @@ -20,6 +22,13 @@ export const createReader = ( if (isZipPackageFormat(format)) return new ZipArtifactReader(path, format, undefined, rootSource); switch (format) { + case "ines": + if (rootSource === undefined) + throw new ArtifactReaderFailure( + "integrity", + "iNES inventory requires its admitted source descriptor", + ); + return new InesArtifactReader(path, rootSource); case "directory": return new DirectoryArtifactReader(path); case "asar": @@ -40,6 +49,7 @@ export const inventoryLimitations = ( format: ArtifactOccurrence["artifact_format"], reader: ArtifactReader | undefined, ): string[] => { + if (format === "ines") return [INES_LIMITATION]; if (reader !== undefined) return isZipPackageFormat(reader.format) ? [ZIP_NON_ENTRY_TAIL_LIMITATION] diff --git a/src/artifacts/inventory/scanCanonical.ts b/src/artifacts/inventory/scanCanonical.ts index ef4df6d71..c9aaf4ba3 100644 --- a/src/artifacts/inventory/scanCanonical.ts +++ b/src/artifacts/inventory/scanCanonical.ts @@ -2,6 +2,7 @@ import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySn import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"; import { AsarArtifactReader } from "../AsarArtifactReader.js"; +import { InesArtifactReader } from "../InesArtifactReader.js"; import { lstat } from "node:fs/promises"; import type { Stats } from "node:fs"; @@ -270,6 +271,9 @@ const buildInventorySnapshot = async ( }); return { manifest, + ...(reader instanceof InesArtifactReader && rootDigest !== null + ? { ines: await reader.verifyReconstruction(rootDigest.sha256, signal) } + : {}), nodes: orderedNodes, occurrences: orderedOccurrences, edges: orderedEdges, diff --git a/src/contracts/artifactToolContracts.ts b/src/contracts/artifactToolContracts.ts index f1935e288..b212970a8 100644 --- a/src/contracts/artifactToolContracts.ts +++ b/src/contracts/artifactToolContracts.ts @@ -67,7 +67,7 @@ const artifact = < export const ARTIFACT_TOOL_CONTRACTS = [ artifact( "inspect_artifact", - "Inspect an active archive or application package in one call. Returns the complete content-addressed artifact graph once, as the nested inventory Evidence, with root and integrity observations, integrity contradictions, hypotheses, unexplored branches, limitations, and next probes inline. Read-only DMG mounting is used automatically on supported macOS hosts. Integrity mismatches fail by default; record-and-continue keeps mismatches explicitly untrusted. It does not extract files; use extract_artifact to materialize its regular contents.", + "Inspect an active archive or application package in one call. Returns the complete content-addressed artifact graph once, as the nested inventory Evidence, with root and integrity observations, integrity contradictions, hypotheses, unexplored branches, limitations, and next probes inline. Classic iNES includes header facts, PRG/CHR file-region hashes and byte-exact reconstruction evidence; mapper execution and CPU bank mappings remain unobserved. Read-only DMG mounting is used automatically on supported macOS hosts. Integrity mismatches fail by default; record-and-continue keeps mismatches explicitly untrusted. It does not extract files; use extract_artifact to materialize its regular contents.", artifactInventoryInputSchema, ), artifact( diff --git a/src/contracts/toolOutputSchemaPrimitives.ts b/src/contracts/toolOutputSchemaPrimitives.ts index 20c5517ee..bb1e77759 100644 --- a/src/contracts/toolOutputSchemaPrimitives.ts +++ b/src/contracts/toolOutputSchemaPrimitives.ts @@ -65,6 +65,7 @@ export const targetFormatSchema = z.enum([ "msix", "appx", "asar", + "ines", "dmg", "pkg", "plist", diff --git a/src/domain/analysisSnapshot.ts b/src/domain/analysisSnapshot.ts index 57e876653..b8721b55e 100644 --- a/src/domain/analysisSnapshot.ts +++ b/src/domain/analysisSnapshot.ts @@ -66,6 +66,7 @@ const formatSchema = z.enum([ "hsp", "app-pack", "asar", + "ines", "dmg", "pkg", "plist", diff --git a/src/domain/artifactGraph.ts b/src/domain/artifactGraph.ts index fd5d0177d..9b47b55f1 100644 --- a/src/domain/artifactGraph.ts +++ b/src/domain/artifactGraph.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { prefixedDigestSchema } from "./../domain/digests.js"; +import { inesInventorySchema } from "./inesContainer.js"; const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/u); const artifactIdSchema = prefixedDigestSchema("art"); @@ -42,6 +43,7 @@ const artifactFormatSchema = z.enum([ "zip", "pkg", "asar", + "ines", "javascript-bundle", "source-map", "mach-o-universal", @@ -69,6 +71,7 @@ const artifactFormatSchema = z.enum([ /** Formats established from bytes or a virtual directory tree, independent of path roles. */ const artifactContentFormatSchema = artifactFormatSchema.extract([ + "ines", "zip", "mach-o-universal", "mach-o", @@ -196,6 +199,7 @@ export const artifactInventoryResultSchema = z.object({ edges: z.array(artifactEdgeSchema), provenance: z.array(artifactCommandSchema), integrity_contradictions: z.array(integrityContradictionSchema).default([]), + ines: inesInventorySchema.optional(), limitations: z.array(z.string()), }); diff --git a/src/domain/artifactInspection.ts b/src/domain/artifactInspection.ts index 19ccb9b8f..4488aba9d 100644 --- a/src/domain/artifactInspection.ts +++ b/src/domain/artifactInspection.ts @@ -16,7 +16,12 @@ const artifactIdSchema = prefixedDigestSchema("art"); const inspectionObservationSchema = z.strictObject({ observation_id: prefixedDigestSchema("aio"), - kind: z.enum(["root-manifest", "integrity"]), + kind: z.enum([ + "root-manifest", + "integrity", + "container-header", + "reconstruction", + ]), subject: textSchema, value: jsonValueSchema, evidence_id: evidenceIdSchema, @@ -209,6 +214,22 @@ const allObservations = ( ...inventory.integrity_contradictions.map((value) => observation("integrity", value.contradiction_id, value, evidenceId), ), + ...(inventory.ines === undefined + ? [] + : [ + observation( + "container-header", + inventory.manifest.root_artifact_id, + inventory.ines.header, + evidenceId, + ), + observation( + "reconstruction", + inventory.manifest.root_artifact_id, + inventory.ines.reconstruction, + evidenceId, + ), + ]), ]; const observation = ( diff --git a/src/domain/artifactInventorySnapshot.ts b/src/domain/artifactInventorySnapshot.ts index a219727a1..b34b8915f 100644 --- a/src/domain/artifactInventorySnapshot.ts +++ b/src/domain/artifactInventorySnapshot.ts @@ -14,5 +14,6 @@ export interface ArtifactInventorySnapshot { ArtifactInventoryResult["provenance"][number] >; readonly integrity_contradictions: readonly IntegrityContradiction[]; + readonly ines?: ArtifactInventoryResult["ines"]; readonly limitations: readonly string[]; } diff --git a/src/domain/binaryTargetTypes.ts b/src/domain/binaryTargetTypes.ts index 3bd79144f..ee697ed05 100644 --- a/src/domain/binaryTargetTypes.ts +++ b/src/domain/binaryTargetTypes.ts @@ -127,6 +127,7 @@ export type BinaryTarget = | "hsp" | "app-pack" | "asar" + | "ines" | "dmg" | "pkg"; }) diff --git a/src/domain/evidence.ts b/src/domain/evidence.ts index 8e21686e5..6aaac7226 100644 --- a/src/domain/evidence.ts +++ b/src/domain/evidence.ts @@ -59,6 +59,7 @@ const subjectSchema = z.object({ "hsp", "app-pack", "asar", + "ines", "dmg", "pkg", "plist", diff --git a/src/domain/inesContainer.test.ts b/src/domain/inesContainer.test.ts new file mode 100644 index 000000000..d135a2d28 --- /dev/null +++ b/src/domain/inesContainer.test.ts @@ -0,0 +1,92 @@ +import { expect, it } from "vitest"; +import { parseInesLayout } from "./inesContainer.js"; + +const header = (): Buffer => { + const bytes = Buffer.alloc(16); + bytes.write("NES\x1a", "latin1"); + bytes[4] = 1; + return bytes; +}; + +it("bounds the complete classic header count without inventing RAM regions", () => { + const bytes = header(); + bytes[4] = 255; + bytes[5] = 255; + bytes[6] = 0xfe; + bytes[7] = 0xf3; + const parsed = parseInesLayout(bytes, 16 + 512 + 255 * (16_384 + 8_192)); + if (!parsed.ok) throw new Error(parsed.error); + expect(parsed.value.header).toMatchObject({ + mapper: 255, + trainer_present: true, + battery_flag: true, + mirroring: "four-screen", + }); + expect(parsed.value.regions).toHaveLength(512); + expect(parsed.value.regions.at(-1)).toMatchObject({ + path: "chr/254.bin", + length: 8_192, + }); +}); + +it.each([ + { + label: "short header", + modify: (bytes: Buffer) => bytes.subarray(0, 8), + size: 8, + reason: "truncated iNES header", + }, + { + label: "short payload", + modify: (bytes: Buffer) => bytes, + size: 16, + reason: "truncated iNES payload", + }, + { + label: "NES 2.0", + modify: (bytes: Buffer) => { + bytes[7] = 8; + return bytes; + }, + size: 16_400, + reason: "nes-2.0", + }, + { + label: "dirty legacy header", + modify: (bytes: Buffer) => { + bytes[12] = 1; + return bytes; + }, + size: 16_400, + reason: "ambiguous iNES mapper", + }, + { + label: "empty PRG", + modify: (bytes: Buffer) => { + bytes[4] = 0; + return bytes; + }, + size: 16, + reason: "at least one PRG", + }, + { + label: "unsupported variant", + modify: (bytes: Buffer) => { + bytes[7] = 4; + return bytes; + }, + size: 16_400, + reason: "unsupported iNES header variant", + }, + { + label: "unsafe extent", + modify: (bytes: Buffer) => bytes, + size: Number.MAX_SAFE_INTEGER + 1, + reason: "invalid iNES file extent", + }, +])("refuses $label before producing regions", ({ modify, size, reason }) => { + expect(parseInesLayout(modify(header()), size)).toMatchObject({ + ok: false, + error: expect.stringContaining(reason), + }); +}); diff --git a/src/domain/inesContainer.ts b/src/domain/inesContainer.ts new file mode 100644 index 000000000..c1042b1b3 --- /dev/null +++ b/src/domain/inesContainer.ts @@ -0,0 +1,131 @@ +import { z } from "zod"; +import { err, ok, type Result } from "./result.js"; + +/** Byte signatures select a container family, never an executable architecture. */ +export const nesContainerFormat = ( + prefix: Buffer, +): "ines" | "nes-2.0" | "unif" | undefined => { + if (prefix.subarray(0, 4).equals(Buffer.from("UNIF"))) return "unif"; + if (!prefix.subarray(0, 4).equals(Buffer.from([0x4e, 0x45, 0x53, 0x1a]))) + return undefined; + return prefix.length >= 8 && (prefix.readUInt8(7) & 0x0c) === 0x08 + ? "nes-2.0" + : "ines"; +}; + +const inesHeaderSchema = z.strictObject({ + format: z.literal("ines"), + header_hex: z.string().regex(/^[a-f0-9]{32}$/u), + prg_rom_units_16k: z.number().int().min(1).max(255), + chr_rom_units_8k: z.number().int().min(0).max(255), + mapper: z.number().int().min(0).max(255), + flags_6: z.number().int().min(0).max(255), + flags_7: z.number().int().min(0).max(255), + trainer_present: z.boolean(), + battery_flag: z.boolean(), + mirroring: z.enum(["horizontal", "vertical", "four-screen"]), +}); + +/** Verified concatenation is a byte identity claim, not an emulator claim. */ +export const inesInventorySchema = z.strictObject({ + header: inesHeaderSchema, + reconstruction: z.strictObject({ + region_order: z.array(z.string().min(1)).min(2), + bytes: z.number().int().min(16).max(Number.MAX_SAFE_INTEGER), + sha256: z.string().regex(/^[a-f0-9]{64}$/u), + verified: z.literal(true), + }), +}); + +export type InesInventory = z.infer; + +export interface InesRegion { + readonly path: string; + readonly offset: number; + readonly length: number; +} + +export interface InesLayout { + readonly header: InesInventory["header"]; + readonly regions: readonly InesRegion[]; +} + +/** + * Classic iNES counts 16 KiB PRG and 8 KiB CHR file units. Those units do not + * establish the mapper's runtime windows. Preserve all other bytes verbatim. + */ +export const parseInesLayout = ( + prefix: Buffer, + fileSize: number, +): Result => { + const format = nesContainerFormat(prefix); + if (format !== "ines") + return err( + format === undefined + ? "missing iNES byte signature" + : `${format} containers are not supported by the classic iNES lane`, + ); + if (!Number.isSafeInteger(fileSize) || fileSize < 0) + return err("invalid iNES file extent: expected a nonnegative safe integer"); + if (fileSize < 16 || prefix.length < 16) + return err("truncated iNES header: expected 16 bytes"); + const prgUnits = prefix.readUInt8(4); + const chrUnits = prefix.readUInt8(5); + const flags6 = prefix.readUInt8(6); + const flags7 = prefix.readUInt8(7); + if ((flags7 & 0x0c) !== 0) return err("unsupported iNES header variant"); + // Dirty legacy headers make the upper mapper bits ambiguous. Do not repair + // them or claim a mapper that the observed header cannot establish. + if (prefix.subarray(12, 16).some((byte) => byte !== 0)) + return err( + "ambiguous iNES mapper: reserved header bytes 12..15 are nonzero", + ); + if (prgUnits === 0) return err("iNES requires at least one PRG-ROM unit"); + const trainer = (flags6 & 4) !== 0; + const declaredEnd = + 16 + (trainer ? 512 : 0) + prgUnits * 16_384 + chrUnits * 8_192; + if (declaredEnd > fileSize) + return err( + `truncated iNES payload: header declares ${declaredEnd} bytes; file has ${fileSize}`, + ); + const regions: InesRegion[] = [{ path: "header.bin", offset: 0, length: 16 }]; + let offset = 16; + if (trainer) { + regions.push({ path: "trainer.bin", offset, length: 512 }); + offset += 512; + } + for (const [kind, count, length] of [ + ["prg", prgUnits, 16_384], + ["chr", chrUnits, 8_192], + ] as const) + for (let index = 0; index < count; index += 1) { + regions.push({ + path: `${kind}/${String(index).padStart(3, "0")}.bin`, + offset, + length, + }); + offset += length; + } + if (offset < fileSize) + regions.push({ path: "trailing.bin", offset, length: fileSize - offset }); + return ok({ + header: { + format: "ines", + header_hex: prefix.subarray(0, 16).toString("hex"), + prg_rom_units_16k: prgUnits, + chr_rom_units_8k: chrUnits, + mapper: (flags6 >>> 4) | (flags7 & 0xf0), + flags_6: flags6, + flags_7: flags7, + trainer_present: trainer, + battery_flag: (flags6 & 2) !== 0, + mirroring: + (flags6 & 8) !== 0 + ? "four-screen" + : (flags6 & 1) !== 0 + ? "vertical" + : "horizontal", + }, + regions, + }); +}; diff --git a/tests/acceptance/inesContainer.test.ts b/tests/acceptance/inesContainer.test.ts new file mode 100644 index 000000000..ba1bf6725 --- /dev/null +++ b/tests/acceptance/inesContainer.test.ts @@ -0,0 +1,179 @@ +import { createHash } from "node:crypto"; +import { readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { artifactInspectionResultSchema } from "../../src/domain/artifactInspection.js"; +import { + artifactInventoryResultSchema, + artifactExtractionResultSchema, +} from "../../src/domain/artifactGraph.js"; +import { parseEvidence } from "../../src/domain/evidence.js"; +import { cliTest } from "../support/cli/cliFixture.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; +import { parseMcpToolError } from "../fixtures/mcpToolError.js"; +import { inesFixture } from "../fixtures/ines.js"; + +const inventoryFrom = (input: unknown) => { + const evidence = parseEvidence(input); + expect(evidence.subject).toMatchObject({ + format: "ines", + architecture: null, + }); + const inspection = artifactInspectionResultSchema.parse( + evidence.normalized_result, + ); + expect(inspection.coverage.status).toBe("complete-within-substeps"); + expect(inspection.observations.map(({ kind }) => kind)).toEqual([ + "root-manifest", + "container-header", + "reconstruction", + ]); + const substep = inspection.substeps[0]; + if (substep === undefined) throw new Error("Missing inventory substep"); + parseEvidence(substep.evidence); + return artifactInventoryResultSchema.parse( + substep.evidence.normalized_result, + ); +}; + +cliTest( + "classic iNES has the same complete byte inventory through compiled CLI and stdio MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-ines-public-"); + const path = join(root, "卡带.data"); + const bytes = inesFixture({ + trainer: true, + trailing: Buffer.from([0, 255, 3, 0]), + }); + await writeFile(path, bytes); + const cliResult = await cli.run({ + arguments: ["inspect-artifact", path, "--json"], + }); + expect(cliResult.exitCode, cliResult.stderr).toBe(0); + const inventory = inventoryFrom(cliResult.json); + const digest = createHash("sha256").update(bytes).digest("hex"); + expect(inventory.ines).toMatchObject({ + header: { + mapper: 19, + trainer_present: true, + prg_rom_units_16k: 2, + chr_rom_units_8k: 1, + }, + reconstruction: { bytes: bytes.length, sha256: digest, verified: true }, + }); + const expected = [ + ["header.bin", 0, 16], + ["trainer.bin", 16, 512], + ["prg/000.bin", 528, 16_384], + ["prg/001.bin", 16_912, 16_384], + ["chr/000.bin", 33_296, 8_192], + ["trailing.bin", 41_488, 4], + ] as const; + const nodes = new Map( + inventory.nodes.map((node) => [node.artifact_id, node]), + ); + for (const [logical_path, offset, length] of expected) { + const occurrence = inventory.occurrences.find( + (item) => item.logical_path === logical_path, + ); + if ( + occurrence?.artifact_id === undefined || + occurrence.artifact_id === null + ) + throw new Error(`Missing region ${logical_path}`); + expect(occurrence.source_location).toEqual({ offset, length }); + expect(nodes.get(occurrence.artifact_id)?.sha256).toBe( + createHash("sha256") + .update(bytes.subarray(offset, offset + length)) + .digest("hex"), + ); + } + + const { call } = await connectLocalToolsMcp(); + const opened = await call("open_binary", { path }); + if (opened.isError) + throw new Error(JSON.stringify(parseMcpToolError(opened))); + const inspected = await call("inspect_artifact", {}); + expect( + inspected.isError, + JSON.stringify(inspected.structuredContent), + ).not.toBe(true); + expect(inventoryFrom(inspected.structuredContent)).toEqual(inventory); + const extracted = await call("extract_artifact", {}); + expect( + extracted.isError, + JSON.stringify(extracted.structuredContent), + ).not.toBe(true); + const extraction = artifactExtractionResultSchema.parse( + parseEvidence(extracted.structuredContent).normalized_result, + ); + try { + expect(extraction.artifacts).toHaveLength(expected.length); + expect( + Buffer.concat( + await Promise.all( + expected.map(([region]) => + readFile(join(extraction.output_root, region)), + ), + ), + ), + ).toEqual(bytes); + } finally { + // The successful extraction transfers this exact output directory to the caller. + await rm(extraction.output_root, { recursive: true, force: true }); + } + await call("close_binary", {}); + expect(await readFile(path)).toEqual(bytes); + }, +); + +cliTest( + "unsupported NES containers fail explicitly and leave a valid MCP session usable", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-ines-public-refusal-"); + const validPath = join(root, "valid.nes"); + await writeFile(validPath, inesFixture({ chrUnits: 0 })); + const { call } = await connectLocalToolsMcp(); + const opened = await call("open_binary", { path: validPath }); + if (opened.isError) + throw new Error(JSON.stringify(parseMcpToolError(opened))); + for (const [name, bytes, reason] of [ + ["truncated", inesFixture().subarray(0, 20), "truncated iNES payload"], + [ + "nes2", + (() => { + const bytes = inesFixture(); + bytes[7] = 8; + return bytes; + })(), + "nes-2.0", + ], + ["unif", Buffer.from("UNIF"), "unif"], + ] as const) { + const path = join(root, `${name}.nes`); + await writeFile(path, bytes); + const refused = await cli.run({ + arguments: ["inspect-artifact", path, "--json"], + }); + expect(refused.exitCode).not.toBe(0); + expect(JSON.stringify(refused.json)).toContain(reason); + const switched = await call("open_binary", { path }); + expect(switched.isError).toBe(true); + expect(JSON.stringify(parseMcpToolError(switched))).toContain(reason); + } + const retained = await call("inspect_artifact", {}); + expect( + retained.isError, + JSON.stringify(retained.structuredContent), + ).not.toBe(true); + const inventory = inventoryFrom(retained.structuredContent); + expect(inventory.ines?.header.chr_rom_units_8k).toBe(0); + expect( + inventory.occurrences.some(({ logical_path }) => + logical_path.startsWith("chr/"), + ), + ).toBe(false); + await call("close_binary", {}); + }, +); diff --git a/tests/boundary/filesystem/inesArtifact.test.ts b/tests/boundary/filesystem/inesArtifact.test.ts new file mode 100644 index 000000000..6d7651d48 --- /dev/null +++ b/tests/boundary/filesystem/inesArtifact.test.ts @@ -0,0 +1,212 @@ +import { createHash } from "node:crypto"; +import { lstat, open, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { inesFixture } from "../../fixtures/ines.js"; +import { inventoryArtifact } from "../../fixtures/artifactInventory.js"; +import { InesArtifactReader } from "../../../src/artifacts/InesArtifactReader.js"; +import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js"; +import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js"; +import { extractArtifact } from "../../../src/artifacts/extraction/ArtifactExtraction.js"; + +const sha256 = (bytes: Buffer) => + createHash("sha256").update(bytes).digest("hex"); + +it("routes iNES bytes as an archive independently of the file suffix", async () => { + const root = await createTestTempDirectory("rea-ines-target-"); + const path = join(root, "cartridge.data"); + await writeFile(path, inesFixture()); + const target = await parseBinaryTarget(path); + if (!target.ok) throw target.error; + expect(target.value).toMatchObject({ kind: "archive", format: "ines" }); +}); + +it.each([true, false])( + "inventories and reconstructs all bytes with trainer=%s", + async (trainer) => { + const root = await createTestTempDirectory("rea-ines-inventory-"); + const path = join(root, "fixture.nes"); + const bytes = inesFixture({ + trainer, + chrUnits: trainer ? 1 : 0, + trailing: Buffer.from("retained tail"), + }); + await writeFile(path, bytes); + const inventory = await inventoryArtifact(path); + expect(inventory.ines?.header).toMatchObject({ + header_hex: bytes.subarray(0, 16).toString("hex"), + mapper: 19, + trainer_present: trainer, + chr_rom_units_8k: trainer ? 1 : 0, + }); + expect(inventory.ines?.reconstruction).toMatchObject({ + bytes: bytes.length, + sha256: sha256(bytes), + verified: true, + }); + const children = inventory.occurrences.filter( + ({ logical_path }) => logical_path !== ".", + ); + const ordered = [...children].sort( + (a, b) => + (a.source_location?.offset ?? 0) - (b.source_location?.offset ?? 0), + ); + const nodes = new Map( + inventory.nodes.map((node) => [node.artifact_id, node]), + ); + const headerRegion = children.find( + ({ logical_path }) => logical_path === "header.bin", + ); + if ( + headerRegion?.artifact_id === undefined || + headerRegion.artifact_id === null + ) + throw new Error("Missing header region"); + expect(nodes.get(headerRegion.artifact_id)?.format).toBe("file"); + let offset = 0; + for (const occurrence of ordered) { + const range = occurrence.source_location; + if (range === null || occurrence.artifact_id === null) + throw new Error("Missing region identity"); + expect(range.offset).toBe(offset); + const original = bytes.subarray( + range.offset, + range.offset + range.length, + ); + expect(nodes.get(occurrence.artifact_id)).toMatchObject({ + sha256: sha256(original), + size: range.length, + }); + offset += range.length; + } + expect(offset).toBe(bytes.length); + expect(inventory.ines?.reconstruction.region_order).toEqual( + ordered.map(({ logical_path }) => logical_path), + ); + expect(await inventoryArtifact(path)).toEqual(inventory); + + const scope = new ArtifactResourceScope(); + const outputRoot = join(root, "extracted"); + try { + const extraction = await extractArtifact({ + inputPath: path, + inputFormat: "ines", + outputRoot, + environment: process.env, + integrityPolicy: "fail", + resourceScope: scope, + }); + expect(extraction.artifacts).toHaveLength(children.length); + const reconstructed = Buffer.concat( + await Promise.all( + ordered.map(({ logical_path }) => + readFile(join(outputRoot, logical_path)), + ), + ), + ); + expect(reconstructed).toEqual(bytes); + } finally { + await scope.close(); + } + }, +); + +it.each([ + { + name: "NES 2.0", + bytes: () => { + const bytes = inesFixture(); + bytes[7] = 8; + return bytes; + }, + reason: "nes-2.0", + }, + { name: "UNIF", bytes: () => Buffer.from("UNIF"), reason: "unif" }, + { + name: "truncated", + bytes: () => inesFixture().subarray(0, 20), + reason: "truncated iNES payload", + }, +])( + "refuses $name explicitly through both file entry points", + async ({ bytes, reason }) => { + const root = await createTestTempDirectory("rea-ines-refusal-"); + const path = join(root, "invalid.nes"); + await writeFile(path, bytes()); + expect(await parseBinaryTarget(path)).toMatchObject({ + ok: false, + error: { reason: expect.stringContaining(reason) }, + }); + await expect(inventoryArtifact(path)).rejects.toMatchObject({ + reason: "format", + message: expect.stringContaining(reason), + }); + }, +); + +it("does not select iNES from a .nes suffix", async () => { + const root = await createTestTempDirectory("rea-ines-suffix-"); + const path = join(root, "not-a-rom.nes"); + await writeFile(path, "ordinary bytes"); + expect(await parseBinaryTarget(path)).toMatchObject({ ok: false }); +}); + +it("rejects forged region ranges, cancellation and a changed retained source", async () => { + const root = await createTestTempDirectory("rea-ines-reader-"); + const path = join(root, "source.nes"); + await writeFile(path, inesFixture()); + const handle = await open(path, "r"); + const reader = new InesArtifactReader(path, { + handle, + initial: await lstat(path), + }); + try { + const iterator = reader.entries()[Symbol.asyncIterator](); + const first = await iterator.next(); + if (first.done) throw new Error("No header region"); + await expect( + reader.open({ ...first.value, byteOffset: 16 }), + ).rejects.toMatchObject({ reason: "integrity" }); + await expect( + reader.open(first.value, AbortSignal.abort()), + ).rejects.toMatchObject({ reason: "cancelled" }); + await writeFile( + path, + Buffer.concat([inesFixture(), Buffer.from("changed")]), + ); + await expect(reader.open(first.value)).rejects.toMatchObject({ + reason: "integrity", + }); + await iterator.return?.(); + await reader.close(); + expect((await handle.stat()).size).toBeGreaterThan(0); + await expect(reader.open(first.value)).rejects.toMatchObject({ + reason: "unavailable", + }); + } finally { + await reader.close(); + await handle.close(); + } +}); + +it("releases an extraction-owned source descriptor on repeated close", async () => { + const root = await createTestTempDirectory("rea-ines-owned-source-"); + const path = join(root, "source.nes"); + await writeFile(path, inesFixture()); + const handle = await open(path, "r"); + const owner = new OwnedFileHandle(handle); + const reader = new InesArtifactReader( + path, + { handle, initial: await handle.stat() }, + owner, + ); + try { + await reader.close(); + await expect(handle.stat()).rejects.toMatchObject({ code: "EBADF" }); + await reader.close(); + } finally { + await reader.close(); + } +}); diff --git a/tests/fixtures/ines.ts b/tests/fixtures/ines.ts new file mode 100644 index 000000000..e151d57ea --- /dev/null +++ b/tests/fixtures/ines.ts @@ -0,0 +1,25 @@ +/** Source-owned iNES bytes; the payload is test data, not a game ROM. */ +export const inesFixture = ( + options: { + readonly trainer?: boolean; + readonly chrUnits?: number; + readonly trailing?: Buffer; + } = {}, +): Buffer => { + const header = Buffer.alloc(16); + header.write("NES\x1a", "latin1"); + header[4] = 2; + header[5] = options.chrUnits ?? 1; + header[6] = 0x31 | (options.trainer === true ? 4 : 0); + header[7] = 0x10; + return Buffer.concat([ + header, + ...(options.trainer === true ? [Buffer.alloc(512, 0x54)] : []), + Buffer.alloc(16_384, 0x41), + Buffer.alloc(16_384, 0x42), + ...Array.from({ length: header.readUInt8(5) }, () => + Buffer.alloc(8_192, 0x43), + ), + options.trailing ?? Buffer.alloc(0), + ]); +};