test: harden verification fixtures and runtime coverage
Use exact discovered test identities, production MCP sessions and byte-exact setup backups. Add CLI signal cleanup coverage and independent Ghidra, Go and firmware verification assertions; prune redundant test configuration and preserve platform limits.
This commit is contained in:
parent
1b14870ace
commit
4d4631ac94
|
|
@ -634,6 +634,7 @@ jobs:
|
|||
tests/boundary/cli/nativeSignatureCli.test.ts
|
||||
tests/boundary/mcp/nativeSignatureTargetBindingMcp.test.ts
|
||||
src/process/capture/FilesystemSnapshot.test.ts
|
||||
tests/process-global/filesystemSnapshotFallbackCancellation.test.ts
|
||||
tests/boundary/process/darwinProcessRunTokenReader.test.ts
|
||||
tests/boundary/process/directAnalysisSignals.test.ts
|
||||
tests/boundary/process/processCaptureLifecycle.test.ts
|
||||
|
|
@ -714,6 +715,8 @@ jobs:
|
|||
- name: Run curated Windows capability tests
|
||||
run: >-
|
||||
npx vitest run --coverage.enabled=false
|
||||
src/process/capture/FilesystemSnapshot.test.ts
|
||||
tests/process-global/filesystemSnapshotFallbackCancellation.test.ts
|
||||
tests/boundary/filesystem/binaryTarget.test.ts
|
||||
tests/boundary/filesystem/safeOutputTree.test.ts
|
||||
tests/boundary/providers/native/artifactProvider.archiveSafety.test.ts
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ behavior, and complete CI evidence before merging.
|
|||
| `npm run test:boundary` | Boundary, CLI boundary, MCP boundary, process boundary, and process-global projects |
|
||||
| `npm run test:mcp` | MCP boundary project |
|
||||
| `npm run test:acceptance` | Complete compiled CLI and MCP acceptance workflows |
|
||||
| `npm run test:python` | LLDB producer retention and launch contracts with a fake LLDB API; no native debugger required |
|
||||
| `npm run test:python` | LLDB and mitmproxy producer contracts plus analysis-efficiency checks; no real debugger or proxy required |
|
||||
| `npm run test:watch` | Dirty source tests in watch mode, without build |
|
||||
| `npm run test:watch:all` | Changed tests from every project; builds at startup, so rebuild after production edits before relying on compiled tests |
|
||||
| `npm run check:changed` | Cached typecheck/lint and branch-related source feedback |
|
||||
|
|
@ -449,8 +449,9 @@ adds in-place reparse, breakaway, environment, and token observations.
|
|||
The separate `scripts/verify-windows-native-inspection.mjs` lane uses the
|
||||
`.cache/windows-native-inspection-fixture` artifact built on Linux with
|
||||
`node scripts/build-windows-native.mjs --inspection-fixture`. Its controlled
|
||||
first deletion failure checks retained NTFS ownership, later-query recovery,
|
||||
successful-admission caching, and environment-teardown cleanup. It neither
|
||||
first deletion failure checks retained NTFS ownership across a same-volume
|
||||
rename, later-query handle-based recovery, successful-admission caching, and
|
||||
environment-teardown cleanup. It neither
|
||||
establishes the cause of the intermittent #1701 host failure nor replaces
|
||||
verification with the shipped production artifact.
|
||||
`npm run verify:ghidra:windows:package` additionally packs and installs REA into
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@
|
|||
"src/application/RealHopperAssertions.ts",
|
||||
"scripts/verify/fridaBindingsCompatibility.ts",
|
||||
"scripts/verify/termux/browser.mjs",
|
||||
"scripts/verify-real-cutter.mjs",
|
||||
"scripts/verify-real-ghidra-noreturn.mjs",
|
||||
"scripts/run-exact-tests.mjs",
|
||||
"tests/processOwnershipRunner.ts"
|
||||
],
|
||||
"project": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.mjs"],
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@
|
|||
"test": "npm run test:with-build",
|
||||
"test:with-build": "npm run test:prepare && vitest run",
|
||||
"test:run": "vitest run",
|
||||
"test:python": "python3 -m unittest bridge/native/test_rea_lldb_tracer.py -v && python3 -m unittest discover -s experiments/analysis-efficiency -p 'test_*.py' -v",
|
||||
"test:python": "python3 -m unittest bridge/native/test_rea_lldb_tracer.py bridge/mitmproxy/test_capture.py -v && python3 -m unittest discover -s experiments/analysis-efficiency -p 'test_*.py' -v",
|
||||
"test:local": "node scripts/run-development-tests.mjs local",
|
||||
"test:fast": "node scripts/ci/test-group.mjs source",
|
||||
"test:built": "node scripts/ci/test-group.mjs built",
|
||||
|
|
|
|||
|
|
@ -202,6 +202,9 @@ if (tier === "reused") {
|
|||
if (GUARDED_WORKFLOWS.has(file)) selected.code = true;
|
||||
if (file !== ".github/workflows/ci.yml") continue;
|
||||
}
|
||||
// The Notes example is also the source-owned agent evaluation fixture.
|
||||
if (file.startsWith("website/public/examples/notes-electron/"))
|
||||
selected.code = true;
|
||||
if (owners.length === 1 && owners[0] === "website") continue;
|
||||
if (/^src\/(contracts|domain)\//.test(file)) selected.docs = true;
|
||||
if (file === "package.json") {
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ export const SCOPES = {
|
|||
"tests/boundary/cli/nativeSignatureCli.test.ts",
|
||||
"tests/boundary/mcp/nativeSignatureTargetBindingMcp.test.ts",
|
||||
"src/process/capture/FilesystemSnapshot.test.ts",
|
||||
"tests/process-global/filesystemSnapshotFallbackCancellation.test.ts",
|
||||
"tests/boundary/process/darwinProcessRunTokenReader.test.ts",
|
||||
"tests/boundary/process/directAnalysisSignals.test.ts",
|
||||
"tests/boundary/process/processCaptureLifecycle.test.ts",
|
||||
|
|
@ -89,6 +90,9 @@ export const SCOPES = {
|
|||
package: [
|
||||
"src/windows/**",
|
||||
"native/windows/**",
|
||||
"src/process/capture/FilesystemSnapshot.ts",
|
||||
"src/process/capture/FilesystemSnapshot.test.ts",
|
||||
"tests/process-global/filesystemSnapshotFallbackCancellation.test.ts",
|
||||
"src/inspector/**",
|
||||
"scripts/verify/inspector/**",
|
||||
"scripts/verify-package.mjs",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { writeWindowsGhidraByteSearchFixture } from "./lib/ghidra-windows-byte-search.mjs";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
// Source-owned static fixtures: no compiler, target loading or execution.
|
||||
|
|
@ -61,7 +62,7 @@ for (const architecture of ["x86_64", "x86"]) {
|
|||
}
|
||||
|
||||
bytes.write(".text", sectionTable, "ascii");
|
||||
bytes.writeUInt32LE(dll ? 0x180 : 0x50, sectionTable + 8);
|
||||
bytes.writeUInt32LE(0x200, sectionTable + 8);
|
||||
bytes.writeUInt32LE(0x1000, sectionTable + 12);
|
||||
bytes.writeUInt32LE(0x200, sectionTable + 16);
|
||||
bytes.writeUInt32LE(0x200, sectionTable + 20);
|
||||
|
|
@ -136,6 +137,8 @@ for (const architecture of ["x86_64", "x86"]) {
|
|||
bytes.write("fixture_return42\0", 0x340, "binary");
|
||||
}
|
||||
|
||||
writeWindowsGhidraByteSearchFixture(bytes);
|
||||
|
||||
await mkdir(dirname(output), { recursive: true });
|
||||
await writeFile(output, bytes);
|
||||
if (!x86 && !dll) process.stdout.write(`${output}\n`);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,149 @@
|
|||
import assert from "node:assert/strict";
|
||||
|
||||
export const WINDOWS_GHIDRA_BYTE_SEARCH_FIXTURE = Object.freeze({
|
||||
pattern: "a0 ?? c0",
|
||||
mask: "f0 ff f0",
|
||||
startFileOffset: 0x380,
|
||||
endFileOffset: 0x392,
|
||||
markerOffsets: Object.freeze([0x380, 0x390]),
|
||||
});
|
||||
|
||||
/** Put two masked, wildcarded signatures in loaded, source-owned PE bytes. */
|
||||
export function writeWindowsGhidraByteSearchFixture(bytes) {
|
||||
bytes.set([0xa1, 0xb2, 0xc3], 0x380);
|
||||
bytes.set([0xa4, 0xb5, 0xc6], 0x390);
|
||||
}
|
||||
|
||||
/** Verify real Ghidra search pages against the immutable source fixture. */
|
||||
export async function verifyWindowsGhidraByteSearch({
|
||||
invoke,
|
||||
sourceBytes,
|
||||
architecture,
|
||||
sourceOwnedFixture = true,
|
||||
fallbackAddress,
|
||||
fallbackFileOffset,
|
||||
runCliPage,
|
||||
}) {
|
||||
const imageBase = architecture === "x86" ? 0x400000n : 0x140000000n;
|
||||
const query = sourceOwnedFixture
|
||||
? {
|
||||
pattern: WINDOWS_GHIDRA_BYTE_SEARCH_FIXTURE.pattern,
|
||||
mask: WINDOWS_GHIDRA_BYTE_SEARCH_FIXTURE.mask,
|
||||
start: addressAtFileOffset(
|
||||
imageBase,
|
||||
WINDOWS_GHIDRA_BYTE_SEARCH_FIXTURE.startFileOffset,
|
||||
),
|
||||
end: addressAtFileOffset(
|
||||
imageBase,
|
||||
WINDOWS_GHIDRA_BYTE_SEARCH_FIXTURE.endFileOffset,
|
||||
),
|
||||
limit: 1,
|
||||
}
|
||||
: {
|
||||
pattern: sourceBytes
|
||||
.subarray(fallbackFileOffset, fallbackFileOffset + 1)
|
||||
.toString("hex"),
|
||||
start: fallbackAddress,
|
||||
end: fallbackAddress,
|
||||
limit: 1,
|
||||
};
|
||||
|
||||
if (!sourceOwnedFixture) {
|
||||
const result = await invoke("search_bytes", query);
|
||||
assert.equal(result.complete, true);
|
||||
assert.equal(result.next_cursor, null);
|
||||
assert.deepEqual(
|
||||
result.matches.map(({ address, file_offset }) => ({
|
||||
address,
|
||||
file_offset,
|
||||
})),
|
||||
[{ address: fallbackAddress, file_offset: fallbackFileOffset }],
|
||||
);
|
||||
assert.ok(result.searched.bytes > 0);
|
||||
return { bounded_source_match: true };
|
||||
}
|
||||
|
||||
const { startFileOffset, endFileOffset } = WINDOWS_GHIDRA_BYTE_SEARCH_FIXTURE;
|
||||
const expected = [];
|
||||
for (
|
||||
let fileOffset = startFileOffset;
|
||||
fileOffset + 2 <= endFileOffset;
|
||||
fileOffset += 1
|
||||
) {
|
||||
const first = sourceBytes[fileOffset];
|
||||
const last = sourceBytes[fileOffset + 2];
|
||||
if (first === undefined || last === undefined) break;
|
||||
if ((first & 0xf0) === 0xa0 && (last & 0xf0) === 0xc0)
|
||||
expected.push({
|
||||
address: addressAtFileOffset(imageBase, fileOffset),
|
||||
file_offset: fileOffset,
|
||||
});
|
||||
}
|
||||
assert.deepEqual(
|
||||
expected.map(({ file_offset }) => file_offset),
|
||||
[...WINDOWS_GHIDRA_BYTE_SEARCH_FIXTURE.markerOffsets],
|
||||
"The source-owned byte oracle must retain both distinct signatures",
|
||||
);
|
||||
|
||||
const first = await invoke("search_bytes", query);
|
||||
assert.deepEqual(
|
||||
first.matches.map(({ address, file_offset }) => ({ address, file_offset })),
|
||||
[expected[0]],
|
||||
);
|
||||
assert.equal(first.complete, false);
|
||||
assert.equal(typeof first.next_cursor, "string");
|
||||
assert.ok(first.searched.bytes > 0);
|
||||
if (runCliPage !== undefined) {
|
||||
const cli = await runCliPage(query);
|
||||
assert.deepEqual(cli.matches, first.matches);
|
||||
assert.deepEqual(cli.searched, first.searched);
|
||||
assert.equal(cli.complete, first.complete);
|
||||
}
|
||||
|
||||
const second = await invoke("search_bytes", {
|
||||
...query,
|
||||
cursor: first.next_cursor,
|
||||
});
|
||||
assert.deepEqual(
|
||||
second.matches.map(({ address, file_offset }) => ({
|
||||
address,
|
||||
file_offset,
|
||||
})),
|
||||
[expected[1]],
|
||||
);
|
||||
assert.equal(second.complete, true);
|
||||
assert.equal(second.next_cursor, null);
|
||||
assert.ok(second.searched.bytes > 0);
|
||||
|
||||
const covered = new Set();
|
||||
for (const page of [first, second]) {
|
||||
assert.equal(
|
||||
page.searched.bytes,
|
||||
page.searched.blocks.reduce((sum, block) => sum + block.bytes, 0),
|
||||
);
|
||||
for (const block of page.searched.blocks) {
|
||||
const start = BigInt(block.start);
|
||||
const end = BigInt(block.end);
|
||||
assert.ok(start >= BigInt(query.start) && end <= BigInt(query.end));
|
||||
for (let offset = start; offset <= end; offset += 1n)
|
||||
covered.add(offset.toString());
|
||||
}
|
||||
}
|
||||
assert.equal(
|
||||
covered.size,
|
||||
endFileOffset - startFileOffset + 1,
|
||||
"Paged byte-search coverage must account for the complete bounded source range",
|
||||
);
|
||||
return {
|
||||
bounded: true,
|
||||
wildcardAndMask: true,
|
||||
matches: expected,
|
||||
continuation: true,
|
||||
examinedBytes: covered.size,
|
||||
cliMcpParity: runCliPage !== undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function addressAtFileOffset(imageBase, fileOffset) {
|
||||
return `0x${(imageBase + 0x1000n + BigInt(fileOffset - 0x200)).toString(16)}`;
|
||||
}
|
||||
|
|
@ -62,36 +62,68 @@ export async function verifyGhidraAddressBoundaries(client, entry, cli) {
|
|||
await query("read_bytes", { address: padded, length: 1 }),
|
||||
await query("read_bytes", { address: entry, length: 1 }),
|
||||
);
|
||||
const aliasKeys = [entry, padded];
|
||||
assert.deepEqual(
|
||||
await query("set_addresses_names", {
|
||||
names: {
|
||||
[entry]: "rea_same_address_alias",
|
||||
[padded]: "rea_same_address_alias",
|
||||
},
|
||||
}),
|
||||
Object.fromEntries(aliasKeys.map((address) => [address, true])),
|
||||
"Equivalent address spellings with one requested name share one edit",
|
||||
);
|
||||
const nameBeforeAliasedBatch = await query("address_name", {
|
||||
address: entry,
|
||||
});
|
||||
assert.equal(nameBeforeAliasedBatch, "rea_same_address_alias");
|
||||
assert.deepEqual(
|
||||
await query("set_addresses_names", {
|
||||
names: {
|
||||
[entry]: "rea_first_address_alias",
|
||||
[padded]: "rea_second_address_alias",
|
||||
},
|
||||
}),
|
||||
Object.fromEntries(aliasKeys.map((address) => [address, false])),
|
||||
"Conflicting spellings of one Ghidra address must be rejected together",
|
||||
);
|
||||
const originalName = await query("address_name", { address: entry });
|
||||
assert.equal(
|
||||
await query("address_name", { address: entry }),
|
||||
nameBeforeAliasedBatch,
|
||||
"A rejected alias collision must leave the preexisting target name unchanged",
|
||||
typeof originalName,
|
||||
"string",
|
||||
"The function entry needs an observed name that can be restored",
|
||||
);
|
||||
const aliasKeys = [entry, padded];
|
||||
let primaryFailure;
|
||||
try {
|
||||
assert.deepEqual(
|
||||
await query("set_addresses_names", {
|
||||
names: {
|
||||
[entry]: "rea_same_address_alias",
|
||||
[padded]: "rea_same_address_alias",
|
||||
},
|
||||
}),
|
||||
Object.fromEntries(aliasKeys.map((address) => [address, true])),
|
||||
"Equivalent address spellings with one requested name share one edit",
|
||||
);
|
||||
const nameBeforeAliasedBatch = await query("address_name", {
|
||||
address: entry,
|
||||
});
|
||||
assert.equal(nameBeforeAliasedBatch, "rea_same_address_alias");
|
||||
assert.deepEqual(
|
||||
await query("set_addresses_names", {
|
||||
names: {
|
||||
[entry]: "rea_first_address_alias",
|
||||
[padded]: "rea_second_address_alias",
|
||||
},
|
||||
}),
|
||||
Object.fromEntries(aliasKeys.map((address) => [address, false])),
|
||||
"Conflicting spellings of one Ghidra address must be rejected together",
|
||||
);
|
||||
assert.equal(
|
||||
await query("address_name", { address: entry }),
|
||||
nameBeforeAliasedBatch,
|
||||
"A rejected alias collision must leave the preexisting target name unchanged",
|
||||
);
|
||||
} catch (cause) {
|
||||
primaryFailure = cause;
|
||||
throw cause;
|
||||
} finally {
|
||||
try {
|
||||
assert.equal(
|
||||
await query("set_address_name", { address: entry, name: originalName }),
|
||||
true,
|
||||
"The temporary alias edit must be restored",
|
||||
);
|
||||
assert.equal(
|
||||
await query("address_name", { address: entry }),
|
||||
originalName,
|
||||
"The original function-entry name must survive restoration",
|
||||
);
|
||||
} catch (cleanupCause) {
|
||||
if (primaryFailure !== undefined)
|
||||
throw new AggregateError(
|
||||
[primaryFailure, cleanupCause],
|
||||
"Ghidra address-boundary verification and alias restoration failed",
|
||||
);
|
||||
throw cleanupCause;
|
||||
}
|
||||
}
|
||||
for (const [command, flags, operation] of [
|
||||
["read-bytes", ["--length", "1"], "read_bytes"],
|
||||
[
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
import type { z } from "zod";
|
||||
|
||||
import type { analysisStringSchema } from "../../src/domain/hopperValues.js";
|
||||
|
||||
type VerifierCall = (
|
||||
name: string,
|
||||
arguments_?: Record<string, unknown>,
|
||||
|
|
@ -44,6 +48,3 @@ export function verifyHopperStringObjects(
|
|||
}[];
|
||||
readonly displayEscapesExcludedFromMatching: true;
|
||||
}>;
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { analysisStringSchema } from "../../src/domain/hopperValues.js";
|
||||
|
|
|
|||
|
|
@ -68,7 +68,11 @@ try {
|
|||
}
|
||||
if (!process.exitCode && !shutdownSignal) {
|
||||
const tested = await run(process.execPath, [
|
||||
resolve("node_modules/vitest/vitest.mjs"),
|
||||
resolve(
|
||||
request.paths.length > 0
|
||||
? "scripts/run-exact-tests.mjs"
|
||||
: "node_modules/vitest/vitest.mjs",
|
||||
),
|
||||
...plan.vitestArguments,
|
||||
]);
|
||||
process.exitCode = tested.code;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,134 @@
|
|||
import { lstat, realpath, stat } from "node:fs/promises";
|
||||
import { parse, relative, resolve, sep } from "node:path";
|
||||
|
||||
import { createVitest, parseCLI } from "vitest/node";
|
||||
|
||||
// CLI file filters are substring matches. Select discovered specifications by
|
||||
// identity instead, retaining each project's include/exclude and runner settings.
|
||||
const { filter, options } = parseCLI(["vitest", ...process.argv.slice(2)]);
|
||||
const vitest = await createVitest({ ...options, watch: false });
|
||||
const signalExitCodes = { SIGINT: 130, SIGTERM: 143 };
|
||||
let shutdownSignal;
|
||||
let closePromise;
|
||||
const close = () => (closePromise ??= vitest.close());
|
||||
for (const signal of Object.keys(signalExitCodes))
|
||||
process.on(signal, () => {
|
||||
if (shutdownSignal !== undefined) return;
|
||||
shutdownSignal = signal;
|
||||
void close().catch((cause) => {
|
||||
console.error(cause instanceof Error ? cause.message : String(cause));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
});
|
||||
try {
|
||||
if (shutdownSignal === undefined) await vitest.standalone();
|
||||
if (shutdownSignal === undefined) {
|
||||
const discovered = new Map();
|
||||
for (const specification of await vitest.globTestSpecifications()) {
|
||||
const path = resolve(specification.moduleId);
|
||||
const specifications = discovered.get(path) ?? [];
|
||||
specifications.push(specification);
|
||||
discovered.set(path, specifications);
|
||||
}
|
||||
const selected = new Set();
|
||||
const missing = [];
|
||||
let discoveredByIdentity;
|
||||
for (const path of filter) {
|
||||
const absolute = resolve(path);
|
||||
let matches = discovered.get(absolute) ?? [];
|
||||
if (matches.length === 0) {
|
||||
try {
|
||||
await lstat(absolute);
|
||||
} catch (cause) {
|
||||
if (cause?.code === "ENOENT" || cause?.code === "ENOTDIR") {
|
||||
missing.push(path);
|
||||
continue;
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
const [canonical, selectedIdentity] = await Promise.all([
|
||||
realpath(absolute),
|
||||
stat(absolute, { bigint: true }),
|
||||
]);
|
||||
if (discoveredByIdentity === undefined) {
|
||||
discoveredByIdentity = new Map();
|
||||
for (const [candidate, specifications] of discovered) {
|
||||
try {
|
||||
const entry = await stat(candidate, { bigint: true });
|
||||
const identity = `${entry.dev}:${entry.ino}`;
|
||||
const entries = discoveredByIdentity.get(identity) ?? [];
|
||||
entries.push([candidate, specifications]);
|
||||
discoveredByIdentity.set(identity, entries);
|
||||
} catch (cause) {
|
||||
if (cause?.code === "ENOENT" || cause?.code === "ENOTDIR")
|
||||
continue;
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
}
|
||||
const identity = `${selectedIdentity.dev}:${selectedIdentity.ino}`;
|
||||
for (const [candidate, specifications] of discoveredByIdentity.get(
|
||||
identity,
|
||||
) ?? []) {
|
||||
try {
|
||||
await lstat(candidate);
|
||||
} catch (cause) {
|
||||
if (cause?.code === "ENOENT" || cause?.code === "ENOTDIR") continue;
|
||||
throw cause;
|
||||
}
|
||||
if (
|
||||
!(await hasCaseMismatchedSymlinkComponent(absolute, candidate)) &&
|
||||
(await realpath(candidate)) === canonical
|
||||
)
|
||||
matches = [...matches, ...specifications];
|
||||
}
|
||||
}
|
||||
if (matches.length === 0) missing.push(path);
|
||||
for (const specification of matches) selected.add(specification);
|
||||
}
|
||||
if (missing.length > 0 || selected.size === 0)
|
||||
throw new Error(
|
||||
`Selected test files were not discovered: ${missing.join(", ")}`,
|
||||
);
|
||||
if (shutdownSignal === undefined)
|
||||
await vitest.runTestSpecifications([...selected]);
|
||||
}
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
if (shutdownSignal !== undefined)
|
||||
process.exitCode = signalExitCodes[shutdownSignal];
|
||||
|
||||
async function hasCaseMismatchedSymlinkComponent(selected, candidate) {
|
||||
const selectedAbsolute = resolve(selected);
|
||||
const candidateAbsolute = resolve(candidate);
|
||||
const root = parse(selectedAbsolute).root;
|
||||
if (root !== parse(candidateAbsolute).root) return true;
|
||||
const selectedParts = relative(root, selectedAbsolute).split(sep);
|
||||
const candidateParts = relative(root, candidateAbsolute).split(sep);
|
||||
let selectedPath = root;
|
||||
let candidatePath = root;
|
||||
for (let index = 0; index < selectedParts.length; index += 1) {
|
||||
const selectedPart = selectedParts[index];
|
||||
const candidatePart = candidateParts[index];
|
||||
if (selectedPart === undefined || candidatePart === undefined) return true;
|
||||
selectedPath = selectedPath
|
||||
? resolve(selectedPath, selectedPart)
|
||||
: selectedPart;
|
||||
candidatePath = candidatePath
|
||||
? resolve(candidatePath, candidatePart)
|
||||
: candidatePart;
|
||||
if (
|
||||
selectedPart !== candidatePart &&
|
||||
selectedPart.toLowerCase() === candidatePart.toLowerCase()
|
||||
) {
|
||||
const [selectedEntry, candidateEntry] = await Promise.all([
|
||||
lstat(selectedPath),
|
||||
lstat(candidatePath),
|
||||
]);
|
||||
if (selectedEntry.isSymbolicLink() || candidateEntry.isSymbolicLink())
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
@ -155,6 +155,18 @@ try {
|
|||
requiredAnswerTermGroups: [["profileapi"], ["profile:read"]],
|
||||
prompt: `請靜態分析已交付的 Electron 程式 ${targets.javascript},說明 renderer 如何透過 preload 讀取 profile,以及 main handler 的關係。不要執行目標或安裝分析引擎;引用完整的產出 Evidence,保留 runtime 未知事項。`,
|
||||
},
|
||||
{
|
||||
id: "notes-export",
|
||||
expectedFirstTool: "analyze_javascript_application",
|
||||
requiresEvidence: true,
|
||||
requiredAnswerTermGroups: [],
|
||||
requiredToolSubsequence: [
|
||||
"analyze_javascript_application",
|
||||
"trace_application_feature",
|
||||
"trace_javascript_semantics",
|
||||
],
|
||||
prompt: `Use REA on the shipped Electron application at ${targets.notesElectron} to investigate its notes export API, preload exposure and main handler. Do not read target files directly or launch Electron. Use the returned application Evidence to trace the exact notes:export IPC channel in both directions, then query semantic relations for the property invoke with forward influence, excluding ambiguous dynamic edges and without an expected sink. Report the selected static facts, source locations and coverage limits using the closed claim contract.`,
|
||||
},
|
||||
{
|
||||
id: "javascript-module-view",
|
||||
expectedFirstTool: "analyze_javascript_application",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import assert from "node:assert/strict";
|
|||
import { isUtf8 } from "node:buffer";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { lstat, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
|
@ -24,6 +24,8 @@ if (process.env.REA_VERIFY_GO_COMMAND !== undefined && !isAbsolute(compiler))
|
|||
let compilerVersion = null;
|
||||
const run = createVerifierRun();
|
||||
const root = await PrivateRuntimeRoot.create({ prefix: "rea-go-verifier-" });
|
||||
// Keep Go's directory enumeration below the private root's Windows ownership lease.
|
||||
const sourceRoot = join(root.path, "source");
|
||||
const entrypoint = fileURLToPath(new URL("./rea.mjs", import.meta.url));
|
||||
const environment = Object.fromEntries(
|
||||
Object.entries(process.env).filter(([, value]) => typeof value === "string"),
|
||||
|
|
@ -115,14 +117,54 @@ try {
|
|||
],
|
||||
{ GOOS: os, GOARCH: arch },
|
||||
);
|
||||
await verify(path, target, oracle, null, "stripped");
|
||||
const strippedInfo = await verify(path, target, oracle, null, "stripped");
|
||||
if (os === "linux" && arch === "amd64") {
|
||||
const sectionless = join(root.path, "linux-amd64-sectionless");
|
||||
const bytes = Buffer.from(await readFile(path));
|
||||
bytes.fill(0, 40, 48); // ELF64 e_shoff.
|
||||
bytes.fill(0, 58, 64); // e_shentsize, e_shnum and e_shstrndx.
|
||||
await writeFile(sectionless, bytes);
|
||||
await verify(sectionless, target, oracle, null, "sectionless");
|
||||
const sectionlessInfo = await verify(
|
||||
sectionless,
|
||||
target,
|
||||
oracle,
|
||||
null,
|
||||
"sectionless",
|
||||
path,
|
||||
);
|
||||
assert.deepEqual(sectionlessInfo, strippedInfo);
|
||||
|
||||
const pie = join(root.path, "linux-amd64-pie");
|
||||
await go(
|
||||
[
|
||||
"build",
|
||||
"-trimpath",
|
||||
"-buildvcs=false",
|
||||
"-ldflags=-s -w",
|
||||
"-buildmode=pie",
|
||||
"-o",
|
||||
pie,
|
||||
".",
|
||||
],
|
||||
{ GOOS: os, GOARCH: arch },
|
||||
);
|
||||
const originalInfo = await verify(pie, target, oracle, null, "pie");
|
||||
const pieBytes = Buffer.from(await readFile(pie));
|
||||
pieBytes.fill(0, 40, 48);
|
||||
pieBytes.fill(0, 58, 64);
|
||||
const pieSectionless = `${pie}-sectionless`;
|
||||
await writeFile(pieSectionless, pieBytes);
|
||||
// Keep the producer image as the independent oracle. Removing section
|
||||
// headers must preserve every metadata field and original source span.
|
||||
const retainedInfo = await verify(
|
||||
pieSectionless,
|
||||
target,
|
||||
oracle,
|
||||
null,
|
||||
"sectionless-pie",
|
||||
pie,
|
||||
);
|
||||
assert.deepEqual(retainedInfo, originalInfo);
|
||||
}
|
||||
}
|
||||
for (const target of targets.filter(([os]) => os === "darwin")) {
|
||||
|
|
@ -143,7 +185,7 @@ try {
|
|||
const expected = JSON.parse(
|
||||
(
|
||||
await execute(oracle, ["--build-byte-fixture", compiler, path], {
|
||||
cwd: root.path,
|
||||
cwd: sourceRoot,
|
||||
env: { ...goEnvironment, GOOS: "linux", GOARCH: "amd64" },
|
||||
timeout: 120_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
|
|
@ -169,7 +211,7 @@ try {
|
|||
}
|
||||
const verifier = await completeVerifierRun(run);
|
||||
try {
|
||||
await assert.rejects(readFile(join(root.path, "go.mod")), { code: "ENOENT" });
|
||||
await assert.rejects(lstat(root.path), { code: "ENOENT" });
|
||||
if (verifier.process_lineage.status === "verified")
|
||||
assert.deepEqual(verifier.process_lineage.descendants, []);
|
||||
} catch (cause) {
|
||||
|
|
@ -214,7 +256,7 @@ console.log(
|
|||
|
||||
async function go(args, overrides = {}) {
|
||||
return execute(compiler, args, {
|
||||
cwd: root.path,
|
||||
cwd: sourceRoot,
|
||||
env: { ...goEnvironment, ...overrides },
|
||||
timeout: 120_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
|
|
@ -222,29 +264,30 @@ async function go(args, overrides = {}) {
|
|||
}
|
||||
|
||||
async function prepareFixture() {
|
||||
await mkdir(sourceRoot);
|
||||
await mkdir(join(root.path, "tmp"));
|
||||
await mkdir(join(root.path, "telemetry"));
|
||||
await writeFile(join(root.path, "telemetry", "mode"), "off\n");
|
||||
await mkdir(join(root.path, "dependency"));
|
||||
await mkdir(join(root.path, "oracle"));
|
||||
await mkdir(join(sourceRoot, "dependency"));
|
||||
await mkdir(join(sourceRoot, "oracle"));
|
||||
await writeFile(
|
||||
join(root.path, "go.mod"),
|
||||
join(sourceRoot, "go.mod"),
|
||||
"module example.test/rea-go-fixture\n\ngo 1.20\n\nrequire example.test/dependency v1.2.3\nreplace example.test/dependency => ./dependency\n",
|
||||
);
|
||||
await writeFile(
|
||||
join(root.path, "dependency", "go.mod"),
|
||||
join(sourceRoot, "dependency", "go.mod"),
|
||||
"module example.test/dependency\n\ngo 1.20\n",
|
||||
);
|
||||
await writeFile(
|
||||
join(root.path, "dependency", "dependency.go"),
|
||||
join(sourceRoot, "dependency", "dependency.go"),
|
||||
'package dependency\nfunc Value() string { return "REA fixture" }\n',
|
||||
);
|
||||
await writeFile(
|
||||
join(root.path, "main.go"),
|
||||
join(sourceRoot, "main.go"),
|
||||
'package main\nimport ("fmt"; "example.test/dependency")\nfunc main() { fmt.Println(dependency.Value()) }\n',
|
||||
);
|
||||
await writeFile(
|
||||
join(root.path, "oracle", "main.go"),
|
||||
join(sourceRoot, "oracle", "main.go"),
|
||||
String.raw`package main
|
||||
|
||||
import (
|
||||
|
|
@ -332,13 +375,14 @@ async function verify(
|
|||
oracle,
|
||||
byteExpected = null,
|
||||
fileKind = "stripped",
|
||||
oraclePath = path,
|
||||
) {
|
||||
const bytes = await readFile(path);
|
||||
const expected =
|
||||
byteExpected ??
|
||||
JSON.parse(
|
||||
(
|
||||
await execute(oracle, [path], {
|
||||
await execute(oracle, [oraclePath], {
|
||||
timeout: 10_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
})
|
||||
|
|
@ -408,6 +452,7 @@ async function verify(
|
|||
? {}
|
||||
: { go_version_bytes_base64: expected.GoVersionBytesBase64 }),
|
||||
});
|
||||
return reports[0].build_info;
|
||||
}
|
||||
|
||||
function verifyByteStrings(info, expected) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { inspectModuleBoundaries } from "./lib/module-boundaries.mjs";
|
||||
|
|
@ -11,24 +11,44 @@ const selected = process.argv.slice(2);
|
|||
for (const file of selected)
|
||||
if (!file.endsWith(".ts"))
|
||||
throw new TypeError(`Expected an exact TypeScript source file: ${file}`);
|
||||
const files =
|
||||
selected.length > 0
|
||||
? selected
|
||||
: (
|
||||
await execute(
|
||||
"git",
|
||||
[
|
||||
"ls-files",
|
||||
"-z",
|
||||
"--cached",
|
||||
"--others",
|
||||
"--exclude-standard",
|
||||
"--",
|
||||
"src",
|
||||
],
|
||||
{ cwd: root, maxBuffer: 8 * 1024 * 1024 },
|
||||
)
|
||||
).stdout.split("\0");
|
||||
|
||||
const enumerateSourceFiles = async () => {
|
||||
if (selected.length > 0) return selected;
|
||||
let isGitRoot = false;
|
||||
try {
|
||||
const { stdout } = await execute("git", ["rev-parse", "--show-cdup"], {
|
||||
cwd: root,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
});
|
||||
isGitRoot = stdout.trim() === "";
|
||||
} catch {
|
||||
// Archives and source snapshots may not be in a Git worktree.
|
||||
}
|
||||
if (isGitRoot) {
|
||||
try {
|
||||
const { stdout } = await execute(
|
||||
"git",
|
||||
[
|
||||
"ls-files",
|
||||
"-z",
|
||||
"--cached",
|
||||
"--others",
|
||||
"--exclude-standard",
|
||||
"--",
|
||||
"src",
|
||||
],
|
||||
{ cwd: root, maxBuffer: 8 * 1024 * 1024 },
|
||||
);
|
||||
return stdout.split("\0");
|
||||
} catch {
|
||||
// Fall back to disk when Git's index is unavailable or unreadable.
|
||||
}
|
||||
}
|
||||
const entries = await readdir(resolve(root, "src"), { recursive: true });
|
||||
return entries.map((entry) => join("src", entry));
|
||||
};
|
||||
|
||||
const files = await enumerateSourceFiles();
|
||||
|
||||
let checked = 0;
|
||||
for (const file of new Set(files.filter((path) => path.endsWith(".ts")))) {
|
||||
|
|
@ -36,7 +56,11 @@ for (const file of new Set(files.filter((path) => path.endsWith(".ts")))) {
|
|||
try {
|
||||
source = await readFile(resolve(root, file), "utf8");
|
||||
} catch (cause) {
|
||||
if (selected.length === 0 && cause.code === "ENOENT") continue;
|
||||
if (
|
||||
selected.length === 0 &&
|
||||
(cause.code === "ENOENT" || cause.code === "EISDIR")
|
||||
)
|
||||
continue;
|
||||
throw cause;
|
||||
}
|
||||
checked += 1;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
|||
import { createHash } from "node:crypto";
|
||||
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { isAbsolute, join, relative, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import Ajv2020 from "ajv/dist/2020.js";
|
||||
import {
|
||||
|
|
@ -42,6 +42,21 @@ const skillDirectory = join(
|
|||
"skills",
|
||||
"reverse-engineer-anything",
|
||||
);
|
||||
const isManagedSkillTarget = (target) => {
|
||||
const value = relative(skillDirectory, target);
|
||||
return (
|
||||
value === "" ||
|
||||
(value !== ".." && !value.startsWith(`..${sep}`) && !isAbsolute(value))
|
||||
);
|
||||
};
|
||||
assert.equal(
|
||||
isManagedSkillTarget(`${skillDirectory}-sibling/AGENTS.md`),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isManagedSkillTarget(join(skillDirectory, "..", "outside", "AGENTS.md")),
|
||||
false,
|
||||
);
|
||||
await Promise.all([mkdir(account), mkdir(profile), mkdir(workspace)]);
|
||||
await exec("git", ["init", "--quiet", workspace], { timeout: 30_000 });
|
||||
const environment = {
|
||||
|
|
@ -264,8 +279,7 @@ try {
|
|||
assert(parsed.plannedActions.some((a) => a.target === skillDirectory));
|
||||
assert(
|
||||
parsed.plannedActions.every(
|
||||
(a) =>
|
||||
a.target === configPath || a.target.startsWith(skillDirectory),
|
||||
(a) => a.target === configPath || isManagedSkillTarget(a.target),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
/** Validate a live registration and its byte-identical pre-setup backup. */
|
||||
export function assertClientConfig(
|
||||
cli: string,
|
||||
configPath: string,
|
||||
original: Uint8Array,
|
||||
): Promise<void>;
|
||||
|
|
@ -414,7 +414,8 @@ const verifySetupApply = async ({
|
|||
};
|
||||
};
|
||||
|
||||
const assertClientConfig = async (cli, configPath) => {
|
||||
/** Verify the live registration and exact pre-setup backup bytes. */
|
||||
export const assertClientConfig = async (cli, configPath, original) => {
|
||||
const config = json(await readFile(configPath, "utf8"));
|
||||
if (
|
||||
config.existing !== true ||
|
||||
|
|
@ -422,9 +423,7 @@ const assertClientConfig = async (cli, configPath) => {
|
|||
JSON.stringify(config.mcpServers?.rea?.args) !== JSON.stringify(["mcp"])
|
||||
)
|
||||
throw new Error("packaged client readback failed");
|
||||
if (
|
||||
!(await readFile(`${configPath}.rea.backup`, "utf8")).includes("existing")
|
||||
)
|
||||
if (!(await readFile(`${configPath}.rea.backup`)).equals(original))
|
||||
throw new Error("packaged client backup failed");
|
||||
};
|
||||
|
||||
|
|
@ -467,9 +466,7 @@ const assertCodexSymlink = async ({
|
|||
!codex.includes(`command = "${cli}"`) ||
|
||||
!codex.includes("startup_timeout_sec = 30") ||
|
||||
!codex.startsWith(CODEX_ORIGINAL) ||
|
||||
(await readFile(`${codexConfig}.rea.backup`, "utf8")) !== CODEX_ORIGINAL ||
|
||||
(await readFile(`${cursorConfig}.rea.backup`, "utf8")) !==
|
||||
'{"existing":true}\n'
|
||||
(await readFile(`${codexConfig}.rea.backup`, "utf8")) !== CODEX_ORIGINAL
|
||||
)
|
||||
throw new Error("packaged setup did not preserve config symlinks");
|
||||
};
|
||||
|
|
@ -534,9 +531,11 @@ const verifyConfigReadback = async ({
|
|||
siblingSkillPath,
|
||||
opencodeConfig,
|
||||
opencodeOriginal,
|
||||
claudeOriginal,
|
||||
cursorOriginal,
|
||||
}) => {
|
||||
await assertClientConfig(cli, claudeConfig);
|
||||
await assertClientConfig(cli, cursorConfig);
|
||||
await assertClientConfig(cli, claudeConfig, claudeOriginal);
|
||||
await assertClientConfig(cli, cursorConfig, cursorOriginal);
|
||||
await assertCodexSymlink({
|
||||
cli,
|
||||
codexConfig,
|
||||
|
|
@ -729,6 +728,9 @@ export async function verifyPackageSetup({
|
|||
opencodeConfig,
|
||||
skillPath,
|
||||
];
|
||||
const [claudeOriginal, cursorOriginal] = supportedSetupHost
|
||||
? await Promise.all([readFile(claudeConfig), readFile(cursorConfig)])
|
||||
: [];
|
||||
await verifyInteractiveSetup({
|
||||
command: packageRunnerCli,
|
||||
environment,
|
||||
|
|
@ -772,6 +774,8 @@ export async function verifyPackageSetup({
|
|||
siblingSkillPath,
|
||||
opencodeConfig,
|
||||
opencodeOriginal: OPENCODE_ORIGINAL,
|
||||
claudeOriginal,
|
||||
cursorOriginal,
|
||||
});
|
||||
await verifySetupFailureRecovery({
|
||||
cli,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
requireMcpOperationResult,
|
||||
} from "./lib/mcp-verifier-results.mjs";
|
||||
import { verifyGhidraCompactReferences } from "./lib/real-ghidra-compact-references.mjs";
|
||||
import { verifyWindowsGhidraByteSearch } from "./lib/ghidra-windows-byte-search.mjs";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
|
|
@ -358,11 +359,12 @@ try {
|
|||
const mapping = await call("address_to_file_offset", {
|
||||
address: procedure.address,
|
||||
});
|
||||
const sourceBytes = await readFile(target);
|
||||
assert.equal(memory.complete, true);
|
||||
assert.equal(memory.returned_bytes, 16);
|
||||
assert.equal(
|
||||
memory.bytes_hex,
|
||||
(await readFile(target))
|
||||
sourceBytes
|
||||
.subarray(mapping.file_offset, mapping.file_offset + 16)
|
||||
.toString("hex"),
|
||||
);
|
||||
|
|
@ -374,6 +376,35 @@ try {
|
|||
await call("resolve_containing_procedure", { address: procedure.address });
|
||||
await call("search_procedures", { pattern: procedure.value });
|
||||
await call("search_strings", { pattern: "rea" });
|
||||
report.byteSearch = await verifyWindowsGhidraByteSearch({
|
||||
invoke: call,
|
||||
sourceBytes,
|
||||
architecture: parsedTarget.value.architecture,
|
||||
sourceOwnedFixture: targetArgument === undefined,
|
||||
fallbackAddress: procedure.address,
|
||||
fallbackFileOffset: mapping.file_offset,
|
||||
runCliPage: async (query) => {
|
||||
const cliArguments = [entry, "search-bytes", target, query.pattern];
|
||||
for (const [option, value] of [
|
||||
["--mask", query.mask],
|
||||
["--start", query.start],
|
||||
["--end", query.end],
|
||||
["--limit", String(query.limit)],
|
||||
]) {
|
||||
if (value !== undefined) cliArguments.push(option, value);
|
||||
}
|
||||
cliArguments.push("--provider", "ghidra", "--json");
|
||||
const result = await exec(process.execPath, cliArguments, {
|
||||
env: environment,
|
||||
cwd: callerDirectory,
|
||||
timeout: 360_000,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
});
|
||||
const evidence = JSON.parse(result.stdout);
|
||||
assert.ok(evidence.error === undefined, "CLI byte search failed");
|
||||
return evidence.normalized_result;
|
||||
},
|
||||
});
|
||||
for (const name of [
|
||||
"procedure_assembly",
|
||||
"procedure_callees",
|
||||
|
|
@ -429,9 +460,24 @@ try {
|
|||
name !== "shutdown" &&
|
||||
!GHIDRA_MUTATING_OPERATIONS.has(name),
|
||||
);
|
||||
assert.ok(
|
||||
expected.every((name) => report.mcpOperations.includes(name)),
|
||||
"Packaged verification omitted an admitted operation.",
|
||||
const expectedOperations = new Set([
|
||||
...expected,
|
||||
"open_binary",
|
||||
"binary_overview",
|
||||
"binary_session",
|
||||
"close_binary",
|
||||
]);
|
||||
const observedOperations = new Set(report.mcpOperations);
|
||||
const missingOperations = expected.filter(
|
||||
(name) => !observedOperations.has(name),
|
||||
);
|
||||
const unexpectedOperations = [...observedOperations].filter(
|
||||
(name) => !expectedOperations.has(name),
|
||||
);
|
||||
assert.deepEqual(
|
||||
{ missing: missingOperations, unexpected: unexpectedOperations },
|
||||
{ missing: [], unexpected: [] },
|
||||
"Packaged verification operation coverage drifted.",
|
||||
);
|
||||
const session = await call("binary_session", {});
|
||||
assert.equal(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { copyFile, mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import {
|
||||
copyFile,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
|
@ -9,6 +16,11 @@ import { promisify } from "node:util";
|
|||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { inspectGhidraInstallation } from "../dist/ghidra/GhidraInstallation.js";
|
||||
import { cleanupOwnedProcessGroup } from "../dist/process/ProcessOwnership.js";
|
||||
import {
|
||||
ProviderProcessSupervisor,
|
||||
spawnOwnedProviderProcess,
|
||||
} from "../dist/process/ProviderProcess.js";
|
||||
import { toolContract } from "../dist/contracts/toolContracts.js";
|
||||
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
|
||||
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
||||
|
|
@ -48,10 +60,21 @@ const source = fileURLToPath(
|
|||
const literalSource = fileURLToPath(
|
||||
new URL("../tests/conformance/swift/string-literals.swift", import.meta.url),
|
||||
);
|
||||
const unresolvedJumpSource = fileURLToPath(
|
||||
new URL(
|
||||
"../tests/conformance/swift/unresolved-string-jump.s",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const binary = join(root, "swift-inventory");
|
||||
const literalBinary = join(root, "swift-literals");
|
||||
const literalAssembly = fileURLToPath(
|
||||
new URL("../tests/conformance/swift/decoder-safeguards.S", import.meta.url),
|
||||
);
|
||||
const literalObject = join(root, "decoder-safeguards.o");
|
||||
const reports = [];
|
||||
let literalReport;
|
||||
let decoderCleaned = true;
|
||||
try {
|
||||
await execute("swiftc", ["-O", "-g", source, "-o", binary], {
|
||||
timeout: 60_000,
|
||||
|
|
@ -59,6 +82,19 @@ try {
|
|||
await mkdir(join(root, "copied"));
|
||||
const copied = join(root, "copied", "swift-inventory");
|
||||
await copyFile(binary, copied);
|
||||
await execute(
|
||||
"xcrun",
|
||||
[
|
||||
"clang",
|
||||
"-target",
|
||||
"arm64-apple-macosx13.0",
|
||||
"-c",
|
||||
literalAssembly,
|
||||
"-o",
|
||||
literalObject,
|
||||
],
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
// Decoding covers AArch64 register pairs, so build that slice on any Mac host.
|
||||
await execute(
|
||||
"swiftc",
|
||||
|
|
@ -67,11 +103,14 @@ try {
|
|||
"-target",
|
||||
"arm64-apple-macosx13.0",
|
||||
literalSource,
|
||||
literalObject,
|
||||
unresolvedJumpSource,
|
||||
"-o",
|
||||
literalBinary,
|
||||
],
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await verifyDecoderSafeguards();
|
||||
await client.connect(transport);
|
||||
for (const [variant, path] of [
|
||||
["debug", binary],
|
||||
|
|
@ -154,7 +193,7 @@ try {
|
|||
} finally {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
if (decoderCleaned) await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
|
|
@ -222,8 +261,144 @@ async function verifyStringLiterals() {
|
|||
}
|
||||
decoded[name] = observed.length;
|
||||
}
|
||||
const unresolvedJump = procedures.find(({ value }) =>
|
||||
value.includes("rea_unresolved_string_jump"),
|
||||
);
|
||||
assert(
|
||||
unresolvedJump,
|
||||
"unresolved jump fixture is missing from the procedure inventory",
|
||||
);
|
||||
const unresolvedDossier = await call("analyze_function", {
|
||||
procedure: unresolvedJump.address,
|
||||
});
|
||||
assert.deepEqual(unresolvedDossier.swift_string_literals, []);
|
||||
assert.deepEqual(
|
||||
unresolvedDossier.swift_string_literal_omissions.map(
|
||||
({ reason }) => reason,
|
||||
),
|
||||
["unresolved_jump"],
|
||||
);
|
||||
decoded.unresolvedStringJump =
|
||||
unresolvedDossier.swift_string_literal_omissions.length;
|
||||
return decoded;
|
||||
} finally {
|
||||
await call("close_binary", {});
|
||||
}
|
||||
}
|
||||
|
||||
// Exercise the Java producer inside an ephemeral Ghidra database. The explicit
|
||||
// budget seam makes exhaustion deterministic without exposing a runtime option.
|
||||
async function verifyDecoderSafeguards() {
|
||||
const scripts = join(root, "decoder-scripts");
|
||||
await mkdir(scripts);
|
||||
const bridgeSources = fileURLToPath(
|
||||
new URL("../bridge/ghidra/", import.meta.url),
|
||||
);
|
||||
for (const name of await readdir(bridgeSources)) {
|
||||
if (name.endsWith(".java"))
|
||||
await copyFile(join(bridgeSources, name), join(scripts, name));
|
||||
}
|
||||
await copyFile(
|
||||
fileURLToPath(
|
||||
new URL(
|
||||
"../tests/conformance/swift/ReaSwiftDecoderRegressions.java",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
join(scripts, "ReaSwiftDecoderRegressions.java"),
|
||||
);
|
||||
const report = join(root, "decoder-safeguards.txt");
|
||||
await runDecoderProbe(scripts, report);
|
||||
assert.equal(
|
||||
await readFile(report, "utf8"),
|
||||
"REA_SWIFT_DECODER_SAFEGUARDS_OK\n",
|
||||
);
|
||||
}
|
||||
|
||||
async function runDecoderProbe(scripts, report) {
|
||||
const controller = new AbortController();
|
||||
let interrupt;
|
||||
const interrupted = new Promise((resolve) => {
|
||||
interrupt = () => {
|
||||
controller.abort(new Error("Swift decoder probe interrupted"));
|
||||
resolve(false);
|
||||
};
|
||||
});
|
||||
process.on("SIGINT", interrupt);
|
||||
process.on("SIGTERM", interrupt);
|
||||
let supervisor;
|
||||
let launch;
|
||||
try {
|
||||
launch = await spawnOwnedProviderProcess({
|
||||
command: installation.analyzeHeadlessPath,
|
||||
arguments: [
|
||||
root,
|
||||
"decoder-regressions",
|
||||
"-import",
|
||||
literalBinary,
|
||||
"-scriptPath",
|
||||
scripts,
|
||||
"-postScript",
|
||||
"ReaSwiftDecoderRegressions.java",
|
||||
report,
|
||||
"-deleteProject",
|
||||
"-max-cpu",
|
||||
"2",
|
||||
],
|
||||
runId: run.run_id,
|
||||
expectedCommand: null,
|
||||
env: { JAVA_HOME: installation.javaHome },
|
||||
signal: controller.signal,
|
||||
});
|
||||
decoderCleaned = false;
|
||||
supervisor = new ProviderProcessSupervisor(
|
||||
{
|
||||
...launch,
|
||||
ownsProcessLifetime: true,
|
||||
cleanup: () => cleanupOwnedProcessGroup(launch.ownership),
|
||||
},
|
||||
{ maxDiagnosticBytes: 8 * 1024 * 1024 },
|
||||
);
|
||||
assert.ok(
|
||||
await Promise.race([supervisor.waitForExit(120_000), interrupted]),
|
||||
controller.signal.aborted
|
||||
? "Swift decoder probe interrupted"
|
||||
: "Swift decoder probe timed out",
|
||||
);
|
||||
assert.ok(
|
||||
await supervisor.waitForOutputClose(1_000),
|
||||
"Swift decoder probe output did not close",
|
||||
);
|
||||
const snapshot = supervisor.snapshot();
|
||||
assert.equal(
|
||||
snapshot.exitCode,
|
||||
0,
|
||||
`${snapshot.stdout.text}\n${snapshot.stderr.text}`,
|
||||
);
|
||||
assert.ok(
|
||||
!snapshot.diagnosticTruncated,
|
||||
"Swift decoder probe exceeded its diagnostic capacity",
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
if (supervisor !== undefined) {
|
||||
const stopped = await supervisor.stop();
|
||||
decoderCleaned = stopped.status === "verified-cleanup";
|
||||
assert.ok(
|
||||
decoderCleaned,
|
||||
`Owned Ghidra cleanup incomplete; retained ${root}: ${JSON.stringify(stopped)}`,
|
||||
);
|
||||
} else if (launch !== undefined) {
|
||||
const stopped = await cleanupOwnedProcessGroup(launch.ownership);
|
||||
decoderCleaned = stopped.cleaned;
|
||||
assert.ok(
|
||||
decoderCleaned,
|
||||
`Owned Ghidra cleanup incomplete; retained ${root}: ${JSON.stringify(stopped)}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
process.off("SIGINT", interrupt);
|
||||
process.off("SIGTERM", interrupt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
|
|||
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
|
||||
import { verifyGhidraCompactReferences } from "./lib/real-ghidra-compact-references.mjs";
|
||||
import { verifyGhidraFileOffsets } from "./lib/real-ghidra-file-offsets.mjs";
|
||||
import { verifyWindowsGhidraByteSearch } from "./lib/ghidra-windows-byte-search.mjs";
|
||||
|
||||
const verifierRun = createVerifierRun();
|
||||
const SESSION_CAPABILITIES = GHIDRA_SESSION_CAPABILITIES.filter(
|
||||
|
|
@ -215,6 +216,11 @@ try {
|
|||
});
|
||||
await inventory("search_procedures", { pattern: procedure.value });
|
||||
await inventory("search_strings", { pattern: "rea" });
|
||||
const byteSearch = await verifyWindowsGhidraByteSearch({
|
||||
invoke: inventory,
|
||||
sourceBytes,
|
||||
architecture,
|
||||
});
|
||||
|
||||
for (const operation of [
|
||||
"procedure_assembly",
|
||||
|
|
@ -242,12 +248,18 @@ try {
|
|||
...GHIDRA_INVENTORY_OPERATIONS,
|
||||
...GHIDRA_FUNCTION_OPERATIONS,
|
||||
].filter((name) => !GHIDRA_MUTATING_OPERATIONS.has(name));
|
||||
const expectedOperations = new Set(expected);
|
||||
const missingOperations = expected.filter((name) => !observed.has(name));
|
||||
const unexpectedOperations = [...observed].filter(
|
||||
(name) => !expectedOperations.has(name),
|
||||
);
|
||||
if (
|
||||
observed.size !== expected.length ||
|
||||
expected.some((operation) => !observed.has(operation))
|
||||
missingOperations.length > 0 ||
|
||||
unexpectedOperations.length > 0 ||
|
||||
observed.size !== expectedOperations.size
|
||||
)
|
||||
throw new Error(
|
||||
`Windows Ghidra operation proof was incomplete: ${JSON.stringify([...observed])}`,
|
||||
`Windows Ghidra operation proof was incomplete: ${JSON.stringify({ missing: missingOperations, unexpected: unexpectedOperations })}`,
|
||||
);
|
||||
|
||||
// The reference controls occupy the EXE fixture's additional function.
|
||||
|
|
@ -271,6 +283,7 @@ try {
|
|||
},
|
||||
transport: "authenticated-loopback-tcp",
|
||||
operations: [...observed].sort((left, right) => left.localeCompare(right)),
|
||||
byte_search: byteSearch,
|
||||
cleanup: "complete",
|
||||
compact_references: compactReferences,
|
||||
native_controls: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
export interface OpenAiModelFixture {
|
||||
readonly baseUrl: string;
|
||||
readonly requests: readonly { readonly path: string }[];
|
||||
readonly probes: readonly {
|
||||
readonly path: string | undefined;
|
||||
readonly status: 404;
|
||||
}[];
|
||||
readonly failure: string | undefined;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export function createOpenAiModelFixture(options: {
|
||||
readonly directory: string;
|
||||
readonly prefix: string;
|
||||
readonly onRequest: () =>
|
||||
| {
|
||||
readonly role: "assistant";
|
||||
readonly content: string;
|
||||
}
|
||||
| Promise<{ readonly role: "assistant"; readonly content: string }>;
|
||||
}): Promise<OpenAiModelFixture>;
|
||||
|
|
@ -15,7 +15,8 @@ export const createOpenAiModelFixture = async ({
|
|||
let failure;
|
||||
const server = createServer(async (request, response) => {
|
||||
try {
|
||||
if (request.method === "GET" && request.url?.endsWith("/models")) {
|
||||
const path = new URL(request.url ?? "/", "http://localhost").pathname;
|
||||
if (request.method === "GET" && path.endsWith("/models")) {
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
|
|
@ -47,7 +48,7 @@ export const createOpenAiModelFixture = async ({
|
|||
join(directory, `${prefix}-incoming-${++incoming}.json`),
|
||||
JSON.stringify({ method: request.method, path: request.url, body }),
|
||||
);
|
||||
if (request.url !== "/v1/chat/completions") {
|
||||
if (path !== "/v1/chat/completions") {
|
||||
// Hermes probes custom endpoints for Ollama/llama.cpp metadata.
|
||||
// Unsupported HTTP endpoints return 404, as on an OpenAI-only server.
|
||||
probes.push({
|
||||
|
|
|
|||
|
|
@ -201,6 +201,16 @@ async function stageCli() {
|
|||
),
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
files.extraction_gaps.some(
|
||||
(gap) =>
|
||||
gap.kind === "unknown-chunk-handler" &&
|
||||
gap.range !== null &&
|
||||
gap.range.offset === 0 &&
|
||||
gap.range.length === 64,
|
||||
),
|
||||
"The unknown chunk must carry its explicit recovery row",
|
||||
);
|
||||
return { regions, files };
|
||||
}
|
||||
|
||||
|
|
@ -247,6 +257,20 @@ async function stageMcp(cli) {
|
|||
);
|
||||
assert.equal(depthParsed.coverage, "partial");
|
||||
assert.ok(depthParsed.depth_limited_paths.length > 0);
|
||||
assert.ok(
|
||||
depthParsed.extraction_gaps.some(
|
||||
(gap) =>
|
||||
gap.kind === "depth-limited" &&
|
||||
gap.dependencies.length === 0 &&
|
||||
depthParsed.extraction_continuations.some(
|
||||
({ id, instruction }) =>
|
||||
id === gap.continuation_id &&
|
||||
instruction.includes("max_depth") &&
|
||||
instruction.includes("fresh output_directory"),
|
||||
),
|
||||
),
|
||||
"Depth-limited paths must carry the explicit depth continuation",
|
||||
);
|
||||
|
||||
console.log(
|
||||
"PASS real Binwalk/Unblob: CLI, MCP, independent offsets/digests, range extraction, unknown chunks and depth limits",
|
||||
|
|
@ -292,8 +316,17 @@ async function stageExt4() {
|
|||
);
|
||||
assert.equal(degradedParsed.coverage, "partial");
|
||||
assert.ok(JSON.stringify(degradedParsed.diagnostics).includes("debugfs"));
|
||||
assert.ok(
|
||||
degradedParsed.extraction_gaps.some(
|
||||
(gap) =>
|
||||
gap.kind === "missing-extractor-dependency" &&
|
||||
gap.dependencies.includes("debugfs") &&
|
||||
gap.handler === "extfs",
|
||||
),
|
||||
"The missing extractor must be named in its explicit recovery row",
|
||||
);
|
||||
console.log(
|
||||
"PASS real missing-extractor diagnostics retained despite Unblob exit 1",
|
||||
"PASS real missing-extractor diagnostics and recovery row retained despite Unblob exit 1",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@ import type { JsonValue } from "../domain/jsonValue.js";
|
|||
import { javascriptApplicationAnalysisResultSchema } from "../domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import type { ApplicationNode } from "../domain/javascript/javascriptApplicationGraphSchemas.js";
|
||||
import { javaScriptExportShapeComparisonResultSchema } from "../domain/javascript/javascriptExportShapeComparisonSchemas.js";
|
||||
import {
|
||||
applicationFeatureTraceResultSchema,
|
||||
featureSeedMatchMode,
|
||||
} from "../domain/javascript/javascriptFeatureTraceSchemas.js";
|
||||
import { javaScriptSemanticTraceResultSchema } from "../domain/javascript/javascriptSemanticTraceSchemas.js";
|
||||
import {
|
||||
managedArtifactInspectionSchema,
|
||||
managedMemberInspectionSchema,
|
||||
|
|
@ -28,6 +33,8 @@ export interface AgentEvaluationFixtureTargets {
|
|||
readonly javascriptSha256: string;
|
||||
readonly javascriptShapeLeft: string;
|
||||
readonly javascriptShapeRight: string;
|
||||
readonly notesElectron: string;
|
||||
readonly notesElectronSha256: string;
|
||||
readonly managed?: string;
|
||||
readonly managedSha256?: string;
|
||||
}
|
||||
|
|
@ -50,6 +57,8 @@ export const createAgentEvaluationFixtures = async (
|
|||
const javascript = join(root, "desktop-app.asar");
|
||||
const javascriptShapeLeft = join(root, "parser-v1");
|
||||
const javascriptShapeRight = join(root, "parser-v2");
|
||||
const notesDirectory = join(root, "notes-electron");
|
||||
const notesElectron = join(root, "notes-electron.asar");
|
||||
await Promise.all([
|
||||
mkdir(join(desktop, "renderer"), { recursive: true }),
|
||||
mkdir(javascriptShapeLeft, { recursive: true }),
|
||||
|
|
@ -67,8 +76,14 @@ export const createAgentEvaluationFixtures = async (
|
|||
join(repositoryRoot, "tests/fixtures/replay/parser-v2.mjs"),
|
||||
join(javascriptShapeRight, "parser.mjs"),
|
||||
),
|
||||
cp(
|
||||
join(repositoryRoot, "website/public/examples/notes-electron"),
|
||||
notesDirectory,
|
||||
{ recursive: true },
|
||||
),
|
||||
]);
|
||||
await createPackage(desktop, javascript);
|
||||
await createPackage(notesDirectory, notesElectron);
|
||||
return {
|
||||
javascript,
|
||||
javascriptSha256: createHash("sha256")
|
||||
|
|
@ -76,6 +91,10 @@ export const createAgentEvaluationFixtures = async (
|
|||
.digest("hex"),
|
||||
javascriptShapeLeft,
|
||||
javascriptShapeRight,
|
||||
notesElectron,
|
||||
notesElectronSha256: createHash("sha256")
|
||||
.update(await readFile(notesElectron))
|
||||
.digest("hex"),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -189,21 +208,116 @@ const runtimeSemantics = (value: JsonValue): JsonValue | undefined => {
|
|||
: undefined;
|
||||
};
|
||||
|
||||
const asarClaims = (
|
||||
const featureModules = (value: JsonValue): JsonValue | undefined => {
|
||||
const result = applicationFeatureTraceResultSchema.safeParse(value);
|
||||
if (!result.success || result.data.graph === null) return undefined;
|
||||
if (
|
||||
result.data.seed.kind !== "channel" ||
|
||||
result.data.seed.value !== "notes:export" ||
|
||||
featureSeedMatchMode(result.data.seed) !== "exact"
|
||||
)
|
||||
return undefined;
|
||||
const paths = result.data.graph.nodes
|
||||
.filter((node) => node.kind === "javascript-module")
|
||||
.flatMap((node) => node.observations)
|
||||
.filter(({ properties }) => properties.semantic_role === "source-module")
|
||||
.map(({ properties }) => properties.path);
|
||||
if (paths.length === 0 || !paths.every((path) => typeof path === "string"))
|
||||
return undefined;
|
||||
return {
|
||||
paths: [...new Set(paths)].sort(compareUnicodeCodePoints),
|
||||
coverage: result.data.coverage.status,
|
||||
};
|
||||
};
|
||||
|
||||
const semanticBoundary = (value: JsonValue): JsonValue | undefined => {
|
||||
const result = javaScriptSemanticTraceResultSchema.safeParse(value);
|
||||
if (!result.success) return undefined;
|
||||
return {
|
||||
status: result.data.status,
|
||||
coverage: result.data.coverage.status,
|
||||
relevant_unknowns: result.data.summary.relevant_unknowns,
|
||||
};
|
||||
};
|
||||
|
||||
const notesClaims = (
|
||||
targets: AgentEvaluationFixtureTargets,
|
||||
): readonly FixtureClaimExpectation[] => {
|
||||
const subject = {
|
||||
path: targets.notesElectron,
|
||||
sha256: targets.notesElectronSha256,
|
||||
};
|
||||
const derived = {
|
||||
authority: "analyst-inference",
|
||||
confidence: "inferred",
|
||||
linkedSubjects: [
|
||||
{ operation: "analyze_javascript_application", ...subject },
|
||||
],
|
||||
} satisfies Pick<
|
||||
FixtureClaimExpectation["source"],
|
||||
"authority" | "confidence" | "linkedSubjects"
|
||||
>;
|
||||
// Fixture source establishes bridge/module facts; the semantic claim checks
|
||||
// the bounded static-query contract, not execution or CSV formatter behavior.
|
||||
return [
|
||||
...bridgeClaims(subject, "notes", ["exportCsv"], "notes:export"),
|
||||
{
|
||||
id: "feature_modules",
|
||||
expectedValue: {
|
||||
paths: ["csv.js", "main.js", "preload.js", "renderer.js"],
|
||||
coverage: "complete-within-source",
|
||||
},
|
||||
source: {
|
||||
...derived,
|
||||
operation: "trace_application_feature",
|
||||
parameters: {
|
||||
direction: "both",
|
||||
},
|
||||
select: featureModules,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "semantic_boundary",
|
||||
expectedValue: {
|
||||
status: "ambiguous",
|
||||
coverage: "partial",
|
||||
relevant_unknowns: 1,
|
||||
},
|
||||
source: {
|
||||
...derived,
|
||||
operation: "trace_javascript_semantics",
|
||||
parameters: {
|
||||
query: {
|
||||
seed: { kind: "property", name: "invoke" },
|
||||
direction: "forward-influence",
|
||||
include_ambiguous_dynamic_edges: false,
|
||||
expected: null,
|
||||
},
|
||||
},
|
||||
select: semanticBoundary,
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const bridgeClaims = (
|
||||
subject: { readonly path: string; readonly sha256: string },
|
||||
key: string,
|
||||
members: readonly string[],
|
||||
channel: string,
|
||||
): readonly FixtureClaimExpectation[] => {
|
||||
const source = {
|
||||
operation: "analyze_javascript_application",
|
||||
authority: "shipped-artifact",
|
||||
confidence: "derived",
|
||||
subject: { path: targets.javascript, sha256: targets.javascriptSha256 },
|
||||
subject,
|
||||
} satisfies Omit<FixtureClaimExpectation["source"], "select">;
|
||||
return [
|
||||
{
|
||||
id: "renderer_api",
|
||||
expectedValue: {
|
||||
key: "profileApi",
|
||||
members: ["read"],
|
||||
key,
|
||||
members: [...members],
|
||||
world: "main",
|
||||
source: "preload.js",
|
||||
},
|
||||
|
|
@ -212,7 +326,7 @@ const asarClaims = (
|
|||
{
|
||||
id: "renderer_ipc",
|
||||
expectedValue: {
|
||||
channel: "profile:read",
|
||||
channel,
|
||||
operation: "invoke",
|
||||
source: "preload.js",
|
||||
},
|
||||
|
|
@ -221,7 +335,7 @@ const asarClaims = (
|
|||
{
|
||||
id: "main_ipc",
|
||||
expectedValue: {
|
||||
channel: "profile:read",
|
||||
channel,
|
||||
operation: "handle",
|
||||
source: "main.js",
|
||||
},
|
||||
|
|
@ -235,6 +349,16 @@ const asarClaims = (
|
|||
];
|
||||
};
|
||||
|
||||
const asarClaims = (
|
||||
targets: AgentEvaluationFixtureTargets,
|
||||
): readonly FixtureClaimExpectation[] =>
|
||||
bridgeClaims(
|
||||
{ path: targets.javascript, sha256: targets.javascriptSha256 },
|
||||
"profileApi",
|
||||
["read"],
|
||||
"profile:read",
|
||||
);
|
||||
|
||||
const parserClaims = (
|
||||
targets: AgentEvaluationFixtureTargets,
|
||||
): readonly FixtureClaimExpectation[] => {
|
||||
|
|
@ -399,25 +523,33 @@ export const agentFixtureClaims = (
|
|||
if (scenarioId === "asar") return asarClaims(targets);
|
||||
if (scenarioId === "managed") return managedClaims(targets);
|
||||
if (scenarioId === "javascript-export-shape") return parserClaims(targets);
|
||||
if (scenarioId === "notes-export") return notesClaims(targets);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const bridgeAnswerDescriptions = [
|
||||
'renderer_api: {"key": string, "members": string[], "world": string, "source": string}, describing the exposed bridge API, its members, world, and source file.',
|
||||
'renderer_ipc: {"channel": string, "operation": string, "source": string}, describing the renderer-side IPC operation and its source file.',
|
||||
'main_ipc: {"channel": string, "operation": string, "source": string}, describing the main-side IPC operation and its source file.',
|
||||
'preload: {"path": string, "resolution_status": string}, describing the BrowserWindow-declared preload resolved within the shipped artifact.',
|
||||
];
|
||||
|
||||
const answerValueDescriptions: Readonly<Record<string, readonly string[]>> = {
|
||||
managed: [
|
||||
"public_types: string[], listing the exact public top-level type full names in Unicode code-point order from unfiltered member Evidence (omit type_names).",
|
||||
'entry_point: {"declaring_type": string, "name": string}, resolving the CLI metadata entry-point token against the member inventory from the same artifact SHA-256 and module MVID. Cite member Evidence; both artifact and member Evidence must be delivered.',
|
||||
],
|
||||
asar: [
|
||||
'renderer_api: {"key": string, "members": string[], "world": string, "source": string}, describing the exposed bridge API, its members, world, and source file.',
|
||||
'renderer_ipc: {"channel": string, "operation": string, "source": string}, describing the renderer-side IPC operation and its source file.',
|
||||
'main_ipc: {"channel": string, "operation": string, "source": string}, describing the main-side IPC operation and its source file.',
|
||||
'preload: {"path": string, "resolution_status": string}, describing the BrowserWindow-declared preload resolved within the shipped artifact.',
|
||||
],
|
||||
asar: bridgeAnswerDescriptions,
|
||||
"javascript-export-shape": [
|
||||
'heading_change: {"status": string, "path": string, "discriminant": object or null, "left": object, "right": object}, copying the exact heading-change fields from the comparison result.',
|
||||
'comparison_coverage: {"status": string, "paired_variants": number, "added": number, "removed": number, "changed": number, "unknown": number}, copying comparison coverage and summary counts.',
|
||||
'runtime_semantics: "established" or "not_established", stating whether the cited comparison establishes runtime behavior.',
|
||||
],
|
||||
"notes-export": [
|
||||
...bridgeAnswerDescriptions,
|
||||
'feature_modules: {"paths": string[], "coverage": string}, listing the unique source-module observation paths in the feature trace in Unicode code-point order, and its coverage status.',
|
||||
'semantic_boundary: {"status": string, "coverage": string, "relevant_unknowns": number}, copying the semantic trace status, coverage status, and summary.relevant_unknowns. Static traces do not establish runtime execution.',
|
||||
],
|
||||
};
|
||||
|
||||
/** Describe the strict answer contract without revealing any golden fixture values. */
|
||||
|
|
|
|||
|
|
@ -873,8 +873,9 @@ describe("rest parameter semantic trace CLI", () => {
|
|||
kind === "expression" && label === `argument ${String(index)}`,
|
||||
);
|
||||
if (argument === undefined) throw new Error("Missing argument node");
|
||||
const traced = await runCli([
|
||||
"trace-javascript-semantics",
|
||||
const requestPath = join(root, `trace-request-${index}.json`);
|
||||
await writeFile(
|
||||
requestPath,
|
||||
JSON.stringify({
|
||||
application,
|
||||
query: {
|
||||
|
|
@ -884,6 +885,10 @@ describe("rest parameter semantic trace CLI", () => {
|
|||
expected: { role: "sink", classes: ["parameter"] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const traced = await runCli([
|
||||
"trace-javascript-semantics",
|
||||
requestPath,
|
||||
"--json",
|
||||
]);
|
||||
expect(traced).toMatchObject({
|
||||
|
|
|
|||
|
|
@ -111,17 +111,124 @@ const cancellationSignals =
|
|||
process.platform === "win32"
|
||||
? (["SIGINT"] as const)
|
||||
: (["SIGINT", "SIGTERM"] as const);
|
||||
const webRuntimeCancellationCases = (
|
||||
["observe-web-execution", "inspect-web-event-listeners"] as const
|
||||
).flatMap((command) =>
|
||||
cancellationSignals.map(
|
||||
(signal) => [command, signal, signal === "SIGINT" ? 130 : 143] as const,
|
||||
),
|
||||
|
||||
interface CliCancellationCommand {
|
||||
readonly command: string;
|
||||
readonly operation: string;
|
||||
readonly buildArgs: (endpoint: string) => readonly string[];
|
||||
}
|
||||
|
||||
const cancellationCommands: readonly CliCancellationCommand[] = [
|
||||
{
|
||||
command: "observe-web-execution",
|
||||
operation: "observe_web_execution",
|
||||
buildArgs: (endpoint) => [
|
||||
"observe-web-execution",
|
||||
endpoint,
|
||||
"page-fixture",
|
||||
"--observation-ms",
|
||||
"30000",
|
||||
"--json",
|
||||
],
|
||||
},
|
||||
{
|
||||
command: "inspect-web-event-listeners",
|
||||
operation: "inspect_web_event_listeners",
|
||||
buildArgs: (endpoint) => [
|
||||
"inspect-web-event-listeners",
|
||||
endpoint,
|
||||
"page-fixture",
|
||||
"body",
|
||||
"--json",
|
||||
],
|
||||
},
|
||||
{
|
||||
command: "list-browser-targets",
|
||||
operation: "list_browser_targets",
|
||||
buildArgs: (endpoint) => ["list-browser-targets", endpoint, "--json"],
|
||||
},
|
||||
{
|
||||
command: "inspect-web-page",
|
||||
operation: "inspect_web_page",
|
||||
buildArgs: (endpoint) => [
|
||||
"inspect-web-page",
|
||||
endpoint,
|
||||
"page-fixture",
|
||||
"--json",
|
||||
],
|
||||
},
|
||||
{
|
||||
command: "analyze-web-bundle",
|
||||
operation: "analyze_web_bundle",
|
||||
buildArgs: (endpoint) => [
|
||||
"analyze-web-bundle",
|
||||
endpoint,
|
||||
"page-fixture",
|
||||
"--json",
|
||||
],
|
||||
},
|
||||
{
|
||||
command: "observe-web-session",
|
||||
operation: "observe_web_session",
|
||||
buildArgs: (endpoint) => [
|
||||
"observe-web-session",
|
||||
endpoint,
|
||||
"page-fixture",
|
||||
"--json",
|
||||
],
|
||||
},
|
||||
{
|
||||
command: "discover-webmcp-tools",
|
||||
operation: "discover_webmcp_tools",
|
||||
buildArgs: (endpoint) => [
|
||||
"discover-webmcp-tools",
|
||||
endpoint,
|
||||
"page-fixture",
|
||||
"--json",
|
||||
],
|
||||
},
|
||||
{
|
||||
command: "capture-web-screenshot",
|
||||
operation: "capture_web_screenshot",
|
||||
buildArgs: (endpoint) => [
|
||||
"capture-web-screenshot",
|
||||
endpoint,
|
||||
"page-fixture",
|
||||
"--json",
|
||||
],
|
||||
},
|
||||
{
|
||||
command: "list-javascript-runtime-targets",
|
||||
operation: "list_javascript_runtime_targets",
|
||||
buildArgs: (endpoint) => [
|
||||
"list-javascript-runtime-targets",
|
||||
endpoint,
|
||||
"--json",
|
||||
],
|
||||
},
|
||||
{
|
||||
command: "observe-javascript-runtime",
|
||||
operation: "observe_javascript_runtime",
|
||||
buildArgs: (endpoint) => [
|
||||
"observe-javascript-runtime",
|
||||
endpoint,
|
||||
"target-fixture",
|
||||
"--json",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const cancellationCases = cancellationCommands.flatMap((command) =>
|
||||
cancellationSignals.map((signal) => ({
|
||||
...command,
|
||||
signal,
|
||||
expectedExitCode: signal === "SIGINT" ? 130 : 143,
|
||||
})),
|
||||
);
|
||||
|
||||
it.each(webRuntimeCancellationCases)(
|
||||
"%s preserves %s status when cancellation interrupts CDP discovery",
|
||||
async (command, signal, expectedExitCode) => {
|
||||
it.each(cancellationCases)(
|
||||
"$command preserves $signal status and awaited cleanup during CDP discovery",
|
||||
async ({ command, operation, buildArgs, signal, expectedExitCode }) => {
|
||||
const sockets = new Set<Socket>();
|
||||
const server = createServer(() => undefined);
|
||||
server.on("connection", (socket) => {
|
||||
|
|
@ -136,21 +243,14 @@ it.each(webRuntimeCancellationCases)(
|
|||
const endpoint = `http://127.0.0.1:${String(address.port)}`;
|
||||
const contacted = once(server, "request");
|
||||
|
||||
const args =
|
||||
command === "observe-web-execution"
|
||||
? [
|
||||
command,
|
||||
endpoint,
|
||||
"page-fixture",
|
||||
"--observation-ms",
|
||||
"30000",
|
||||
"--json",
|
||||
]
|
||||
: [command, endpoint, "page-fixture", "body", "--json"];
|
||||
const child = spawn(process.execPath, [entrypoint, ...args], {
|
||||
env: environment(),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[entrypoint, ...buildArgs(endpoint)],
|
||||
{
|
||||
env: environment(),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
const closed = once(child, "close");
|
||||
let stdout = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
|
|
@ -180,7 +280,7 @@ it.each(webRuntimeCancellationCases)(
|
|||
expect(JSON.parse(stdout)).toMatchObject({
|
||||
code: "cancelled",
|
||||
category: "cancelled",
|
||||
details: { cleanup: "complete" },
|
||||
details: { cleanup: "complete", operation },
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ else { if(readFileSync('capture', 'utf8') !== 'recording bytes') throw Error('wr
|
|||
},
|
||||
async (input) => {
|
||||
calls++;
|
||||
if (mode === "launch-failure" && !input.arguments.includes("--version"))
|
||||
throw Object.assign(new Error("spawn E2BIG"), { code: "E2BIG" });
|
||||
if (mode === "file-limit" && !input.arguments.includes("--version")) {
|
||||
const stdout = { text: "", bytes: 0, observedBytes: 0 };
|
||||
const stderr = {
|
||||
|
|
@ -255,6 +257,21 @@ it.each([
|
|||
},
|
||||
);
|
||||
|
||||
it.each(["capture", "fields"] as const)(
|
||||
"does not attribute the version probe's output to a %s command that fails to launch",
|
||||
async (operation) => {
|
||||
const f = await fixture("launch-failure");
|
||||
const result =
|
||||
operation === "capture"
|
||||
? await f.service.inspect({ path: f.path, fields: ["ip.addr"] })
|
||||
: await f.service.inspectFields({ kind: "fields" });
|
||||
if (result.ok) throw new Error("Expected launch failure");
|
||||
expect(f.calls()).toBe(2);
|
||||
expect(result.error.message).toContain("spawn E2BIG");
|
||||
expect(result.error.capturedOutput).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("retains a completed observation and cleanup ownership for retry", async () => {
|
||||
const f = await fixture("ok", true);
|
||||
const result = await f.service.inspect({ path: f.path, fields: ["ip.addr"] });
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
copyFile,
|
||||
lstat,
|
||||
mkdir,
|
||||
readFile,
|
||||
symlink,
|
||||
|
|
@ -44,6 +45,7 @@ const fixture = async () => {
|
|||
await mkdir(join(root, "src"));
|
||||
for (const path of [
|
||||
"scripts/run-development-tests.mjs",
|
||||
"scripts/run-exact-tests.mjs",
|
||||
"scripts/lib/development-tests.mjs",
|
||||
"scripts/lib/test-projects.mjs",
|
||||
])
|
||||
|
|
@ -76,6 +78,135 @@ const testCommand = (root: string, ...arguments_: string[]) =>
|
|||
...arguments_,
|
||||
]);
|
||||
|
||||
describe("development test runner exact selection", () => {
|
||||
it("matches selected path casing to the host filesystem", async () => {
|
||||
const root = await fixture();
|
||||
try {
|
||||
await lstat(join(root, "src/VALUE.test.ts"));
|
||||
} catch (cause) {
|
||||
if (
|
||||
!(cause instanceof Error) ||
|
||||
!("code" in cause) ||
|
||||
cause.code !== "ENOENT"
|
||||
)
|
||||
throw cause;
|
||||
await expect(
|
||||
testCommand(root, "focused", "src/VALUE.test.ts"),
|
||||
).rejects.toMatchObject({
|
||||
code: 1,
|
||||
stderr: expect.stringContaining("ENOENT"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await testCommand(root, "focused", "src/VALUE.test.ts");
|
||||
expect(await readFile(join(root, "selected.txt"), "utf8")).toBe("1");
|
||||
});
|
||||
|
||||
it("matches Unicode-normalized aliases by filesystem identity", async () => {
|
||||
const root = await fixture();
|
||||
const composed = "caf\u00e9.test.ts";
|
||||
const decomposed = composed.normalize("NFD");
|
||||
await writeFile(
|
||||
join(root, "src", composed),
|
||||
'import { it } from "vitest"; it("checks the selected Unicode path", () => {});\n',
|
||||
);
|
||||
const selected = join(root, "src", decomposed);
|
||||
try {
|
||||
await lstat(selected);
|
||||
} catch (cause) {
|
||||
if (
|
||||
!(cause instanceof Error) ||
|
||||
!("code" in cause) ||
|
||||
cause.code !== "ENOENT"
|
||||
)
|
||||
throw cause;
|
||||
await expect(
|
||||
testCommand(root, "focused", `src/${decomposed}`),
|
||||
).rejects.toMatchObject({
|
||||
code: 1,
|
||||
stderr: expect.stringContaining("ENOENT"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const result = await testCommand(root, "focused", `src/${decomposed}`);
|
||||
expect(result.stdout).toContain("1 passed");
|
||||
});
|
||||
|
||||
it.each(["local", "focused"])(
|
||||
"runs only the exact selected file in %s mode",
|
||||
async (mode) => {
|
||||
const root = await fixture();
|
||||
await mkdir(join(root, "src", "nested", "src"), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "src", "nested", "src", "value.test.ts"),
|
||||
'import { it } from "vitest"; it("must not run", () => { throw new Error("unselected nested test ran"); });\n',
|
||||
);
|
||||
const result = await testCommand(root, mode, "src/value.test.ts");
|
||||
expect(result.stdout).toContain("1 passed");
|
||||
expect(result.stdout).not.toContain("nested/src/value.test.ts");
|
||||
expect(await readFile(join(root, "selected.txt"), "utf8")).toBe("1");
|
||||
},
|
||||
);
|
||||
|
||||
it("fails when any selected existing test file is excluded from discovery", async () => {
|
||||
const root = await fixture();
|
||||
await writeFile(
|
||||
join(root, "src", "excluded.test.ts"),
|
||||
'import { it } from "vitest"; it("excluded", () => {});\n',
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "vitest.config.mjs"),
|
||||
'export default { test: { projects: [{ test: { name: "domain", include: ["src/value.test.ts"] } }] } };\n',
|
||||
);
|
||||
await expect(
|
||||
testCommand(root, "focused", "src/value.test.ts", "src/excluded.test.ts"),
|
||||
).rejects.toMatchObject({
|
||||
code: 1,
|
||||
stderr: expect.stringContaining(
|
||||
"Selected test files were not discovered: src/excluded.test.ts",
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
it("runs every selected file and preserves selected test failures", async () => {
|
||||
const root = await fixture();
|
||||
await writeFile(
|
||||
join(root, "src", "failure.test.ts"),
|
||||
'import { it } from "vitest"; it("selected failure", () => { throw new Error("selected regression"); });\n',
|
||||
);
|
||||
await expect(
|
||||
testCommand(root, "focused", "src/value.test.ts", "src/failure.test.ts"),
|
||||
).rejects.toMatchObject({
|
||||
code: 1,
|
||||
stdout: expect.stringContaining("1 failed | 1 passed"),
|
||||
stderr: expect.stringContaining("selected regression"),
|
||||
});
|
||||
expect(await readFile(join(root, "selected.txt"), "utf8")).toBe("1");
|
||||
});
|
||||
|
||||
it("preserves all configured projects for an exact file", async () => {
|
||||
const root = await fixture();
|
||||
await writeFile(
|
||||
join(root, "vitest.config.mjs"),
|
||||
'export default { test: { projects: ["domain", "evaluation"].map(name => ({ test: { name, include: ["src/value.test.ts"] } })) } };\n',
|
||||
);
|
||||
const result = await testCommand(root, "focused", "src/value.test.ts");
|
||||
expect(result.stdout).toMatch(/Test Files\s+2 passed/u);
|
||||
});
|
||||
|
||||
it("fails an exact file with no tests even when another selected file passes", async () => {
|
||||
const root = await fixture();
|
||||
await writeFile(join(root, "src", "empty.test.ts"), "export {};\n");
|
||||
await expect(
|
||||
testCommand(root, "focused", "src/value.test.ts", "src/empty.test.ts"),
|
||||
).rejects.toMatchObject({
|
||||
code: 1,
|
||||
stderr: expect.stringContaining("No test suite found"),
|
||||
});
|
||||
expect(await readFile(join(root, "selected.txt"), "utf8")).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("development test runner public CLI", () => {
|
||||
it("runs an explicitly selected source test on a clean tree without build artifacts", async () => {
|
||||
const root = await fixture();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { build } from "plist";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
cliTest(
|
||||
"compiled CLI inspects an XML keyed archive with a non-plist suffix",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-keyed-xml-extension-cli-");
|
||||
const path = join(root, "Model.archive");
|
||||
await writeFile(
|
||||
path,
|
||||
build({
|
||||
$archiver: "NSKeyedArchiver",
|
||||
$version: 100000,
|
||||
$objects: ["$null", "leaf"],
|
||||
$top: { root: { UID: 1 } },
|
||||
}),
|
||||
);
|
||||
const output = await cli.run({
|
||||
arguments: ["inspect-keyed-archive", path, "--json"],
|
||||
environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" },
|
||||
});
|
||||
expect(output.exitCode, output.stderr).toBe(0);
|
||||
expect(parseEvidence(output.json).normalized_result).toMatchObject({
|
||||
archive_path: "Model.archive",
|
||||
archive_format: "xml-plist",
|
||||
objects: expect.arrayContaining([
|
||||
expect.objectContaining({ value: "leaf" }),
|
||||
]),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
@ -205,6 +205,42 @@ describe("APK family without a root manifest", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("retains encrypted native library paths as unknown component evidence", async () => {
|
||||
const root = await createTestTempDirectory("rea-android-encrypted-");
|
||||
const path = join(root, "Encrypted.apk");
|
||||
const writer = new ZipWriter(new Uint8ArrayWriter());
|
||||
await writer.add(
|
||||
"lib/arm64-v8a/libentry.so",
|
||||
new TextReader("encrypted native library"),
|
||||
{ password: "fixture-only" },
|
||||
);
|
||||
await writeFile(path, await writer.close());
|
||||
const inventory = parseEvidence(
|
||||
await runProviderAnalysis(path, "inventory_artifact", {}),
|
||||
);
|
||||
const projection = androidApplicationProjectionResultSchema.parse(
|
||||
requireSuccessfulProjection(
|
||||
projectAndroidApplicationEvidence({ inventory_evidence: [inventory] }),
|
||||
).normalized_result,
|
||||
);
|
||||
|
||||
expect(projection.unavailable_components).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: "lib/arm64-v8a/libentry.so",
|
||||
artifact_id: null,
|
||||
sha256: null,
|
||||
encrypted: true,
|
||||
}),
|
||||
);
|
||||
expect(projection.coverage).toMatchObject({
|
||||
status: "partial",
|
||||
inventory_complete: true,
|
||||
});
|
||||
expect(projection.limitations).toContainEqual(
|
||||
expect.stringContaining("component absence is unknown"),
|
||||
);
|
||||
});
|
||||
|
||||
describe("case-distinct APK resource names", () => {
|
||||
it("inventories and projects an APK whose resource names differ only in case", async () => {
|
||||
const root = await createTestTempDirectory("rea-android-case-");
|
||||
|
|
|
|||
|
|
@ -616,6 +616,10 @@ it.each([
|
|||
{ code: true, runtime: false, browser: false, package: false },
|
||||
],
|
||||
["website/src/index.ts", { code: false, website: true, package: false }],
|
||||
[
|
||||
"website/public/examples/notes-electron/preload.js",
|
||||
{ code: true, website: true, package: false },
|
||||
],
|
||||
[
|
||||
"website/locales/zh-CN/messages.po",
|
||||
{ code: false, website: true, package: false },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,152 @@
|
|||
// Producer regressions run inside a real, ephemeral Ghidra analysis of the
|
||||
// source-owned AArch64 fixture. No target code is executed and no decoded JSON
|
||||
// fixture substitutes for the Java decoder under test.
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import ghidra.app.util.headless.HeadlessScript;
|
||||
import ghidra.program.model.address.Address;
|
||||
import ghidra.program.model.listing.Function;
|
||||
import ghidra.program.model.listing.FunctionIterator;
|
||||
import ghidra.program.model.listing.Instruction;
|
||||
import ghidra.program.model.listing.InstructionIterator;
|
||||
import ghidra.program.model.lang.Register;
|
||||
import ghidra.program.model.symbol.Symbol;
|
||||
import ghidra.program.model.symbol.SymbolIterator;
|
||||
|
||||
public class ReaSwiftDecoderRegressions extends HeadlessScript {
|
||||
private ReaGhidraBridge decoder;
|
||||
private Class<?> budgetClass;
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
decoder = new ReaGhidraBridge();
|
||||
setInherited(decoder, "currentProgram", currentProgram);
|
||||
setInherited(decoder, "monitor", monitor);
|
||||
setInherited(decoder, "sessionDefaultAddressSpace", currentProgram.getAddressFactory().getDefaultAddressSpace());
|
||||
budgetClass = Class.forName("ReaGhidraBridge$SwiftLiteralBudget", true, decoder.getClass().getClassLoader());
|
||||
Function copied = function("rea_swift_decoder_copied");
|
||||
JsonArray copiedValues = literals(decode(copied, 65536));
|
||||
require(copiedValues.size() > 0, "copied constant pair was not decoded");
|
||||
for (var element : copiedValues) {
|
||||
require(element.getAsJsonObject().get("value").getAsString().equals("ok"), "copied words invented text");
|
||||
}
|
||||
for (String name : new String[] {"select", "selected_copy", "padding", "utf8"}) {
|
||||
Object result = decode(function("rea_swift_decoder_" + name), 65536);
|
||||
require(literals(result).isEmpty(), name + " produced an invented Swift literal");
|
||||
require(omissions(result).isEmpty(), name + " was confused with capacity exhaustion");
|
||||
}
|
||||
for (String name : new String[] {"select", "selected_copy"}) {
|
||||
Function selected = function("rea_swift_decoder_" + name);
|
||||
require((Boolean) invoke("selectedValue", new Class<?>[] {Function.class, Instruction.class, Register.class}, selected, returnInstruction(selected), currentProgram.getRegister("x0")),
|
||||
name + " did not retain conditional-select provenance");
|
||||
}
|
||||
require(invoke("swiftLiteralLayout", new Class<?>[] {long.class, long.class}, 0x6261L, 0xe100000000000000L) == null,
|
||||
"nonzero small-string padding was accepted");
|
||||
require(invoke("swiftLiteralLayout", new Class<?>[] {long.class, long.class}, 0xc0L, 0xa100000000000000L) == null,
|
||||
"malformed small-string UTF-8 was accepted");
|
||||
rejectLarge("rea_swift_decoder_bad_terminator", 0xd000000000000003L);
|
||||
rejectLarge("rea_swift_decoder_bad_large_utf8", 0x1000000000000001L);
|
||||
// Zero covers provenance-state exhaustion; 8192 admits both register
|
||||
// cache entries and then rejects the retained literal/text charge.
|
||||
assertCapacityOmission(copied, 0);
|
||||
assertCapacityOmission(copied, 8192);
|
||||
require(getScriptArgs().length == 1, "Expected owned result path");
|
||||
Files.writeString(Path.of(getScriptArgs()[0]), "REA_SWIFT_DECODER_SAFEGUARDS_OK\n");
|
||||
}
|
||||
|
||||
private void assertCapacityOmission(Function function, long remaining) throws Exception {
|
||||
Object exhausted = decode(function, remaining);
|
||||
require(literals(exhausted).isEmpty(), "capacity failure published decoded small text at budget " + remaining);
|
||||
JsonArray omitted = omissions(exhausted);
|
||||
require(omitted.size() == 1, "capacity failure did not stop subsequent use sites at budget " + remaining);
|
||||
JsonObject omission = omitted.get(0).getAsJsonObject();
|
||||
require(omission.get("reason").getAsString().equals("capacity"), "capacity omission lost its reason");
|
||||
require(!omission.has("value"), "omission claimed a retained literal value");
|
||||
}
|
||||
|
||||
private Object decode(Function function, long remaining) throws Exception {
|
||||
Constructor<?> constructor = budgetClass.getDeclaredConstructor(long.class);
|
||||
constructor.setAccessible(true);
|
||||
return invoke("swiftStringLiterals", new Class<?>[] {Function.class, budgetClass}, function, constructor.newInstance(remaining));
|
||||
}
|
||||
|
||||
private void rejectLarge(String name, long word) throws Exception {
|
||||
Address address = symbol(name).getAddress();
|
||||
long object = 0x8000000000000000L | ((address.getOffset() - 0x20) & 0x00ffffffffffffffL);
|
||||
Object layout = invoke("swiftLiteralLayout", new Class<?>[] {long.class, long.class}, word, object);
|
||||
require(layout != null, "large negative fixture did not reach the memory-text decoder");
|
||||
require(invoke("swiftLargeLiteralText", new Class<?>[] {layout.getClass()}, layout) == null,
|
||||
name + " was accepted as literal text");
|
||||
}
|
||||
|
||||
private Instruction returnInstruction(Function function) {
|
||||
InstructionIterator instructions = currentProgram.getListing().getInstructions(function.getBody(), true);
|
||||
while (instructions.hasNext()) {
|
||||
Instruction instruction = instructions.next();
|
||||
if (instruction.getMnemonicString().equalsIgnoreCase("ret")) return instruction;
|
||||
}
|
||||
throw new AssertionError("Fixture has no return instruction");
|
||||
}
|
||||
|
||||
private Function function(String name) {
|
||||
FunctionIterator functions = currentProgram.getFunctionManager().getFunctions(true);
|
||||
Function found = null;
|
||||
while (functions.hasNext()) {
|
||||
Function candidate = functions.next();
|
||||
if (!candidate.getName().equals(name) && !candidate.getName().equals("_" + name)) continue;
|
||||
require(found == null, "ambiguous fixture function " + name);
|
||||
found = candidate;
|
||||
}
|
||||
require(found != null, "missing fixture function " + name);
|
||||
return found;
|
||||
}
|
||||
|
||||
private Symbol symbol(String name) {
|
||||
SymbolIterator symbols = currentProgram.getSymbolTable().getAllSymbols(true);
|
||||
Symbol found = null;
|
||||
while (symbols.hasNext()) {
|
||||
Symbol candidate = symbols.next();
|
||||
if (!candidate.getName().equals(name) && !candidate.getName().equals("_" + name)) continue;
|
||||
require(found == null, "ambiguous fixture symbol " + name);
|
||||
found = candidate;
|
||||
}
|
||||
require(found != null, "missing fixture symbol " + name);
|
||||
return found;
|
||||
}
|
||||
|
||||
private Object invoke(String name, Class<?>[] types, Object... arguments) throws Exception {
|
||||
Method method = ReaGhidraBridge.class.getDeclaredMethod(name, types);
|
||||
method.setAccessible(true);
|
||||
return method.invoke(decoder, arguments);
|
||||
}
|
||||
|
||||
private JsonArray literals(Object result) throws Exception { return array(result, "literals"); }
|
||||
private JsonArray omissions(Object result) throws Exception { return array(result, "omissions"); }
|
||||
private JsonArray array(Object result, String name) throws Exception {
|
||||
Method method = result.getClass().getDeclaredMethod(name);
|
||||
method.setAccessible(true);
|
||||
return (JsonArray) method.invoke(result);
|
||||
}
|
||||
|
||||
private void setInherited(Object target, String name, Object value) throws Exception {
|
||||
for (Class<?> type = target.getClass(); type != null; type = type.getSuperclass()) {
|
||||
try {
|
||||
Field field = type.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
return;
|
||||
} catch (NoSuchFieldException nextOwner) { }
|
||||
}
|
||||
throw new AssertionError("Missing Ghidra state field " + name);
|
||||
}
|
||||
|
||||
private static void require(boolean condition, String message) {
|
||||
if (!condition) throw new AssertionError(message);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
/* Static-analysis fixture only. Never executed. Deliberate malformed ABI words
|
||||
* cannot be produced by a well-formed Swift source literal. */
|
||||
.text
|
||||
.p2align 2
|
||||
.globl _rea_swift_decoder_copied
|
||||
_rea_swift_decoder_copied:
|
||||
mov x5, #0x6b6f
|
||||
movz x6, #0xe200, lsl #48
|
||||
mov x0, x5
|
||||
mov x1, x6
|
||||
stp x0, x1, [sp, #-16]!
|
||||
ldp x0, x1, [sp], #16
|
||||
ret
|
||||
|
||||
.p2align 2
|
||||
.globl _rea_swift_decoder_select
|
||||
_rea_swift_decoder_select:
|
||||
mov x2, #0x61
|
||||
mov x3, #0x6262
|
||||
movz x6, #0xe100, lsl #48
|
||||
movz x7, #0xe200, lsl #48
|
||||
cmp w4, #0
|
||||
csel x0, x2, x3, eq
|
||||
csel x1, x6, x7, eq
|
||||
ret
|
||||
|
||||
.p2align 2
|
||||
.globl _rea_swift_decoder_selected_copy
|
||||
_rea_swift_decoder_selected_copy:
|
||||
mov x2, #0x61
|
||||
mov x3, #0x62
|
||||
cmp w4, #0
|
||||
csel x5, x2, x3, eq
|
||||
mov x0, x5
|
||||
movz x1, #0xe100, lsl #48
|
||||
ret
|
||||
|
||||
.p2align 2
|
||||
.globl _rea_swift_decoder_padding
|
||||
_rea_swift_decoder_padding:
|
||||
mov x0, #0x6261
|
||||
movz x1, #0xe100, lsl #48
|
||||
ret
|
||||
|
||||
.p2align 2
|
||||
.globl _rea_swift_decoder_utf8
|
||||
_rea_swift_decoder_utf8:
|
||||
mov x0, #0xc0
|
||||
movz x1, #0xa100, lsl #48
|
||||
ret
|
||||
|
||||
.section __DATA,__const
|
||||
.globl _rea_swift_decoder_bad_terminator
|
||||
_rea_swift_decoder_bad_terminator:
|
||||
.byte 0x61, 0x62, 0x63, 0x78
|
||||
.globl _rea_swift_decoder_bad_large_utf8
|
||||
_rea_swift_decoder_bad_large_utf8:
|
||||
.byte 0xc0, 0
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
.text
|
||||
.globl _rea_unresolved_string_jump
|
||||
.p2align 2
|
||||
_rea_unresolved_string_jump:
|
||||
movz x0, #0x4220
|
||||
movk x0, #0x732f, lsl #16
|
||||
movz x1, #0xe400, lsl #48
|
||||
br x16
|
||||
|
|
@ -140,6 +140,8 @@ const managedTranscript = () => {
|
|||
javascriptSha256: "",
|
||||
javascriptShapeLeft: "",
|
||||
javascriptShapeRight: "",
|
||||
notesElectron: "",
|
||||
notesElectronSha256: "",
|
||||
managed: target.path,
|
||||
managedSha256: target.sha256,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import { expect, it } from "vitest";
|
||||
|
||||
import { createOpenAiModelFixture } from "../../../scripts/verify/clients/model-fixture.mjs";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
it("routes OpenAI fixture requests by pathname, not query", async () => {
|
||||
const directory = await createTestTempDirectory("rea-model-fixture-");
|
||||
let calls = 0;
|
||||
const fixture = await createOpenAiModelFixture({
|
||||
directory,
|
||||
prefix: "probe",
|
||||
onRequest: () => {
|
||||
calls += 1;
|
||||
return { role: "assistant", content: "ok" };
|
||||
},
|
||||
});
|
||||
try {
|
||||
const statusOf = async (path: string, method = "POST") =>
|
||||
(
|
||||
await fetch(`${fixture.baseUrl}${path}`, {
|
||||
method,
|
||||
...(method === "POST"
|
||||
? {
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: "rea-client-fixture",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
).status;
|
||||
|
||||
expect([
|
||||
await statusOf("/chat/completions"),
|
||||
await statusOf("/chat/completions?api-version=1"),
|
||||
await statusOf("/completions"),
|
||||
await statusOf("/models?limit=1", "GET"),
|
||||
]).toEqual([200, 200, 404, 200]);
|
||||
expect(calls).toBe(2);
|
||||
expect(fixture.requests.map(({ path }) => path)).toEqual([
|
||||
"/v1/chat/completions",
|
||||
"/v1/chat/completions?api-version=1",
|
||||
]);
|
||||
expect(fixture.probes).toEqual([
|
||||
expect.objectContaining({ path: "/v1/completions", status: 404 }),
|
||||
]);
|
||||
expect(fixture.failure).toBeUndefined();
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
|
@ -275,6 +275,47 @@ describe("module import syntax and source discovery", () => {
|
|||
expect(result.stderr).toContain("src/domain/probe.ts:1: pure-layer");
|
||||
expect(result.stderr).not.toContain("old.ts");
|
||||
});
|
||||
|
||||
it("discovers forbidden nested sources without a Git worktree", async () => {
|
||||
const temporary = await createTestTempDirectory("rea-module-no-git-");
|
||||
await mkdir(join(temporary, "src", "domain", "nested"), {
|
||||
recursive: true,
|
||||
});
|
||||
await writeFile(
|
||||
join(temporary, "src/domain/nested/probe.ts"),
|
||||
'import { createServer } from "../../server/createServer.js";\n',
|
||||
);
|
||||
const result = spawnSync(process.execPath, [verifier], {
|
||||
cwd: temporary,
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (result.error !== undefined) throw result.error;
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("src/domain/nested/probe.ts");
|
||||
expect(result.stderr).toContain("src/server/createServer.js");
|
||||
});
|
||||
|
||||
it("discovers sources in an ignored archive nested under an ancestor Git worktree", async () => {
|
||||
const outer = await createTestTempDirectory("rea-module-ancestor-git-");
|
||||
const archive = join(outer, "archive");
|
||||
await writeFile(join(outer, ".gitignore"), "archive/\n");
|
||||
await execute("git", ["init", "--quiet"], { cwd: outer });
|
||||
await execute("git", ["add", "--", ".gitignore"], { cwd: outer });
|
||||
await mkdir(join(archive, "src", "domain"), { recursive: true });
|
||||
await writeFile(
|
||||
join(archive, "src/domain/probe.ts"),
|
||||
'import { createServer } from "../server/createServer.js";\n',
|
||||
);
|
||||
|
||||
const result = spawnSync(process.execPath, [verifier], {
|
||||
cwd: archive,
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (result.error !== undefined) throw result.error;
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("src/domain/probe.ts:1: pure-layer");
|
||||
expect(result.stderr).toContain("src/server/createServer.js");
|
||||
});
|
||||
});
|
||||
|
||||
describe("test lane import boundaries", () => {
|
||||
|
|
|
|||
|
|
@ -319,6 +319,8 @@ describe("updated executable maintenance planning", () => {
|
|||
it("runs real setup as a noninteractive dry run and creates no new integrations or backups", async () => {
|
||||
const configPath = await writeClient("codex", staleCodex);
|
||||
const untouched = await writeClient("claude_code", '{"mcpServers":{}}');
|
||||
const skillContent = `---\nname: ${PRODUCT_IDENTITY.skillName}\nmetadata:\n version: "1"\n---\n# REA\n`;
|
||||
const skillPath = await writeSkill(skillContent);
|
||||
vi.stubEnv("npm_command", "exec");
|
||||
const result = await planIntegrationMaintenance(
|
||||
home,
|
||||
|
|
@ -328,18 +330,35 @@ describe("updated executable maintenance planning", () => {
|
|||
);
|
||||
expect(result).toMatchObject({
|
||||
status: "planned",
|
||||
scope: { clients: ["codex"], skill: false },
|
||||
plannedActions: [
|
||||
{ id: "configure_client:codex", kind: "configure_client" },
|
||||
],
|
||||
scope: {
|
||||
clients: ["codex"],
|
||||
skill: true,
|
||||
skillDestinations: [{ client: "shared", path: dirname(skillPath) }],
|
||||
},
|
||||
});
|
||||
if (result.status !== "planned") throw new Error(JSON.stringify(result));
|
||||
expect(result.plannedActions).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "configure_client:codex",
|
||||
kind: "configure_client",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "install_skill",
|
||||
kind: "install_skill",
|
||||
target: dirname(skillPath),
|
||||
}),
|
||||
]);
|
||||
expect(await readFile(configPath, "utf8")).toBe(staleCodex);
|
||||
expect(await readFile(untouched, "utf8")).toBe('{"mcpServers":{}}');
|
||||
expect(await readFile(skillPath, "utf8")).toBe(skillContent);
|
||||
await expect(access(`${configPath}.rea.backup`)).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
await expect(access(`${skillPath}.rea.backup`)).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
await expect(
|
||||
access(join(home, ".agents", "skills", PRODUCT_IDENTITY.skillName)),
|
||||
access(join(home, ".claude", "skills", PRODUCT_IDENTITY.skillName)),
|
||||
).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import type { Client } from "@modelcontextprotocol/client";
|
||||
import { expect, type OnTestFinishedHandler } from "vitest";
|
||||
|
||||
import { createBinarySession } from "../../../src/composition/binary.js";
|
||||
import { parseConfig } from "../../../src/config/parseConfig.js";
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import type { TestMcp } from "./mcpFixture.js";
|
||||
|
||||
/** Open one real target in a production session served over MCP. */
|
||||
export const openProductionTarget = async (
|
||||
mcp: TestMcp,
|
||||
onTestFinished: (handler: OnTestFinishedHandler) => void,
|
||||
path: string,
|
||||
): Promise<Client> => {
|
||||
const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" });
|
||||
if (!configured.ok) throw configured.error;
|
||||
const session = createBinarySession(
|
||||
configured.value,
|
||||
silentLogger,
|
||||
process.env,
|
||||
);
|
||||
onTestFinished(async () => {
|
||||
await session.close();
|
||||
});
|
||||
const client = await mcp.connect(createServer({ kind: "session", session }));
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path },
|
||||
});
|
||||
expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true);
|
||||
return client;
|
||||
};
|
||||
Loading…
Reference in New Issue