Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75f1bf97e1 | ||
|
|
a4dd4e8128 | ||
|
|
346275525e | ||
|
|
781827b7b4 | ||
|
|
dc8a3e6078 | ||
|
|
0434be924e | ||
|
|
502266b3f7 | ||
|
|
1aaaf06928 | ||
|
|
ee4a11df81 | ||
|
|
480a467dac | ||
|
|
2904bdb4f8 | ||
|
|
00f31692b5 | ||
|
|
de2ab343f1 | ||
|
|
717b5a215e | ||
|
|
5e7fcdcdc3 | ||
|
|
89914a04cd | ||
|
|
96608517ba | ||
|
|
577d6c3e9b | ||
|
|
f6748a511e | ||
|
|
faa6f89566 | ||
|
|
c530a37813 | ||
|
|
75c2741912 | ||
|
|
0d018d5a77 | ||
|
|
9d4c517b84 | ||
|
|
6d0a6b5d51 | ||
|
|
baadb8114c | ||
|
|
6c93ebabb0 | ||
|
|
2d1d8c45ea | ||
|
|
d4530488c3 | ||
|
|
3590893742 | ||
|
|
72b78e41b9 | ||
|
|
a74a599fce | ||
|
|
d3e315cf33 | ||
|
|
54c2e4e81c | ||
|
|
c17cb017fa | ||
|
|
2b29105bf9 | ||
|
|
681f34879e | ||
|
|
a60d60dc6b | ||
|
|
1c6217258a | ||
|
|
e47b667670 | ||
|
|
78820dd69a | ||
|
|
db24a47c15 | ||
|
|
071f632e92 | ||
|
|
7cba3acaa4 | ||
|
|
ca646b6cb9 | ||
|
|
2f0e4d79ea | ||
|
|
9b8916152b | ||
|
|
e6d4d7fb84 | ||
|
|
5ba8b2f6bf | ||
|
|
574f6bb3bd | ||
|
|
414307c9d5 |
@@ -213,6 +213,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
|
||||
|
||||
@@ -522,6 +522,12 @@ jobs:
|
||||
npm run verify:legacy-simulator
|
||||
npm run verify:apple-dispatch
|
||||
npm run verify:interface-builder
|
||||
- name: Verify portable iNES CLI, MCP and source boundaries
|
||||
run: >-
|
||||
npm run test:focused --
|
||||
tests/acceptance/inesContainer.test.ts
|
||||
tests/boundary/filesystem/inesArtifact.test.ts
|
||||
tests/boundary/filesystem/inesCancellation.test.ts
|
||||
- name: Run native Apple and process boundary regressions
|
||||
run: >-
|
||||
npm run test:focused --
|
||||
@@ -533,6 +539,7 @@ jobs:
|
||||
tests/boundary/cli/nativePlistSelectionFailureCli.test.ts
|
||||
tests/boundary/cli/nativeDemangleCli.test.ts
|
||||
tests/boundary/mcp/nativeDemangleMcp.test.ts
|
||||
tests/boundary/mcp/nativeMachoSummaryMcp.test.ts
|
||||
tests/boundary/providers/native/nativeSignatureTargetBinding.test.ts
|
||||
tests/boundary/cli/nativeSignatureCli.test.ts
|
||||
tests/boundary/mcp/nativeSignatureTargetBindingMcp.test.ts
|
||||
|
||||
@@ -48,6 +48,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: "SQLite databases", link: "/sqlite-databases" },
|
||||
{ text: "Packet captures", link: "/packet-captures" },
|
||||
{ text: "Signal recordings", link: "/signal-recordings" },
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# 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 accumulates the concatenation digest during
|
||||
the same physical-order region scan that produces node hashes, then compares
|
||||
it with the original whole-file digest. Final source freshness is also checked.
|
||||
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. This container
|
||||
increment addresses the first milestone of [#2072](https://github.com/morluto/rea/issues/2072);
|
||||
the related [#1641](https://github.com/morluto/rea/issues/1641) tracks 6502/65816
|
||||
native analysis with explicit load addresses and provider limitations. Extracted
|
||||
PRG units still need caller-selected CPU windows before static code analysis;
|
||||
their physical file offsets do not establish those addresses.
|
||||
|
||||
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 portable CLI/stdio MCP acceptance and source-identity checks run in Linux
|
||||
and macOS CI. The same workflows have also been executed on native Windows x64.
|
||||
|
||||
Format references: [iNES](https://www.nesdev.org/wiki/INES) and
|
||||
[NES 2.0](https://www.nesdev.org/wiki/NES_2.0).
|
||||
@@ -4,6 +4,12 @@
|
||||
export const SCOPES = {
|
||||
// Apple artifact CLI/MCP lane on macOS, including its focused regressions.
|
||||
apple: [
|
||||
"src/artifacts/InesArtifactReader.ts",
|
||||
"src/domain/inesContainer.ts",
|
||||
"tests/fixtures/ines.ts",
|
||||
"tests/acceptance/inesContainer.test.ts",
|
||||
"tests/boundary/filesystem/inesArtifact.test.ts",
|
||||
"tests/boundary/filesystem/inesCancellation.test.ts",
|
||||
"src/native/Apple*",
|
||||
"src/native/NativeMacOS*",
|
||||
"src/native/NativeUi*",
|
||||
@@ -19,6 +25,7 @@ export const SCOPES = {
|
||||
"tests/boundary/cli/nativePlistSelectionFailureCli.test.ts",
|
||||
"tests/boundary/cli/nativeDemangleCli.test.ts",
|
||||
"tests/boundary/mcp/nativeDemangleMcp.test.ts",
|
||||
"tests/boundary/mcp/nativeMachoSummaryMcp.test.ts",
|
||||
"tests/boundary/providers/native/nativeSignatureTargetBinding.test.ts",
|
||||
"tests/boundary/cli/nativeSignatureCli.test.ts",
|
||||
"tests/boundary/mcp/nativeSignatureTargetBindingMcp.test.ts",
|
||||
|
||||
@@ -16,17 +16,28 @@ import { compareBrowserScenarios } from "../../dist/domain/browserScenarioDiff.j
|
||||
import { browserScenarioCaptureSchema } from "../../dist/domain/browserScenarioCapture.js";
|
||||
import { parseEvidence } from "../../dist/domain/evidence.js";
|
||||
|
||||
// Readiness includes descendant document loads, so complete network capture
|
||||
// does not depend on whether an inline script runs before an iframe finishes.
|
||||
const document = (path) => {
|
||||
const icon = '<link rel="icon" href="data:,">';
|
||||
if (path.startsWith("/nested"))
|
||||
return `<script>throw new Error("nested popup failure")</script>
|
||||
<script>opener.postMessage("nested-ready", "*")</script>`;
|
||||
return `${icon}<script>throw new Error("nested popup failure")</script>
|
||||
<script>addEventListener("load", () => opener.postMessage("nested-ready", "*"))</script>`;
|
||||
if (path.startsWith("/popup"))
|
||||
return `<script>throw new Error("popup failure")</script>
|
||||
<script>onmessage = e => {
|
||||
if (e.data === "nested-ready") opener.postMessage("popup-ready", "*");
|
||||
}; window.open("/nested");</script><iframe src="/child"></iframe>`;
|
||||
if (path.startsWith("/child")) return "child frame";
|
||||
return `<script>onmessage = e => {
|
||||
return `${icon}<script>throw new Error("popup failure")</script>
|
||||
<script>
|
||||
let loaded = false, nested = false;
|
||||
const ready = () => {
|
||||
if (loaded && nested) opener.postMessage("popup-ready", "*");
|
||||
};
|
||||
addEventListener("load", () => { loaded = true; ready(); });
|
||||
onmessage = e => {
|
||||
if (e.data === "nested-ready") { nested = true; ready(); }
|
||||
};
|
||||
window.open("/nested");
|
||||
</script><iframe src="/child"></iframe>`;
|
||||
if (path.startsWith("/child")) return `${icon}child frame`;
|
||||
return `${icon}<script>onmessage = e => {
|
||||
if (e.data === "popup-ready") document.body.dataset.ready = "true";
|
||||
}</script><button onclick="window.open('/redirect')">Open popup</button>`;
|
||||
};
|
||||
@@ -74,7 +85,25 @@ const assertNetwork = (result, origin) => {
|
||||
1,
|
||||
`${kind} ${path} must be captured exactly once`,
|
||||
);
|
||||
assert.equal(result.completeness.equality_eligible, true);
|
||||
assert.equal(
|
||||
result.completeness.equality_eligible,
|
||||
true,
|
||||
JSON.stringify({
|
||||
limitations: result.limitations,
|
||||
unfinished: result.events.items.filter(
|
||||
(event) => event.kind === "request-unfinished",
|
||||
),
|
||||
}),
|
||||
);
|
||||
assert.equal(
|
||||
result.events.items.filter(
|
||||
(event) =>
|
||||
event.kind === "request-finished" &&
|
||||
event.url?.url === origin + "/child",
|
||||
).length,
|
||||
1,
|
||||
"child document must finish before network capture ends",
|
||||
);
|
||||
assert.equal(compareWithSelf(result).overall_status, "unchanged");
|
||||
};
|
||||
|
||||
@@ -179,13 +208,27 @@ const verifyTransports = async (executable, origin) => {
|
||||
|
||||
/** Verify popup timing, selection, coverage, and shared-context isolation on Chromium. */
|
||||
export async function verifyPopupEventCoverage(executable) {
|
||||
let slowChild = true;
|
||||
let childTimer;
|
||||
const childStarted = Promise.withResolvers();
|
||||
const server = createServer((request, response) => {
|
||||
if (request.url === "/redirect") {
|
||||
response.writeHead(302, { Location: "/popup" });
|
||||
response.end();
|
||||
} else {
|
||||
response.setHeader("Content-Type", "text/html");
|
||||
response.end(document(request.url ?? "/"));
|
||||
if (slowChild && request.url === "/child") {
|
||||
// Keep one response open while the nested page can announce readiness.
|
||||
// This exposes premature capture cutoff even on fast local browsers.
|
||||
response.writeHead(200);
|
||||
response.write(document("/child"));
|
||||
childStarted.resolve();
|
||||
childTimer = setTimeout(() => response.end(), 1500);
|
||||
} else if (slowChild && request.url === "/nested") {
|
||||
void childStarted.promise.then(() => response.end(document("/nested")));
|
||||
} else {
|
||||
response.end(document(request.url ?? "/"));
|
||||
}
|
||||
}
|
||||
});
|
||||
server.listen(0, "127.0.0.1");
|
||||
@@ -198,6 +241,7 @@ export async function verifyPopupEventCoverage(executable) {
|
||||
await capture(scenarioFor(executable, origin, ["network"])),
|
||||
origin,
|
||||
);
|
||||
slowChild = false;
|
||||
const errors = await capture(
|
||||
scenarioFor(executable, origin, ["page-errors"]),
|
||||
);
|
||||
@@ -239,6 +283,7 @@ export async function verifyPopupEventCoverage(executable) {
|
||||
stdio_mcp: true,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(childTimer);
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve, reject) =>
|
||||
server.close((error) =>
|
||||
|
||||
@@ -12,10 +12,13 @@ import {
|
||||
BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS,
|
||||
waitForBrowserDevtoolsPort,
|
||||
} from "../../dist/browser/BrowserProcessStartup.js";
|
||||
import { discoverCdpEndpoint } from "../../dist/browser/CdpEndpoint.js";
|
||||
import { BrowserObservationError } from "../../dist/domain/browserObservationError.js";
|
||||
|
||||
/** Launch the synthetic fixture browser and await CDP in its own verified process group. */
|
||||
/** Admit the owned fixture only after CDP serves its requested page. */
|
||||
export async function startRuntimeFixtureBrowser(executable, origin) {
|
||||
const profile = await mkdtemp(join(tmpdir(), "rea-real-web-runtime-"));
|
||||
const deadline = performance.now() + BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS;
|
||||
let owned;
|
||||
try {
|
||||
owned = await spawnOwnedProviderProcess({
|
||||
@@ -53,7 +56,16 @@ export async function startRuntimeFixtureBrowser(executable, origin) {
|
||||
stderr: () => stderr,
|
||||
// Match the broader real-browser verifier's allowance for cold Chrome
|
||||
// startup on shared runners; early process failures still reject at once.
|
||||
timeoutMs: BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS,
|
||||
timeoutMs: Math.max(1, deadline - performance.now()),
|
||||
});
|
||||
// Port publication can precede a responsive discovery service and renderer.
|
||||
// Both phases share the owned fixture's startup budget; public CDP calls
|
||||
// retain their normal per-response deadlines and failure semantics.
|
||||
await waitForRuntimeFixturePage({
|
||||
child: owned.process,
|
||||
port,
|
||||
origin,
|
||||
deadline,
|
||||
});
|
||||
return {
|
||||
port,
|
||||
@@ -68,17 +80,86 @@ export async function startRuntimeFixtureBrowser(executable, origin) {
|
||||
},
|
||||
};
|
||||
} catch (cause) {
|
||||
if (owned !== undefined) await stopOwnedBrowser(owned, profile);
|
||||
await rm(profile, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 5,
|
||||
retryDelay: 100,
|
||||
});
|
||||
await closeRuntimeFixtureResources(
|
||||
[
|
||||
async () => {
|
||||
// Keep the profile when stopping its browser cannot be confirmed.
|
||||
if (owned !== undefined) await stopOwnedBrowser(owned, profile);
|
||||
await rm(profile, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 5,
|
||||
retryDelay: 100,
|
||||
});
|
||||
},
|
||||
],
|
||||
cause,
|
||||
);
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForRuntimeFixturePage({ child, port, origin, deadline }) {
|
||||
const controller = new AbortController();
|
||||
let lastFailure;
|
||||
const onExit = () =>
|
||||
controller.abort(
|
||||
new Error(
|
||||
"Owned runtime fixture exited before CDP discovery became ready",
|
||||
{ cause: lastFailure },
|
||||
),
|
||||
);
|
||||
const onError = (cause) => controller.abort(cause);
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
controller.abort(
|
||||
new Error(
|
||||
"Owned runtime fixture page was not discoverable within its startup budget",
|
||||
{ cause: lastFailure },
|
||||
),
|
||||
),
|
||||
Math.max(1, deadline - performance.now()),
|
||||
);
|
||||
child.once("exit", onExit);
|
||||
child.once("error", onError);
|
||||
try {
|
||||
for (;;) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) onExit();
|
||||
controller.signal.throwIfAborted();
|
||||
try {
|
||||
const discovery = await discoverCdpEndpoint(
|
||||
`http://127.0.0.1:${port}`,
|
||||
"list_browser_targets",
|
||||
controller.signal,
|
||||
);
|
||||
if (
|
||||
discovery.targets.some(
|
||||
(target) =>
|
||||
target.type === "page" &&
|
||||
(target.url === origin || target.url.startsWith(`${origin}/`)),
|
||||
)
|
||||
)
|
||||
return;
|
||||
} catch (cause) {
|
||||
controller.signal.throwIfAborted();
|
||||
// Only transient startup failures are retryable. Invalid protocol
|
||||
// responses and payload-limit failures must remain visible immediately.
|
||||
if (
|
||||
!(cause instanceof BrowserObservationError) ||
|
||||
!["timeout", "endpoint_unreachable"].includes(cause.reason)
|
||||
)
|
||||
throw cause;
|
||||
lastFailure = cause;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
child.removeListener("exit", onExit);
|
||||
child.removeListener("error", onError);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopOwnedBrowser(owned, profile) {
|
||||
const child = owned.process;
|
||||
const exited =
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { readFileHandlePrefix } from "../filesystem/readFileHandlePrefix.js";
|
||||
import { constants, type Stats } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
access,
|
||||
lstat,
|
||||
realpath,
|
||||
stat,
|
||||
type FileHandle,
|
||||
} from "node:fs/promises";
|
||||
nesContainerFormat,
|
||||
parseInesLayout,
|
||||
} from "../domain/inesContainer.js";
|
||||
import { access, realpath, stat, type FileHandle } from "node:fs/promises";
|
||||
import { extname, isAbsolute, resolve } from "node:path";
|
||||
|
||||
import { BinaryTargetError } from "../domain/configurationErrors.js";
|
||||
import {
|
||||
openRegularFile,
|
||||
sameRegularFileState,
|
||||
verifyRegularFileState,
|
||||
} from "../filesystem/RegularFile.js";
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
@@ -162,15 +161,17 @@ export const parseBinaryTarget = async (
|
||||
availableArchitectures: ["x86"],
|
||||
});
|
||||
}
|
||||
// Probe WASM content before suffix-only database routing; an explicit
|
||||
// Probe content before suffix-only database routing; an explicit
|
||||
// database selection keeps the caller's chosen interpretation.
|
||||
const artifactFormat =
|
||||
targetKind === "database"
|
||||
? undefined
|
||||
: await detectArtifactFormat(path, handle, signal);
|
||||
: await detectArtifactFormat(path, handle, metadata, signal);
|
||||
if (
|
||||
targetKind === "database" ||
|
||||
(targetKind === undefined && path.toLowerCase().endsWith(".hop"))
|
||||
(targetKind === undefined &&
|
||||
artifactFormat === undefined &&
|
||||
path.toLowerCase().endsWith(".hop"))
|
||||
)
|
||||
return ok({
|
||||
path,
|
||||
@@ -212,6 +213,13 @@ export const parseBinaryTarget = async (
|
||||
await handle.close();
|
||||
}
|
||||
} catch (cause: unknown) {
|
||||
// The filesystem admission helper throws the caller's abort reason after
|
||||
// confirming descriptor release. Cleanup failures carry a different owner
|
||||
// error and must remain visible rather than being reduced to cancellation.
|
||||
if (signal?.aborted === true && cause === signal.reason)
|
||||
return err(
|
||||
new AnalysisCancelledError("resolve_binary_target", { cause }),
|
||||
);
|
||||
if (cause instanceof AnalysisCancelledError) return err(cause);
|
||||
if (cause instanceof BinaryTargetError) return err(cause);
|
||||
if (!(cause instanceof Error)) throw cause;
|
||||
@@ -264,9 +272,26 @@ const throwIfTargetResolutionCancelled = (signal?: AbortSignal): void => {
|
||||
throw new AnalysisCancelledError("resolve_binary_target");
|
||||
};
|
||||
|
||||
/** Preserve cancellation when an asynchronous identity check succeeds or rejects. */
|
||||
const verifyTargetState = async (
|
||||
path: string,
|
||||
handle: FileHandle,
|
||||
initial: Stats,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await verifyRegularFileState(path, { handle, initial });
|
||||
} catch (cause: unknown) {
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
throw cause;
|
||||
}
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
};
|
||||
|
||||
const detectArtifactFormat = async (
|
||||
path: string,
|
||||
handle: FileHandle,
|
||||
initial: Stats,
|
||||
signal?: AbortSignal,
|
||||
): Promise<
|
||||
| Exclude<
|
||||
@@ -284,32 +309,44 @@ const detectArtifactFormat = async (
|
||||
> => {
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
const lower = path.toLowerCase();
|
||||
const magic = Buffer.alloc(8);
|
||||
const observed = await handle.read(magic, 0, magic.length, 0);
|
||||
const magic = await readFileHandlePrefix(handle, 16, () =>
|
||||
throwIfTargetResolutionCancelled(signal),
|
||||
);
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
await verifyTargetState(path, handle, initial, signal);
|
||||
if (
|
||||
observed.bytesRead === 8 &&
|
||||
magic.equals(Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0]))
|
||||
magic.length >= 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;
|
||||
const nesFormat = nesContainerFormat(prefix);
|
||||
if (nesFormat !== undefined) {
|
||||
const parsed = parseInesLayout(prefix, initial.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";
|
||||
return zipPackageFormatForPath(lower) ?? "zip";
|
||||
}
|
||||
// Binary plists, including keyed archives, often use other extensions.
|
||||
if (hasBinaryPlistSignature(magic.subarray(0, observed.bytesRead)))
|
||||
return "plist";
|
||||
if (hasBinaryPlistSignature(magic)) return "plist";
|
||||
const named = namedArtifactFormat(lower);
|
||||
if (named !== undefined) return named;
|
||||
if (
|
||||
lower.endsWith(".pkg") &&
|
||||
observed.bytesRead >= 4 &&
|
||||
magic.length >= 4 &&
|
||||
magic.subarray(0, 4).equals(Buffer.from("xar!"))
|
||||
)
|
||||
return "pkg";
|
||||
@@ -350,7 +387,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,
|
||||
@@ -377,12 +414,8 @@ const sha256Handle = async (
|
||||
throw new BinaryTargetError(path, "target grew while being read");
|
||||
}
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
const [opened, currentPath] = await Promise.all([handle.stat(), lstat(path)]);
|
||||
if (
|
||||
!sameRegularFileState(initial, opened) ||
|
||||
!sameRegularFileState(initial, currentPath) ||
|
||||
position !== initial.size
|
||||
)
|
||||
await verifyTargetState(path, handle, initial, signal);
|
||||
if (position !== initial.size)
|
||||
throw new BinaryTargetError(path, "target changed while being read");
|
||||
return hash.digest("hex");
|
||||
};
|
||||
|
||||
@@ -23,10 +23,11 @@ export const abortIfNeeded = (signal?: AbortSignal): void => {
|
||||
);
|
||||
};
|
||||
|
||||
/** Hash every stream byte and retain its bounded classification prefix. */
|
||||
/** Hash every stream byte, optionally feeding the same bytes to a scan aggregate. */
|
||||
export const hashReadable = async (
|
||||
stream: Readable,
|
||||
signal?: AbortSignal,
|
||||
observeChunk?: (chunk: Buffer) => void,
|
||||
): Promise<HashResult> => {
|
||||
const hash = createHash("sha256");
|
||||
const prefixes: Buffer[] = [];
|
||||
@@ -37,6 +38,7 @@ export const hashReadable = async (
|
||||
const chunk = streamChunkToBuffer(raw);
|
||||
bytes += chunk.length;
|
||||
hash.update(chunk);
|
||||
observeChunk?.(chunk);
|
||||
if (prefixBytes < ARTIFACT_CLASSIFICATION_PREFIX_BYTES) {
|
||||
const selected = chunk.subarray(
|
||||
0,
|
||||
|
||||
@@ -188,7 +188,10 @@ class ArtifactClient implements AnalysisClient {
|
||||
const result = await extractArtifact(
|
||||
{
|
||||
inputPath: this.target.sourcePath ?? this.target.path,
|
||||
inputFormat: this.target.format,
|
||||
...(this.target.sourcePath === undefined ||
|
||||
this.target.sourcePath === this.target.path
|
||||
? { expectedRootSha256: this.target.sha256 }
|
||||
: {}),
|
||||
outputRoot: parsed.output_root,
|
||||
environment: this.environment,
|
||||
integrityPolicy: parsed.integrity_policy,
|
||||
@@ -391,6 +394,9 @@ class ArtifactClient implements AnalysisClient {
|
||||
environment: this.environment,
|
||||
...(options?.signal === undefined ? {} : { signal: options.signal }),
|
||||
integrity: { mode: parsed.integrity_policy },
|
||||
...(this.target.kind === "archive" && this.target.format === "ines"
|
||||
? { expectedRootSha256: this.target.sha256 }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,187 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import { lstat, realpath } from "node:fs/promises";
|
||||
import { 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,
|
||||
hashStableRootArtifactHandle,
|
||||
} from "../inventory/hashStableRootArtifact.js";
|
||||
import {
|
||||
ArtifactPathRegistry,
|
||||
normalizeArtifactPath,
|
||||
@@ -32,10 +37,7 @@ import type {
|
||||
ArtifactResourceOwner,
|
||||
ArtifactResourceScope,
|
||||
} from "../ArtifactResourceScope.js";
|
||||
import {
|
||||
isMachOFormat,
|
||||
type BinaryTarget,
|
||||
} from "../../domain/binaryTargetTypes.js";
|
||||
import { isMachOFormat } from "../../domain/binaryTargetTypes.js";
|
||||
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
|
||||
import { scanArtifactInventoryInScope } from "../inventory/ArtifactInventory.js";
|
||||
import type { ArtifactIntegrityPolicyName } from "../../domain/artifactIntegrityPolicy.js";
|
||||
@@ -43,7 +45,8 @@ import type { ArtifactIntegrityPolicyName } from "../../domain/artifactIntegrity
|
||||
/** Local extraction input with the output root chosen by the adapter. */
|
||||
export interface ArtifactExtractionInput {
|
||||
readonly inputPath: string;
|
||||
readonly inputFormat: BinaryTarget["format"];
|
||||
/** Digest of the active session target, when extraction is session-bound. */
|
||||
readonly expectedRootSha256?: string;
|
||||
readonly outputRoot: string;
|
||||
readonly environment: Readonly<NodeJS.ProcessEnv>;
|
||||
readonly integrityPolicy: ArtifactIntegrityPolicyName;
|
||||
@@ -64,13 +67,22 @@ const extractArtifactInScope = async (
|
||||
// Cancellation wins over every later refusal, including unsupported formats.
|
||||
abortIfNeeded(signal);
|
||||
const sourcePath = await realpath(input.inputPath);
|
||||
await requireExtractableFormat(sourcePath, input);
|
||||
const snapshot = await scanArtifactInventoryInScope(sourcePath, {
|
||||
resourceScope: input.resourceScope,
|
||||
signal,
|
||||
environment: input.environment,
|
||||
integrity: { mode: input.integrityPolicy },
|
||||
admitRootFormat: (format) =>
|
||||
requireExtractableFormat(format, input.inputPath),
|
||||
});
|
||||
if (
|
||||
input.expectedRootSha256 !== undefined &&
|
||||
snapshot.manifest.root_sha256 !== input.expectedRootSha256
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Active artifact digest changed: expected ${input.expectedRootSha256}, observed ${snapshot.manifest.root_sha256}`,
|
||||
);
|
||||
return materializeArtifactInventoryInScope(
|
||||
input,
|
||||
sourcePath,
|
||||
@@ -217,7 +229,12 @@ const materializeSelection = async ({
|
||||
const byPath = new Map(
|
||||
selected.map((item) => [item.occurrence.logical_path, item]),
|
||||
);
|
||||
const reader = await createReader(sourcePath, input);
|
||||
const reader = await createReader(
|
||||
sourcePath,
|
||||
input,
|
||||
inventory.manifest,
|
||||
signal,
|
||||
);
|
||||
const readerOwner: ArtifactResourceOwner = {
|
||||
kind: "reader" as const,
|
||||
reader,
|
||||
@@ -402,43 +419,89 @@ interface ExtractionInventory {
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse a format without an extraction reader before inventory work starts.
|
||||
* The target kind, not the host, is unsupported by extraction. The error keeps
|
||||
* the caller's spelling of the path; I/O uses the canonical path.
|
||||
* Admit the scanner's format, preserving the caller's path in a refusal.
|
||||
*/
|
||||
const requireExtractableFormat = async (
|
||||
path: string,
|
||||
input: Pick<ArtifactExtractionInput, "inputPath" | "inputFormat">,
|
||||
): Promise<boolean> => {
|
||||
const format = input.inputFormat;
|
||||
const directory = (await lstat(path)).isDirectory();
|
||||
const requireExtractableFormat = (
|
||||
format: ArtifactGraphManifest["root_format"],
|
||||
inputPath: string,
|
||||
): void => {
|
||||
if (
|
||||
!directory &&
|
||||
format !== "directory" &&
|
||||
format !== "asar" &&
|
||||
format !== "ines" &&
|
||||
!isMachOFormat(format) &&
|
||||
!isZipPackageFormat(format)
|
||||
)
|
||||
throw new AnalysisUnsupportedTargetError(
|
||||
"extract_artifact",
|
||||
input.inputPath,
|
||||
inputPath,
|
||||
`Artifact format has no extraction reader: ${format}`,
|
||||
);
|
||||
return directory;
|
||||
};
|
||||
|
||||
const createReader = async (
|
||||
path: string,
|
||||
input: Pick<
|
||||
ArtifactExtractionInput,
|
||||
"inputPath" | "inputFormat" | "environment"
|
||||
"inputPath" | "environment" | "resourceScope"
|
||||
>,
|
||||
manifest: ArtifactGraphManifest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ArtifactReader> => {
|
||||
const format = input.inputFormat;
|
||||
if (await requireExtractableFormat(path, input))
|
||||
return new DirectoryArtifactReader(path);
|
||||
// The current scan owns format selection; the opened target may be stale.
|
||||
const format = manifest.root_format;
|
||||
if (format === "ines") {
|
||||
const owner = await openRootArtifact(
|
||||
path,
|
||||
input.resourceScope,
|
||||
signal,
|
||||
"inventoried",
|
||||
);
|
||||
try {
|
||||
const initial = await owner.handle.stat();
|
||||
const digest = await hashStableRootArtifactHandle(
|
||||
path,
|
||||
owner.handle,
|
||||
initial,
|
||||
signal,
|
||||
);
|
||||
if (digest.sha256 !== manifest.root_sha256)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Inventoried iNES source bytes changed: ${path}`,
|
||||
);
|
||||
return new InesArtifactReader(
|
||||
path,
|
||||
{
|
||||
handle: owner.handle,
|
||||
initial,
|
||||
},
|
||||
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 (format === "directory") return new DirectoryArtifactReader(path);
|
||||
if (format === "asar") return new AsarArtifactReader(path);
|
||||
if (isZipPackageFormat(format)) return new ZipArtifactReader(path, format);
|
||||
return new MachOSliceArtifactReader(path, input.environment);
|
||||
if (format === "mach-o" || format === "mach-o-universal")
|
||||
return new MachOSliceArtifactReader(path, input.environment);
|
||||
throw new AnalysisUnsupportedTargetError(
|
||||
"extract_artifact",
|
||||
input.inputPath,
|
||||
`Artifact format has no extraction reader: ${format}`,
|
||||
);
|
||||
};
|
||||
|
||||
const preflight = (entry: ArtifactEntry): void => {
|
||||
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
classifyFatContainerIdentity,
|
||||
readFatContainerHeader,
|
||||
} from "../../domain/apple/machoContainer.js";
|
||||
import {
|
||||
nesContainerFormat,
|
||||
parseInesLayout,
|
||||
} from "../../domain/inesContainer.js";
|
||||
|
||||
/** Mutable internal occurrence used until root-bound IDs are known. */
|
||||
export interface MutableOccurrence {
|
||||
@@ -227,7 +231,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,
|
||||
@@ -427,6 +431,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
|
||||
@@ -440,6 +445,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 (hasBinaryPlistSignature(prefix)) return "plist";
|
||||
const fatFormat = classifyFatArtifact(prefix, fileSize);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type ArtifactInventoryOutput,
|
||||
} from "../../domain/artifactGraph.js";
|
||||
import { abortIfNeeded } from "../ArtifactHash.js";
|
||||
import { ArtifactReaderFailure } from "../ArtifactReader.js";
|
||||
import { scanCanonicalArtifactInventoryInScope } from "./scanCanonical.js";
|
||||
import type { ArtifactInventoryOptions } from "./types.js";
|
||||
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
|
||||
@@ -33,5 +34,14 @@ export const scanArtifactInventoryInScope = async (
|
||||
): Promise<ArtifactInventorySnapshot> => {
|
||||
abortIfNeeded(options.signal);
|
||||
const path = await realpath(inputPath);
|
||||
return scanCanonicalArtifactInventoryInScope(path, options);
|
||||
const snapshot = await scanCanonicalArtifactInventoryInScope(path, options);
|
||||
if (
|
||||
options.expectedRootSha256 !== undefined &&
|
||||
snapshot.manifest.root_sha256 !== options.expectedRootSha256
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Active artifact digest changed: expected ${options.expectedRootSha256}, observed ${snapshot.manifest.root_sha256}`,
|
||||
);
|
||||
return snapshot;
|
||||
};
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { sameRegularFileState } from "../../filesystem/RegularFile.js";
|
||||
import { readFileHandlePrefix } from "../../filesystem/readFileHandlePrefix.js";
|
||||
import {
|
||||
sameRegularFileState,
|
||||
verifyRegularFileState,
|
||||
RegularFileChangedError,
|
||||
} from "../../filesystem/RegularFile.js";
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import type { Stats } from "node:fs";
|
||||
import type { StableRegularFileDescriptor } from "../../filesystem/RegularFile.js";
|
||||
import type { OwnedFileHandle } from "../../filesystem/OwnedFileHandle.js";
|
||||
|
||||
import { classifyArtifactContent } from "./ArtifactGraphConstruction.js";
|
||||
import { ARTIFACT_CLASSIFICATION_PREFIX_BYTES } from "../ArtifactHash.js";
|
||||
import {
|
||||
nesContainerFormat,
|
||||
parseInesLayout,
|
||||
} from "../../domain/inesContainer.js";
|
||||
import {
|
||||
abortIfNeeded,
|
||||
ARTIFACT_CLASSIFICATION_PREFIX_BYTES,
|
||||
} from "../ArtifactHash.js";
|
||||
import type { ArtifactOccurrence } from "../../domain/artifactGraph.js";
|
||||
import {
|
||||
hasZipSignature,
|
||||
@@ -41,7 +53,10 @@ export const classifyAndHashRoot = async (
|
||||
path: string,
|
||||
directory: boolean,
|
||||
expectedMetadata: Stats,
|
||||
options: Pick<ArtifactInventoryOptions, "resourceScope" | "signal">,
|
||||
options: Pick<
|
||||
ArtifactInventoryOptions,
|
||||
"resourceScope" | "signal" | "admitRootFormat"
|
||||
>,
|
||||
): Promise<RootClassification> => {
|
||||
return options.resourceScope.run(async () => {
|
||||
const result = await classifyAndHashRootForInventory(
|
||||
@@ -71,15 +86,21 @@ 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,
|
||||
expectedMetadata: Stats,
|
||||
options: Pick<ArtifactInventoryOptions, "resourceScope" | "signal">,
|
||||
options: Pick<
|
||||
ArtifactInventoryOptions,
|
||||
"resourceScope" | "signal" | "admitRootFormat"
|
||||
>,
|
||||
): Promise<RootInventoryClassification> => {
|
||||
if (directory)
|
||||
abortIfNeeded(options.signal);
|
||||
if (directory) {
|
||||
options.admitRootFormat?.("directory");
|
||||
return { format: "directory", digest: null, rootSource: undefined };
|
||||
}
|
||||
const ownedHandle = await openRootArtifact(
|
||||
path,
|
||||
options.resourceScope,
|
||||
@@ -99,12 +120,20 @@ export const classifyAndHashRootForInventory = async (
|
||||
| { readonly kind: "failed"; readonly cause: unknown };
|
||||
try {
|
||||
const initial = await handle.stat();
|
||||
abortIfNeeded(options.signal);
|
||||
if (!sameRegularFileState(expectedMetadata, initial))
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Root artifact changed before inventory: ${path}`,
|
||||
);
|
||||
const format = await classifyRootFormat(path, handle);
|
||||
const format = await classifyRootFormat(
|
||||
path,
|
||||
handle,
|
||||
initial,
|
||||
options.signal,
|
||||
);
|
||||
abortIfNeeded(options.signal);
|
||||
options.admitRootFormat?.(format);
|
||||
const digest = await hashStableRootArtifactHandle(
|
||||
path,
|
||||
handle,
|
||||
@@ -112,7 +141,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: {
|
||||
@@ -124,7 +155,23 @@ export const classifyAndHashRootForInventory = async (
|
||||
},
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
outcome = { kind: "failed", cause };
|
||||
outcome = {
|
||||
kind: "failed",
|
||||
cause:
|
||||
options.signal?.aborted === true
|
||||
? new ArtifactReaderFailure(
|
||||
"cancelled",
|
||||
"Artifact inventory cancelled",
|
||||
{ cause },
|
||||
)
|
||||
: cause instanceof RegularFileChangedError
|
||||
? new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Root artifact changed during inventory: ${path}`,
|
||||
{ cause: cause.cause ?? cause },
|
||||
)
|
||||
: cause,
|
||||
};
|
||||
}
|
||||
if (outcome.kind === "failed" || outcome.value.rootSource === undefined) {
|
||||
const cleanupAttempt = await options.resourceScope.release(owner);
|
||||
@@ -144,10 +191,23 @@ export const classifyAndHashRootForInventory = async (
|
||||
const classifyRootFormat = async (
|
||||
path: string,
|
||||
handle: FileHandle,
|
||||
initial: Stats,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ArtifactOccurrence["artifact_format"]> => {
|
||||
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);
|
||||
abortIfNeeded(signal);
|
||||
const prefix = await readFileHandlePrefix(
|
||||
handle,
|
||||
ARTIFACT_CLASSIFICATION_PREFIX_BYTES,
|
||||
() => abortIfNeeded(signal),
|
||||
);
|
||||
abortIfNeeded(signal);
|
||||
await verifyRegularFileState(path, { handle, initial });
|
||||
abortIfNeeded(signal);
|
||||
if (nesContainerFormat(prefix) !== undefined) {
|
||||
const parsed = parseInesLayout(prefix, initial.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,3 @@
|
||||
import { lstat } from "node:fs/promises";
|
||||
import type { Stats } from "node:fs";
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import { readFileHandleChunks } from "../../filesystem/readFileHandleChunks.js";
|
||||
@@ -9,7 +8,8 @@ import {
|
||||
NonRegularFileReadError,
|
||||
RegularFileAdmissionFailure,
|
||||
openRegularFile,
|
||||
sameRegularFileState,
|
||||
RegularFileChangedError,
|
||||
verifyRegularFileState,
|
||||
} from "../../filesystem/RegularFile.js";
|
||||
import {
|
||||
abortIfNeeded,
|
||||
@@ -23,6 +23,7 @@ export const openRootArtifact = async (
|
||||
path: string,
|
||||
resourceScope: ArtifactResourceScope,
|
||||
signal?: AbortSignal,
|
||||
admission: "initial" | "inventoried" = "initial",
|
||||
): Promise<OwnedFileHandle> => {
|
||||
try {
|
||||
return new OwnedFileHandle(
|
||||
@@ -31,14 +32,41 @@ export const openRootArtifact = async (
|
||||
} catch (cause: unknown) {
|
||||
const primary =
|
||||
cause instanceof RegularFileAdmissionFailure ? cause.cause : cause;
|
||||
const changed =
|
||||
admission === "inventoried" &&
|
||||
(primary instanceof NonRegularFileReadError ||
|
||||
primary instanceof RegularFileChangedError ||
|
||||
(primary instanceof Error &&
|
||||
"code" in primary &&
|
||||
[
|
||||
"ENOENT",
|
||||
"ENOTDIR",
|
||||
"ELOOP",
|
||||
"EISDIR",
|
||||
"ENOTFILE",
|
||||
"ESTALE",
|
||||
].includes(String(primary.code))));
|
||||
// Normalize the primary failure before cleanup so failed closes keep both facts.
|
||||
const failure =
|
||||
primary instanceof NonRegularFileReadError
|
||||
signal?.aborted === true
|
||||
? new ArtifactReaderFailure(
|
||||
"format",
|
||||
`Artifact root is not a regular file: ${path}`,
|
||||
"cancelled",
|
||||
"Artifact admission cancelled",
|
||||
{ cause: primary },
|
||||
)
|
||||
: primary;
|
||||
: changed
|
||||
? new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Inventoried artifact source changed: ${path}`,
|
||||
{ cause: primary },
|
||||
)
|
||||
: primary instanceof NonRegularFileReadError
|
||||
? new ArtifactReaderFailure(
|
||||
"format",
|
||||
`Artifact root is not a regular file: ${path}`,
|
||||
{ cause: primary },
|
||||
)
|
||||
: primary;
|
||||
if (cause instanceof RegularFileAdmissionFailure) {
|
||||
const resource = `root artifact descriptor for ${path}`;
|
||||
const cleanup = await resourceScope.release({
|
||||
@@ -105,12 +133,20 @@ export const hashStableRootArtifactHandle = async (
|
||||
signal,
|
||||
);
|
||||
abortIfNeeded(signal);
|
||||
const [opened, currentPath] = await Promise.all([handle.stat(), lstat(path)]);
|
||||
if (
|
||||
!sameRegularFileState(initial, opened) ||
|
||||
!sameRegularFileState(initial, currentPath) ||
|
||||
digest.bytes !== initial.size
|
||||
)
|
||||
try {
|
||||
await verifyRegularFileState(path, { handle, initial });
|
||||
} catch (cause: unknown) {
|
||||
abortIfNeeded(signal);
|
||||
if (cause instanceof RegularFileChangedError)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Root artifact changed during inventory: ${path}`,
|
||||
{ cause: cause.cause ?? cause },
|
||||
);
|
||||
throw cause;
|
||||
}
|
||||
abortIfNeeded(signal);
|
||||
if (digest.bytes !== initial.size)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Root artifact changed during inventory: ${path}`,
|
||||
|
||||
@@ -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";
|
||||
@@ -24,6 +26,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":
|
||||
@@ -52,6 +61,7 @@ export const inventoryAssessment = (
|
||||
} => {
|
||||
let coverageStatus: ArtifactInventoryCoverage = "complete";
|
||||
const limitations: string[] = [];
|
||||
if (format === "ines") limitations.push(INES_LIMITATION);
|
||||
if (reader !== undefined) {
|
||||
if (isZipPackageFormat(reader.format))
|
||||
limitations.push(ZIP_NON_ENTRY_TAIL_LIMITATION);
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
|
||||
import type { InesInventory } from "../../domain/inesContainer.js";
|
||||
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
|
||||
|
||||
import { AsarArtifactReader } from "../AsarArtifactReader.js";
|
||||
import {
|
||||
InesArtifactReader,
|
||||
type InesRegionScan,
|
||||
} from "../InesArtifactReader.js";
|
||||
|
||||
import { lstat } from "node:fs/promises";
|
||||
import type { Stats } from "node:fs";
|
||||
@@ -99,6 +104,7 @@ export const scanCanonicalArtifactInventoryInScope = async (
|
||||
occurrences,
|
||||
pendingContradictions,
|
||||
caseCollisionLimitation,
|
||||
inesRegionScan,
|
||||
} = await scanReader(reader, ownedReaders, options.signal, integrity);
|
||||
outcome = {
|
||||
kind: "completed",
|
||||
@@ -115,6 +121,7 @@ export const scanCanonicalArtifactInventoryInScope = async (
|
||||
occurrences,
|
||||
pendingContradictions,
|
||||
caseCollisionLimitation,
|
||||
inesRegionScan,
|
||||
}),
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
@@ -196,6 +203,7 @@ interface SnapshotBuildInput {
|
||||
readonly occurrences: MutableOccurrence[];
|
||||
readonly pendingContradictions: PendingIntegrityContradiction[];
|
||||
readonly caseCollisionLimitation: string | undefined;
|
||||
readonly inesRegionScan: InesRegionScan | undefined;
|
||||
}
|
||||
|
||||
const buildInventorySnapshot = async (
|
||||
@@ -274,8 +282,22 @@ const buildInventorySnapshot = async (
|
||||
integrityContradictions,
|
||||
input.caseCollisionLimitation,
|
||||
);
|
||||
let ines: InesInventory | undefined;
|
||||
if (reader instanceof InesArtifactReader) {
|
||||
if (rootDigest === null || input.inesRegionScan === undefined)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
"Missing iNES reconstruction scan",
|
||||
);
|
||||
ines = await reader.verifyReconstruction(
|
||||
rootDigest.sha256,
|
||||
input.inesRegionScan,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
return {
|
||||
manifest,
|
||||
...(ines === undefined ? {} : { ines }),
|
||||
nodes: orderedNodes,
|
||||
occurrences: orderedOccurrences,
|
||||
edges: orderedEdges,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isAbsolute } from "node:path";
|
||||
import { createHash, type Hash } from "node:crypto";
|
||||
|
||||
import {
|
||||
annotateEnglishUnicodeCaseCollisions,
|
||||
@@ -11,6 +12,10 @@ import {
|
||||
type ArtifactReader,
|
||||
} from "../ArtifactReader.js";
|
||||
import { AsarArtifactReader } from "../AsarArtifactReader.js";
|
||||
import {
|
||||
InesArtifactReader,
|
||||
type InesRegionScan,
|
||||
} from "../InesArtifactReader.js";
|
||||
import type { ArtifactNode } from "../../domain/artifactGraph.js";
|
||||
import {
|
||||
classifyArtifactContent,
|
||||
@@ -55,6 +60,13 @@ interface ScanContext {
|
||||
readonly registry: ArtifactPathRegistry;
|
||||
readonly expandedContainerIds: Set<string>;
|
||||
readonly ownedReaders: ArtifactReader[];
|
||||
readonly reconstruction:
|
||||
| {
|
||||
readonly hash: Hash;
|
||||
readonly regionOrder: string[];
|
||||
bytes: number;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export const scanReader = async (
|
||||
@@ -67,6 +79,7 @@ export const scanReader = async (
|
||||
readonly occurrences: MutableOccurrence[];
|
||||
readonly pendingContradictions: PendingIntegrityContradiction[];
|
||||
readonly caseCollisionLimitation: string | undefined;
|
||||
readonly inesRegionScan: InesRegionScan | undefined;
|
||||
}> => {
|
||||
const { nodes, occurrences, pendingContradictions } = emptyScan();
|
||||
if (reader === undefined)
|
||||
@@ -75,6 +88,7 @@ export const scanReader = async (
|
||||
occurrences,
|
||||
pendingContradictions,
|
||||
caseCollisionLimitation: undefined,
|
||||
inesRegionScan: undefined,
|
||||
};
|
||||
const context: ScanContext = {
|
||||
reader,
|
||||
@@ -87,6 +101,10 @@ export const scanReader = async (
|
||||
registry: new ArtifactPathRegistry(),
|
||||
expandedContainerIds: new Set<string>(),
|
||||
ownedReaders,
|
||||
reconstruction:
|
||||
reader instanceof InesArtifactReader
|
||||
? { hash: createHash("sha256"), regionOrder: [], bytes: 0 }
|
||||
: undefined,
|
||||
};
|
||||
await visitArtifactEntries(context, reader, "");
|
||||
// Archive directories may appear after their children. Resolve containment
|
||||
@@ -101,6 +119,14 @@ export const scanReader = async (
|
||||
occurrences,
|
||||
pendingContradictions,
|
||||
caseCollisionLimitation: annotateEnglishUnicodeCaseCollisions(occurrences),
|
||||
inesRegionScan:
|
||||
context.reconstruction === undefined
|
||||
? undefined
|
||||
: {
|
||||
region_order: context.reconstruction.regionOrder,
|
||||
bytes: context.reconstruction.bytes,
|
||||
sha256: context.reconstruction.hash.digest("hex"),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -233,10 +259,22 @@ const digestArtifactEntry = async (
|
||||
> => {
|
||||
if ((entry.kind !== "file" && entry.kind !== "slice") || entry.encrypted)
|
||||
return undefined;
|
||||
const reconstruction =
|
||||
currentReader === context.reader ? context.reconstruction : undefined;
|
||||
if (reconstruction !== undefined && entry.byteOffset !== reconstruction.bytes)
|
||||
throw new ArtifactReaderFailure("integrity", "Noncontiguous iNES regions");
|
||||
const digest = await hashReadable(
|
||||
await currentReader.open(entry, context.signal),
|
||||
context.signal,
|
||||
reconstruction === undefined
|
||||
? undefined
|
||||
: (chunk) => {
|
||||
// Hash the same bytes used for each node, in physical region order.
|
||||
reconstruction.hash.update(chunk);
|
||||
reconstruction.bytes += chunk.length;
|
||||
},
|
||||
);
|
||||
reconstruction?.regionOrder.push(entry.path);
|
||||
const mismatched =
|
||||
entry.declaredSha256 !== null && entry.declaredSha256 !== digest.sha256;
|
||||
if (mismatched && entry.declaredSha256 !== null) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ArtifactResourceScope } from "../ArtifactResourceScope.js";
|
||||
import type { ArtifactOccurrence } from "../../domain/artifactGraph.js";
|
||||
|
||||
/** Resolved integrity behavior admitted to the artifact scanner. */
|
||||
export type ArtifactIntegrityPolicy =
|
||||
@@ -12,7 +13,13 @@ export const STRICT_INTEGRITY_POLICY: ArtifactIntegrityPolicy = {
|
||||
/** Options shared by artifact inventory scans. */
|
||||
export interface ArtifactInventoryOptions {
|
||||
readonly resourceScope: ArtifactResourceScope;
|
||||
/** Bind a session-owned leaf inventory to the admitted root digest. */
|
||||
readonly expectedRootSha256?: string | undefined;
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
readonly integrity?: ArtifactIntegrityPolicy | undefined;
|
||||
readonly environment?: Readonly<NodeJS.ProcessEnv> | undefined;
|
||||
/** Admit the current root before payload hashing or native child inventory. */
|
||||
readonly admitRootFormat?: (
|
||||
format: ArtifactOccurrence["artifact_format"],
|
||||
) => void;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,17 @@ import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisTimeoutError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { WEB_RUNTIME_LIMITS } from "../domain/webRuntime.js";
|
||||
|
||||
export const PLAYWRIGHT_NETWORK_READ_TIMEOUT_MS = 5_000;
|
||||
|
||||
// Cleanup drains bounded content reads before closing Chrome and removing its
|
||||
// profile. Cover those stages and retain the observed 30-second allowance for
|
||||
// slow persistent-context close on shared runners.
|
||||
export const PLAYWRIGHT_SCENARIO_CLEANUP_TIMEOUT_MS = Math.max(
|
||||
30_000,
|
||||
PLAYWRIGHT_NETWORK_READ_TIMEOUT_MS + WEB_RUNTIME_LIMITS.cleanupTimeoutMs,
|
||||
);
|
||||
|
||||
const OPERATION = "capture_browser_scenario" as const;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, expect, it, vi } from "vitest";
|
||||
import type { Browser } from "playwright-core";
|
||||
|
||||
import { browserScenarioSchema } from "../domain/browserScenario.js";
|
||||
import { PLAYWRIGHT_SCENARIO_CLEANUP_TIMEOUT_MS } from "./PlaywrightExecutionBoundary.js";
|
||||
import {
|
||||
attachedScenarioLeaseKey,
|
||||
openPlaywrightScenarioBrowser,
|
||||
@@ -144,6 +145,35 @@ it("keeps attached-target ownership until cancelled cleanup actually settles", a
|
||||
expect(settled).toEqual([true]);
|
||||
});
|
||||
|
||||
it("waits for bounded event finalization and owned browser teardown", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const closeBrowser = vi.fn(
|
||||
() => new Promise<void>((resolve) => setTimeout(resolve, 1_500)),
|
||||
);
|
||||
const removeProfile = vi.fn(async () => undefined);
|
||||
const cleanup = new PlaywrightScenarioBrowserCleanupOwner({
|
||||
closeBrowser,
|
||||
removeProfile,
|
||||
});
|
||||
const finished = cleanup
|
||||
.close(() => new Promise<void>((resolve) => setTimeout(resolve, 4_500)))
|
||||
.then(
|
||||
() => ({ ok: true }),
|
||||
(cause: unknown) => ({ ok: false, cause }),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(4_500);
|
||||
expect(removeProfile).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1_500);
|
||||
expect(await finished).toEqual({ ok: true });
|
||||
expect(cleanup.browserClosed).toBe(true);
|
||||
expect(closeBrowser).toHaveBeenCalledOnce();
|
||||
expect(removeProfile).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps attached-target ownership after the cleanup response timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
@@ -170,7 +200,9 @@ it("keeps attached-target ownership after the cleanup response timeout", async (
|
||||
responseSettled = true;
|
||||
})
|
||||
.catch(() => {});
|
||||
await vi.advanceTimersByTimeAsync(29_999);
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
PLAYWRIGHT_SCENARIO_CLEANUP_TIMEOUT_MS - 1,
|
||||
);
|
||||
expect(responseSettled).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await timeoutExpectation;
|
||||
|
||||
@@ -8,13 +8,12 @@ import type { BrowserScenario } from "../domain/browserScenario.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { isLiteralLoopbackHostname } from "../domain/browserObservation.js";
|
||||
import { BrowserObservationError } from "../domain/browserObservationError.js";
|
||||
import { withPlaywrightExecutionBoundary } from "./PlaywrightExecutionBoundary.js";
|
||||
import {
|
||||
PLAYWRIGHT_SCENARIO_CLEANUP_TIMEOUT_MS,
|
||||
withPlaywrightExecutionBoundary,
|
||||
} from "./PlaywrightExecutionBoundary.js";
|
||||
|
||||
const OPERATION = "capture_browser_scenario" as const;
|
||||
// Closing a real Chrome persistent context can take several seconds even when
|
||||
// it succeeds. The shorter operation deadline must not misreport cleanup.
|
||||
const CLEANUP_CONFIRMATION_TIMEOUT_MS = 30_000;
|
||||
|
||||
const throwIfScenarioCancelled = (signal: AbortSignal | undefined): void => {
|
||||
if (signal?.aborted === true)
|
||||
throw new BrowserObservationError(OPERATION, "cancelled");
|
||||
@@ -76,7 +75,7 @@ export class PlaywrightScenarioBrowserCleanupOwner {
|
||||
try {
|
||||
await withPlaywrightExecutionBoundary(
|
||||
() => cleanup,
|
||||
CLEANUP_CONFIRMATION_TIMEOUT_MS,
|
||||
PLAYWRIGHT_SCENARIO_CLEANUP_TIMEOUT_MS,
|
||||
signal,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
|
||||
@@ -14,19 +14,9 @@ const mocks = vi.hoisted(() => ({
|
||||
finishEvents: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./PlaywrightScenarioBrowser.js", () => ({
|
||||
vi.mock("./PlaywrightScenarioBrowser.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./PlaywrightScenarioBrowser.js")>()),
|
||||
openPlaywrightScenarioBrowser: mocks.openBrowser,
|
||||
failBrowserScenarioOperation: async (
|
||||
cleanup: () => Promise<void>,
|
||||
cause: unknown,
|
||||
) => {
|
||||
try {
|
||||
await cleanup();
|
||||
} catch (cleanupCause: unknown) {
|
||||
throw new AggregateError([cause, cleanupCause]);
|
||||
}
|
||||
throw cause;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./PlaywrightScenarioStorage.js", () => ({
|
||||
@@ -63,6 +53,7 @@ vi.mock("./PlaywrightScenarioEvents.js", () => ({
|
||||
|
||||
import { browserScenarioSchema } from "../domain/browserScenario.js";
|
||||
import { PlaywrightScenarioSession } from "./PlaywrightScenarioSession.js";
|
||||
import { PlaywrightScenarioBrowserCleanupOwner } from "./PlaywrightScenarioBrowser.js";
|
||||
|
||||
const scenario = browserScenarioSchema.parse({
|
||||
browser: { mode: "launch", executable_path: process.execPath },
|
||||
@@ -106,25 +97,100 @@ const fixture = () => {
|
||||
return browserClosed;
|
||||
},
|
||||
};
|
||||
mocks.openBrowser.mockResolvedValue({
|
||||
const opened = {
|
||||
page,
|
||||
context,
|
||||
browser,
|
||||
cleanup,
|
||||
profilePath: "/tmp/profile",
|
||||
});
|
||||
};
|
||||
mocks.openBrowser.mockResolvedValue(opened);
|
||||
mocks.storage = {
|
||||
initialize: vi.fn(async () => undefined),
|
||||
settle: vi.fn(async () => undefined),
|
||||
close: vi.fn(async () => undefined),
|
||||
};
|
||||
return { cleanup, page, storage: mocks.storage };
|
||||
return { cleanup, page, context, storage: mocks.storage, opened };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("finishes initialization cleanup independently of request cancellation", async () => {
|
||||
const { context, opened } = fixture();
|
||||
const controller = new AbortController();
|
||||
const closeBrowser = vi.fn(async () => undefined);
|
||||
const removeProfile = vi.fn(async () => undefined);
|
||||
mocks.openBrowser.mockResolvedValue({
|
||||
...opened,
|
||||
cleanup: new PlaywrightScenarioBrowserCleanupOwner({
|
||||
closeBrowser,
|
||||
removeProfile,
|
||||
}),
|
||||
});
|
||||
context.addCookies.mockImplementationOnce(async () => {
|
||||
controller.abort();
|
||||
});
|
||||
const retainCleanup = vi.fn();
|
||||
|
||||
await expect(
|
||||
PlaywrightScenarioSession.open(
|
||||
scenario,
|
||||
{},
|
||||
{
|
||||
signal: controller.signal,
|
||||
retainCleanup,
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({ _tag: "AnalysisCancelledError" });
|
||||
expect(closeBrowser).toHaveBeenCalledTimes(1);
|
||||
expect(removeProfile).toHaveBeenCalledTimes(1);
|
||||
expect(retainCleanup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("can retry cleanup of a browser that opened after request cancellation", async () => {
|
||||
const { opened } = fixture();
|
||||
const closeBrowser = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("transport close failed"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const removeProfile = vi.fn(async () => undefined);
|
||||
const lateOpened = {
|
||||
...opened,
|
||||
cleanup: new PlaywrightScenarioBrowserCleanupOwner({
|
||||
closeBrowser,
|
||||
removeProfile,
|
||||
}),
|
||||
};
|
||||
let resolveOpening: ((value: typeof lateOpened) => void) | undefined;
|
||||
const opening = new Promise<typeof lateOpened>((resolve) => {
|
||||
resolveOpening = resolve;
|
||||
});
|
||||
mocks.openBrowser.mockReturnValueOnce(opening);
|
||||
const controller = new AbortController();
|
||||
const retained: (() => Promise<unknown>)[] = [];
|
||||
const result = PlaywrightScenarioSession.open(
|
||||
scenario,
|
||||
{},
|
||||
{
|
||||
signal: controller.signal,
|
||||
retainCleanup: (close) => retained.push(close),
|
||||
},
|
||||
);
|
||||
const cancelled = expect(result).rejects.toMatchObject({
|
||||
_tag: "AnalysisCancelledError",
|
||||
});
|
||||
controller.abort();
|
||||
await cancelled;
|
||||
resolveOpening?.(lateOpened);
|
||||
await vi.waitFor(() => expect(closeBrowser).toHaveBeenCalledTimes(1));
|
||||
expect(retained).toHaveLength(1);
|
||||
await expect(retained[0]?.()).resolves.toBeUndefined();
|
||||
expect(closeBrowser).toHaveBeenCalledTimes(2);
|
||||
expect(removeProfile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses browser disconnect as terminal storage release after detach failure", async () => {
|
||||
const { cleanup, storage } = fixture();
|
||||
const detachFailure = new Error("CDP detach rejected");
|
||||
|
||||
@@ -14,7 +14,10 @@ import { performPlaywrightScenarioAction } from "./PlaywrightScenarioActions.js"
|
||||
import { capturePlaywrightStepArtifacts } from "./PlaywrightScenarioArtifacts.js";
|
||||
import { PlaywrightScenarioEvents } from "./PlaywrightScenarioEvents.js";
|
||||
import { PlaywrightScenarioStorage } from "./PlaywrightScenarioStorage.js";
|
||||
import { withPlaywrightExecutionBoundary } from "./PlaywrightExecutionBoundary.js";
|
||||
import {
|
||||
PLAYWRIGHT_SCENARIO_CLEANUP_TIMEOUT_MS,
|
||||
withPlaywrightExecutionBoundary,
|
||||
} from "./PlaywrightExecutionBoundary.js";
|
||||
import {
|
||||
browserScenarioCaptureData,
|
||||
browserScenarioOperationFailure,
|
||||
@@ -148,13 +151,17 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
|
||||
options.signal,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
// Resource release outlives the cancelled request, including retained retries.
|
||||
const lateCleanup = () =>
|
||||
opening.then(
|
||||
(lateOpened) => lateOpened.cleanup.close(undefined, options.signal),
|
||||
(lateOpened) => lateOpened.cleanup.close(),
|
||||
() => undefined,
|
||||
);
|
||||
options.retainCleanup?.(() =>
|
||||
withPlaywrightExecutionBoundary(lateCleanup, 1_000),
|
||||
withPlaywrightExecutionBoundary(
|
||||
lateCleanup,
|
||||
PLAYWRIGHT_SCENARIO_CLEANUP_TIMEOUT_MS,
|
||||
),
|
||||
);
|
||||
void lateCleanup().catch((cleanupCause: unknown) => {
|
||||
// best-effort cleanup: retainCleanup keeps the retry capability.
|
||||
@@ -280,7 +287,6 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
|
||||
events === undefined
|
||||
? undefined
|
||||
: () => events?.finish() ?? Promise.resolve(),
|
||||
options.signal,
|
||||
);
|
||||
} catch (failure: unknown) {
|
||||
browserFailure = { cause: failure };
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
} from "../../domain/browserNetworkEvidence.js";
|
||||
import type { BrowserScenarioEvent } from "../../domain/browserScenarioCaptureValues.js";
|
||||
import type { BrowserScenarioSecrets } from "../BrowserScenarioSecrets.js";
|
||||
import { PLAYWRIGHT_NETWORK_READ_TIMEOUT_MS } from "../PlaywrightExecutionBoundary.js";
|
||||
import {
|
||||
captureNetworkBody,
|
||||
captureNetworkHeaders,
|
||||
@@ -76,7 +77,7 @@ export class PlaywrightScenarioNetwork {
|
||||
|
||||
constructor(private readonly options: NetworkCaptureOptions) {
|
||||
this.readOptions = {
|
||||
timeoutMs: options.readTimeoutMs ?? 5_000,
|
||||
timeoutMs: options.readTimeoutMs ?? PLAYWRIGHT_NETWORK_READ_TIMEOUT_MS,
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ const artifact = <
|
||||
export const ARTIFACT_TOOL_CONTRACTS = [
|
||||
artifact(
|
||||
"inspect_artifact",
|
||||
"Inspect an active archive or application package in one call. By default returns the complete content-addressed artifact graph once, as the typed nested inventory Evidence, with root and integrity observations, integrity contradictions, hypotheses, unexplored branches, limitations, and next probes inline. Optional detail summary retains the complete inspection and returns its derived summary view; read exact paths or inventory pages with inspect_analysis_view using parent_evidence_id. This changes delivery, not inventory coverage. 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. By default returns the complete content-addressed artifact graph once, as the typed nested inventory Evidence, with root and integrity observations, integrity contradictions, hypotheses, unexplored branches, limitations, and next probes inline. Optional detail summary retains the complete inspection and returns its derived summary view; read exact paths or inventory pages with inspect_analysis_view using parent_evidence_id. This changes delivery, not inventory coverage. 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.",
|
||||
artifactInspectionInputSchema,
|
||||
),
|
||||
artifact(
|
||||
|
||||
@@ -13,6 +13,7 @@ const EXECUTION_WORDING =
|
||||
/\b(?:run|runs|running|launch|launches|launching|execute|executes|executing|execution|start|starts|install|installs|uninstall|uninstalls)\b/iu;
|
||||
|
||||
const EXECUTION_WORDING_EXCEPTIONS: Readonly<Record<string, string>> = {
|
||||
inspect_artifact: "states that mapper execution remains unobserved",
|
||||
start_frida_session:
|
||||
"spawned targets remain suspended until resume_frida_session",
|
||||
inspect_native_load_image: "states that it does not execute the target",
|
||||
|
||||
@@ -62,6 +62,7 @@ export const targetFormatSchema = z.enum([
|
||||
"raw-binary",
|
||||
...ZIP_PACKAGE_FORMATS,
|
||||
"asar",
|
||||
"ines",
|
||||
"dmg",
|
||||
"pkg",
|
||||
"plist",
|
||||
|
||||
@@ -69,6 +69,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",
|
||||
@@ -202,6 +205,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()),
|
||||
/** Optional only for persisted inventory Evidence written before this field. */
|
||||
coverage_status: artifactInventoryCoverageSchema.optional(),
|
||||
@@ -211,6 +215,8 @@ export const artifactInventoryResultSchema = z.object({
|
||||
export const artifactInventoryOutputSchema =
|
||||
artifactInventoryResultSchema.extend({
|
||||
coverage_status: artifactInventoryCoverageSchema,
|
||||
// JSON output may omit container facts, but cannot carry an undefined member.
|
||||
ines: inesInventorySchema.exactOptional(),
|
||||
});
|
||||
export type ArtifactInventoryOutput = z.infer<
|
||||
typeof artifactInventoryOutputSchema
|
||||
|
||||
@@ -17,7 +17,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,
|
||||
@@ -228,6 +233,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 = (
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
artifactInventoryOutputSchema,
|
||||
artifactInventoryResultSchema,
|
||||
} from "./artifactGraph.js";
|
||||
import { jsonValueSchema } from "./jsonValue.js";
|
||||
|
||||
const digest = "a".repeat(64);
|
||||
const inventory = {
|
||||
manifest: {
|
||||
manifest_id: `agm_${digest}`,
|
||||
root_artifact_id: `art_${digest}`,
|
||||
root_sha256: digest,
|
||||
root_format: "zip",
|
||||
graph_sha256: digest,
|
||||
node_count: 1,
|
||||
occurrence_count: 1,
|
||||
edge_count: 0,
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
artifact_id: `art_${digest}`,
|
||||
format: "zip",
|
||||
sha256: digest,
|
||||
size: 22,
|
||||
media_type: null,
|
||||
architecture: null,
|
||||
content_state: "materialized",
|
||||
limitations: [],
|
||||
},
|
||||
],
|
||||
occurrences: [
|
||||
{
|
||||
occurrence_id: `occ_${digest}`,
|
||||
artifact_id: `art_${digest}`,
|
||||
parent_occurrence_id: null,
|
||||
logical_path: ".",
|
||||
entry_kind: "file",
|
||||
artifact_kind: "container",
|
||||
artifact_format: "zip",
|
||||
declared_size: 22,
|
||||
compressed_size: null,
|
||||
executable: false,
|
||||
encrypted: false,
|
||||
hash_status: "verified",
|
||||
source_location: null,
|
||||
limitations: [],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
provenance: [],
|
||||
integrity_contradictions: [],
|
||||
limitations: [],
|
||||
coverage_status: "complete",
|
||||
};
|
||||
|
||||
it("keeps absent container facts absent in JSON-safe inventory output", () => {
|
||||
const output = artifactInventoryOutputSchema.parse(inventory);
|
||||
expect(Object.hasOwn(output, "ines")).toBe(false);
|
||||
expect(jsonValueSchema.safeParse(output).success).toBe(true);
|
||||
expect(
|
||||
artifactInventoryOutputSchema.safeParse({ ...inventory, ines: undefined })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps historical coverage omission separate from current output requirements", () => {
|
||||
const { coverage_status: _coverage, ...historical } = inventory;
|
||||
const imported = artifactInventoryResultSchema.parse(historical);
|
||||
expect(Object.hasOwn(imported, "coverage_status")).toBe(false);
|
||||
expect(artifactInventoryOutputSchema.safeParse(historical).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -16,5 +16,6 @@ export interface ArtifactInventorySnapshot {
|
||||
ArtifactInventoryResult["provenance"][number]
|
||||
>;
|
||||
readonly integrity_contradictions: readonly IntegrityContradiction[];
|
||||
readonly ines?: ArtifactInventoryResult["ines"];
|
||||
readonly limitations: readonly string[];
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ export type BinaryTarget =
|
||||
| (BinaryTargetIdentity &
|
||||
NonExecutableMetadata & {
|
||||
readonly kind: "archive";
|
||||
readonly format: ZipPackageFormat | "asar" | "dmg" | "pkg";
|
||||
readonly format: ZipPackageFormat | "asar" | "ines" | "dmg" | "pkg";
|
||||
})
|
||||
| (BinaryTargetIdentity &
|
||||
NonExecutableMetadata & {
|
||||
|
||||
+21
-12
@@ -19,7 +19,7 @@ const TARGET: BinaryTarget = {
|
||||
const PROVIDER = { id: "fixture", name: "Fixture provider", version: "1" };
|
||||
const PROFILE = createAnalysisProfile(PROVIDER, { loader: "default" });
|
||||
|
||||
it("rejects a detected Evidence subject with the raw-only 6502 architecture", () => {
|
||||
it("keeps 6502 confined to explicit raw Evidence subjects alongside iNES", () => {
|
||||
const selected = {
|
||||
path: TARGET.path,
|
||||
sha256: TARGET.sha256,
|
||||
@@ -33,17 +33,26 @@ it("rejects a detected Evidence subject with the raw-only 6502 architecture", ()
|
||||
result: null,
|
||||
}).subject,
|
||||
).toMatchObject({ format: "raw-binary", architecture: "6502" });
|
||||
const malformed = {
|
||||
...selected,
|
||||
format: "pe" as const,
|
||||
};
|
||||
expect(() =>
|
||||
createEvidence(malformed, PROVIDER, {
|
||||
operation: "inspect",
|
||||
parameters: {},
|
||||
result: null,
|
||||
}),
|
||||
).toThrow();
|
||||
expect(
|
||||
createEvidence(
|
||||
{
|
||||
path: selected.path,
|
||||
sha256: selected.sha256,
|
||||
format: "ines",
|
||||
},
|
||||
PROVIDER,
|
||||
{ operation: "inspect", parameters: {}, result: null },
|
||||
).subject,
|
||||
).toMatchObject({ format: "ines", architecture: null });
|
||||
for (const format of ["pe", "ines"] as const) {
|
||||
expect(() =>
|
||||
createEvidence({ ...selected, format }, PROVIDER, {
|
||||
operation: "inspect",
|
||||
parameters: {},
|
||||
result: null,
|
||||
}),
|
||||
).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves omitted relationship diagnostics and authenticates reported diagnostics", () => {
|
||||
|
||||
@@ -56,6 +56,7 @@ const subjectFormatSchema = z.enum([
|
||||
"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,
|
||||
});
|
||||
};
|
||||
@@ -40,8 +40,11 @@ export class RegularFileAdmissionFailure extends Error {
|
||||
/** A regular input no longer matches the extent or identity admitted for reading. */
|
||||
export class RegularFileChangedError extends Error {
|
||||
readonly code = "ESTALE";
|
||||
constructor(readonly path: string) {
|
||||
super(`Selected input changed while being read: ${path}`);
|
||||
constructor(
|
||||
readonly path: string,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(`Selected input changed while being read: ${path}`, options);
|
||||
this.name = "RegularFileChangedError";
|
||||
}
|
||||
}
|
||||
@@ -52,6 +55,30 @@ export interface StableRegularFileDescriptor {
|
||||
readonly initial: Stats;
|
||||
}
|
||||
|
||||
/** Revalidate admitted bytes before interpreting them, including lost path identity. */
|
||||
export const verifyRegularFileState = async (
|
||||
path: string,
|
||||
source: StableRegularFileDescriptor,
|
||||
): Promise<void> => {
|
||||
const [opened, selected] = await Promise.all([
|
||||
source.handle.stat(),
|
||||
lstat(path).catch((cause: unknown) => {
|
||||
if (
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
["ENOENT", "ENOTDIR", "ELOOP"].includes(String(cause.code))
|
||||
)
|
||||
throw new RegularFileChangedError(path, { cause });
|
||||
throw cause;
|
||||
}),
|
||||
]);
|
||||
if (
|
||||
!sameRegularFileState(source.initial, opened) ||
|
||||
!sameRegularFileState(source.initial, selected)
|
||||
)
|
||||
throw new RegularFileChangedError(path);
|
||||
};
|
||||
|
||||
/** Open and verify the selected file descriptor without blocking on pipes. */
|
||||
export const openRegularFile = async (
|
||||
path: string,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
|
||||
/** Fill a positional prefix until its bound or true EOF, preserving caller cancellation errors. */
|
||||
export const readFileHandlePrefix = async (
|
||||
handle: Pick<FileHandle, "read">,
|
||||
length: number,
|
||||
checkCancellation: () => void,
|
||||
): Promise<Buffer> => {
|
||||
const prefix = Buffer.alloc(length);
|
||||
let offset = 0;
|
||||
while (offset < length) {
|
||||
checkCancellation();
|
||||
let bytesRead: number;
|
||||
try {
|
||||
({ bytesRead } = await handle.read(
|
||||
prefix,
|
||||
offset,
|
||||
length - offset,
|
||||
offset,
|
||||
));
|
||||
} catch (cause: unknown) {
|
||||
checkCancellation();
|
||||
throw cause;
|
||||
}
|
||||
checkCancellation();
|
||||
if (bytesRead === 0) break;
|
||||
offset += bytesRead;
|
||||
}
|
||||
return prefix.subarray(0, offset);
|
||||
};
|
||||
@@ -0,0 +1,313 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
import {
|
||||
artifactInspectionOutputSchema,
|
||||
artifactInspectionResultSchema,
|
||||
} from "../../src/domain/artifactInspection.js";
|
||||
import {
|
||||
artifactInventoryOutputSchema,
|
||||
artifactExtractionResultSchema,
|
||||
} from "../../src/domain/artifactGraph.js";
|
||||
import { analysisViewResultSchema } from "../../src/domain/analysisView/analysisView.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";
|
||||
import { writeOrderedZip } from "../fixtures/artifactEntryOrder.js";
|
||||
import { ZIP_PACKAGE_FORMATS } from "../../src/domain/zipPackageFormat.js";
|
||||
import { toolContract } from "../../src/contracts/toolContracts.js";
|
||||
import { buildBinary } from "plist";
|
||||
|
||||
cliTest(
|
||||
"refuses extraction after an already open plist is replaced through MCP",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-ines-plist-refresh-");
|
||||
const path = join(root, "source.bin");
|
||||
await writeFile(path, buildBinary({ value: "old" }));
|
||||
const { call } = await connectLocalToolsMcp();
|
||||
const opened = await call("open_binary", { path });
|
||||
expect(opened.isError, JSON.stringify(opened.structuredContent)).not.toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
toolContract("open_binary").outputSchema.parse(opened.structuredContent)
|
||||
.result,
|
||||
).toMatchObject({ format: "plist" });
|
||||
const bytes = inesFixture();
|
||||
await writeFile(path, bytes);
|
||||
const extracted = await call("extract_artifact", {});
|
||||
expect(extracted.isError).toBe(true);
|
||||
expect(JSON.stringify(parseMcpToolError(extracted))).toContain(
|
||||
"Active artifact digest changed",
|
||||
);
|
||||
await call("close_binary", {});
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"refreshes an iNES path through MCP for every ZIP package family",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-ines-zip-refresh-");
|
||||
const { call } = await connectLocalToolsMcp();
|
||||
for (const format of ZIP_PACKAGE_FORMATS) {
|
||||
const path = join(
|
||||
root,
|
||||
`cartridge.${format === "app-pack" ? "app" : format}`,
|
||||
);
|
||||
await writeFile(path, inesFixture());
|
||||
const first = await call("open_binary", { path });
|
||||
expect(first.isError, JSON.stringify(first.structuredContent)).not.toBe(
|
||||
true,
|
||||
);
|
||||
await writeOrderedZip(path, ["payload.txt"]);
|
||||
const refreshed = await call("open_binary", { path });
|
||||
expect(
|
||||
refreshed.isError,
|
||||
JSON.stringify({ format, response: refreshed }),
|
||||
).not.toBe(true);
|
||||
expect(
|
||||
toolContract("open_binary").outputSchema.parse(
|
||||
refreshed.structuredContent,
|
||||
).result,
|
||||
).toMatchObject({ kind: "archive", format });
|
||||
const session = await call("binary_session", {});
|
||||
expect(
|
||||
session.isError,
|
||||
JSON.stringify(session.structuredContent),
|
||||
).not.toBe(true);
|
||||
expect(
|
||||
toolContract("binary_session").outputSchema.parse(
|
||||
session.structuredContent,
|
||||
).result,
|
||||
).toMatchObject({ open: true, kind: "archive", format });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
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);
|
||||
const inventory = artifactInventoryOutputSchema.parse(
|
||||
substep.evidence.normalized_result,
|
||||
);
|
||||
expect(inventory.coverage_status).toBe("complete");
|
||||
expect(inventory.limitations).toContainEqual(
|
||||
expect.stringContaining("CPU bank mappings"),
|
||||
);
|
||||
return inventory;
|
||||
};
|
||||
|
||||
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, "卡带.hop");
|
||||
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 typed = artifactInspectionOutputSchema.parse(
|
||||
parseEvidence(inspected.structuredContent).normalized_result,
|
||||
);
|
||||
expect(typed.substeps[0]?.evidence.normalized_result).toMatchObject({
|
||||
ines: inventory.ines,
|
||||
});
|
||||
const withInventory = (normalized_result: unknown) => ({
|
||||
...typed,
|
||||
substeps: typed.substeps.map((substep) => ({
|
||||
...substep,
|
||||
evidence: { ...substep.evidence, normalized_result },
|
||||
})),
|
||||
});
|
||||
expect(
|
||||
artifactInspectionOutputSchema.safeParse(
|
||||
withInventory({ ...inventory, ines: undefined }),
|
||||
).success,
|
||||
).toBe(false);
|
||||
const withoutInes = { ...inventory };
|
||||
delete withoutInes.ines;
|
||||
expect(
|
||||
artifactInspectionOutputSchema.safeParse(withInventory(withoutInes))
|
||||
.success,
|
||||
).toBe(true);
|
||||
|
||||
const summary = await call("inspect_artifact", { detail: "summary" });
|
||||
expect(summary.isError, JSON.stringify(summary.structuredContent)).not.toBe(
|
||||
true,
|
||||
);
|
||||
const view = analysisViewResultSchema.parse(
|
||||
parseEvidence(summary.structuredContent).normalized_result,
|
||||
);
|
||||
expect(view.kind).toBe("summary");
|
||||
const regions = await call("inspect_analysis_view", {
|
||||
source: {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: view.parent_evidence_id,
|
||||
},
|
||||
view: {
|
||||
kind: "inventory",
|
||||
collection: "occurrences",
|
||||
logical_paths: ["prg/000.bin"],
|
||||
},
|
||||
});
|
||||
expect(regions.isError, JSON.stringify(regions.structuredContent)).not.toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
parseEvidence(regions.structuredContent).normalized_result,
|
||||
).toMatchObject({
|
||||
kind: "inventory",
|
||||
items: inventory.occurrences.filter(
|
||||
(item) => item.logical_path === "prg/000.bin",
|
||||
),
|
||||
});
|
||||
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,200 @@
|
||||
import { ChildProcess } from "node:child_process";
|
||||
import { createServer } from "node:http";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
const mock = vi.hoisted(() => ({ spawn: vi.fn(), port: vi.fn() }));
|
||||
vi.mock("../../../dist/process/ProviderProcess.js", () => ({
|
||||
spawnOwnedProviderProcess: mock.spawn,
|
||||
}));
|
||||
vi.mock("../../../dist/process/ProcessOwnershipObservation.js", () => ({
|
||||
createSystemProcessOwnershipHost: () => ({ listProcesses: async () => [] }),
|
||||
}));
|
||||
vi.mock("../../../dist/browser/BrowserProcessStartup.js", () => ({
|
||||
waitForBrowserDevtoolsPort: mock.port,
|
||||
}));
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
interface FixtureLease {
|
||||
readonly port: number;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
type StartFixture = (
|
||||
executable: string,
|
||||
origin: string,
|
||||
) => Promise<FixtureLease>;
|
||||
|
||||
const startFixture = async (): Promise<StartFixture> =>
|
||||
z
|
||||
.object({
|
||||
startRuntimeFixtureBrowser: z.custom<StartFixture>(
|
||||
(value) => typeof value === "function",
|
||||
),
|
||||
})
|
||||
.parse(
|
||||
await import(
|
||||
new URL(
|
||||
"../../../scripts/lib/browser-runtime-fixture-lifecycle.mjs",
|
||||
import.meta.url,
|
||||
).href
|
||||
),
|
||||
).startRuntimeFixtureBrowser;
|
||||
|
||||
const deferred = () => {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((complete) => {
|
||||
resolve = complete;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
const fixture = async (
|
||||
mode: "empty targets" | "discovery timeout" | "malformed" | "exited",
|
||||
) => {
|
||||
let ready = false;
|
||||
let requests = 0;
|
||||
const listed = deferred();
|
||||
const published = deferred();
|
||||
const server = createServer((request, response) => {
|
||||
requests += 1;
|
||||
if (mode === "exited") {
|
||||
listed.resolve();
|
||||
return;
|
||||
}
|
||||
if (mode === "malformed") {
|
||||
response.end("{broken");
|
||||
return;
|
||||
}
|
||||
if (mode === "discovery timeout" && requests === 1) {
|
||||
response.once("close", () => {
|
||||
ready = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
response.end(
|
||||
JSON.stringify(
|
||||
request.url === "/json/version"
|
||||
? {
|
||||
Browser: "FixtureChrome/1",
|
||||
"Protocol-Version": "1.3",
|
||||
"User-Agent": "FixtureChrome",
|
||||
"V8-Version": "1",
|
||||
"WebKit-Version": "1",
|
||||
webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/browser/fixture`,
|
||||
}
|
||||
: ready
|
||||
? [{ id: "fixture", type: "page", title: "fixture", url: origin }]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
if (request.url === "/json/list") listed.resolve();
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string")
|
||||
throw new Error("Missing fixture address");
|
||||
const port = address.port;
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const child = new ChildProcess();
|
||||
let signalCode: NodeJS.Signals | null = null;
|
||||
Object.defineProperty(child, "signalCode", { get: () => signalCode });
|
||||
vi.spyOn(child, "kill").mockImplementation(() => {
|
||||
signalCode = "SIGTERM";
|
||||
child.emit("exit", null, "SIGTERM");
|
||||
return true;
|
||||
});
|
||||
const cleanup = vi.fn(async () => ({ cleaned: true }));
|
||||
mock.spawn.mockResolvedValue({
|
||||
process: child,
|
||||
ownership: { processGroupId: 7 },
|
||||
cleanup,
|
||||
});
|
||||
mock.port.mockImplementation(async () => {
|
||||
published.resolve();
|
||||
return port;
|
||||
});
|
||||
return {
|
||||
origin,
|
||||
listed: listed.promise,
|
||||
published: published.promise,
|
||||
cleanup,
|
||||
exit: () => child.kill("SIGTERM"),
|
||||
makeReady: () => {
|
||||
ready = true;
|
||||
},
|
||||
requests: () => requests,
|
||||
close: async () => {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
it.each(["empty targets", "discovery timeout"] as const)(
|
||||
"does not hand out the owned fixture while CDP has %s",
|
||||
async (mode) => {
|
||||
const start = await startFixture();
|
||||
const server = await fixture(mode);
|
||||
const pending = start(process.execPath, server.origin);
|
||||
let settled = false;
|
||||
void pending.then(
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
);
|
||||
try {
|
||||
await server.published;
|
||||
await setImmediate();
|
||||
expect(settled).toBe(false);
|
||||
if (mode === "empty targets") {
|
||||
await server.listed;
|
||||
expect(settled).toBe(false);
|
||||
server.makeReady();
|
||||
}
|
||||
await pending;
|
||||
expect(server.requests()).toBeGreaterThanOrEqual(3);
|
||||
} finally {
|
||||
server.makeReady();
|
||||
await (await pending.catch(() => undefined))?.close();
|
||||
await server.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves malformed discovery and closes the owned fixture", async () => {
|
||||
const start = await startFixture();
|
||||
const server = await fixture("malformed");
|
||||
const pending = start(process.execPath, server.origin);
|
||||
try {
|
||||
await expect(pending).rejects.toMatchObject({
|
||||
reason: "invalid_endpoint_response",
|
||||
});
|
||||
expect(server.requests()).toBe(1);
|
||||
expect(server.cleanup).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await (await pending.catch(() => undefined))?.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("interrupts pending discovery when the owned fixture exits", async () => {
|
||||
const start = await startFixture();
|
||||
const server = await fixture("exited");
|
||||
const pending = start(process.execPath, server.origin);
|
||||
const rejected = expect(pending).rejects.toThrow(
|
||||
"exited before CDP discovery",
|
||||
);
|
||||
try {
|
||||
await server.listed;
|
||||
server.exit();
|
||||
await rejected;
|
||||
expect(server.cleanup).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await (await pending.catch(() => undefined))?.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
@@ -246,16 +246,19 @@ describe("binary target I/O: package classification", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies a binary plist by its magic under any extension", async () => {
|
||||
const directory = await createTestTempDirectory("rea-bplist-target-");
|
||||
const path = join(directory, "Model.archive");
|
||||
await writeFile(path, buildBinary({ $archiver: "NSKeyedArchiver" }));
|
||||
const result = await parseBinaryTarget(path);
|
||||
expect(result.ok && result.value).toMatchObject({
|
||||
kind: "artifact",
|
||||
format: "plist",
|
||||
});
|
||||
});
|
||||
it.each(["archive", "hop"])(
|
||||
"classifies binary plist magic under a .%s extension",
|
||||
async (suffix) => {
|
||||
const directory = await createTestTempDirectory("rea-bplist-target-");
|
||||
const path = join(directory, `Model.${suffix}`);
|
||||
await writeFile(path, buildBinary({ $archiver: "NSKeyedArchiver" }));
|
||||
const result = await parseBinaryTarget(path);
|
||||
expect(result.ok && result.value).toMatchObject({
|
||||
kind: "artifact",
|
||||
format: "plist",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("does not trust a ZIP-family extension without ZIP magic", async () => {
|
||||
const directory = await createTestTempDirectory("rea-fake-archive-");
|
||||
@@ -275,6 +278,10 @@ describe("binary target I/O: filesystem and app bundle target resolution", () =>
|
||||
await writeFile(join(directory, "sample.hop"), "database");
|
||||
await writeFile(join(directory, "text"), "hello");
|
||||
const database = await parseBinaryTarget("sample.hop", { cwd: directory });
|
||||
expect(database).toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "database", format: "analysis-database" },
|
||||
});
|
||||
expect(database.ok && database.value.sha256).toBe(
|
||||
"3549b0028b75d981cdda2e573e9cb49dedc200185876df299f912b79f69dabd8",
|
||||
);
|
||||
|
||||
@@ -554,6 +554,30 @@ it.each([
|
||||
"tests/boundary/cli/nativePlistCli.test.ts",
|
||||
{ code: true, apple: true, package: false, runtime: false },
|
||||
],
|
||||
[
|
||||
"src/artifacts/InesArtifactReader.ts",
|
||||
{ code: true, apple: true, package: false, runtime: true },
|
||||
],
|
||||
[
|
||||
"src/domain/inesContainer.ts",
|
||||
{ code: true, apple: true, docs: true, package: false, runtime: true },
|
||||
],
|
||||
[
|
||||
"tests/fixtures/ines.ts",
|
||||
{ code: true, apple: true, package: false, runtime: false },
|
||||
],
|
||||
[
|
||||
"tests/acceptance/inesContainer.test.ts",
|
||||
{ code: true, apple: true, package: false, runtime: false },
|
||||
],
|
||||
[
|
||||
"tests/boundary/filesystem/inesArtifact.test.ts",
|
||||
{ code: true, apple: true, package: false, runtime: false },
|
||||
],
|
||||
[
|
||||
"tests/boundary/filesystem/inesCancellation.test.ts",
|
||||
{ code: true, apple: true, package: false, runtime: false },
|
||||
],
|
||||
[
|
||||
"tests/boundary/filesystem/windowsNpxRegistration.test.ts",
|
||||
{ code: true, package: true, runtime: true, apple: false },
|
||||
|
||||
@@ -0,0 +1,746 @@
|
||||
import { readFileHandlePrefix } from "../../../src/filesystem/readFileHandlePrefix.js";
|
||||
import { abortIfNeeded } from "../../../src/artifacts/ArtifactHash.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
lstat,
|
||||
mkdir,
|
||||
open,
|
||||
readFile,
|
||||
unlink,
|
||||
writeFile,
|
||||
type FileHandle,
|
||||
} from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { inesFixture } from "../../fixtures/ines.js";
|
||||
import {
|
||||
inventoryArtifact,
|
||||
scanArtifactInventory,
|
||||
materializeArtifactInventory,
|
||||
} 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,
|
||||
materializeArtifactInventory as materializeInScope,
|
||||
} from "../../../src/artifacts/extraction/ArtifactExtraction.js";
|
||||
|
||||
const sha256 = (bytes: Buffer) =>
|
||||
createHash("sha256").update(bytes).digest("hex");
|
||||
|
||||
const fileReadPrototype = async (
|
||||
path: string,
|
||||
): Promise<Pick<FileHandle, "read">> => {
|
||||
const handle = await open(path, "r");
|
||||
try {
|
||||
return Object.getPrototypeOf(handle) as Pick<FileHandle, "read">;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
};
|
||||
|
||||
it("reuses scanned iNES region bytes for reconstruction without another full read", async () => {
|
||||
const root = await createTestTempDirectory("rea-ines-read-budget-");
|
||||
const path = join(root, "large-tail.nes");
|
||||
const bytes = inesFixture({ trailing: Buffer.alloc(2 * 1024 * 1024, 0x5a) });
|
||||
await writeFile(path, bytes);
|
||||
const prototype = await fileReadPrototype(path);
|
||||
const originalRead = prototype.read;
|
||||
let bytesRead = 0;
|
||||
const read = vi.spyOn(prototype, "read").mockImplementation(async function (
|
||||
this: FileHandle,
|
||||
...args
|
||||
) {
|
||||
const result = await originalRead.call(this, ...args);
|
||||
bytesRead += result.bytesRead;
|
||||
return result;
|
||||
});
|
||||
try {
|
||||
const result = await inventoryArtifact(path);
|
||||
expect(result.ines?.reconstruction).toMatchObject({
|
||||
bytes: bytes.length,
|
||||
sha256: sha256(bytes),
|
||||
verified: true,
|
||||
});
|
||||
// Initial root hash, region scan, final freshness hash, plus bounded headers.
|
||||
expect(bytesRead).toBeLessThanOrEqual(3 * bytes.length + 64 * 1024);
|
||||
} finally {
|
||||
read.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects region bytes that disagree with stable whole-file hashes", async () => {
|
||||
const root = await createTestTempDirectory("rea-ines-region-digest-");
|
||||
const path = join(root, "source.nes");
|
||||
const bytes = inesFixture();
|
||||
await writeFile(path, bytes);
|
||||
const prototype = await fileReadPrototype(path);
|
||||
const originalRead = prototype.read;
|
||||
let changed = false;
|
||||
const read = vi.spyOn(prototype, "read").mockImplementation(async function (
|
||||
this: FileHandle,
|
||||
...args
|
||||
) {
|
||||
const result = await originalRead.call(this, ...args);
|
||||
const arguments_: readonly unknown[] = args;
|
||||
if (!changed && arguments_[3] === 16n && result.bytesRead === 16_384) {
|
||||
// Alter only the region observation, leaving both whole-file passes intact.
|
||||
if (!Buffer.isBuffer(result.buffer))
|
||||
throw new Error("Expected byte buffer");
|
||||
result.buffer.writeUInt8(result.buffer.readUInt8(0) ^ 0xff, 0);
|
||||
changed = true;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
try {
|
||||
await expect(inventoryArtifact(path)).rejects.toMatchObject({
|
||||
reason: "integrity",
|
||||
message: "iNES reconstruction differs from the source",
|
||||
});
|
||||
expect(changed).toBe(true);
|
||||
expect(await readFile(path)).toEqual(bytes);
|
||||
} finally {
|
||||
read.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["data", "hop", "HOP"])(
|
||||
"routes iNES bytes as an archive with a .%s suffix",
|
||||
async (suffix) => {
|
||||
const root = await createTestTempDirectory("rea-ines-target-");
|
||||
const path = join(root, `cartridge.${suffix}`);
|
||||
await writeFile(path, inesFixture());
|
||||
const target = await parseBinaryTarget(path);
|
||||
if (!target.ok) throw target.error;
|
||||
expect(target.value).toMatchObject({ kind: "archive", format: "ines" });
|
||||
},
|
||||
);
|
||||
|
||||
it("honors explicit database selection before parsing NES-signature bytes", async () => {
|
||||
const root = await createTestTempDirectory("rea-ines-database-");
|
||||
for (const bytes of [inesFixture(), Buffer.from("NES\x1a")]) {
|
||||
const path = join(root, "selected.hop");
|
||||
await writeFile(path, bytes);
|
||||
expect(
|
||||
await parseBinaryTarget(path, { targetKind: "database" }),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "database", format: "analysis-database" },
|
||||
});
|
||||
}
|
||||
});
|
||||
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,
|
||||
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.each(["before", "during"])(
|
||||
"reports source removal %s a region read as integrity failure",
|
||||
async (when) => {
|
||||
const root = await createTestTempDirectory("rea-ines-removed-source-");
|
||||
const path = join(root, "source.nes");
|
||||
await writeFile(path, inesFixture());
|
||||
const handle = await open(path, "r");
|
||||
const reader = new InesArtifactReader(path, {
|
||||
handle,
|
||||
initial: await handle.stat(),
|
||||
});
|
||||
try {
|
||||
const entries = [];
|
||||
for await (const entry of reader.entries()) entries.push(entry);
|
||||
const entry = entries[0];
|
||||
if (entry === undefined) throw new Error("No header region");
|
||||
if (when === "before") {
|
||||
await unlink(path);
|
||||
await expect(reader.open(entry)).rejects.toMatchObject({
|
||||
reason: "integrity",
|
||||
cause: { code: "ENOENT" },
|
||||
});
|
||||
} else {
|
||||
const stream = await reader.open(entry);
|
||||
await unlink(path);
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const chunk of stream)
|
||||
expect(Buffer.isBuffer(chunk)).toBe(true);
|
||||
})(),
|
||||
).rejects.toMatchObject({
|
||||
reason: "integrity",
|
||||
cause: { code: "ENOENT" },
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await reader.close();
|
||||
await handle.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["truncated", "overwritten"])(
|
||||
"reports a header %s during admission as an integrity failure",
|
||||
async (change) => {
|
||||
const root = await createTestTempDirectory("rea-ines-header-race-");
|
||||
const path = join(root, "source.nes");
|
||||
await writeFile(path, inesFixture());
|
||||
const handle = await open(path, "r");
|
||||
const reader = new InesArtifactReader(path, {
|
||||
handle,
|
||||
initial: await handle.stat(),
|
||||
});
|
||||
const originalRead = handle.read.bind(handle);
|
||||
// Mutate the actual file after pre-read validation, at the read boundary.
|
||||
const read = vi
|
||||
.spyOn(handle, "read")
|
||||
.mockImplementationOnce(async (...args) => {
|
||||
const changed =
|
||||
change === "truncated"
|
||||
? Buffer.alloc(0)
|
||||
: Buffer.alloc(inesFixture().length);
|
||||
await writeFile(path, changed);
|
||||
return originalRead(...args);
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
reader.entries()[Symbol.asyncIterator]().next(),
|
||||
).rejects.toMatchObject({
|
||||
reason: "integrity",
|
||||
});
|
||||
} finally {
|
||||
read.mockRestore();
|
||||
await reader.close();
|
||||
await handle.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["inventory", "target"])(
|
||||
"rejects changed iNES prefixes at the %s entry point",
|
||||
async (route) => {
|
||||
const root = await createTestTempDirectory("rea-ines-classification-race-");
|
||||
const path = join(root, "source.nes");
|
||||
for (const change of ["truncated", "counts", "signature"] as const) {
|
||||
const bytes = inesFixture();
|
||||
await writeFile(path, bytes);
|
||||
const prototype = await fileReadPrototype(path);
|
||||
const originalRead = prototype.read;
|
||||
const read = vi
|
||||
.spyOn(prototype, "read")
|
||||
.mockImplementationOnce(async function (this: FileHandle, ...args) {
|
||||
read.mockRestore();
|
||||
const changed =
|
||||
change === "truncated" ? bytes.subarray(0, 16) : Buffer.from(bytes);
|
||||
if (change === "counts") changed[4] = 255;
|
||||
if (change === "signature") changed.fill(0, 0, 4);
|
||||
await writeFile(path, changed);
|
||||
return originalRead.call(this, ...args);
|
||||
});
|
||||
try {
|
||||
if (route === "inventory") {
|
||||
await expect(inventoryArtifact(path)).rejects.toMatchObject({
|
||||
reason: "integrity",
|
||||
});
|
||||
} else {
|
||||
expect(await parseBinaryTarget(path)).toMatchObject({
|
||||
ok: false,
|
||||
error: { reason: expect.stringContaining("changed") },
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
read.mockRestore();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ route: "inventory", hashPass: 1 },
|
||||
{ route: "inventory", hashPass: 2 },
|
||||
{ route: "target", hashPass: 1 },
|
||||
])(
|
||||
"reports source loss in $route hash pass $hashPass as a changed input",
|
||||
async ({ route, hashPass }) => {
|
||||
const root = await createTestTempDirectory("rea-ines-hash-loss-");
|
||||
const path = join(root, "source.nes");
|
||||
await writeFile(path, inesFixture());
|
||||
const prototype = await fileReadPrototype(path);
|
||||
const originalRead = prototype.read;
|
||||
let observedPasses = 0;
|
||||
const read = vi.spyOn(prototype, "read").mockImplementation(async function (
|
||||
this: FileHandle,
|
||||
...args
|
||||
) {
|
||||
const result = await originalRead.call(this, ...args);
|
||||
const readArguments: readonly unknown[] = args;
|
||||
const [buffer, , , position] = readArguments;
|
||||
if (
|
||||
Buffer.isBuffer(buffer) &&
|
||||
buffer.byteLength > 8192 &&
|
||||
(position === 0 || position === 0n)
|
||||
) {
|
||||
observedPasses += 1;
|
||||
if (observedPasses === hashPass) {
|
||||
read.mockRestore();
|
||||
await unlink(path);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
try {
|
||||
if (route === "inventory") {
|
||||
await expect(inventoryArtifact(path)).rejects.toMatchObject({
|
||||
reason: "integrity",
|
||||
cause: { code: "ENOENT" },
|
||||
});
|
||||
} else {
|
||||
expect(await parseBinaryTarget(path)).toMatchObject({
|
||||
ok: false,
|
||||
error: { reason: expect.stringContaining("changed") },
|
||||
});
|
||||
}
|
||||
expect(observedPasses).toBe(hashPass);
|
||||
} finally {
|
||||
read.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["removed", "directory", "malformed"])(
|
||||
"rejects a %s source after inventory before extraction",
|
||||
async (change) => {
|
||||
const root = await createTestTempDirectory("rea-ines-extraction-change-");
|
||||
const path = join(root, "source.nes");
|
||||
const outputRoot = join(root, "output");
|
||||
await writeFile(path, inesFixture());
|
||||
const snapshot = await scanArtifactInventory(path);
|
||||
await unlink(path);
|
||||
if (change === "directory") await mkdir(path);
|
||||
if (change === "malformed") await writeFile(path, "changed cartridge");
|
||||
await expect(
|
||||
materializeArtifactInventory(
|
||||
{
|
||||
inputPath: path,
|
||||
outputRoot,
|
||||
environment: process.env,
|
||||
integrityPolicy: "fail",
|
||||
},
|
||||
path,
|
||||
snapshot,
|
||||
),
|
||||
).rejects.toMatchObject({ reason: "integrity" });
|
||||
await expect(lstat(outputRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"EISDIR",
|
||||
"ENOTFILE",
|
||||
"ESTALE",
|
||||
"EACCES",
|
||||
"cancelled",
|
||||
...(process.platform === "win32" ? ["changed metadata"] : []),
|
||||
])(
|
||||
"preserves the %s admission meaning while reopening extraction",
|
||||
async (code) => {
|
||||
const root = await createTestTempDirectory("rea-ines-reopen-error-");
|
||||
const path = join(root, "source.nes");
|
||||
await writeFile(path, inesFixture());
|
||||
const snapshot = await scanArtifactInventory(path);
|
||||
const sample = await open(path, "r");
|
||||
const prototype = Object.getPrototypeOf(sample) as Pick<
|
||||
typeof sample,
|
||||
"stat"
|
||||
>;
|
||||
await sample.close();
|
||||
const originalStat = prototype.stat;
|
||||
const controller = new AbortController();
|
||||
const stat = vi
|
||||
.spyOn(prototype, "stat")
|
||||
.mockImplementationOnce(async function (this: FileHandle, ...args) {
|
||||
if (code === "changed metadata") {
|
||||
const metadata = await originalStat.call(this, ...args);
|
||||
// Change the real file between descriptor stat and admission lstat.
|
||||
await writeFile(path, "replacement cartridge");
|
||||
return metadata;
|
||||
}
|
||||
if (code !== "cancelled")
|
||||
throw Object.assign(new Error("injected admission failure"), {
|
||||
code,
|
||||
});
|
||||
const result = await originalStat.call(this, ...args);
|
||||
controller.abort();
|
||||
return result;
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
materializeArtifactInventory(
|
||||
{
|
||||
inputPath: path,
|
||||
outputRoot: join(root, "output"),
|
||||
environment: process.env,
|
||||
integrityPolicy: "fail",
|
||||
},
|
||||
path,
|
||||
snapshot,
|
||||
controller.signal,
|
||||
),
|
||||
).rejects.toMatchObject(
|
||||
code === "EACCES"
|
||||
? { code }
|
||||
: { reason: code === "cancelled" ? "cancelled" : "integrity" },
|
||||
);
|
||||
} finally {
|
||||
stat.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("retains a failed close after rejecting changed extraction bytes", async () => {
|
||||
const root = await createTestTempDirectory("rea-ines-reopen-cleanup-");
|
||||
const path = join(root, "source.nes");
|
||||
await writeFile(path, inesFixture());
|
||||
const snapshot = await scanArtifactInventory(path);
|
||||
await writeFile(path, "changed source");
|
||||
const scope = new ArtifactResourceScope();
|
||||
const originalClose = OwnedFileHandle.prototype.close;
|
||||
const owners: OwnedFileHandle[] = [];
|
||||
const close = vi
|
||||
.spyOn(OwnedFileHandle.prototype, "close")
|
||||
.mockImplementation(async function (this: OwnedFileHandle) {
|
||||
owners.push(this);
|
||||
if (owners.length === 1) throw new Error("injected close failure");
|
||||
await originalClose.call(this);
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
materializeInScope(
|
||||
{
|
||||
inputPath: path,
|
||||
outputRoot: join(root, "output"),
|
||||
environment: process.env,
|
||||
integrityPolicy: "fail",
|
||||
resourceScope: scope,
|
||||
},
|
||||
path,
|
||||
snapshot,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
reason: "integrity",
|
||||
cleanup: { reason: expect.stringContaining("injected close failure") },
|
||||
});
|
||||
expect(owners).toHaveLength(1);
|
||||
await scope.close();
|
||||
expect(owners).toHaveLength(2);
|
||||
expect(owners[1]).toBe(owners[0]);
|
||||
} finally {
|
||||
close.mockRestore();
|
||||
await scope.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();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["target", "inventory", "reader"])(
|
||||
"fills short iNES prefix reads at the %s boundary",
|
||||
async (route) => {
|
||||
const root = await createTestTempDirectory("rea-ines-short-read-");
|
||||
const path = join(root, "source.nes");
|
||||
await writeFile(path, inesFixture());
|
||||
const sample = await open(path, "r");
|
||||
const initial = await sample.stat();
|
||||
const prototype = Object.getPrototypeOf(sample) as {
|
||||
read(
|
||||
buffer: Buffer,
|
||||
offset: number,
|
||||
length: number,
|
||||
position: number | bigint,
|
||||
): Promise<{ bytesRead: number; buffer: Buffer }>;
|
||||
};
|
||||
const originalRead = prototype.read;
|
||||
const read = vi.spyOn(prototype, "read").mockImplementation(async function (
|
||||
this: FileHandle,
|
||||
...args
|
||||
) {
|
||||
const [buffer, offset, length, position] = args;
|
||||
return originalRead.call(
|
||||
this,
|
||||
buffer,
|
||||
offset,
|
||||
typeof position === "number" && position < 16
|
||||
? Math.min(length, 3)
|
||||
: length,
|
||||
position,
|
||||
);
|
||||
});
|
||||
try {
|
||||
if (route === "target") {
|
||||
expect(await parseBinaryTarget(path)).toMatchObject({
|
||||
ok: true,
|
||||
value: { format: "ines" },
|
||||
});
|
||||
} else if (route === "inventory") {
|
||||
expect(
|
||||
(await inventoryArtifact(path)).ines?.reconstruction.verified,
|
||||
).toBe(true);
|
||||
} else {
|
||||
const reader = new InesArtifactReader(path, {
|
||||
handle: sample,
|
||||
initial,
|
||||
});
|
||||
try {
|
||||
const entries = [];
|
||||
for await (const entry of reader.entries()) entries.push(entry.path);
|
||||
expect(entries).toContain("prg/000.bin");
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
read.mockRestore();
|
||||
await sample.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["eof", "cancelled", "failed-and-cancelled"])(
|
||||
"preserves %s while filling a short prefix",
|
||||
async (mode) => {
|
||||
const root = await createTestTempDirectory("rea-prefix-boundary-");
|
||||
const path = join(root, "source");
|
||||
const bytes = Buffer.from(
|
||||
mode === "eof" ? "NES\x1a" : "NES\x1a123456789012",
|
||||
);
|
||||
await writeFile(path, bytes);
|
||||
const handle = await open(path, "r");
|
||||
const prototype = Object.getPrototypeOf(handle) as {
|
||||
read(
|
||||
buffer: Buffer,
|
||||
offset: number,
|
||||
length: number,
|
||||
position: number,
|
||||
): Promise<{ bytesRead: number; buffer: Buffer }>;
|
||||
};
|
||||
const original = prototype.read;
|
||||
const controller = new AbortController();
|
||||
let reads = 0;
|
||||
const read = vi.spyOn(prototype, "read").mockImplementation(async function (
|
||||
this: typeof handle,
|
||||
buffer,
|
||||
offset,
|
||||
length,
|
||||
position,
|
||||
) {
|
||||
reads++;
|
||||
if (reads === 2 && mode === "failed-and-cancelled") {
|
||||
controller.abort();
|
||||
throw new Error("injected read failure");
|
||||
}
|
||||
const observed = await original.call(
|
||||
this,
|
||||
buffer,
|
||||
offset,
|
||||
Math.min(length, 3),
|
||||
position,
|
||||
);
|
||||
if (reads === 2 && mode === "cancelled") controller.abort();
|
||||
return observed;
|
||||
});
|
||||
try {
|
||||
const result = readFileHandlePrefix(handle, 16, () =>
|
||||
abortIfNeeded(controller.signal),
|
||||
);
|
||||
if (mode === "eof") {
|
||||
expect(await result).toEqual(bytes);
|
||||
expect(reads).toBe(3);
|
||||
} else {
|
||||
await expect(result).rejects.toMatchObject({ reason: "cancelled" });
|
||||
expect(reads).toBe(2);
|
||||
}
|
||||
} finally {
|
||||
read.mockRestore();
|
||||
await handle.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,311 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { open, writeFile, type FileHandle } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
|
||||
import { InesArtifactReader } from "../../../src/artifacts/InesArtifactReader.js";
|
||||
import { scanReader } from "../../../src/artifacts/inventory/scanReader.js";
|
||||
import { hashStableRootArtifactHandle } from "../../../src/artifacts/inventory/hashStableRootArtifact.js";
|
||||
import { inventoryArtifact } from "../../fixtures/artifactInventory.js";
|
||||
import { inesFixture } from "../../fixtures/ines.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { RegularFileAdmissionFailure } from "../../../src/filesystem/RegularFile.js";
|
||||
|
||||
// Complete the real I/O, then cancel while its caller is still awaiting it.
|
||||
const cancelAfterNextStat = (
|
||||
handle: FileHandle,
|
||||
controller: AbortController,
|
||||
mutate?: () => Promise<void>,
|
||||
) => {
|
||||
const original = handle.stat.bind(handle);
|
||||
const stat = vi
|
||||
.spyOn(handle, "stat")
|
||||
.mockImplementationOnce(async (...args) => {
|
||||
await mutate?.();
|
||||
const observed = await original(...args);
|
||||
controller.abort();
|
||||
return observed;
|
||||
});
|
||||
return () => stat.mockRestore();
|
||||
};
|
||||
|
||||
const malformed = (format: string) => {
|
||||
if (format === "UNIF") return Buffer.from("UNIF");
|
||||
const bytes = inesFixture();
|
||||
if (format === "NES 2.0") {
|
||||
bytes[7] = 8;
|
||||
return bytes;
|
||||
}
|
||||
return bytes.subarray(0, 20);
|
||||
};
|
||||
|
||||
it.each(["default", "caller-error", "failed-close"])(
|
||||
"preserves cancellation and descriptor ownership during %s admission",
|
||||
async (reasonKind) => {
|
||||
const root = await createTestTempDirectory("rea-ines-cancel-admission-");
|
||||
const path = join(root, "source.nes");
|
||||
await writeFile(path, inesFixture());
|
||||
const sample = await open(path, "r");
|
||||
const prototype = Object.getPrototypeOf(sample) as Pick<FileHandle, "stat">;
|
||||
await sample.close();
|
||||
const original = prototype.stat;
|
||||
const controller = new AbortController();
|
||||
let admitted: FileHandle | undefined;
|
||||
const cleanupFailure = new Error("descriptor close failed");
|
||||
let restoreClose = () => {};
|
||||
const stat = vi
|
||||
.spyOn(prototype, "stat")
|
||||
.mockImplementationOnce(async function (this: FileHandle, ...args) {
|
||||
admitted = this;
|
||||
const observed = await original.call(this, ...args);
|
||||
if (reasonKind === "failed-close") {
|
||||
const close = vi
|
||||
.spyOn(this, "close")
|
||||
.mockRejectedValueOnce(cleanupFailure);
|
||||
restoreClose = () => close.mockRestore();
|
||||
}
|
||||
controller.abort(
|
||||
reasonKind === "caller-error"
|
||||
? new Error("caller stopped admission")
|
||||
: undefined,
|
||||
);
|
||||
return observed;
|
||||
});
|
||||
try {
|
||||
const result = parseBinaryTarget(path, { signal: controller.signal });
|
||||
if (reasonKind === "failed-close") {
|
||||
const failure = await result.catch((cause: unknown) => cause);
|
||||
expect(failure).toBeInstanceOf(RegularFileAdmissionFailure);
|
||||
if (!(failure instanceof RegularFileAdmissionFailure)) throw failure;
|
||||
expect(failure.cause).toBe(controller.signal.reason);
|
||||
expect(failure.cleanupCause).toBe(cleanupFailure);
|
||||
await failure.owner.close();
|
||||
} else {
|
||||
await expect(result).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisCancelledError",
|
||||
operation: "resolve_binary_target",
|
||||
},
|
||||
});
|
||||
}
|
||||
if (admitted === undefined)
|
||||
throw new Error("Admission descriptor was not observed");
|
||||
await expect(admitted.stat()).rejects.toMatchObject({ code: "EBADF" });
|
||||
} finally {
|
||||
restoreClose();
|
||||
stat.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["truncated", "read"],
|
||||
["NES 2.0", "read"],
|
||||
["UNIF", "read"],
|
||||
["truncated", "validation"],
|
||||
["NES 2.0", "validation"],
|
||||
["UNIF", "validation"],
|
||||
])("honors cancellation during %s root prefix %s", async (format, phase) => {
|
||||
const root = await createTestTempDirectory("rea-ines-cancel-prefix-");
|
||||
const path = join(root, "source.nes");
|
||||
await writeFile(path, malformed(format));
|
||||
const sample = await open(path, "r");
|
||||
const prototype = Object.getPrototypeOf(sample) as Pick<FileHandle, "read">;
|
||||
await sample.close();
|
||||
const original = prototype.read;
|
||||
const controller = new AbortController();
|
||||
let admitted: FileHandle | undefined;
|
||||
let restoreStat = () => {};
|
||||
const read = vi
|
||||
.spyOn(prototype, "read")
|
||||
.mockImplementationOnce(async function (this: FileHandle, ...args) {
|
||||
admitted = this;
|
||||
const observed = await original.call(this, ...args);
|
||||
if (phase === "read") controller.abort();
|
||||
else restoreStat = cancelAfterNextStat(this, controller);
|
||||
return observed;
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
inventoryArtifact(path, { signal: controller.signal }),
|
||||
).rejects.toMatchObject({ reason: "cancelled" });
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
if (admitted === undefined)
|
||||
throw new Error("Prefix descriptor was not observed");
|
||||
await expect(admitted.stat()).rejects.toMatchObject({ code: "EBADF" });
|
||||
} finally {
|
||||
restoreStat();
|
||||
read.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("honors cancellation during malformed reader header validation", async () => {
|
||||
const root = await createTestTempDirectory("rea-ines-cancel-header-");
|
||||
const path = join(root, "source.nes");
|
||||
await writeFile(path, malformed("truncated"));
|
||||
const handle = await open(path, "r");
|
||||
const reader = new InesArtifactReader(path, {
|
||||
handle,
|
||||
initial: await handle.stat(),
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const original = handle.read.bind(handle);
|
||||
let restoreStat = () => {};
|
||||
const read = vi
|
||||
.spyOn(handle, "read")
|
||||
.mockImplementationOnce(async (...args) => {
|
||||
const observed = await original(...args);
|
||||
restoreStat = cancelAfterNextStat(handle, controller);
|
||||
return observed;
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
reader.entries(controller.signal)[Symbol.asyncIterator]().next(),
|
||||
).rejects.toMatchObject({ reason: "cancelled" });
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
} finally {
|
||||
restoreStat();
|
||||
read.mockRestore();
|
||||
await reader.close();
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["stream", "reconstruction"])(
|
||||
"does not complete %s after cancellation in the final region validation",
|
||||
async (operation) => {
|
||||
const root = await createTestTempDirectory("rea-ines-cancel-region-");
|
||||
const path = join(root, "source.nes");
|
||||
const bytes = inesFixture();
|
||||
await writeFile(path, bytes);
|
||||
const handle = await open(path, "r");
|
||||
const reader = new InesArtifactReader(path, {
|
||||
handle,
|
||||
initial: await handle.stat(),
|
||||
});
|
||||
const controller = new AbortController();
|
||||
let restoreStat = () => {};
|
||||
const original = handle.read.bind(handle);
|
||||
const read = vi
|
||||
.spyOn(handle, "read")
|
||||
.mockImplementation(async (...args) => {
|
||||
const observed = await original(...args);
|
||||
const arguments_: readonly unknown[] = args;
|
||||
const position = arguments_[3];
|
||||
if (
|
||||
(typeof position === "number" || typeof position === "bigint") &&
|
||||
observed.bytesRead > 0 &&
|
||||
Number(position) + observed.bytesRead === bytes.length
|
||||
)
|
||||
restoreStat = cancelAfterNextStat(handle, controller);
|
||||
return observed;
|
||||
});
|
||||
try {
|
||||
const entries = [];
|
||||
for await (const entry of reader.entries()) entries.push(entry);
|
||||
const last = entries.at(-1);
|
||||
if (last === undefined) throw new Error("Missing last iNES region");
|
||||
const result =
|
||||
operation === "reconstruction"
|
||||
? (async () => {
|
||||
const { inesRegionScan } = await scanReader(
|
||||
reader,
|
||||
[reader],
|
||||
controller.signal,
|
||||
);
|
||||
if (inesRegionScan === undefined)
|
||||
throw new Error("Missing region scan");
|
||||
return reader.verifyReconstruction(
|
||||
createHash("sha256").update(bytes).digest("hex"),
|
||||
inesRegionScan,
|
||||
controller.signal,
|
||||
);
|
||||
})()
|
||||
: (async () => {
|
||||
for await (const chunk of await reader.open(
|
||||
last,
|
||||
controller.signal,
|
||||
))
|
||||
expect(Buffer.isBuffer(chunk)).toBe(true);
|
||||
})();
|
||||
await expect(result).rejects.toMatchObject({ reason: "cancelled" });
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
} finally {
|
||||
restoreStat();
|
||||
read.mockRestore();
|
||||
await reader.close();
|
||||
await handle.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("does not publish a root digest cancelled during its final state check", async () => {
|
||||
const root = await createTestTempDirectory("rea-ines-cancel-hash-");
|
||||
const path = join(root, "source.nes");
|
||||
await writeFile(path, inesFixture());
|
||||
const handle = await open(path, "r");
|
||||
const initial = await handle.stat();
|
||||
const controller = new AbortController();
|
||||
const restore = cancelAfterNextStat(handle, controller);
|
||||
try {
|
||||
await expect(
|
||||
hashStableRootArtifactHandle(path, handle, initial, controller.signal),
|
||||
).rejects.toMatchObject({ reason: "cancelled" });
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
} finally {
|
||||
restore();
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["prefix", false],
|
||||
["prefix", true],
|
||||
["hash", false],
|
||||
["hash", true],
|
||||
] as const)(
|
||||
"cancels target %s validation with concurrent change=%s",
|
||||
async (phase, changed) => {
|
||||
const root = await createTestTempDirectory("rea-ines-cancel-target-");
|
||||
const path = join(root, "source.nes");
|
||||
await writeFile(path, inesFixture());
|
||||
const sample = await open(path, "r");
|
||||
const prototype = Object.getPrototypeOf(sample) as Pick<FileHandle, "read">;
|
||||
await sample.close();
|
||||
const original = prototype.read;
|
||||
const controller = new AbortController();
|
||||
let admitted: FileHandle | undefined;
|
||||
let restoreStat = () => {};
|
||||
const read = vi.spyOn(prototype, "read").mockImplementation(async function (
|
||||
this: FileHandle,
|
||||
...args
|
||||
) {
|
||||
const observed = await original.call(this, ...args);
|
||||
if (
|
||||
admitted === undefined &&
|
||||
(phase === "prefix" || observed.bytesRead === 0)
|
||||
) {
|
||||
admitted = this;
|
||||
restoreStat = cancelAfterNextStat(
|
||||
this,
|
||||
controller,
|
||||
changed ? () => writeFile(path, "changed cartridge") : undefined,
|
||||
);
|
||||
}
|
||||
return observed;
|
||||
});
|
||||
try {
|
||||
expect(
|
||||
await parseBinaryTarget(path, { signal: controller.signal }),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
if (admitted === undefined)
|
||||
throw new Error("Hash descriptor was not observed");
|
||||
await expect(admitted.stat()).rejects.toMatchObject({ code: "EBADF" });
|
||||
} finally {
|
||||
restoreStat();
|
||||
read.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -488,7 +488,7 @@ describe("Ghidra Windows launcher", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ghidra Windows launcher runtime", () => {
|
||||
describe("Ghidra Windows runtime isolation", () => {
|
||||
it.skipIf(process.platform !== "win32").each([
|
||||
{
|
||||
properties:
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AsarArtifactReader } from "../../../../src/artifacts/AsarArtifactReader
|
||||
import { SafeOutputTree } from "../../../../src/artifacts/SafeOutputTree.js";
|
||||
import { ProviderCleanupError } from "../../../../src/domain/providerCleanupError.js";
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
import { artifactGraphRootSha256 } from "../../../fixtures/artifactGraphRootSha256.js";
|
||||
|
||||
const deferred = () => {
|
||||
let resolve: () => void = () => {};
|
||||
@@ -29,11 +30,11 @@ const createArchive = async () => {
|
||||
return { root, archive };
|
||||
};
|
||||
|
||||
const createClient = (path: string) =>
|
||||
const createClient = async (path: string) =>
|
||||
new ArtifactProvider(process.env).createClient({
|
||||
path,
|
||||
sourcePath: path,
|
||||
sha256: "0".repeat(64),
|
||||
sha256: await artifactGraphRootSha256(path),
|
||||
kind: "archive",
|
||||
format: "asar",
|
||||
});
|
||||
@@ -41,7 +42,7 @@ const createClient = (path: string) =>
|
||||
it("retains a failed reader close, blocks new work, and retries the same reader", async () => {
|
||||
const { root, archive } = await createArchive();
|
||||
const output = join(root, "output");
|
||||
const client = createClient(archive);
|
||||
const client = await createClient(archive);
|
||||
const originalClose = AsarArtifactReader.prototype.close;
|
||||
const closeOwners: AsarArtifactReader[] = [];
|
||||
let readerCloseBlocked = true;
|
||||
@@ -107,7 +108,7 @@ it("retains a failed reader close, blocks new work, and retries the same reader"
|
||||
it("retains a failed rollback owner and preserves the materialization failure", async () => {
|
||||
const { root, archive } = await createArchive();
|
||||
const output = join(root, "output");
|
||||
const client = createClient(archive);
|
||||
const client = await createClient(archive);
|
||||
const originalWrite = SafeOutputTree.prototype.write;
|
||||
const originalRollback = SafeOutputTree.prototype.rollback;
|
||||
const rollbackOwners: SafeOutputTree[] = [];
|
||||
@@ -167,51 +168,55 @@ it("retains a failed rollback owner and preserves the materialization failure",
|
||||
await expect(access(output)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("retains a tree whose setup failed after root identity was captured", async () => {
|
||||
const { root, archive } = await createArchive();
|
||||
const output = join(root, "output");
|
||||
const blocker = join(output, "unowned.txt");
|
||||
const client = createClient(archive);
|
||||
const prototypeHandle = await open(join(root, "prototype-handle"), "w");
|
||||
const handlePrototype = Object.getPrototypeOf(prototypeHandle) as Pick<
|
||||
typeof prototypeHandle,
|
||||
"chmod"
|
||||
>;
|
||||
await prototypeHandle.close();
|
||||
await rm(join(root, "prototype-handle"));
|
||||
onTestFinished(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await rm(blocker, { force: true });
|
||||
await client.close();
|
||||
});
|
||||
vi.spyOn(handlePrototype, "chmod").mockImplementationOnce(async function (
|
||||
this: typeof prototypeHandle,
|
||||
) {
|
||||
await writeFile(blocker, "keep until owner retry");
|
||||
throw new Error("injected output setup failure");
|
||||
});
|
||||
// Windows intentionally skips directory fchmod; this fault requires POSIX setup.
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"retains a tree whose POSIX setup failed after root identity was captured",
|
||||
async () => {
|
||||
const { root, archive } = await createArchive();
|
||||
const output = join(root, "output");
|
||||
const blocker = join(output, "unowned.txt");
|
||||
const client = await createClient(archive);
|
||||
const prototypeHandle = await open(join(root, "prototype-handle"), "w");
|
||||
const handlePrototype = Object.getPrototypeOf(prototypeHandle) as Pick<
|
||||
typeof prototypeHandle,
|
||||
"chmod"
|
||||
>;
|
||||
await prototypeHandle.close();
|
||||
await rm(join(root, "prototype-handle"));
|
||||
onTestFinished(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await rm(blocker, { force: true });
|
||||
await client.close();
|
||||
});
|
||||
vi.spyOn(handlePrototype, "chmod").mockImplementationOnce(async function (
|
||||
this: typeof prototypeHandle,
|
||||
) {
|
||||
await writeFile(blocker, "keep until owner retry");
|
||||
throw new Error("injected output setup failure");
|
||||
});
|
||||
|
||||
const extraction = await client.execute("extract_artifact", {
|
||||
output_root: output,
|
||||
});
|
||||
expect(extraction).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "ArtifactOperationError",
|
||||
detail: expect.stringContaining("injected output setup failure"),
|
||||
cleanup: { resources: [output] },
|
||||
},
|
||||
});
|
||||
expect(await readFile(blocker, "utf8")).toBe("keep until owner retry");
|
||||
await rm(blocker);
|
||||
expect(await client.close()).toMatchObject({ ok: true });
|
||||
await expect(access(output)).rejects.toThrow();
|
||||
});
|
||||
const extraction = await client.execute("extract_artifact", {
|
||||
output_root: output,
|
||||
});
|
||||
expect(extraction).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "ArtifactOperationError",
|
||||
detail: expect.stringContaining("injected output setup failure"),
|
||||
cleanup: { resources: [output] },
|
||||
},
|
||||
});
|
||||
expect(await readFile(blocker, "utf8")).toBe("keep until owner retry");
|
||||
await rm(blocker);
|
||||
expect(await client.close()).toMatchObject({ ok: true });
|
||||
await expect(access(output)).rejects.toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it("waits for admitted extraction before close and rejects later work", async () => {
|
||||
const { root, archive } = await createArchive();
|
||||
const output = join(root, "output");
|
||||
const client = createClient(archive);
|
||||
const client = await createClient(archive);
|
||||
const originalWrite = SafeOutputTree.prototype.write;
|
||||
const started = deferred();
|
||||
const finish = deferred();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
import { artifactGraphRootSha256 } from "../../../fixtures/artifactGraphRootSha256.js";
|
||||
|
||||
import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js";
|
||||
import { artifactExtractionExecutionSchema } from "../../../../src/contracts/artifactToolContracts.js";
|
||||
@@ -21,7 +22,7 @@ describe("artifact extraction", () => {
|
||||
await writeFile(join(source, "payload.txt"), "committed\n");
|
||||
const output = join(root, "output");
|
||||
const client = new ArtifactProvider(process.env).createClient(
|
||||
target(source, "directory"),
|
||||
await target(source, "directory"),
|
||||
);
|
||||
|
||||
const result = await client.execute("extract_artifact", {
|
||||
@@ -40,7 +41,7 @@ describe("artifact extraction", () => {
|
||||
await mkdir(join(source, "assets"), { recursive: true });
|
||||
await writeFile(join(source, "assets", "selected.js"), "selected();\n");
|
||||
await writeFile(join(source, "assets", "ignored.js"), "ignored();\n");
|
||||
const targetValue = target(source, "directory");
|
||||
const targetValue = await target(source, "directory");
|
||||
const graph = await inventory(targetValue);
|
||||
expect(
|
||||
graph.occurrences.some(
|
||||
@@ -136,13 +137,13 @@ const inventory = async (targetValue: BinaryTarget) => {
|
||||
return artifactInventoryResultSchema.parse(result.value.result);
|
||||
};
|
||||
|
||||
const target = (
|
||||
const target = async (
|
||||
path: string,
|
||||
format: Extract<BinaryTarget, { kind: "archive" }>["format"] | "directory",
|
||||
): BinaryTarget => ({
|
||||
): Promise<BinaryTarget> => ({
|
||||
path,
|
||||
sourcePath: path,
|
||||
sha256: "0".repeat(64),
|
||||
sha256: await artifactGraphRootSha256(path),
|
||||
kind: "archive",
|
||||
format: format === "directory" ? "asar" : format,
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { artifactExtractionResultSchema } from "../../../../src/domain/artifactGraph.js";
|
||||
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
import { artifactGraphRootSha256 } from "../../../fixtures/artifactGraphRootSha256.js";
|
||||
import { DirectoryArtifactReader } from "../../../../src/artifacts/DirectoryArtifactReader.js";
|
||||
|
||||
it.each(["file", "directory"] as const)(
|
||||
@@ -82,7 +83,6 @@ it("rolls back when cancellation arrives while closing the materialization reade
|
||||
materializeArtifactInventory(
|
||||
{
|
||||
inputPath: source,
|
||||
inputFormat: "asar",
|
||||
outputRoot: output,
|
||||
environment: process.env,
|
||||
integrityPolicy: "fail",
|
||||
@@ -172,7 +172,6 @@ const inventoriedExtraction = async (source: string, output: string) => {
|
||||
materializeArtifactInventory(
|
||||
{
|
||||
inputPath: source,
|
||||
inputFormat: "asar",
|
||||
outputRoot: output,
|
||||
environment: process.env,
|
||||
integrityPolicy: "fail",
|
||||
@@ -182,12 +181,12 @@ const inventoriedExtraction = async (source: string, output: string) => {
|
||||
);
|
||||
};
|
||||
|
||||
const extract = (source: string, output: string) =>
|
||||
const extract = async (source: string, output: string) =>
|
||||
new ArtifactProvider(process.env)
|
||||
.createClient({
|
||||
path: source,
|
||||
sourcePath: source,
|
||||
sha256: "0".repeat(64),
|
||||
sha256: await artifactGraphRootSha256(source),
|
||||
kind: "archive",
|
||||
format: "asar",
|
||||
})
|
||||
|
||||
@@ -1,27 +1,131 @@
|
||||
import { access, symlink, writeFile } from "node:fs/promises";
|
||||
import { createPackage } from "@electron/asar";
|
||||
import { access, mkdir, readFile, symlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildBinary } from "plist";
|
||||
|
||||
import { writeOrderedZip } from "../../../fixtures/artifactEntryOrder.js";
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
import { artifactGraphRootSha256 } from "../../../fixtures/artifactGraphRootSha256.js";
|
||||
|
||||
import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js";
|
||||
import { inesFixture } from "../../../fixtures/ines.js";
|
||||
import { nativeAotPeFixture } from "../../../fixtures/nativeaotPe.js";
|
||||
import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js";
|
||||
import { artifactExtractionExecutionSchema } from "../../../../src/contracts/artifactToolContracts.js";
|
||||
import { artifactExtractionResultSchema } from "../../../../src/domain/artifactGraph.js";
|
||||
import type { BinaryTarget } from "../../../../src/domain/binaryTargetTypes.js";
|
||||
|
||||
it.each(["plist", "pe"] as const)(
|
||||
"refuses iNES bytes after a non-extractable %s target is replaced",
|
||||
async (format) => {
|
||||
const root = await createTestTempDirectory("rea-extract-current-");
|
||||
const path = join(root, "source.bin");
|
||||
await writeFile(
|
||||
path,
|
||||
format === "plist" ? buildBinary({ value: "old" }) : nativeAotPeFixture(),
|
||||
);
|
||||
const target = await parseBinaryTarget(path);
|
||||
if (!target.ok) throw target.error;
|
||||
expect(target.value.format).toBe(format);
|
||||
const client = new ArtifactProvider(process.env).createClient(target.value);
|
||||
await writeFile(path, inesFixture());
|
||||
const output = join(root, "output");
|
||||
const result = await client.execute(
|
||||
"extract_artifact",
|
||||
artifactExtractionExecutionSchema.parse({ output_root: output }),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "ArtifactOperationError",
|
||||
operation: "extract_artifact",
|
||||
reason: "integrity",
|
||||
},
|
||||
});
|
||||
await expect(access(output)).rejects.toThrow();
|
||||
await client.close();
|
||||
},
|
||||
);
|
||||
|
||||
describe("artifact extraction format support", () => {
|
||||
it("refuses unsupported formats before inventory or output", async () => {
|
||||
it.each(["zip", "asar", "hap", "hsp", "app"] as const)(
|
||||
"refuses a replaced iNES target that scans as %s",
|
||||
async (format) => {
|
||||
const root = await createTestTempDirectory("rea-extract-replaced-");
|
||||
const path = join(root, `source.${format}`);
|
||||
await writeFile(path, inesFixture());
|
||||
const target = await parseBinaryTarget(path);
|
||||
if (!target.ok) throw target.error;
|
||||
expect(target.value.format).toBe("ines");
|
||||
const client = new ArtifactProvider(process.env).createClient(
|
||||
target.value,
|
||||
);
|
||||
if (format !== "asar") {
|
||||
await writeOrderedZip(path, ["payload.txt"]);
|
||||
} else {
|
||||
const source = join(root, "source");
|
||||
await mkdir(source);
|
||||
await writeFile(join(source, "payload.txt"), "evidence\n");
|
||||
await createPackage(source, path);
|
||||
}
|
||||
const output = join(root, "output");
|
||||
const result = await client.execute(
|
||||
"extract_artifact",
|
||||
artifactExtractionExecutionSchema.parse({ output_root: output }),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "ArtifactOperationError",
|
||||
operation: "extract_artifact",
|
||||
reason: "integrity",
|
||||
},
|
||||
});
|
||||
await expect(access(output)).rejects.toThrow();
|
||||
await client.close();
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses a scanned non-container instead of selecting a stale reader", async () => {
|
||||
const root = await createTestTempDirectory("rea-extract-replaced-file-");
|
||||
const path = join(root, "source.bin");
|
||||
await writeFile(path, inesFixture());
|
||||
const target = await parseBinaryTarget(path);
|
||||
if (!target.ok) throw target.error;
|
||||
const client = new ArtifactProvider(process.env).createClient(target.value);
|
||||
await writeFile(path, "ordinary replacement bytes");
|
||||
const output = join(root, "output");
|
||||
expect(
|
||||
await client.execute(
|
||||
"extract_artifact",
|
||||
artifactExtractionExecutionSchema.parse({ output_root: output }),
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisUnsupportedTargetError",
|
||||
reason: expect.stringContaining(
|
||||
"Artifact format has no extraction reader:",
|
||||
),
|
||||
},
|
||||
});
|
||||
await expect(access(output)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("refuses a currently unsupported format before child inventory or output", async () => {
|
||||
const root = await createTestTempDirectory("rea-extract-format-");
|
||||
const image = join(root, "Image.dmg");
|
||||
await writeFile(image, "not mounted");
|
||||
const bytes = Buffer.alloc(512);
|
||||
bytes.write("koly", 0, "ascii");
|
||||
await writeFile(image, bytes);
|
||||
const alias = join(root, "Alias.dmg");
|
||||
await symlink(image, alias);
|
||||
const output = join(root, "output");
|
||||
const extract = (signal?: AbortSignal) =>
|
||||
const extract = async (signal?: AbortSignal) =>
|
||||
new ArtifactProvider(process.env)
|
||||
.createClient(archiveTarget(alias, "dmg"))
|
||||
.createClient(await archiveTarget(alias, "dmg"))
|
||||
.execute(
|
||||
"extract_artifact",
|
||||
artifactExtractionExecutionSchema.parse({ output_root: output }),
|
||||
@@ -55,7 +159,7 @@ describe("artifact extraction format support", () => {
|
||||
);
|
||||
await writeOrderedZip(archive, ["payload.txt"]);
|
||||
const result = await new ArtifactProvider(process.env)
|
||||
.createClient(archiveTarget(archive, format))
|
||||
.createClient(await archiveTarget(archive, format))
|
||||
.execute(
|
||||
"extract_artifact",
|
||||
artifactExtractionExecutionSchema.parse({
|
||||
@@ -73,13 +177,13 @@ describe("artifact extraction format support", () => {
|
||||
);
|
||||
});
|
||||
|
||||
const archiveTarget = (
|
||||
const archiveTarget = async (
|
||||
path: string,
|
||||
format: Extract<BinaryTarget, { kind: "archive" }>["format"],
|
||||
): BinaryTarget => ({
|
||||
): Promise<BinaryTarget> => ({
|
||||
path,
|
||||
sourcePath: path,
|
||||
sha256: "0".repeat(64),
|
||||
sha256: await artifactGraphRootSha256(path),
|
||||
kind: "archive",
|
||||
format,
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { writeOrderedZip } from "../../../fixtures/artifactEntryOrder.js";
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
import { artifactGraphRootSha256 } from "../../../fixtures/artifactGraphRootSha256.js";
|
||||
|
||||
import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js";
|
||||
import { artifactExtractionExecutionSchema } from "../../../../src/contracts/artifactToolContracts.js";
|
||||
@@ -16,7 +17,7 @@ describe("artifact extraction identity", () => {
|
||||
const source = join(root, "source.zip");
|
||||
await writeOrderedZip(source, ["ä.js", "a.js", "Z.js"]);
|
||||
const result = await new ArtifactProvider(process.env)
|
||||
.createClient(archiveTarget(source))
|
||||
.createClient(await archiveTarget(source))
|
||||
.execute(
|
||||
"extract_artifact",
|
||||
artifactExtractionExecutionSchema.parse({
|
||||
@@ -34,10 +35,10 @@ describe("artifact extraction identity", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const archiveTarget = (path: string): BinaryTarget => ({
|
||||
const archiveTarget = async (path: string): Promise<BinaryTarget> => ({
|
||||
path,
|
||||
sourcePath: path,
|
||||
sha256: "0".repeat(64),
|
||||
sha256: await artifactGraphRootSha256(path),
|
||||
kind: "archive",
|
||||
format: "zip",
|
||||
});
|
||||
|
||||
@@ -7,8 +7,13 @@ import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js
|
||||
|
||||
import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js";
|
||||
import { ArtifactReaderFailure } from "../../../../src/artifacts/ArtifactReader.js";
|
||||
import {
|
||||
CPU,
|
||||
fatImage,
|
||||
machoImage,
|
||||
} from "../../../../src/artifacts/apple/MachoImage.fixture.js";
|
||||
import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js";
|
||||
import { artifactExtractionExecutionSchema } from "../../../../src/contracts/artifactToolContracts.js";
|
||||
import type { BinaryTarget } from "../../../../src/domain/binaryTargetTypes.js";
|
||||
|
||||
const sliceReader = vi.hoisted(() => ({
|
||||
reached: vi.fn(),
|
||||
@@ -41,10 +46,19 @@ it("routes universal Mach-O extraction through slice inventory", async () => {
|
||||
);
|
||||
const root = await createTestTempDirectory("rea-extract-universal-");
|
||||
const image = join(root, "Universal");
|
||||
await writeFile(image, Buffer.from([0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 0]));
|
||||
await writeFile(
|
||||
image,
|
||||
fatImage([
|
||||
{ cpu: CPU.arm64, bytes: machoImage({ cpu: CPU.arm64 }) },
|
||||
{ cpu: CPU.x86_64, bytes: machoImage({ cpu: CPU.x86_64 }) },
|
||||
]),
|
||||
);
|
||||
const target = await parseBinaryTarget(image);
|
||||
if (!target.ok) throw target.error;
|
||||
expect(target.value.format).toBe("mach-o-universal");
|
||||
const output = join(root, "output");
|
||||
const result = await new ArtifactProvider(process.env)
|
||||
.createClient(universalTarget(image))
|
||||
.createClient(target.value)
|
||||
.execute(
|
||||
"extract_artifact",
|
||||
artifactExtractionExecutionSchema.parse({ output_root: output }),
|
||||
@@ -61,14 +75,3 @@ it("routes universal Mach-O extraction through slice inventory", async () => {
|
||||
expect(sliceReader.reached).toHaveBeenCalledOnce();
|
||||
await expect(access(output)).rejects.toThrow();
|
||||
});
|
||||
|
||||
const universalTarget = (path: string): BinaryTarget => ({
|
||||
path,
|
||||
sourcePath: path,
|
||||
sha256: "0".repeat(64),
|
||||
kind: "executable",
|
||||
format: "mach-o-universal",
|
||||
architecture: "arm64",
|
||||
availableArchitectures: ["arm64"],
|
||||
universalSlice: { offset: 0, size: 32 },
|
||||
});
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js";
|
||||
import { scanArtifactInventory } from "../../src/artifacts/inventory/ArtifactInventory.js";
|
||||
|
||||
/** Compute the root identity used by artifact-provider extraction fixtures. */
|
||||
export const artifactGraphRootSha256 = async (
|
||||
path: string,
|
||||
): Promise<string> => {
|
||||
const resources = new ArtifactResourceScope();
|
||||
try {
|
||||
const snapshot = await scanArtifactInventory(path, {
|
||||
resourceScope: resources,
|
||||
environment: process.env,
|
||||
integrity: { mode: "fail" },
|
||||
});
|
||||
return snapshot.manifest.root_sha256;
|
||||
} finally {
|
||||
await resources.close();
|
||||
}
|
||||
};
|
||||
Vendored
+25
@@ -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),
|
||||
]);
|
||||
};
|
||||
Reference in New Issue
Block a user