188 lines
6.4 KiB
TypeScript
188 lines
6.4 KiB
TypeScript
import { readFileHandlePrefix } from "../filesystem/readFileHandlePrefix.js";
|
|
import { Readable } from "node:stream";
|
|
import {
|
|
parseInesLayout,
|
|
type InesInventory,
|
|
type InesLayout,
|
|
} from "../domain/inesContainer.js";
|
|
import type { ArtifactCommand } from "../domain/artifactGraph.js";
|
|
import {
|
|
RegularFileChangedError,
|
|
verifyRegularFileState,
|
|
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.";
|
|
|
|
/** Actual concatenated bytes observed by the inventory's ordered region scan. */
|
|
export type InesRegionScan = Omit<InesInventory["reconstruction"], "verified">;
|
|
|
|
/** 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(signal);
|
|
const prefix = await readFileHandlePrefix(this.source.handle, 16, () =>
|
|
abortIfNeeded(signal),
|
|
);
|
|
abortIfNeeded(signal);
|
|
// Concurrent source changes take precedence over malformed header diagnostics.
|
|
await this.#verifySource(signal);
|
|
const parsed = parseInesLayout(prefix, this.source.initial.size);
|
|
if (!parsed.ok) throw new ArtifactReaderFailure("format", parsed.error);
|
|
this.#layout = parsed.value;
|
|
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(signal);
|
|
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(signal);
|
|
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 },
|
|
);
|
|
}
|
|
|
|
/** Validate the completed region scan without rereading its payload. */
|
|
async verifyReconstruction(
|
|
expectedSha256: string,
|
|
observed: InesRegionScan,
|
|
signal?: AbortSignal,
|
|
): Promise<InesInventory> {
|
|
abortIfNeeded(signal);
|
|
const layout = this.#layout;
|
|
if (layout === undefined || this.#entries.size !== layout.regions.length)
|
|
throw new ArtifactReaderFailure("integrity", "Incomplete iNES inventory");
|
|
await this.#verifySource(signal);
|
|
if (
|
|
observed.region_order.length !== layout.regions.length ||
|
|
layout.regions.some(
|
|
(region, index) => observed.region_order[index] !== region.path,
|
|
) ||
|
|
observed.bytes !== this.source.initial.size ||
|
|
observed.sha256 !== expectedSha256
|
|
)
|
|
throw new ArtifactReaderFailure(
|
|
"integrity",
|
|
"iNES reconstruction differs from the source",
|
|
);
|
|
return {
|
|
header: layout.header,
|
|
reconstruction: {
|
|
...observed,
|
|
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(signal?: AbortSignal): Promise<void> {
|
|
abortIfNeeded(signal);
|
|
if (this.#closed)
|
|
throw new ArtifactReaderFailure("unavailable", "iNES reader is closed");
|
|
try {
|
|
await verifyRegularFileState(this.path, this.source);
|
|
} catch (cause: unknown) {
|
|
abortIfNeeded(signal);
|
|
if (cause instanceof RegularFileChangedError)
|
|
throw new ArtifactReaderFailure(
|
|
"integrity",
|
|
"iNES source changed during reading",
|
|
{ cause: cause.cause ?? cause },
|
|
);
|
|
throw cause;
|
|
}
|
|
// State validation is asynchronous even after the final byte was consumed.
|
|
abortIfNeeded(signal);
|
|
}
|
|
}
|