feat(artifacts): inspect classic iNES container regions

This commit is contained in:
64579525 2026-10-12 15:12:03 +08:00
parent b3dec005bc
commit 414307c9d5
25 changed files with 1032 additions and 14 deletions

View File

@ -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

View File

@ -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",

66
docs/ines-containers.md Normal file
View File

@ -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).

View File

@ -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<BinaryTarget, { kind: "archive" }>["format"] =>
isZipPackageFormat(format) || ["asar", "dmg", "pkg"].includes(format);
isZipPackageFormat(format) || ["asar", "ines", "dmg", "pkg"].includes(format);
const sha256Handle = async (
handle: FileHandle,

View File

@ -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.

View File

@ -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<ArtifactEntry>;
open(entry: ArtifactEntry, signal?: AbortSignal): Promise<Readable>;
provenance(): readonly ArtifactCommand[];

View File

@ -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<string, ArtifactEntry>();
#layout: InesLayout | undefined;
#closed = false;
constructor(
private readonly path: string,
private readonly source: StableRegularFileDescriptor,
private readonly ownedSource?: OwnedFileHandle,
) {}
async *entries(signal?: AbortSignal): AsyncIterable<ArtifactEntry> {
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<Readable> {
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<InesInventory> {
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<void> {
// 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<void> {
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",
);
}
}

View File

@ -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<ArtifactReader> => {
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);
};

View File

@ -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) {

View File

@ -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 (

View File

@ -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]

View File

@ -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,

View File

@ -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(

View File

@ -65,6 +65,7 @@ export const targetFormatSchema = z.enum([
"msix",
"appx",
"asar",
"ines",
"dmg",
"pkg",
"plist",

View File

@ -66,6 +66,7 @@ const formatSchema = z.enum([
"hsp",
"app-pack",
"asar",
"ines",
"dmg",
"pkg",
"plist",

View File

@ -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()),
});

View File

@ -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 = (

View File

@ -14,5 +14,6 @@ export interface ArtifactInventorySnapshot {
ArtifactInventoryResult["provenance"][number]
>;
readonly integrity_contradictions: readonly IntegrityContradiction[];
readonly ines?: ArtifactInventoryResult["ines"];
readonly limitations: readonly string[];
}

View File

@ -127,6 +127,7 @@ export type BinaryTarget =
| "hsp"
| "app-pack"
| "asar"
| "ines"
| "dmg"
| "pkg";
})

View File

@ -59,6 +59,7 @@ const subjectSchema = z.object({
"hsp",
"app-pack",
"asar",
"ines",
"dmg",
"pkg",
"plist",

View File

@ -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),
});
});

131
src/domain/inesContainer.ts Normal file
View File

@ -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<typeof inesInventorySchema>;
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<InesLayout, string> => {
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,
});
};

View File

@ -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", {});
},
);

View File

@ -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();
}
});

25
tests/fixtures/ines.ts vendored Normal file
View File

@ -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),
]);
};