feat(artifacts): inspect classic iNES container regions
This commit is contained in:
parent
b3dec005bc
commit
414307c9d5
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ export const targetFormatSchema = z.enum([
|
|||
"msix",
|
||||
"appx",
|
||||
"asar",
|
||||
"ines",
|
||||
"dmg",
|
||||
"pkg",
|
||||
"plist",
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ const formatSchema = z.enum([
|
|||
"hsp",
|
||||
"app-pack",
|
||||
"asar",
|
||||
"ines",
|
||||
"dmg",
|
||||
"pkg",
|
||||
"plist",
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -14,5 +14,6 @@ export interface ArtifactInventorySnapshot {
|
|||
ArtifactInventoryResult["provenance"][number]
|
||||
>;
|
||||
readonly integrity_contradictions: readonly IntegrityContradiction[];
|
||||
readonly ines?: ArtifactInventoryResult["ines"];
|
||||
readonly limitations: readonly string[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ export type BinaryTarget =
|
|||
| "hsp"
|
||||
| "app-pack"
|
||||
| "asar"
|
||||
| "ines"
|
||||
| "dmg"
|
||||
| "pkg";
|
||||
})
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ const subjectSchema = z.object({
|
|||
"hsp",
|
||||
"app-pack",
|
||||
"asar",
|
||||
"ines",
|
||||
"dmg",
|
||||
"pkg",
|
||||
"plist",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
@ -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", {});
|
||||
},
|
||||
);
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
@ -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),
|
||||
]);
|
||||
};
|
||||
Loading…
Reference in New Issue