fix(ghidra): supervise Swift probes and cover retained text capacity
This commit is contained in:
@@ -55,6 +55,9 @@ export const SCOPES = {
|
||||
"tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts",
|
||||
"tests/boundary/filesystem/setupUnavailableProvider.test.ts",
|
||||
"tests/boundary/filesystem/windowsNpxRegistration.test.ts",
|
||||
"tests/boundary/android/workspaceGuardianRetry.test.ts",
|
||||
"src/android/JadxWorkspaceRoot.ts",
|
||||
"scripts/rea-android-workspace-guardian.mjs",
|
||||
"tests/acceptance/setup/homeDirectoryFallback.test.ts",
|
||||
"tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts",
|
||||
"src/domain/javascript/javascriptRuntimeStaticCandidates.ts",
|
||||
@@ -218,6 +221,7 @@ export const SCOPES = {
|
||||
"src/artifacts/readStableArtifact*.ts",
|
||||
"src/artifacts/ArtifactReader.ts",
|
||||
"scripts/verify-evm-interface.mjs",
|
||||
"scripts/verify-solidity-metadata.mjs",
|
||||
"scripts/fixtures/evm-interface.sol",
|
||||
"third_party/evmole/**",
|
||||
],
|
||||
@@ -359,6 +363,7 @@ export const SCOPES = {
|
||||
"website/**",
|
||||
"scripts/*website*.py",
|
||||
"README*.md",
|
||||
"docs/readme/README*.md",
|
||||
".github/workflows/website-check.yml",
|
||||
".github/workflows/website-pages.yml",
|
||||
],
|
||||
|
||||
@@ -8,7 +8,10 @@ import { spawnOwnedProviderProcess } from "../../dist/process/ProviderProcess.js
|
||||
import { createSystemProcessOwnershipHost } from "../../dist/process/ProcessOwnershipObservation.js";
|
||||
import { liveProcesses } from "../../dist/process/ProcessOwnershipProcessTree.js";
|
||||
import { CdpConnection } from "../../dist/browser/CdpConnection.js";
|
||||
import { waitForBrowserDevtoolsPort } from "../../dist/browser/BrowserProcessStartup.js";
|
||||
import {
|
||||
BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS,
|
||||
waitForBrowserDevtoolsPort,
|
||||
} from "../../dist/browser/BrowserProcessStartup.js";
|
||||
|
||||
/** Launch the synthetic fixture browser and await CDP in its own verified process group. */
|
||||
export async function startRuntimeFixtureBrowser(executable, origin) {
|
||||
@@ -50,7 +53,7 @@ export async function startRuntimeFixtureBrowser(executable, origin) {
|
||||
stderr: () => stderr,
|
||||
// Match the broader real-browser verifier's allowance for cold Chrome
|
||||
// startup on shared runners; early process failures still reject at once.
|
||||
timeoutMs: 60_000,
|
||||
timeoutMs: BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
return {
|
||||
port,
|
||||
|
||||
+58
-11
@@ -1,13 +1,41 @@
|
||||
import { readFile, readdir, stat } from "node:fs/promises";
|
||||
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
|
||||
const readmeLinkTargets = (content, language = "en") =>
|
||||
[...content.matchAll(/\]\(([^)\s]+)\)/gu)]
|
||||
.map((match) => match[1] ?? "")
|
||||
const repoRelative = (root, from, target) => {
|
||||
const index = target.search(/[#?]/u);
|
||||
const pathPart = index === -1 ? target : target.slice(0, index);
|
||||
const suffix = index === -1 ? "" : target.slice(index);
|
||||
if (pathPart.length === 0 || /^[a-z][a-z0-9+.-]*:/iu.test(pathPart))
|
||||
return target;
|
||||
const absolute = resolve(root, dirname(from), pathPart);
|
||||
const withinRepo = relative(root, absolute);
|
||||
if (
|
||||
withinRepo === ".." ||
|
||||
withinRepo.startsWith(`..${sep}`) ||
|
||||
isAbsolute(withinRepo)
|
||||
)
|
||||
return target;
|
||||
return `${withinRepo.split(sep).join("/")}${suffix}`;
|
||||
};
|
||||
|
||||
const isLanguageNavigation = (target) => {
|
||||
const path = target.split(/[#?]/u)[0];
|
||||
return (
|
||||
path === "README.md" ||
|
||||
/^docs\/readme\/README_[A-Za-z0-9-]+\.md$/u.test(path)
|
||||
);
|
||||
};
|
||||
|
||||
const readmeDestinations = (content) => [
|
||||
...content.matchAll(/\]\(([^)\s]+)\)/gu),
|
||||
...content.matchAll(/\b(?:src|href)="([^"]+)"/gu),
|
||||
];
|
||||
|
||||
const readmeLinkTargets = (root, from, content, language = "en") =>
|
||||
readmeDestinations(content)
|
||||
.map((match) => repoRelative(root, from, match[1] ?? ""))
|
||||
.filter(
|
||||
(target) =>
|
||||
!target.startsWith("#") &&
|
||||
!/^README(?:_[A-Za-z0-9-]+)?\.md$/u.test(target),
|
||||
(target) => !target.startsWith("#") && !isLanguageNavigation(target),
|
||||
)
|
||||
.map((target) => {
|
||||
if (language === "en" || !URL.canParse(target)) return target;
|
||||
@@ -70,9 +98,19 @@ export const documentationFactIssues = async (root, catalog) => {
|
||||
const issues = await skillReferenceIssues(
|
||||
join(root, "skills/reverse-engineer-anything"),
|
||||
);
|
||||
const readmePaths = (await readdir(root))
|
||||
.filter((path) => /^README(?:_[A-Za-z0-9-]+)?\.md$/u.test(path))
|
||||
.sort();
|
||||
const translatedFolder = join(root, "docs/readme");
|
||||
let translatedNames = [];
|
||||
try {
|
||||
translatedNames = await readdir(translatedFolder);
|
||||
} catch (cause) {
|
||||
if (cause?.code !== "ENOENT") throw cause;
|
||||
}
|
||||
const readmePaths = [
|
||||
"README.md",
|
||||
...translatedNames
|
||||
.filter((name) => /^README_[A-Za-z0-9-]+\.md$/u.test(name))
|
||||
.map((name) => `docs/readme/${name}`),
|
||||
].sort();
|
||||
const readmes = new Map();
|
||||
for (const path of readmePaths) {
|
||||
const content = await readFile(join(root, path), "utf8");
|
||||
@@ -94,7 +132,11 @@ export const documentationFactIssues = async (root, catalog) => {
|
||||
);
|
||||
}
|
||||
}
|
||||
const canonicalLinks = readmeLinkTargets(readmes.get("README.md") ?? "");
|
||||
const canonicalLinks = readmeLinkTargets(
|
||||
root,
|
||||
"README.md",
|
||||
readmes.get("README.md") ?? "",
|
||||
);
|
||||
const languageInventory = JSON.parse(
|
||||
await readFile(join(root, "website/locales/languages.json"), "utf8"),
|
||||
);
|
||||
@@ -104,7 +146,12 @@ export const documentationFactIssues = async (root, catalog) => {
|
||||
for (const path of readmePaths.filter((path) => path !== "README.md"))
|
||||
if (
|
||||
JSON.stringify(
|
||||
readmeLinkTargets(readmes.get(path) ?? "", readmeLanguages.get(path)),
|
||||
readmeLinkTargets(
|
||||
root,
|
||||
path,
|
||||
readmes.get(path) ?? "",
|
||||
readmeLanguages.get(path),
|
||||
),
|
||||
) !== JSON.stringify(canonicalLinks)
|
||||
)
|
||||
issues.push(`${path}: documentation links differ from README.md`);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { WindowsPrivateRuntime } from "../dist/windows/WindowsPrivateRuntime.js";
|
||||
|
||||
if (process.platform !== "win32" || process.argv.length !== 3)
|
||||
throw new Error(
|
||||
"Android workspace guardian requires Windows and one parent directory",
|
||||
);
|
||||
|
||||
// This process owns the native directory handle. Detached process creation
|
||||
// keeps it alive when the REA owner is terminated without running JS cleanup.
|
||||
const runtime = WindowsPrivateRuntime.create(process.argv[2], "rea-android-");
|
||||
const ready = await new Promise((resolve) => {
|
||||
process.stdout.once("error", () => resolve(false));
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
path: runtime.observation.path,
|
||||
volumeSerial: runtime.observation.volumeSerial,
|
||||
fileId: runtime.observation.fileId,
|
||||
privateDacl: runtime.observation.privateDacl,
|
||||
}) + "\n",
|
||||
(error) => resolve(error == null),
|
||||
);
|
||||
});
|
||||
|
||||
// The owner holds the write end until close/death. A failed readiness pipe
|
||||
// also grants cleanup authority, including owner death during startup.
|
||||
if (ready) {
|
||||
for await (const _ of process.stdin) {
|
||||
// No commands are accepted; only EOF grants cleanup authority.
|
||||
}
|
||||
} else {
|
||||
process.stdin.destroy();
|
||||
}
|
||||
|
||||
// A failed native walk retains the directory identity lease for a later retry.
|
||||
// Keep this process alive so a transient sharing violation cannot orphan it.
|
||||
for (;;) {
|
||||
try {
|
||||
await runtime.close();
|
||||
process.exit(0);
|
||||
} catch {
|
||||
await delay(1000);
|
||||
}
|
||||
}
|
||||
@@ -261,7 +261,9 @@ class WebsiteTranslationChecks(unittest.TestCase):
|
||||
inventory = [item for item in languages(ROOT) if item["code"] in {"en", "zh-CN", "ar"}]
|
||||
(root / "website/locales/languages.json").write_text(json.dumps(inventory))
|
||||
for item in inventory:
|
||||
(root / item["readme"]).write_text("# REA\n")
|
||||
destination = root / item["readme"]
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_text("# REA\n")
|
||||
WEBSITE.sync_pages(root)
|
||||
extract_catalogs(root)
|
||||
yield root
|
||||
|
||||
@@ -225,6 +225,26 @@ try {
|
||||
for (const mode of ["cli", "mcp"]) {
|
||||
const report = await inspect(mode, path, encoding);
|
||||
assert.equal(report.bytecode.hex, hex);
|
||||
assert.equal(report.bytecode.metadata.status, "decoded");
|
||||
assert.deepEqual(report.bytecode.metadata.compiler_version, {
|
||||
representation: "release-bytes",
|
||||
value: "0.8.30",
|
||||
});
|
||||
assert.equal(
|
||||
report.bytecode.metadata.bytecode_offset +
|
||||
report.bytecode.metadata.cbor_bytes +
|
||||
2,
|
||||
Buffer.from(hex, "hex").length,
|
||||
);
|
||||
assert.ok(
|
||||
report.bytecode.metadata.entries.some(
|
||||
({ key, value }) =>
|
||||
key.kind === "text" &&
|
||||
key.value === "solc" &&
|
||||
value.kind === "bytes" &&
|
||||
value.value === "00081e",
|
||||
),
|
||||
);
|
||||
assert.deepEqual(
|
||||
report.functions
|
||||
.filter(({ dispatch }) => dispatch === "abi")
|
||||
|
||||
+16
-14
@@ -1,12 +1,12 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { toolContract } from "../dist/contracts/toolContracts.js";
|
||||
import { PrivateRuntimeRoot } from "../dist/process/PrivateRuntimeRoot.js";
|
||||
import { hashStableFile } from "../dist/filesystem/StableFileHash.js";
|
||||
import { mcpTextValue } from "./lib/mcp-verifier-results.mjs";
|
||||
|
||||
@@ -27,17 +27,16 @@ const expectations =
|
||||
options.expectations === null
|
||||
? null
|
||||
: JSON.parse(await readFile(options.expectations, "utf8"));
|
||||
const root = await PrivateRuntimeRoot.create({
|
||||
prefix: "rea-flutter-verifier-",
|
||||
});
|
||||
// This is a shared test parent, not a leased provider-owned Windows runtime.
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-flutter-verifier-"));
|
||||
const entrypoint = resolve("scripts/rea.mjs");
|
||||
const environment = Object.fromEntries(
|
||||
Object.entries(process.env).filter(([, value]) => typeof value === "string"),
|
||||
);
|
||||
Object.assign(environment, {
|
||||
TMPDIR: root.path,
|
||||
TMP: root.path,
|
||||
TEMP: root.path,
|
||||
TMPDIR: root,
|
||||
TMP: root,
|
||||
TEMP: root,
|
||||
});
|
||||
const client = new Client({ name: "flutter-verifier", version: "1" });
|
||||
const transport = new StdioClientTransport({
|
||||
@@ -190,15 +189,18 @@ try {
|
||||
} finally {
|
||||
// Server shutdown retries retained provider cleanup and must settle before success.
|
||||
try {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
try {
|
||||
await client.close();
|
||||
} finally {
|
||||
await transport.close();
|
||||
}
|
||||
assert.deepEqual(
|
||||
await readdir(root.path),
|
||||
await readdir(root),
|
||||
[],
|
||||
"Provider snapshots must be released before verification succeeds",
|
||||
`Provider snapshots remain under ${root}`,
|
||||
);
|
||||
} finally {
|
||||
await root.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
process.stdout.write(
|
||||
|
||||
@@ -76,6 +76,7 @@ export async function verifyPackageEnvironment({
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
APPDATA: join(home, "AppData", "Roaming"),
|
||||
LOCALAPPDATA: join(home, "AppData", "Local"),
|
||||
CLAUDE_CONFIG_DIR: home,
|
||||
CODEX_HOME: codexDir,
|
||||
COPILOT_HOME: join(home, ".copilot"),
|
||||
|
||||
@@ -98,6 +98,7 @@ export async function verifyPackageUpdate({
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
APPDATA: join(home, "AppData", "Roaming"),
|
||||
LOCALAPPDATA: join(home, "AppData", "Local"),
|
||||
CLAUDE_CONFIG_DIR: home,
|
||||
CODEX_HOME: join(home, ".codex"),
|
||||
COPILOT_HOME: join(home, ".copilot"),
|
||||
|
||||
@@ -118,6 +118,7 @@ async function verifyPublishedUpdate({ version: targetVersion, canaryRoot }) {
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
APPDATA: join(home, "AppData", "Roaming"),
|
||||
LOCALAPPDATA: join(home, "AppData", "Local"),
|
||||
CLAUDE_CONFIG_DIR: home,
|
||||
CODEX_HOME: join(home, ".codex"),
|
||||
COPILOT_HOME: join(home, ".copilot"),
|
||||
|
||||
@@ -9,7 +9,10 @@ import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { CdpBrowserProvider } from "../dist/browser/CdpBrowserProvider.js";
|
||||
import { waitForBrowserDevtoolsPort } from "../dist/browser/BrowserProcessStartup.js";
|
||||
import {
|
||||
BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS,
|
||||
waitForBrowserDevtoolsPort,
|
||||
} from "../dist/browser/BrowserProcessStartup.js";
|
||||
import { createBrowserScenarioProvider } from "../dist/composition/browserScenario.js";
|
||||
import {
|
||||
inspectWebPageInputSchema,
|
||||
@@ -47,7 +50,6 @@ import { artifactCliEvidence, artifactMcpResult } from "./lib/artifact-e2e.mjs";
|
||||
import { verifyScenarioEnvironment } from "./lib/browser-scenario-environment-e2e.mjs";
|
||||
import { verifyScenarioStorage } from "./lib/browser-scenario-storage-e2e.mjs";
|
||||
|
||||
const REAL_BROWSER_STARTUP_TIMEOUT_MS = 60_000;
|
||||
const SCENARIO_SECRET_VALUE = "rea-browser-verifier-secret";
|
||||
const SCENARIO_URL_SECRET_VALUE = "rea-browser-url-verifier-secret";
|
||||
process.env.REA_BROWSER_VERIFIER_SECRET = SCENARIO_SECRET_VALUE;
|
||||
@@ -92,7 +94,7 @@ try {
|
||||
executable,
|
||||
activePortPath: join(profile, "DevToolsActivePort"),
|
||||
stderr: () => stderr,
|
||||
timeoutMs: REAL_BROWSER_STARTUP_TIMEOUT_MS,
|
||||
timeoutMs: BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
const endpoint = `http://127.0.0.1:${String(port)}`;
|
||||
const provider = new CdpBrowserProvider();
|
||||
|
||||
@@ -12,6 +12,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
|
||||
import {
|
||||
inspectMipsReadelf,
|
||||
inspectLegacyMipsReadelf,
|
||||
assertMipsLoadedImage,
|
||||
assertMipsProbe,
|
||||
assertMipsGlobal,
|
||||
@@ -122,6 +123,157 @@ async function verifySoftFloat(call, symbols, byteOrder) {
|
||||
const reports = [];
|
||||
let primaryFailure;
|
||||
try {
|
||||
if (!psp) {
|
||||
// Recordless legacy admission: ABI field 0, no ABI flags record, pinned
|
||||
// 32-bit language. Independent GNU readelf checks the header interpretation.
|
||||
for (const byteOrder of ["little", "big"]) {
|
||||
const fixture = await buildMipsFixture(workspace, byteOrder, "hard", {
|
||||
legacy: true,
|
||||
});
|
||||
const legacy = await inspectLegacyMipsReadelf(fixture.path, byteOrder);
|
||||
const resolved = await parseBinaryTarget(fixture.path);
|
||||
if (!resolved.ok) throw resolved.error;
|
||||
assert.equal(resolved.value.mips.flags, 0x50000001);
|
||||
assert.equal(resolved.value.mips.abiFlags, null);
|
||||
assert.deepEqual(
|
||||
resolved.value.mips.limitations ?? [],
|
||||
legacy.limitations,
|
||||
"REA legacy ABI-record absence interpretation disagrees with GNU readelf",
|
||||
);
|
||||
assert.equal(resolved.value.sha256, fixture.sha256);
|
||||
const snapshotPath = join(workspace, `${byteOrder}-legacy.snapshot.json`);
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client({
|
||||
name: "rea-real-mips-legacy-proof",
|
||||
version: "1",
|
||||
});
|
||||
let stderr = "";
|
||||
transport.stderr?.on("data", (chunk) => {
|
||||
stderr = (stderr + chunk.toString()).slice(-65536);
|
||||
});
|
||||
const call = async (name, args = {}) => {
|
||||
const reply = await client.callTool(
|
||||
{ name, arguments: args },
|
||||
undefined,
|
||||
{ timeout: 180000 },
|
||||
);
|
||||
return requireMcpOperationResult(reply, name);
|
||||
};
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const selected = await call("open_binary", {
|
||||
path: fixture.path,
|
||||
provider_id: "ghidra",
|
||||
});
|
||||
assert.equal(selected.architecture, "mips");
|
||||
assert.equal(selected.sha256, fixture.sha256);
|
||||
const image = await call("inspect_native_load_image");
|
||||
assertMipsLoadedImage(image.observations, byteOrder, fixture.sha256);
|
||||
// The pinned import language is the checked one, not an auto-selection.
|
||||
assert.equal(
|
||||
image.observations.language_id,
|
||||
`MIPS:${byteOrder === "little" ? "LE" : "BE"}:32:default`,
|
||||
);
|
||||
const procedures = await call("list_procedures");
|
||||
const entry = procedures.find((p) => p.value === "rea_mips_entry");
|
||||
assert.ok(entry, "Entry function missing from legacy inventory");
|
||||
assert.equal(
|
||||
BigInt(entry.address),
|
||||
BigInt(legacy.symbols.rea_mips_entry),
|
||||
);
|
||||
const probe = await call("inspect_native_instruction", {
|
||||
address: legacy.symbols.rea_mips_probe,
|
||||
});
|
||||
const probeBranch = await call("inspect_native_instruction", {
|
||||
address: `0x${(BigInt(legacy.symbols.rea_mips_probe) + 4n).toString(16)}`,
|
||||
});
|
||||
assertMipsProbe(
|
||||
probe,
|
||||
probeBranch,
|
||||
byteOrder,
|
||||
legacy.symbols.rea_mips_probe,
|
||||
);
|
||||
const dossier = functionDossierSchema.parse(
|
||||
await call("analyze_function", { procedure: entry.address }),
|
||||
);
|
||||
assert.ok(dossier.pseudocode.trim().length > 0);
|
||||
const saved = await call("close_binary", {
|
||||
snapshot_path: snapshotPath,
|
||||
});
|
||||
assert.ok(saved.evidence_records > 0);
|
||||
const snapshot = parseAnalysisSnapshot(
|
||||
JSON.parse(await readFile(snapshotPath, "utf8")),
|
||||
);
|
||||
assert.equal(snapshot.target.architecture, "mips");
|
||||
const parameters = snapshot.binding.analysis_profile.parameters;
|
||||
assert.equal(
|
||||
parameters.language_id,
|
||||
`MIPS:${byteOrder === "little" ? "LE" : "BE"}:32:default`,
|
||||
);
|
||||
assert.equal(parameters.language_selection, "provider-pinned-v1");
|
||||
assert.equal(
|
||||
parameters.mips_support_lane,
|
||||
"elf32-exec-arch32-legacy-pinned-v1",
|
||||
);
|
||||
assert.equal(parameters.mips_abi_basis, "inferred-o32-assumption-v1");
|
||||
assert.ok(
|
||||
snapshot.evidence_bundle.records.some((record) =>
|
||||
parseEvidence(record).limitations.some((item) =>
|
||||
item.includes("analysis assumption"),
|
||||
),
|
||||
),
|
||||
"Legacy o32 assumption limitation missing from retained Evidence",
|
||||
);
|
||||
assert.ok(
|
||||
snapshot.evidence_bundle.records.some((record) =>
|
||||
parseEvidence(record).limitations.some((item) =>
|
||||
item.includes("Floating-point ABI semantics are unverified"),
|
||||
),
|
||||
),
|
||||
"Legacy floating-point uncertainty missing from retained Evidence",
|
||||
);
|
||||
await call("open_binary", {
|
||||
path: fixture.path,
|
||||
provider_id: "ghidra",
|
||||
snapshot_path: snapshotPath,
|
||||
});
|
||||
assert.deepEqual(await call("list_procedures"), procedures);
|
||||
await call("close_binary");
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
`MIPS legacy ${byteOrder} verification failed; MCP stderr: ${stderr}`,
|
||||
{ cause },
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
await client.close();
|
||||
} finally {
|
||||
await transport.close();
|
||||
}
|
||||
}
|
||||
reports.push({
|
||||
...fixture,
|
||||
independent_reader: legacy,
|
||||
variant: "recordless-legacy-pinned",
|
||||
effective_language: `MIPS:${
|
||||
byteOrder === "little" ? "LE" : "BE"
|
||||
}:32:default`,
|
||||
procedures: "verified-through-snapshot",
|
||||
checks: [
|
||||
"recordless header admitted with inferred o32 assumption",
|
||||
"pinned 32-bit language checked after import",
|
||||
"legacy limitations retained in Evidence",
|
||||
"snapshot round-trip and profile parameters",
|
||||
],
|
||||
status: "passed",
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [byteOrder, floatAbi] of psp
|
||||
? [["little", "hard"]]
|
||||
: [
|
||||
|
||||
@@ -16,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";
|
||||
@@ -63,6 +68,7 @@ const literalAssembly = fileURLToPath(
|
||||
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,
|
||||
@@ -180,7 +186,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(
|
||||
@@ -277,30 +283,89 @@ async function verifyDecoderSafeguards() {
|
||||
join(scripts, "ReaSwiftDecoderRegressions.java"),
|
||||
);
|
||||
const report = join(root, "decoder-safeguards.txt");
|
||||
await execute(
|
||||
installation.analyzeHeadlessPath,
|
||||
[
|
||||
root,
|
||||
"decoder-regressions",
|
||||
"-import",
|
||||
literalBinary,
|
||||
"-scriptPath",
|
||||
scripts,
|
||||
"-postScript",
|
||||
"ReaSwiftDecoderRegressions.java",
|
||||
report,
|
||||
"-deleteProject",
|
||||
"-max-cpu",
|
||||
"2",
|
||||
],
|
||||
{
|
||||
env: { ...process.env, JAVA_HOME: installation.javaHome },
|
||||
timeout: 120_000,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
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;
|
||||
try {
|
||||
const 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)}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
process.off("SIGINT", interrupt);
|
||||
process.off("SIGTERM", interrupt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { isAbsolute } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { inspectSolidityMetadata } from "../dist/evm/SolidityMetadata.js";
|
||||
|
||||
const compilerModule = process.env.REA_VERIFY_SOLC_MODULE;
|
||||
if (!isAbsolute(compilerModule ?? ""))
|
||||
throw new Error(
|
||||
"Select an absolute caller-installed solc 0.8.30 module with REA_VERIFY_SOLC_MODULE.",
|
||||
);
|
||||
const solc = (await import(pathToFileURL(compilerModule).href)).default;
|
||||
assert.equal(solc.version(), "0.8.30+commit.73712a01.Emscripten.clang");
|
||||
const source = await readFile(
|
||||
new URL("./fixtures/evm-interface.sol", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const cases = [
|
||||
{ name: "unoptimized", optimizer: false, viaIR: false, metadata: {} },
|
||||
{ name: "viaIR", optimizer: true, viaIR: true, metadata: {} },
|
||||
{
|
||||
name: "without-content-hash",
|
||||
optimizer: false,
|
||||
viaIR: false,
|
||||
metadata: { bytecodeHash: "none" },
|
||||
},
|
||||
{
|
||||
name: "without-CBOR",
|
||||
optimizer: false,
|
||||
viaIR: false,
|
||||
metadata: { appendCBOR: false },
|
||||
},
|
||||
];
|
||||
const reports = [];
|
||||
for (const fixture of cases) {
|
||||
const compiled = JSON.parse(
|
||||
solc.compile(
|
||||
JSON.stringify({
|
||||
language: "Solidity",
|
||||
sources: { "fixture.sol": { content: source } },
|
||||
settings: {
|
||||
optimizer: { enabled: fixture.optimizer },
|
||||
viaIR: fixture.viaIR,
|
||||
metadata: fixture.metadata,
|
||||
outputSelection: { "*": { "*": ["evm.deployedBytecode.object"] } },
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
!(compiled.errors ?? []).some((error) => error.severity === "error"),
|
||||
JSON.stringify(compiled.errors),
|
||||
);
|
||||
const bytes = Buffer.from(
|
||||
compiled.contracts["fixture.sol"].InterfaceFixture.evm.deployedBytecode
|
||||
.object,
|
||||
"hex",
|
||||
);
|
||||
const cpu = process.cpuUsage();
|
||||
const started = performance.now();
|
||||
const parsed = inspectSolidityMetadata(bytes);
|
||||
const firstMs = performance.now() - started;
|
||||
const firstCpu = process.cpuUsage(cpu);
|
||||
if (fixture.metadata.appendCBOR === false)
|
||||
assert.equal(parsed.status, "unavailable");
|
||||
else {
|
||||
assert.equal(parsed.status, "decoded");
|
||||
assert.deepEqual(parsed.compiler_version, {
|
||||
representation: "release-bytes",
|
||||
value: "0.8.30",
|
||||
});
|
||||
assert.equal(parsed.bytecode_offset + parsed.cbor_bytes + 2, bytes.length);
|
||||
if (fixture.metadata.bytecodeHash === "none")
|
||||
assert.deepEqual(
|
||||
parsed.entries.map(({ key }) => key),
|
||||
[{ kind: "text", value: "solc" }],
|
||||
);
|
||||
}
|
||||
const warmResults = new Array(100);
|
||||
const warmStart = performance.now();
|
||||
for (let index = 0; index < warmResults.length; index++)
|
||||
warmResults[index] = inspectSolidityMetadata(bytes);
|
||||
const warmMs = performance.now() - warmStart;
|
||||
for (const result of warmResults) assert.deepEqual(result, parsed);
|
||||
reports.push({
|
||||
fixture: fixture.name,
|
||||
bytes: bytes.length,
|
||||
status: parsed.status,
|
||||
first_decode_ms: firstMs,
|
||||
first_decode_cpu_us: firstCpu,
|
||||
warm_100_decode_ms: warmMs,
|
||||
});
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
compiler_version: solc.version(),
|
||||
contract_execution: "not-performed",
|
||||
cases: reports,
|
||||
process_peak_rss_kib: process.resourceUsage().maxRSS,
|
||||
measurements:
|
||||
"In-process decode timings; peak RSS includes the compiler. End-to-end Linux provider/CLI costs and agent tokens were not measured.",
|
||||
}),
|
||||
);
|
||||
@@ -472,10 +472,9 @@ process.stdout.write(JSON.stringify({executable:basename(process.execPath),abiVe
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
assert.equal(String(ready).trim(), "ready");
|
||||
await assert.rejects(
|
||||
retryRuntime.close(),
|
||||
/owned cleanup object|Delete owned object/u,
|
||||
);
|
||||
// The reader denies delete sharing. Cleanup must retain ownership when
|
||||
// its handle-relative open receives ERROR_SHARING_VIOLATION (32).
|
||||
await assert.rejects(retryRuntime.close(), { win32Code: 32 });
|
||||
assert.equal(
|
||||
windowsPrivateRuntime(retryRuntime.observation.path),
|
||||
retryRuntime,
|
||||
|
||||
@@ -52,8 +52,10 @@ try {
|
||||
"bridge/ghidra/ReaGhidraBridge.java",
|
||||
"native/windows/build/manifest.json",
|
||||
"native/windows/build/rea-windows-x64.node",
|
||||
"dist/android/JadxWorkspaceRoot.js",
|
||||
"dist/main.js",
|
||||
"dist/cli.js",
|
||||
"scripts/rea-android-workspace-guardian.mjs",
|
||||
"scripts/rea.mjs",
|
||||
])
|
||||
if (!packagedPaths.has(required))
|
||||
@@ -108,6 +110,7 @@ try {
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
APPDATA: join(home, "AppData", "Roaming"),
|
||||
LOCALAPPDATA: join(home, "AppData", "Local"),
|
||||
CLAUDE_CONFIG_DIR: home,
|
||||
CODEX_HOME: join(home, ".codex"),
|
||||
XDG_CONFIG_HOME: join(home, ".config"),
|
||||
|
||||
@@ -173,6 +173,50 @@ try {
|
||||
assert.deepEqual(structured.normalized_result, evidence.normalized_result);
|
||||
console.log(`PASS MCP/CLI parity ${task.name}`);
|
||||
}
|
||||
const overloadedInput = {
|
||||
path,
|
||||
class_name: "androidx.activity.ComponentActivity",
|
||||
method_name: "setContentView",
|
||||
overload_index: 0,
|
||||
};
|
||||
const overloadedCli = await execute(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"trace-android-references",
|
||||
path,
|
||||
overloadedInput.class_name,
|
||||
"--method-name",
|
||||
overloadedInput.method_name,
|
||||
"--overload-index",
|
||||
"0",
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
{
|
||||
cwd: repository,
|
||||
env: environment,
|
||||
timeout: 150_000,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
const overloadedEvidence = parseEvidence(JSON.parse(overloadedCli.stdout));
|
||||
assert.equal(overloadedEvidence.normalized_result.overload_index, 0);
|
||||
assert.equal(
|
||||
overloadedEvidence.normalized_result.reported_signature,
|
||||
"setContentView(int): void",
|
||||
);
|
||||
assert.equal(overloadedEvidence.normalized_result.total, 1);
|
||||
const overloadedMcp = await client.callTool(
|
||||
{ name: "trace_android_references", arguments: overloadedInput },
|
||||
{ timeout: 150_000 },
|
||||
);
|
||||
assert.notEqual(overloadedMcp.isError, true, JSON.stringify(overloadedMcp));
|
||||
assert.deepEqual(
|
||||
parseEvidence(overloadedMcp.structuredContent).normalized_result,
|
||||
overloadedEvidence.normalized_result,
|
||||
);
|
||||
console.log("PASS real overloaded method references through CLI/MCP");
|
||||
} catch (cause) {
|
||||
process.stderr.write(stderr);
|
||||
throw cause;
|
||||
@@ -205,7 +249,7 @@ assert.equal(method.body_status, "available");
|
||||
assert.equal(method.source.status, "complete");
|
||||
assert.ok(method.source.text.includes("getIntent()"));
|
||||
assert.ok(method.source.text.includes("setListAdapter"));
|
||||
assert.equal(method.method.dex_descriptor, null);
|
||||
assert.equal(method.method.dex_descriptor, "(Landroid/os/Bundle;)V");
|
||||
const references = cliResults.get("trace_android_references").normalized_result;
|
||||
assert.ok(references.total > 0);
|
||||
assert.ok(
|
||||
@@ -264,3 +308,8 @@ if (process.env.REA_ANDROID_REPORT !== undefined)
|
||||
JSON.stringify(report, null, 2) + "\n",
|
||||
);
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
assert.notEqual(
|
||||
windowsLifecycle?.cli_abrupt_owner_exit_workspace_cleanup,
|
||||
false,
|
||||
"Android workspace remained after abrupt owner exit",
|
||||
);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { parseEvidence } from "../../../dist/domain/evidence.js";
|
||||
import { hashAndroidFile } from "./digest.mjs";
|
||||
|
||||
/** The public result keeps one using node and exposes its two raw DEX invokes. */
|
||||
export const verifyAndroidDirectInvokes = async ({
|
||||
apk,
|
||||
dexEntry,
|
||||
repository,
|
||||
entrypoint,
|
||||
environment,
|
||||
execute,
|
||||
}) => {
|
||||
const digest = await hashAndroidFile(apk);
|
||||
const check = (value) => {
|
||||
const evidence = parseEvidence(value);
|
||||
assert.equal(evidence.subject.digest.sha256, digest);
|
||||
assert.equal(evidence.operation, "trace_android_references");
|
||||
const result = evidence.normalized_result;
|
||||
assert.equal(result.class_name, "fixture.Target");
|
||||
assert.equal(result.method_name, "mark");
|
||||
assert.equal(result.dex_descriptor, "()V");
|
||||
assert.equal(result.reference_unit, "using_node");
|
||||
assert.equal(result.total, 1);
|
||||
assert.equal(result.references.length, 1);
|
||||
const source = result.references[0];
|
||||
assert.equal(source.reported_full_name, "fixture.Caller.twice");
|
||||
assert.deepEqual(
|
||||
source.direct_invokes.map((site) => site.dex_code_unit_offset),
|
||||
[0, 3],
|
||||
);
|
||||
for (const site of source.direct_invokes) {
|
||||
assert.equal(site.dex_entry, dexEntry);
|
||||
assert.equal(site.source_raw_method_id, "fixture.Caller.twice()V");
|
||||
assert.equal(site.opcode, "INVOKE_STATIC");
|
||||
}
|
||||
assert.equal(
|
||||
source.direct_invokes[0].dex_method_index,
|
||||
source.direct_invokes[1].dex_method_index,
|
||||
);
|
||||
return evidence;
|
||||
};
|
||||
const cliResponse = await execute(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"trace-android-references",
|
||||
apk,
|
||||
"fixture.Target",
|
||||
"--method-name",
|
||||
"mark",
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
{
|
||||
cwd: repository,
|
||||
env: environment,
|
||||
timeout: 150_000,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
const cli = check(JSON.parse(cliResponse.stdout));
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
cwd: repository,
|
||||
env: environment,
|
||||
stderr: "pipe",
|
||||
});
|
||||
let stderr = "";
|
||||
transport.stderr?.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
const client = new Client({
|
||||
name: "rea-real-android-direct-invokes",
|
||||
version: "1",
|
||||
});
|
||||
try {
|
||||
await client.connect(transport, { timeout: 30_000 });
|
||||
const response = await client.callTool(
|
||||
{
|
||||
name: "trace_android_references",
|
||||
arguments: {
|
||||
path: apk,
|
||||
class_name: "fixture.Target",
|
||||
method_name: "mark",
|
||||
},
|
||||
},
|
||||
{ timeout: 150_000 },
|
||||
);
|
||||
assert.notEqual(response.isError, true, JSON.stringify(response));
|
||||
const mcp = check(response.structuredContent);
|
||||
assert.deepEqual(mcp.normalized_result, cli.normalized_result);
|
||||
assert.deepEqual(mcp.parameters, cli.parameters);
|
||||
assert.deepEqual(mcp.provider, cli.provider);
|
||||
} catch (cause) {
|
||||
process.stderr.write(stderr);
|
||||
throw cause;
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
assert.equal(
|
||||
await hashAndroidFile(apk),
|
||||
digest,
|
||||
"Static inspection changed fixture bytes",
|
||||
);
|
||||
console.log(
|
||||
`PASS real CLI/MCP direct-invoke offsets from ${dexEntry}, parity and unchanged APK`,
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { delimiter, join, resolve } from "node:path";
|
||||
import { verifyAndroidInitializers } from "./initializers.mjs";
|
||||
import { verifyAndroidDirectInvokes } from "./direct-invokes.mjs";
|
||||
|
||||
/** Prove cold metadata stays unmaterialized and overload identities survive code generation. */
|
||||
export const verifyAndroidMetadata = async ({
|
||||
@@ -60,6 +61,7 @@ export const verifyAndroidMetadata = async ({
|
||||
apk,
|
||||
resolve(repository, "tests/fixtures/android/static-initializers"),
|
||||
classes,
|
||||
resolve(repository, "tests/fixtures/android/direct-invokes"),
|
||||
],
|
||||
{ timeout: 120_000, maxBuffer: 8 * 1024 * 1024 },
|
||||
);
|
||||
@@ -71,12 +73,19 @@ export const verifyAndroidMetadata = async ({
|
||||
result.stdout,
|
||||
/INITIALIZER_REGRESSION_PASS folded=2 bounded=true/u,
|
||||
);
|
||||
assert.match(
|
||||
result.stdout,
|
||||
/DIRECT_INVOKE_REGRESSION_PASS nodes=1 offsets=0,3 cross_dex=true/u,
|
||||
);
|
||||
console.log(
|
||||
"PASS real cold metadata, full generic types and stable overloaded method selection",
|
||||
);
|
||||
console.log(
|
||||
"PASS real folded and mixed static initializers, absent bodies and bounded smali",
|
||||
);
|
||||
console.log(
|
||||
"PASS real raw DEX direct-invoke offsets and cross-DEX source identity",
|
||||
);
|
||||
await verifyAndroidInitializers({
|
||||
apk: resolve(classes, "initializers.apk"),
|
||||
repository,
|
||||
@@ -84,6 +93,22 @@ export const verifyAndroidMetadata = async ({
|
||||
environment,
|
||||
execute,
|
||||
});
|
||||
await verifyAndroidDirectInvokes({
|
||||
apk: resolve(classes, "direct-invokes.apk"),
|
||||
dexEntry: "classes.dex",
|
||||
repository,
|
||||
entrypoint,
|
||||
environment,
|
||||
execute,
|
||||
});
|
||||
await verifyAndroidDirectInvokes({
|
||||
apk: resolve(classes, "direct-invokes-multidex.apk"),
|
||||
dexEntry: "classes2.dex",
|
||||
repository,
|
||||
entrypoint,
|
||||
environment,
|
||||
execute,
|
||||
});
|
||||
} finally {
|
||||
await rm(classes, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { stat, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createJadxWorkspaceRoot } from "../../../dist/android/JadxWorkspaceRoot.js";
|
||||
|
||||
const source = fileURLToPath(import.meta.url);
|
||||
const marker = "REA synthetic guardian isolation test\n";
|
||||
const exists = (path) =>
|
||||
stat(path).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
|
||||
if (process.argv[1] === source && process.argv[2] === "owner") {
|
||||
const root = await createJadxWorkspaceRoot();
|
||||
await writeFile(join(root.path, "synthetic.txt"), marker);
|
||||
process.stdout.write(JSON.stringify({ path: root.path }) + "\n");
|
||||
await new Promise(() => {});
|
||||
}
|
||||
|
||||
const bounded = async (promise, milliseconds, label) => {
|
||||
let timer;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(label)), milliseconds);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
const launch = async () => {
|
||||
const child = spawn(process.execPath, [source, "owner"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
});
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr = (stderr + chunk.toString()).slice(-8192);
|
||||
});
|
||||
const exit = new Promise((resolve, reject) => {
|
||||
child.once("close", resolve);
|
||||
child.once("error", reject);
|
||||
});
|
||||
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
||||
try {
|
||||
const ready = await bounded(
|
||||
lines[Symbol.asyncIterator]().next(),
|
||||
20000,
|
||||
`Android guardian owner readiness timed out: ${stderr}`,
|
||||
);
|
||||
assert.equal(ready.done, false, stderr);
|
||||
const { path } = JSON.parse(ready.value);
|
||||
assert.equal(typeof path, "string");
|
||||
assert.equal(await exists(join(path, "synthetic.txt")), true);
|
||||
return { child, exit, path };
|
||||
} catch (cause) {
|
||||
child.kill();
|
||||
await bounded(exit, 15000, "Android guardian owner did not exit");
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
const stop = async ({ child, exit, path }) => {
|
||||
assert.equal(child.kill(), true);
|
||||
await bounded(exit, 15000, "Android guardian owner did not exit");
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (!(await exists(path))) return;
|
||||
await delay(100);
|
||||
}
|
||||
throw new Error(`Android guardian retained ${path} after owner death`);
|
||||
};
|
||||
|
||||
/** Two independent owners must never delete one another's private workspace. */
|
||||
export async function verifyWindowsGuardianIsolation() {
|
||||
assert.equal(process.platform, "win32");
|
||||
const first = await launch();
|
||||
let second;
|
||||
try {
|
||||
second = await launch();
|
||||
assert.notEqual(first.path, second.path);
|
||||
await stop(first);
|
||||
assert.equal(
|
||||
await exists(join(second.path, "synthetic.txt")),
|
||||
true,
|
||||
"First owner deleted the second owner's workspace",
|
||||
);
|
||||
await stop(second);
|
||||
console.log("PASS Windows Android guardian two-owner cleanup isolation");
|
||||
} finally {
|
||||
for (const owner of [first, second]) {
|
||||
if (owner === undefined) continue;
|
||||
if (owner.child.exitCode === null && owner.child.signalCode === null)
|
||||
owner.child.kill();
|
||||
await bounded(owner.exit, 15000, "Android guardian owner did not exit");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
resolveJadxConfigurationInputs,
|
||||
validateJadxConfiguration,
|
||||
} from "../../../dist/android/JadxConfiguration.js";
|
||||
import { verifyWindowsGuardianIsolation } from "./windows-guardian-isolation.mjs";
|
||||
|
||||
/** Verify real Windows cancellation, disconnect and abrupt CLI owner exit. */
|
||||
export async function verifyWindowsAndroidLifecycle(options) {
|
||||
@@ -24,15 +25,16 @@ export async function verifyWindowsAndroidLifecycle(options) {
|
||||
await verifyJavaSelection(options.environment);
|
||||
const cancellation = await verifyMcp(options, "cancel");
|
||||
await verifyMcp(options, "disconnect");
|
||||
await verifyCliOwnerExit(options);
|
||||
const abruptWorkspaceRemoved = await verifyCliOwnerExit(options);
|
||||
await verifyWindowsGuardianIsolation();
|
||||
return {
|
||||
mcp_cancelled_engine_exited: true,
|
||||
mcp_cancelled_workspace_removed: true,
|
||||
mcp_ping_and_next_call: true,
|
||||
mcp_disconnect_engine_exited: true,
|
||||
cli_abrupt_owner_exit_engine_exited: true,
|
||||
// A forced Windows process exit cannot execute JavaScript workspace cleanup.
|
||||
cli_abrupt_owner_exit_workspace_cleanup: false,
|
||||
cli_abrupt_owner_exit_workspace_cleanup: abruptWorkspaceRemoved,
|
||||
guardian_two_owner_isolation: true,
|
||||
observed_resources: cancellation,
|
||||
java_home_and_quoted_case_insensitive_path: true,
|
||||
};
|
||||
@@ -161,6 +163,7 @@ async function verifyCliOwnerExit({
|
||||
path,
|
||||
repository,
|
||||
}) {
|
||||
const baseline = new Set(await runtimeRoots());
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[entrypoint, "inspect-android-package", path, "--format", "json"],
|
||||
@@ -185,7 +188,40 @@ async function verifyCliOwnerExit({
|
||||
await deadline(exited, 15000);
|
||||
await observer.verifyExit();
|
||||
assert.equal(output, "", "Aborted CLI published successful Evidence");
|
||||
console.log("PASS Windows CLI abrupt owner exit: real Java job cleanup");
|
||||
const created = (await runtimeRoots()).filter(
|
||||
(name) => !baseline.has(name),
|
||||
);
|
||||
assert.ok(
|
||||
created.length <= 1,
|
||||
"Concurrent Android workspaces make abrupt cleanup observation ambiguous",
|
||||
);
|
||||
if (created.length === 0) {
|
||||
console.log(
|
||||
"PASS Windows CLI abrupt owner exit: real Java job and workspace cleanup",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
const ownedName = created[0];
|
||||
try {
|
||||
await waitFor(
|
||||
async () => !(await runtimeRoots()).includes(ownedName),
|
||||
5000,
|
||||
"Android workspace remained after abrupt owner exit",
|
||||
);
|
||||
} catch (cause) {
|
||||
if (
|
||||
!(cause instanceof Error) ||
|
||||
cause.message !== "Android workspace remained after abrupt owner exit"
|
||||
)
|
||||
throw cause;
|
||||
}
|
||||
const removed = !(await runtimeRoots()).includes(ownedName);
|
||||
console.log(
|
||||
removed
|
||||
? "PASS Windows CLI abrupt owner exit: real Java job and workspace cleanup"
|
||||
: "OBSERVED Windows CLI abrupt owner exit: Java job cleaned, workspace residue remains",
|
||||
);
|
||||
return removed;
|
||||
} finally {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill();
|
||||
await deadline(exited, 15000);
|
||||
|
||||
+18
-3
@@ -25,11 +25,27 @@ LITERALS = {"REA", "Reverse Engineer Anything", "REA · Reverse Engineer Anythin
|
||||
FALLBACK_NOTICE = "Translation is being updated. Read this page in English for now."
|
||||
|
||||
|
||||
def readme_edition_paths(root):
|
||||
"""Return repo-relative README editions: root English plus docs/readme translations."""
|
||||
editions = set()
|
||||
if (root / "README.md").is_file():
|
||||
editions.add("README.md")
|
||||
folder = root / "docs" / "readme"
|
||||
if folder.is_dir():
|
||||
editions.update(
|
||||
f"docs/readme/{path.name}"
|
||||
for path in folder.glob("README_*.md")
|
||||
if path.is_file()
|
||||
)
|
||||
return editions
|
||||
|
||||
|
||||
def languages(root):
|
||||
"""Load the website language inventory and check it against README editions."""
|
||||
path = root / "website/locales/languages.json"
|
||||
editions = readme_edition_paths(root)
|
||||
if not path.exists():
|
||||
if any(root.glob("README_*.md")):
|
||||
if any(edition != "README.md" for edition in editions):
|
||||
raise ValueError("Missing languages.json for the translated README editions")
|
||||
return [{"code": "en", "name": "English", "readme": "README.md", "dir": "ltr"}]
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
@@ -49,8 +65,7 @@ def languages(root):
|
||||
raise ValueError(f"Duplicate README edition: {item['readme']}")
|
||||
codes.add(item["code"])
|
||||
filenames.add(item["readme"])
|
||||
readmes = {path.name for path in root.glob("README*.md")}
|
||||
if readmes and ({item["readme"] for item in data} != readmes):
|
||||
if editions and ({item["readme"] for item in data} != editions):
|
||||
raise ValueError("Website languages must match the README language editions")
|
||||
return data
|
||||
|
||||
|
||||
Reference in New Issue
Block a user