Compare commits

...
Author SHA1 Message Date
morlutoandGitHub 75f1bf97e1 Merge branch 'main' into fix/main-sync-2087-captured-c6503ed 2026-10-14 02:47:45 +08:00
morluto a4dd4e8128 Merge commit '8fef2aa64ec1ee742e8b70b825938d30a0ea7319' into HEAD 2026-10-14 02:10:22 +08:00
Codex 346275525e fix(artifacts): bind session inventories to target identity 2026-10-14 02:01:13 +08:00
吴佳浩Albenandmorluto 781827b7b4 fix: repair the static-check failures on the default branch (#2895)
* fix: repair the static-check failures on the default branch

The default branch fails its own required `Static checks` job three ways, so
every pull request reports that job as failed regardless of its own change.

- `npm run format:check` reports `src/domain/functionComparison.test.ts` and
  `src/ida/IdaOperationRunner.ts`.
- `npm run lint` reports a duplicate `import { join } from "node:path"` in
  `tests/boundary/mcp/ghidraInventoryMcp.test.ts` (lines 4 and 17).
- `npm run typecheck` reports `format: "devin"` missing from the client format
  union in `src/application/SupportedClients.ts`, and an unchecked index in
  `tests/boundary/cli/clientProfileLifecycle.test.ts`, whose pair array is
  inferred as `string[][]`.

Apply the pinned formatter, drop the duplicate import, list the `"devin"` format
that its own definition already uses, and mark the pair array `as const`.

Validation: `npm run check:fast` and `npm run format:check` pass.

* fix(setup): keep Devin on the JSON configuration dialect

---------

Co-authored-by: 吴佳浩 <wujiahao@supremind.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
(cherry picked from commit df3d96da80)
2026-10-14 00:53:27 +08:00
morluto dc8a3e6078 fix(artifacts): bind extraction to opened target 2026-10-14 00:46:08 +08:00
morluto 0434be924e fix(artifacts): pass target metadata to format detection 2026-10-14 00:40:12 +08:00
morluto 502266b3f7 chore: merge current main into PR #2816 2026-10-14 00:06:29 +08:00
morluto 1aaaf06928 chore(artifacts): reconcile captured main and preserve iNES history 2026-10-13 23:02:09 +08:00
morluto ee4a11df81 chore(artifacts): preserve contributor iNES merge history 2026-10-13 22:06:37 +08:00
64579525 480a467dac Merge main and preserve iNES evidence format boundaries 2026-10-13 21:46:10 +08:00
morluto 2904bdb4f8 chore: merge captured main into iNES container support 2026-10-13 21:45:12 +08:00
64579525 00f31692b5 test(browser): await discoverable fixture pages before runtime verification 2026-10-13 20:36:35 +08:00
64579525 de2ab343f1 merge: retain main CI planning and artifact checks 2026-10-13 19:37:50 +08:00
64579525 717b5a215e test(javascript): align parameter provenance with main contract 2026-10-13 16:34:28 +08:00
64579525 5e7fcdcdc3 merge: integrate main and retain per-slice FAT alignment 2026-10-13 16:21:36 +08:00
64579525 89914a04cd ci: run Mach-O summary delivery coverage on macOS 2026-10-13 16:11:54 +08:00
64579525 96608517ba fix(artifacts): classify changed admission as integrity failure 2026-10-13 16:07:11 +08:00
64579525 577d6c3e9b test(artifacts): share file read setup and satisfy CI checks 2026-10-13 15:46:10 +08:00
64579525 f6748a511e fix(artifacts): reuse current classification and region reads 2026-10-13 15:38:40 +08:00
64579525 faa6f89566 Merge origin/main and retain inventory-owned extraction routing 2026-10-13 14:50:13 +08:00
64579525 c530a37813 Merge origin/main for artifact admission and evidence fixes 2026-10-13 14:32:52 +08:00
64579525 75c2741912 refactor(artifacts): use JSON-safe inventory producer output directly 2026-10-13 12:21:11 +08:00
64579525 0d018d5a77 chore: preserve current main archive typing and runtime fixes 2026-10-13 12:12:03 +08:00
64579525 9d4c517b84 fix(target): preserve typed cancellation during descriptor admission 2026-10-13 12:05:26 +08:00
64579525 6d0a6b5d51 fix(contracts): share ZIP target formats across MCP lifecycle schemas 2026-10-13 11:55:14 +08:00
64579525 baadb8114c fix: preserve cleanup retries and avoid duplicate inventory parsing 2026-10-13 11:43:58 +08:00
64579525 6c93ebabb0 chore: reconcile iNES support with current main 2026-10-13 11:42:47 +08:00
64579525 2d1d8c45ea test(ci): own iNES macOS coverage and portable fixture paths 2026-10-13 11:04:10 +08:00
64579525 d4530488c3 test(artifacts): verify iNES workflows on macOS 2026-10-13 10:37:48 +08:00
64579525 3590893742 chore: sync main performance verification updates 2026-10-13 10:17:41 +08:00
64579525 72b78e41b9 test(artifacts): admit valid universal inputs before routing 2026-10-13 10:12:17 +08:00
64579525 a74a599fce fix(browser): budget scenario cleanup for content drain and shutdown 2026-10-13 10:00:52 +08:00
64579525 d3e315cf33 Merge remote-tracking branch 'origin/main' into feat/2072-ines-container 2026-10-13 09:36:35 +08:00
64579525 54c2e4e81c chore: reconcile iNES inventory with current main 2026-10-13 09:36:35 +08:00
64579525 c17cb017fa fix(artifacts): complete prefix reads and share ZIP format admission 2026-10-12 23:27:26 +08:00
64579525 2b29105bf9 fix(artifacts): select extraction readers from scanned format 2026-10-12 23:03:51 +08:00
64579525 681f34879e fix(artifacts): prefer artifact magic over implicit database suffix 2026-10-12 22:41:12 +08:00
64579525 a60d60dc6b chore: reconcile documentation with latest main 2026-10-12 22:20:52 +08:00
64579525 1c6217258a chore: merge main and preserve tool effect exceptions 2026-10-12 22:17:04 +08:00
64579525 e47b667670 fix(artifacts): honor cancellation across source validation 2026-10-12 22:06:50 +08:00
64579525 78820dd69a test(browser): wait for popup document loads before capture 2026-10-12 21:50:38 +08:00
64579525 db24a47c15 fix: reconcile iNES inspection with upstream summary delivery 2026-10-12 21:35:46 +08:00
64579525 071f632e92 fix: preserve binary plist detection with extended magic probe 2026-10-12 21:02:02 +08:00
64579525 7cba3acaa4 Merge remote-tracking branch 'origin/main' into feat/2072-ines-container 2026-10-12 20:56:36 +08:00
64579525 ca646b6cb9 test(artifacts): clarify non-executing inspection wording 2026-10-12 20:56:16 +08:00
64579525 2f0e4d79ea fix(artifacts): bind iNES extraction admission to inventory 2026-10-12 17:15:12 +08:00
64579525 9b8916152b fix(artifacts): normalize source loss during root hashing 2026-10-12 16:52:05 +08:00
64579525 e6d4d7fb84 fix(artifacts): validate admitted roots before classification 2026-10-12 16:37:04 +08:00
64579525 5ba8b2f6bf fix(artifacts): revalidate iNES source before parsing header 2026-10-12 16:11:55 +08:00
64579525 574f6bb3bd fix(artifacts): preserve iNES source disappearance as integrity failure 2026-10-12 15:50:02 +08:00
64579525 414307c9d5 feat(artifacts): inspect classic iNES container regions 2026-10-12 15:17:50 +08:00
57 changed files with 3209 additions and 232 deletions
@@ -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
+7
View File
@@ -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
+1
View File
@@ -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" },
+72
View File
@@ -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).
+7
View File
@@ -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",
+55 -10
View File
@@ -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 =
+58 -25
View File
@@ -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");
};
+3 -1
View File
@@ -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,
+7 -1
View File
@@ -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 }
: {}),
});
}
}
+1 -1
View File
@@ -92,7 +92,7 @@ export const sameArtifactEntry = (
/** Read-only adapter over one directory, archive, or virtual container. */
export interface ArtifactReader {
readonly format: "directory" | ZipPackageFormat | "asar" | "file";
readonly format: "directory" | ZipPackageFormat | "asar" | "ines" | "file";
entries(signal?: AbortSignal): AsyncIterable<ArtifactEntry>;
open(entry: ArtifactEntry, signal?: AbortSignal): Promise<Readable>;
provenance(): readonly ArtifactCommand[];
+187
View File
@@ -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);
}
}
+88 -25
View File
@@ -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);
+11 -1
View File
@@ -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;
};
+72 -12
View File
@@ -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}`,
+10
View File
@@ -1,4 +1,6 @@
import { AsarArtifactReader } from "../AsarArtifactReader.js";
import { InesArtifactReader, INES_LIMITATION } from "../InesArtifactReader.js";
import { ArtifactReaderFailure } from "../ArtifactReader.js";
import type { ArtifactReader } from "../ArtifactReader.js";
import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
import { MachOSliceArtifactReader } from "../MachOSliceArtifactReader.js";
@@ -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);
+22
View File
@@ -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,
+38
View File
@@ -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) {
+7
View File
@@ -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;
+33 -1
View File
@@ -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;
+5 -6
View File
@@ -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) {
+81 -15
View File
@@ -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");
+10 -4
View File
@@ -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 }),
};
}
+1 -1
View File
@@ -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(
+1
View File
@@ -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",
+1
View File
@@ -69,6 +69,7 @@ const formatSchema = z.enum([
"hsp",
"app-pack",
"asar",
"ines",
"dmg",
"pkg",
"plist",
+6
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { prefixedDigestSchema } from "./../domain/digests.js";
import { inesInventorySchema } from "./inesContainer.js";
const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/u);
const artifactIdSchema = prefixedDigestSchema("art");
@@ -42,6 +43,7 @@ const artifactFormatSchema = z.enum([
"zip",
"pkg",
"asar",
"ines",
"javascript-bundle",
"source-map",
"mach-o-universal",
@@ -69,6 +71,7 @@ const artifactFormatSchema = z.enum([
/** Formats established from bytes or a virtual directory tree, independent of path roles. */
const artifactContentFormatSchema = artifactFormatSchema.extract([
"ines",
"zip",
"mach-o-universal",
"mach-o",
@@ -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
+22 -1
View File
@@ -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,
);
});
+1
View File
@@ -16,5 +16,6 @@ export interface ArtifactInventorySnapshot {
ArtifactInventoryResult["provenance"][number]
>;
readonly integrity_contradictions: readonly IntegrityContradiction[];
readonly ines?: ArtifactInventoryResult["ines"];
readonly limitations: readonly string[];
}
+1 -1
View File
@@ -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
View File
@@ -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", () => {
+1
View File
@@ -56,6 +56,7 @@ const subjectFormatSchema = z.enum([
"hsp",
"app-pack",
"asar",
"ines",
"dmg",
"pkg",
"plist",
+92
View File
@@ -0,0 +1,92 @@
import { expect, it } from "vitest";
import { parseInesLayout } from "./inesContainer.js";
const header = (): Buffer => {
const bytes = Buffer.alloc(16);
bytes.write("NES\x1a", "latin1");
bytes[4] = 1;
return bytes;
};
it("bounds the complete classic header count without inventing RAM regions", () => {
const bytes = header();
bytes[4] = 255;
bytes[5] = 255;
bytes[6] = 0xfe;
bytes[7] = 0xf3;
const parsed = parseInesLayout(bytes, 16 + 512 + 255 * (16_384 + 8_192));
if (!parsed.ok) throw new Error(parsed.error);
expect(parsed.value.header).toMatchObject({
mapper: 255,
trainer_present: true,
battery_flag: true,
mirroring: "four-screen",
});
expect(parsed.value.regions).toHaveLength(512);
expect(parsed.value.regions.at(-1)).toMatchObject({
path: "chr/254.bin",
length: 8_192,
});
});
it.each([
{
label: "short header",
modify: (bytes: Buffer) => bytes.subarray(0, 8),
size: 8,
reason: "truncated iNES header",
},
{
label: "short payload",
modify: (bytes: Buffer) => bytes,
size: 16,
reason: "truncated iNES payload",
},
{
label: "NES 2.0",
modify: (bytes: Buffer) => {
bytes[7] = 8;
return bytes;
},
size: 16_400,
reason: "nes-2.0",
},
{
label: "dirty legacy header",
modify: (bytes: Buffer) => {
bytes[12] = 1;
return bytes;
},
size: 16_400,
reason: "ambiguous iNES mapper",
},
{
label: "empty PRG",
modify: (bytes: Buffer) => {
bytes[4] = 0;
return bytes;
},
size: 16,
reason: "at least one PRG",
},
{
label: "unsupported variant",
modify: (bytes: Buffer) => {
bytes[7] = 4;
return bytes;
},
size: 16_400,
reason: "unsupported iNES header variant",
},
{
label: "unsafe extent",
modify: (bytes: Buffer) => bytes,
size: Number.MAX_SAFE_INTEGER + 1,
reason: "invalid iNES file extent",
},
])("refuses $label before producing regions", ({ modify, size, reason }) => {
expect(parseInesLayout(modify(header()), size)).toMatchObject({
ok: false,
error: expect.stringContaining(reason),
});
});
+131
View File
@@ -0,0 +1,131 @@
import { z } from "zod";
import { err, ok, type Result } from "./result.js";
/** Byte signatures select a container family, never an executable architecture. */
export const nesContainerFormat = (
prefix: Buffer,
): "ines" | "nes-2.0" | "unif" | undefined => {
if (prefix.subarray(0, 4).equals(Buffer.from("UNIF"))) return "unif";
if (!prefix.subarray(0, 4).equals(Buffer.from([0x4e, 0x45, 0x53, 0x1a])))
return undefined;
return prefix.length >= 8 && (prefix.readUInt8(7) & 0x0c) === 0x08
? "nes-2.0"
: "ines";
};
const inesHeaderSchema = z.strictObject({
format: z.literal("ines"),
header_hex: z.string().regex(/^[a-f0-9]{32}$/u),
prg_rom_units_16k: z.number().int().min(1).max(255),
chr_rom_units_8k: z.number().int().min(0).max(255),
mapper: z.number().int().min(0).max(255),
flags_6: z.number().int().min(0).max(255),
flags_7: z.number().int().min(0).max(255),
trainer_present: z.boolean(),
battery_flag: z.boolean(),
mirroring: z.enum(["horizontal", "vertical", "four-screen"]),
});
/** Verified concatenation is a byte identity claim, not an emulator claim. */
export const inesInventorySchema = z.strictObject({
header: inesHeaderSchema,
reconstruction: z.strictObject({
region_order: z.array(z.string().min(1)).min(2),
bytes: z.number().int().min(16).max(Number.MAX_SAFE_INTEGER),
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
verified: z.literal(true),
}),
});
export type InesInventory = z.infer<typeof inesInventorySchema>;
export interface InesRegion {
readonly path: string;
readonly offset: number;
readonly length: number;
}
export interface InesLayout {
readonly header: InesInventory["header"];
readonly regions: readonly InesRegion[];
}
/**
* Classic iNES counts 16 KiB PRG and 8 KiB CHR file units. Those units do not
* establish the mapper's runtime windows. Preserve all other bytes verbatim.
*/
export const parseInesLayout = (
prefix: Buffer,
fileSize: number,
): Result<InesLayout, string> => {
const format = nesContainerFormat(prefix);
if (format !== "ines")
return err(
format === undefined
? "missing iNES byte signature"
: `${format} containers are not supported by the classic iNES lane`,
);
if (!Number.isSafeInteger(fileSize) || fileSize < 0)
return err("invalid iNES file extent: expected a nonnegative safe integer");
if (fileSize < 16 || prefix.length < 16)
return err("truncated iNES header: expected 16 bytes");
const prgUnits = prefix.readUInt8(4);
const chrUnits = prefix.readUInt8(5);
const flags6 = prefix.readUInt8(6);
const flags7 = prefix.readUInt8(7);
if ((flags7 & 0x0c) !== 0) return err("unsupported iNES header variant");
// Dirty legacy headers make the upper mapper bits ambiguous. Do not repair
// them or claim a mapper that the observed header cannot establish.
if (prefix.subarray(12, 16).some((byte) => byte !== 0))
return err(
"ambiguous iNES mapper: reserved header bytes 12..15 are nonzero",
);
if (prgUnits === 0) return err("iNES requires at least one PRG-ROM unit");
const trainer = (flags6 & 4) !== 0;
const declaredEnd =
16 + (trainer ? 512 : 0) + prgUnits * 16_384 + chrUnits * 8_192;
if (declaredEnd > fileSize)
return err(
`truncated iNES payload: header declares ${declaredEnd} bytes; file has ${fileSize}`,
);
const regions: InesRegion[] = [{ path: "header.bin", offset: 0, length: 16 }];
let offset = 16;
if (trainer) {
regions.push({ path: "trainer.bin", offset, length: 512 });
offset += 512;
}
for (const [kind, count, length] of [
["prg", prgUnits, 16_384],
["chr", chrUnits, 8_192],
] as const)
for (let index = 0; index < count; index += 1) {
regions.push({
path: `${kind}/${String(index).padStart(3, "0")}.bin`,
offset,
length,
});
offset += length;
}
if (offset < fileSize)
regions.push({ path: "trailing.bin", offset, length: fileSize - offset });
return ok({
header: {
format: "ines",
header_hex: prefix.subarray(0, 16).toString("hex"),
prg_rom_units_16k: prgUnits,
chr_rom_units_8k: chrUnits,
mapper: (flags6 >>> 4) | (flags7 & 0xf0),
flags_6: flags6,
flags_7: flags7,
trainer_present: trainer,
battery_flag: (flags6 & 2) !== 0,
mirroring:
(flags6 & 8) !== 0
? "four-screen"
: (flags6 & 1) !== 0
? "vertical"
: "horizontal",
},
regions,
});
};
+29 -2
View File
@@ -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,
+30
View File
@@ -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);
};
+313
View File
@@ -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();
}
});
+17 -10
View File
@@ -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
View File
@@ -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();
}
};
+25
View File
@@ -0,0 +1,25 @@
/** Source-owned iNES bytes; the payload is test data, not a game ROM. */
export const inesFixture = (
options: {
readonly trainer?: boolean;
readonly chrUnits?: number;
readonly trailing?: Buffer;
} = {},
): Buffer => {
const header = Buffer.alloc(16);
header.write("NES\x1a", "latin1");
header[4] = 2;
header[5] = options.chrUnits ?? 1;
header[6] = 0x31 | (options.trainer === true ? 4 : 0);
header[7] = 0x10;
return Buffer.concat([
header,
...(options.trainer === true ? [Buffer.alloc(512, 0x54)] : []),
Buffer.alloc(16_384, 0x41),
Buffer.alloc(16_384, 0x42),
...Array.from({ length: header.readUInt8(5) }, () =>
Buffer.alloc(8_192, 0x43),
),
options.trailing ?? Buffer.alloc(0),
]);
};