Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d4dce1263 | ||
|
|
1e034a80fe | ||
|
|
e28762a5c7 | ||
|
|
c1f1570a0c | ||
|
|
bcc3ad6014 | ||
|
|
258ecd361b | ||
|
|
6d8f95e38d | ||
|
|
c1b5ccf0a4 | ||
|
|
5f30ea6ff2 | ||
|
|
d438675677 | ||
|
|
a84dea5500 | ||
|
|
2a66ec9094 | ||
|
|
d1235fadc9 | ||
|
|
a01148553e | ||
|
|
a6f76f5da9 | ||
|
|
2ca3ad2386 | ||
|
|
5d0a883605 | ||
|
|
0f0d6e4d43 | ||
|
|
6d4cefc4f7 | ||
|
|
2a0b58e440 |
@@ -25,6 +25,12 @@ extraction have independent prerequisites; one does not require the other.
|
|||||||
Both require util-linux `prlimit`, normally `/usr/bin/prlimit`; override its
|
Both require util-linux `prlimit`, normally `/usr/bin/prlimit`; override its
|
||||||
absolute path with `REA_FIRMWARE_PRLIMIT_COMMAND` if needed.
|
absolute path with `REA_FIRMWARE_PRLIMIT_COMMAND` if needed.
|
||||||
|
|
||||||
|
REA retains at most 8 MiB of combined stdout and stderr per command. Exceeding
|
||||||
|
that diagnostic budget stops analysis with an explicit truncated-output error;
|
||||||
|
child process limits do not bound parent-side pipe retention. Completed commands
|
||||||
|
drain their output streams before recording diagnostics. Failed worker cleanup
|
||||||
|
retains the worker and workspace for retry before another request or at shutdown.
|
||||||
|
|
||||||
Unblob needs the external extractor for the selected format on its inherited
|
Unblob needs the external extractor for the selected format on its inherited
|
||||||
`PATH`: for example, `debugfs` for ext2/ext3/ext4. Its default Landlock sandbox
|
`PATH`: for example, `debugfs` for ext2/ext3/ext4. Its default Landlock sandbox
|
||||||
and skip rules stay enabled. Sandbox startup failures remain actionable errors;
|
and skip rules stay enabled. Sandbox startup failures remain actionable errors;
|
||||||
|
|||||||
@@ -38,6 +38,20 @@ Inspect separately obtained components explicitly and preserve their identities.
|
|||||||
A valid implementation marker establishes a candidate native boundary, not
|
A valid implementation marker establishes a candidate native boundary, not
|
||||||
recovered native semantics or runtime behavior.
|
recovered native semantics or runtime behavior.
|
||||||
|
|
||||||
|
### Resource admission
|
||||||
|
|
||||||
|
The static provider admits regular input files up to 128 MiB before retaining
|
||||||
|
their bytes. Reads remain bounded if a file grows after admission. Metadata,
|
||||||
|
CIL decoding, and projected inspection records have a separate conservative
|
||||||
|
64 MiB representation budget; shared heap strings are cached, but repeated
|
||||||
|
output occurrences still consume that budget. These are inspection resource
|
||||||
|
policies, not PE format limits or a hard bound on total V8 heap use.
|
||||||
|
|
||||||
|
Input overflow returns a resource constraint. Decode or projection overflow
|
||||||
|
reports `resource-limit` with unavailable or partial coverage; omitted facts do
|
||||||
|
not become empty observations. Ambiguous PE section mappings are rejected
|
||||||
|
rather than interpreted according to section order.
|
||||||
|
|
||||||
## Analysis objective
|
## Analysis objective
|
||||||
|
|
||||||
REA's managed-code track is intended to answer five different questions without
|
REA's managed-code track is intended to answer five different questions without
|
||||||
|
|||||||
@@ -187,6 +187,10 @@ Every capture requires `truncation_details`, with separate accounting for:
|
|||||||
input, so raw omissions also limit rendered coverage.
|
input, so raw omissions also limit rendered coverage.
|
||||||
- `filesystem_before` and `filesystem_after`: file-count/depth limits,
|
- `filesystem_before` and `filesystem_after`: file-count/depth limits,
|
||||||
enumeration failures, whole-file hash budget and bytes successfully hashed.
|
enumeration failures, whole-file hash budget and bytes successfully hashed.
|
||||||
|
Directory names are read incrementally within the remaining entry capacity.
|
||||||
|
When a directory exceeds that capacity, the retained subset follows filesystem
|
||||||
|
enumeration order and is then sorted; it is explicitly incomplete and cannot
|
||||||
|
establish that an omitted path is absent.
|
||||||
Each retained regular file without a digest has an aliased path, size,
|
Each retained regular file without a digest has an aliased path, size,
|
||||||
remaining budget and reason: `file_bytes_budget`,
|
remaining budget and reason: `file_bytes_budget`,
|
||||||
`file_changed_or_short_read`, or `file_unavailable`. Its `system_code` is
|
`file_changed_or_short_read`, or `file_unavailable`. Its `system_code` is
|
||||||
|
|||||||
@@ -187,6 +187,30 @@ The loopback OpenAI completions/SSE custom model declares a one-million-token
|
|||||||
context and synthetic usage. Live xAI APIs, native Windows and default OS-home
|
context and synthetic usage. Live xAI APIs, native Windows and default OS-home
|
||||||
skill discovery remain unverified.
|
skill discovery remain unverified.
|
||||||
|
|
||||||
|
`verify:commandcode-client` requires installed Command Code (verified with npm
|
||||||
|
1.79.2); select it with `REA_VERIFY_COMMANDCODE_COMMAND`. Auth, MCP tokens and
|
||||||
|
sessions use the actual OS home, so provision a disposable POSIX account rather
|
||||||
|
than overriding `HOME`. Set `REA_VERIFY_COMMANDCODE_ACCOUNT_HOME` to that account's
|
||||||
|
actual home and create `.rea-client-verification` there with exactly
|
||||||
|
`Disposable REA client verification account` followed by a newline. The lane
|
||||||
|
owns that account's `.commandcode` configuration and shared REA skill. Build the
|
||||||
|
runtime as the checkout owner, then run `node scripts/verify-commandcode-client.mjs`
|
||||||
|
as the disposable account; the npm shortcut also needs a writable checkout.
|
||||||
|
The verifier does not create accounts or install clients. It guards setup
|
||||||
|
targets, preserves an unrelated registration and backups, checks idempotence,
|
||||||
|
and verifies native default shared-skill discovery and complete activation.
|
||||||
|
Default deferred-schema delivery stays enabled: the native prompt advertises
|
||||||
|
every REA tool, exact-name `search_tools` returns every input schema, and a native
|
||||||
|
shell query recovers the saved catalog's count and digest when the client spills
|
||||||
|
it. Actual analysis of a Unicode-path fixture delivers full named-schema Evidence
|
||||||
|
to the next model request. Use `-- chat` for ordinary chat or
|
||||||
|
`REA_VERIFY_RUNTIME_ROOT` for a production-only installed package. The loopback
|
||||||
|
OpenAI completions/SSE model is a keyless BYOK endpoint declaring a million-token
|
||||||
|
context with synthetic usage. A synthetic account-key value satisfies the
|
||||||
|
client's print-mode gate; native local-only mode refuses hosted API calls.
|
||||||
|
Updates, telemetry and cron are disabled. Hosted authentication, live models
|
||||||
|
and Windows remain unverified.
|
||||||
|
|
||||||
`verify:omp-client` requires installed OMP (verified with the official Linux x64
|
`verify:omp-client` requires installed OMP (verified with the official Linux x64
|
||||||
18.8.7 binary); select it with `REA_VERIFY_OMP_COMMAND`. This optional POSIX lane
|
18.8.7 binary); select it with `REA_VERIFY_OMP_COMMAND`. This optional POSIX lane
|
||||||
isolates the default agent, global config and XDG roots, guards setup targets,
|
isolates the default agent, global config and XDG roots, guards setup targets,
|
||||||
|
|||||||
@@ -188,6 +188,7 @@
|
|||||||
"verify:gemini-client": "npm run build:cached && node scripts/verify-gemini-client.mjs",
|
"verify:gemini-client": "npm run build:cached && node scripts/verify-gemini-client.mjs",
|
||||||
"verify:opencode-client": "npm run build:cached && node scripts/verify-opencode-client.mjs",
|
"verify:opencode-client": "npm run build:cached && node scripts/verify-opencode-client.mjs",
|
||||||
"verify:grok-client": "npm run build:cached && node scripts/verify-grok-client.mjs",
|
"verify:grok-client": "npm run build:cached && node scripts/verify-grok-client.mjs",
|
||||||
|
"verify:commandcode-client": "npm run build:cached && node scripts/verify-commandcode-client.mjs",
|
||||||
"verify:omp-client": "npm run build:cached && node scripts/verify-omp-client.mjs",
|
"verify:omp-client": "npm run build:cached && node scripts/verify-omp-client.mjs",
|
||||||
"verify:copilot-client": "npm run build:cached && node scripts/verify-copilot-client.mjs",
|
"verify:copilot-client": "npm run build:cached && node scripts/verify-copilot-client.mjs",
|
||||||
"verify:claude-client": "npm run build:cached && node scripts/verify-claude-client.mjs",
|
"verify:claude-client": "npm run build:cached && node scripts/verify-claude-client.mjs",
|
||||||
|
|||||||
@@ -0,0 +1,452 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { isAbsolute, join, relative } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import Ajv2020 from "ajv/dist/2020.js";
|
||||||
|
import {
|
||||||
|
TOOL_CONTRACTS,
|
||||||
|
toolContract,
|
||||||
|
} from "../dist/contracts/toolContracts.js";
|
||||||
|
import { exec } from "./lib/verify-package-core.mjs";
|
||||||
|
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
||||||
|
import { createOpenAiModelFixture } from "./verify/clients/model-fixture.mjs";
|
||||||
|
|
||||||
|
assert(
|
||||||
|
process.platform !== "win32",
|
||||||
|
"This verifier requires a disposable POSIX account; Windows is unverified",
|
||||||
|
);
|
||||||
|
const account = process.env.REA_VERIFY_COMMANDCODE_ACCOUNT_HOME;
|
||||||
|
assert(
|
||||||
|
account && isAbsolute(account) && account === homedir(),
|
||||||
|
"Run as a disposable account whose actual OS home matches REA_VERIFY_COMMANDCODE_ACCOUNT_HOME; do not override HOME",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await readFile(join(account, ".rea-client-verification"), "utf8"),
|
||||||
|
"Disposable REA client verification account\n",
|
||||||
|
);
|
||||||
|
const repo = fileURLToPath(new URL("..", import.meta.url));
|
||||||
|
const runtimeRoot = process.env.REA_VERIFY_RUNTIME_ROOT ?? repo;
|
||||||
|
const command = process.env.REA_VERIFY_COMMANDCODE_COMMAND ?? "cmd";
|
||||||
|
const mode = process.argv[2] ?? "call";
|
||||||
|
assert(
|
||||||
|
["chat", "call"].includes(mode),
|
||||||
|
"Usage: verify-commandcode-client.mjs [chat|call]",
|
||||||
|
);
|
||||||
|
const verifier = createVerifierRun();
|
||||||
|
const lab = await mkdtemp(join(account, "rea-commandcode-client-"));
|
||||||
|
const workspace = join(lab, "workspace");
|
||||||
|
const target = join(workspace, "fixture α测试");
|
||||||
|
const profile = join(account, ".commandcode");
|
||||||
|
const configPath = join(profile, "mcp.json");
|
||||||
|
const skillDirectory = join(
|
||||||
|
account,
|
||||||
|
".agents",
|
||||||
|
"skills",
|
||||||
|
"reverse-engineer-anything",
|
||||||
|
);
|
||||||
|
await Promise.all([
|
||||||
|
mkdir(target, { recursive: true }),
|
||||||
|
mkdir(profile, { recursive: true }),
|
||||||
|
mkdir(join(lab, "tmp")),
|
||||||
|
]);
|
||||||
|
await exec("git", ["init", "--quiet", workspace], { timeout: 30_000 });
|
||||||
|
await writeFile(
|
||||||
|
join(target, "app.js"),
|
||||||
|
'export function result() { return { clientCompatibility: "REA_CLIENT_FIXTURE", count: 7 }; }\n',
|
||||||
|
);
|
||||||
|
const environment = {
|
||||||
|
PATH: process.env.PATH,
|
||||||
|
NODE_OPTIONS: process.env.NODE_OPTIONS,
|
||||||
|
USERPROFILE: account,
|
||||||
|
TMPDIR: join(lab, "tmp"),
|
||||||
|
CMD_LOCAL_ONLY: "1",
|
||||||
|
// Native print mode checks for an account key even with a keyless BYOK model.
|
||||||
|
// This synthetic value is never a real credential; local-only refuses hosted API calls.
|
||||||
|
COMMAND_CODE_API_KEY: "local-fixture-only",
|
||||||
|
COMMANDCODE_SKIP_UPDATES: "1",
|
||||||
|
COMMANDCODE_DISABLE_CRON: "1",
|
||||||
|
OTEL_SDK_DISABLED: "true",
|
||||||
|
REA_PROCESS_RUN_ID: verifier.run_id,
|
||||||
|
};
|
||||||
|
const expectedNames = TOOL_CONTRACTS.map(
|
||||||
|
(tool) => "mcp__rea__" + tool.name,
|
||||||
|
).sort();
|
||||||
|
const schemaValidator = new Ajv2020({ strict: false, validateFormats: false });
|
||||||
|
const textValues = (value) =>
|
||||||
|
typeof value === "string"
|
||||||
|
? [value]
|
||||||
|
: value !== null && typeof value === "object"
|
||||||
|
? Object.values(value).flatMap(textValues)
|
||||||
|
: [];
|
||||||
|
let clientVersion;
|
||||||
|
let expectedSkill;
|
||||||
|
let inventoryChecked = false;
|
||||||
|
let skillLoaded = false;
|
||||||
|
let schemasChecked = 0;
|
||||||
|
let catalogArtifact;
|
||||||
|
let catalogDigest;
|
||||||
|
let catalogProjection;
|
||||||
|
let artifactProjectionSeen = false;
|
||||||
|
let evidenceSeen = false;
|
||||||
|
let evidenceBytes;
|
||||||
|
let finalSeen = false;
|
||||||
|
let stage = 0;
|
||||||
|
let failure;
|
||||||
|
const call = (name, args) => ({
|
||||||
|
role: "assistant",
|
||||||
|
content: null,
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
id: `call_commandcode_${++stage}`,
|
||||||
|
type: "function",
|
||||||
|
function: { name, arguments: JSON.stringify(args) },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const validateCatalog = async (text) => {
|
||||||
|
const entries = [
|
||||||
|
...text.matchAll(
|
||||||
|
/^### (mcp__rea__[A-Za-z0-9_]+)\n[\s\S]*?\nParameters:\n```json\n([\s\S]*?)\n```/gm,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
assert.deepEqual(
|
||||||
|
entries.map((entry) => entry[1]).sort(),
|
||||||
|
expectedNames,
|
||||||
|
"Native exact-name search must return every REA input schema",
|
||||||
|
);
|
||||||
|
for (const entry of entries) {
|
||||||
|
const schema = JSON.parse(entry[2]);
|
||||||
|
assert(
|
||||||
|
schemaValidator.validateSchema(schema),
|
||||||
|
`${entry[1]}: invalid native schema: ${JSON.stringify(schemaValidator.errors)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
schemasChecked = entries.length;
|
||||||
|
catalogDigest = createHash("sha256").update(text).digest("hex");
|
||||||
|
await writeFile(join(lab, "native-catalog.md"), text);
|
||||||
|
};
|
||||||
|
const fixture = await createOpenAiModelFixture({
|
||||||
|
directory: lab,
|
||||||
|
prefix: "commandcode",
|
||||||
|
onRequest: async ({ body, tools }) => {
|
||||||
|
assert(tools.some((tool) => tool.name === "search_tools"));
|
||||||
|
const strings = textValues(body.messages);
|
||||||
|
if (!inventoryChecked) {
|
||||||
|
const names = [
|
||||||
|
...new Set(
|
||||||
|
strings.flatMap(
|
||||||
|
(text) => text.match(/mcp__rea__[A-Za-z0-9_]+/g) ?? [],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
].sort();
|
||||||
|
assert.deepEqual(
|
||||||
|
names,
|
||||||
|
expectedNames,
|
||||||
|
"Default native deferred-tool prompt must advertise the complete REA inventory",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
tools.filter((tool) => tool.name.startsWith("mcp__rea__")).length,
|
||||||
|
0,
|
||||||
|
"Keep native lazy schema delivery enabled",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
tools
|
||||||
|
.find((tool) => tool.name === "activate_skill")
|
||||||
|
?.parameters.properties.name.enum.includes(
|
||||||
|
"reverse-engineer-anything",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
inventoryChecked = true;
|
||||||
|
}
|
||||||
|
if (mode === "chat") {
|
||||||
|
assert.equal(
|
||||||
|
body.messages.filter((message) => message.role === "tool").length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
finalSeen = true;
|
||||||
|
return { role: "assistant", content: "REA_CLIENT_COMPATIBILITY_OK" };
|
||||||
|
}
|
||||||
|
if (stage === 0)
|
||||||
|
return call("activate_skill", { name: "reverse-engineer-anything" });
|
||||||
|
assert(
|
||||||
|
strings.some((text) => text.includes(expectedSkill)),
|
||||||
|
"The model must receive the complete activated skill body",
|
||||||
|
);
|
||||||
|
skillLoaded = true;
|
||||||
|
const last = body.messages
|
||||||
|
.filter((message) => message.role === "tool")
|
||||||
|
.at(-1)?.content;
|
||||||
|
assert.equal(typeof last, "string");
|
||||||
|
if (stage === 1)
|
||||||
|
return call("search_tools", {
|
||||||
|
query: "select:" + expectedNames.join(","),
|
||||||
|
});
|
||||||
|
if (stage === 2) {
|
||||||
|
const spill = last.match(
|
||||||
|
/\[full output saved to: (.+?) — read it with read_file/,
|
||||||
|
);
|
||||||
|
if (spill) {
|
||||||
|
catalogArtifact = spill[1];
|
||||||
|
const child = relative(environment.TMPDIR, catalogArtifact);
|
||||||
|
assert(
|
||||||
|
isAbsolute(catalogArtifact) &&
|
||||||
|
child &&
|
||||||
|
!child.startsWith("..") &&
|
||||||
|
!isAbsolute(child),
|
||||||
|
"Native spill must remain inside this run's temporary directory",
|
||||||
|
);
|
||||||
|
const text = await readFile(catalogArtifact, "utf8");
|
||||||
|
await validateCatalog(text);
|
||||||
|
catalogProjection = { schemas: schemasChecked, sha256: catalogDigest };
|
||||||
|
const query =
|
||||||
|
"const fs=require('node:fs'),crypto=require('node:crypto');const text=fs.readFileSync(process.argv[1],'utf8');const schemas=[...text.matchAll(/^### (mcp__rea__[A-Za-z0-9_]+)\\n/gm)].length;console.log(JSON.stringify({rea_catalog:{schemas,sha256:crypto.createHash('sha256').update(text).digest('hex')}}));";
|
||||||
|
return call("shell_command", {
|
||||||
|
command: process.execPath,
|
||||||
|
args: ["-e", query, catalogArtifact],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await validateCatalog(last);
|
||||||
|
return call("mcp__rea__analyze_javascript_application", {
|
||||||
|
input_path: target,
|
||||||
|
format: "directory",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (catalogArtifact && !artifactProjectionSeen) {
|
||||||
|
const line = last
|
||||||
|
.split("\n")
|
||||||
|
.find((item) => item.startsWith('{"rea_catalog":'));
|
||||||
|
assert(line, "Native shell must return the selected saved-catalog facts");
|
||||||
|
assert.deepEqual(JSON.parse(line).rea_catalog, catalogProjection);
|
||||||
|
artifactProjectionSeen = true;
|
||||||
|
return call("mcp__rea__analyze_javascript_application", {
|
||||||
|
input_path: target,
|
||||||
|
format: "directory",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const evidence = toolContract(
|
||||||
|
"analyze_javascript_application",
|
||||||
|
).outputSchema.parse(JSON.parse(last));
|
||||||
|
assert.equal(evidence.subject.local_path, target);
|
||||||
|
const shapes = evidence.normalized_result.graph.nodes
|
||||||
|
.flatMap((node) => node.observations)
|
||||||
|
.map((observation) => observation.properties)
|
||||||
|
.filter(
|
||||||
|
(properties) =>
|
||||||
|
properties.semantic_role === "export-return-shapes" &&
|
||||||
|
properties.exported_name === "result",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
shapes.length > 0 &&
|
||||||
|
shapes.every(
|
||||||
|
(shape) => shape.return_shape_coverage?.projection_complete,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const fields = shapes.flatMap((shape) =>
|
||||||
|
shape.static_return_shapes.flatMap((item) => item.fields),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
fields.some(
|
||||||
|
(field) =>
|
||||||
|
field.path === "/clientCompatibility" &&
|
||||||
|
field.state === "literal" &&
|
||||||
|
field.value === "REA_CLIENT_FIXTURE",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
fields.some(
|
||||||
|
(field) =>
|
||||||
|
field.path === "/count" &&
|
||||||
|
field.state === "literal" &&
|
||||||
|
field.value === 7,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
evidenceBytes = Buffer.byteLength(last);
|
||||||
|
await writeFile(join(lab, "native-evidence.json"), last);
|
||||||
|
evidenceSeen = true;
|
||||||
|
finalSeen = true;
|
||||||
|
return { role: "assistant", content: "REA_CLIENT_COMPATIBILITY_OK" };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await writeFile(
|
||||||
|
join(profile, "providers.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
provider: {
|
||||||
|
"rea-local": {
|
||||||
|
baseURL: fixture.baseUrl,
|
||||||
|
apiKey: false,
|
||||||
|
models: {
|
||||||
|
"rea-client-fixture": { contextWindow: 1_000_000, maxOutput: 4096 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const original =
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
mcpServers: { other: { command: "unrelated-server", enabled: false } },
|
||||||
|
preserved: "keep unrelated settings",
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
) + "\n";
|
||||||
|
await writeFile(configPath, original);
|
||||||
|
const rea = (args) =>
|
||||||
|
exec(
|
||||||
|
process.execPath,
|
||||||
|
[join(runtimeRoot, "scripts/rea.mjs"), ...args, "--json"],
|
||||||
|
{ env: environment, cwd: workspace, timeout: 60_000 },
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
clientVersion = (
|
||||||
|
await exec(command, ["--version"], {
|
||||||
|
env: environment,
|
||||||
|
cwd: workspace,
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
).stdout.trim();
|
||||||
|
for (const args of [
|
||||||
|
["setup", "--client", "commandcode", "--dry-run"],
|
||||||
|
["setup", "--client", "commandcode", "--yes"],
|
||||||
|
["doctor", "--client", "commandcode", "--skill"],
|
||||||
|
]) {
|
||||||
|
const response = await rea(args);
|
||||||
|
await writeFile(
|
||||||
|
join(lab, `${args[0]}${args.includes("--dry-run") ? "-plan" : ""}.json`),
|
||||||
|
response.stdout,
|
||||||
|
);
|
||||||
|
const result = JSON.parse(response.stdout);
|
||||||
|
if (args[0] === "doctor") assert.equal(result.healthy, true);
|
||||||
|
else
|
||||||
|
assert.equal(
|
||||||
|
result.status,
|
||||||
|
args.includes("--dry-run") ? "planned" : "ready",
|
||||||
|
);
|
||||||
|
if (args.includes("--dry-run")) {
|
||||||
|
assert(
|
||||||
|
result.plannedActions.some((action) => action.target === configPath),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
result.plannedActions.every(
|
||||||
|
(action) =>
|
||||||
|
action.target === configPath ||
|
||||||
|
action.target === skillDirectory ||
|
||||||
|
action.target.startsWith(skillDirectory + "/"),
|
||||||
|
),
|
||||||
|
"Refuse setup writes outside the disposable account's configuration and skill",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.equal(await readFile(`${configPath}.rea.backup`, "utf8"), original);
|
||||||
|
const configured = await readFile(configPath, "utf8");
|
||||||
|
assert.equal(JSON.parse(configured).preserved, "keep unrelated settings");
|
||||||
|
assert.equal(
|
||||||
|
JSON.parse(configured).mcpServers.other.command,
|
||||||
|
"unrelated-server",
|
||||||
|
);
|
||||||
|
const repeated = JSON.parse(
|
||||||
|
(await rea(["setup", "--client", "commandcode", "--yes"])).stdout,
|
||||||
|
);
|
||||||
|
assert.deepEqual(repeated.appliedActions, []);
|
||||||
|
assert.equal(await readFile(configPath, "utf8"), configured);
|
||||||
|
assert.equal(await readFile(`${configPath}.rea.backup`, "utf8"), original);
|
||||||
|
expectedSkill = (await readFile(join(skillDirectory, "SKILL.md"), "utf8"))
|
||||||
|
.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "")
|
||||||
|
.trim();
|
||||||
|
const execution = exec(
|
||||||
|
command,
|
||||||
|
[
|
||||||
|
"-p",
|
||||||
|
mode === "chat"
|
||||||
|
? "Say hello briefly."
|
||||||
|
: `Use the REA skill to analyze ${target}.`,
|
||||||
|
"-m",
|
||||||
|
"rea-local/rea-client-fixture",
|
||||||
|
"--local-only",
|
||||||
|
"--skip-onboarding",
|
||||||
|
"--trust",
|
||||||
|
"--output-format",
|
||||||
|
"json",
|
||||||
|
"--max-turns",
|
||||||
|
"8",
|
||||||
|
"--yolo",
|
||||||
|
],
|
||||||
|
{
|
||||||
|
env: environment,
|
||||||
|
cwd: workspace,
|
||||||
|
timeout: 120_000,
|
||||||
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
execution.child.stdin.end();
|
||||||
|
const response = await execution;
|
||||||
|
await writeFile(join(lab, `${mode}.stdout`), response.stdout);
|
||||||
|
await writeFile(join(lab, `${mode}.stderr`), response.stderr);
|
||||||
|
assert.equal(fixture.failure, undefined);
|
||||||
|
const events = response.stdout
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => JSON.parse(line));
|
||||||
|
assert(
|
||||||
|
events.some(
|
||||||
|
(event) =>
|
||||||
|
event.type === "result" &&
|
||||||
|
event.subtype === "success" &&
|
||||||
|
event.finalText === "REA_CLIENT_COMPATIBILITY_OK",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert(inventoryChecked && finalSeen);
|
||||||
|
if (mode === "call")
|
||||||
|
assert(
|
||||||
|
skillLoaded &&
|
||||||
|
schemasChecked === expectedNames.length &&
|
||||||
|
evidenceSeen &&
|
||||||
|
(!catalogArtifact || artifactProjectionSeen),
|
||||||
|
);
|
||||||
|
} catch (cause) {
|
||||||
|
failure = cause;
|
||||||
|
if (typeof cause?.stdout === "string")
|
||||||
|
await writeFile(join(lab, `${mode}.stdout`), cause.stdout);
|
||||||
|
if (typeof cause?.stderr === "string")
|
||||||
|
await writeFile(join(lab, `${mode}.stderr`), cause.stderr);
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
const receipt = {
|
||||||
|
client: "Command Code",
|
||||||
|
clientVersion,
|
||||||
|
mode,
|
||||||
|
passed: failure === undefined,
|
||||||
|
artifacts: lab,
|
||||||
|
runtimeRoot,
|
||||||
|
host: {
|
||||||
|
platform: process.platform,
|
||||||
|
architecture: process.arch,
|
||||||
|
node: process.versions.node,
|
||||||
|
},
|
||||||
|
modelFixture:
|
||||||
|
"loopback OpenAI completions/SSE, keyless BYOK, synthetic account-key gate and usage; no hosted authentication or live-model claim",
|
||||||
|
discovery:
|
||||||
|
"actual disposable OS account home; native user MCP configuration and default shared skill discovery; Windows unverified",
|
||||||
|
interface:
|
||||||
|
"default deferred schemas, complete exact-name search, native saved-catalog query when needed, actual JavaScript MCP call",
|
||||||
|
inventoryChecked,
|
||||||
|
catalogSize: inventoryChecked ? expectedNames.length : 0,
|
||||||
|
schemasChecked,
|
||||||
|
skillLoaded,
|
||||||
|
catalogArtifact,
|
||||||
|
catalogDigest,
|
||||||
|
artifactProjectionSeen,
|
||||||
|
evidenceSeen,
|
||||||
|
evidenceBytes,
|
||||||
|
finalSeen,
|
||||||
|
requests: fixture.requests,
|
||||||
|
probes: fixture.probes,
|
||||||
|
fixtureFailure: fixture.failure,
|
||||||
|
verifier: await completeVerifierRun(verifier),
|
||||||
|
};
|
||||||
|
await writeFile(join(lab, "receipt.json"), JSON.stringify(receipt, null, 2));
|
||||||
|
console.log(JSON.stringify(receipt));
|
||||||
|
}
|
||||||
|
if (failure !== undefined) throw failure;
|
||||||
@@ -78,12 +78,14 @@ export const parseApktoolVersionOutput = (
|
|||||||
export const inspectApktoolClient = async (
|
export const inspectApktoolClient = async (
|
||||||
selection: ApktoolCommandSelection,
|
selection: ApktoolCommandSelection,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
environment: Readonly<Record<string, string | undefined>> = process.env,
|
||||||
): Promise<ApktoolClientIdentity> => {
|
): Promise<ApktoolClientIdentity> => {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileOutput(selection.command, ["--version"], {
|
const { stdout } = await execFileOutput(selection.command, ["--version"], {
|
||||||
timeout: VERSION_TIMEOUT_MS,
|
timeout: VERSION_TIMEOUT_MS,
|
||||||
stopSignal: "SIGTERM",
|
stopSignal: "SIGTERM",
|
||||||
maxBuffer: VERSION_MAX_OUTPUT_BYTES,
|
maxBuffer: VERSION_MAX_OUTPUT_BYTES,
|
||||||
|
env: { ...process.env, ...environment },
|
||||||
...(signal === undefined ? {} : { signal }),
|
...(signal === undefined ? {} : { signal }),
|
||||||
});
|
});
|
||||||
return parseApktoolVersionOutput(stdout);
|
return parseApktoolVersionOutput(stdout);
|
||||||
|
|||||||
+327
-133
@@ -1,18 +1,20 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
import { createReadStream, constants as fsConstants } from "node:fs";
|
import { createWriteStream, constants as fsConstants } from "node:fs";
|
||||||
import {
|
import {
|
||||||
access,
|
access,
|
||||||
chmod,
|
chmod,
|
||||||
copyFile,
|
|
||||||
mkdir,
|
mkdir,
|
||||||
mkdtemp,
|
mkdtemp,
|
||||||
|
opendir,
|
||||||
open,
|
open,
|
||||||
readdir,
|
|
||||||
rm,
|
rm,
|
||||||
stat,
|
stat,
|
||||||
} from "node:fs/promises";
|
} from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { Transform, type TransformCallback } from "node:stream";
|
||||||
|
import { pipeline } from "node:stream/promises";
|
||||||
|
import type { FileHandle } from "node:fs/promises";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExecutionOptions,
|
ExecutionOptions,
|
||||||
@@ -33,11 +35,19 @@ import {
|
|||||||
AnalysisUnsupportedTargetError,
|
AnalysisUnsupportedTargetError,
|
||||||
} from "../domain/analysisErrorCore.js";
|
} from "../domain/analysisErrorCore.js";
|
||||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||||
|
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||||
import { err, ok, type Result } from "../domain/result.js";
|
import { err, ok, type Result } from "../domain/result.js";
|
||||||
|
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||||
import {
|
import {
|
||||||
execFileOutput,
|
openRegularFile,
|
||||||
execFileOutputFailure,
|
sameRegularFileState,
|
||||||
} from "../process/ExecFileOutput.js";
|
} from "../filesystem/RegularFile.js";
|
||||||
|
import {
|
||||||
|
OwnedCommandFailure,
|
||||||
|
runOwnedCommand,
|
||||||
|
} from "../process/OwnedCommand.js";
|
||||||
|
import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js";
|
||||||
|
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
||||||
import {
|
import {
|
||||||
ApktoolConfigurationFailure,
|
ApktoolConfigurationFailure,
|
||||||
apktoolConfigurationAvailability,
|
apktoolConfigurationAvailability,
|
||||||
@@ -64,20 +74,22 @@ const DECODE_MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
|
|||||||
const MANIFEST_MAX_BYTES = 512 * 1024;
|
const MANIFEST_MAX_BYTES = 512 * 1024;
|
||||||
const STRINGS_XML_MAX_BYTES = 8 * 1024 * 1024;
|
const STRINGS_XML_MAX_BYTES = 8 * 1024 * 1024;
|
||||||
const MAX_STRINGS = 2_000;
|
const MAX_STRINGS = 2_000;
|
||||||
const MAX_DECODED_FILES_COUNTED = 100_000;
|
const MAX_DECODED_ENTRIES_VISITED = 100_000;
|
||||||
const STDERR_EXCERPT_BYTES = 512;
|
const STDERR_EXCERPT_BYTES = 512;
|
||||||
|
|
||||||
/** Stream a local file digest without retaining large APKs in memory. */
|
const throwIfCancelled = (
|
||||||
const sha256File = async (path: string): Promise<string> => {
|
operation: string,
|
||||||
const hash = createHash("sha256");
|
signal: AbortSignal | undefined,
|
||||||
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
): void => {
|
||||||
return hash.digest("hex");
|
if (signal?.aborted === true) throw new AnalysisCancelledError(operation);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Read at most the byte budget plus one byte, before decoding UTF-8. */
|
/** Read at most the byte budget plus one byte, before decoding UTF-8. */
|
||||||
const readBoundedText = async (
|
const readBoundedText = async (
|
||||||
path: string,
|
path: string,
|
||||||
budget: number,
|
budget: number,
|
||||||
|
operation: string,
|
||||||
|
signal: AbortSignal | undefined,
|
||||||
): Promise<{ text: string | null; truncated: boolean }> => {
|
): Promise<{ text: string | null; truncated: boolean }> => {
|
||||||
const file = await open(path, "r").catch((cause: unknown) => {
|
const file = await open(path, "r").catch((cause: unknown) => {
|
||||||
if ((cause as NodeJS.ErrnoException).code === "ENOENT") return null;
|
if ((cause as NodeJS.ErrnoException).code === "ENOENT") return null;
|
||||||
@@ -85,9 +97,11 @@ const readBoundedText = async (
|
|||||||
});
|
});
|
||||||
if (file === null) return { text: null, truncated: false };
|
if (file === null) return { text: null, truncated: false };
|
||||||
try {
|
try {
|
||||||
|
throwIfCancelled(operation, signal);
|
||||||
const bytes = Buffer.alloc(budget + 1);
|
const bytes = Buffer.alloc(budget + 1);
|
||||||
let length = 0;
|
let length = 0;
|
||||||
while (length < bytes.length) {
|
while (length < bytes.length) {
|
||||||
|
throwIfCancelled(operation, signal);
|
||||||
const result = await file.read(
|
const result = await file.read(
|
||||||
bytes,
|
bytes,
|
||||||
length,
|
length,
|
||||||
@@ -97,6 +111,7 @@ const readBoundedText = async (
|
|||||||
if (result.bytesRead === 0) break;
|
if (result.bytesRead === 0) break;
|
||||||
length += result.bytesRead;
|
length += result.bytesRead;
|
||||||
}
|
}
|
||||||
|
throwIfCancelled(operation, signal);
|
||||||
const truncated = length > budget;
|
const truncated = length > budget;
|
||||||
// A prefix may end within a UTF-8 character; omit those trailing bytes.
|
// A prefix may end within a UTF-8 character; omit those trailing bytes.
|
||||||
const decoder = new TextDecoder("utf-8");
|
const decoder = new TextDecoder("utf-8");
|
||||||
@@ -125,97 +140,141 @@ const runApktool = async (
|
|||||||
arguments_: readonly string[],
|
arguments_: readonly string[],
|
||||||
limits: DecodeLimits,
|
limits: DecodeLimits,
|
||||||
signal: AbortSignal | undefined,
|
signal: AbortSignal | undefined,
|
||||||
|
environment: Readonly<Record<string, string | undefined>>,
|
||||||
|
onCleanupOwner: (owner: ProviderProcessSupervisor) => void,
|
||||||
): Promise<Result<{ stdout: string; stderr: string }, AnalysisError>> => {
|
): Promise<Result<{ stdout: string; stderr: string }, AnalysisError>> => {
|
||||||
if (signal?.aborted === true)
|
if (signal?.aborted === true)
|
||||||
return err(new AnalysisCancelledError(operation));
|
return err(new AnalysisCancelledError(operation));
|
||||||
try {
|
try {
|
||||||
return ok(
|
const output = await runOwnedCommand(
|
||||||
await execFileOutput(command, arguments_, {
|
{
|
||||||
timeout: limits.timeoutMs,
|
command,
|
||||||
maxBuffer: limits.maxOutputBytes,
|
arguments: arguments_,
|
||||||
stopSignal: "SIGTERM",
|
runId: randomUUID(),
|
||||||
...(signal === undefined ? {} : { signal }),
|
hostEnvironment: { ...process.env, ...environment },
|
||||||
}),
|
},
|
||||||
|
{
|
||||||
|
timeoutMs: limits.timeoutMs,
|
||||||
|
diagnosticBytes: limits.maxOutputBytes,
|
||||||
|
},
|
||||||
|
signal === undefined ? {} : { signal },
|
||||||
);
|
);
|
||||||
|
return ok({ stdout: output.stdout.text, stderr: output.stderr.text });
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (signal?.aborted || execFileOutputFailure(cause)?.code === "ABORT_ERR")
|
if (!(cause instanceof OwnedCommandFailure))
|
||||||
return err(new AnalysisCancelledError(operation, { cause }));
|
|
||||||
const failure = execFileOutputFailure(cause);
|
|
||||||
if (failure === undefined)
|
|
||||||
return err(
|
return err(
|
||||||
new AnalysisProtocolError(`apktool failed for ${operation}`, { cause }),
|
new AnalysisProtocolError(`apktool failed for ${operation}`, { cause }),
|
||||||
);
|
);
|
||||||
if (failure.outputTruncated)
|
if (cause.cleanupOwner !== undefined) onCleanupOwner(cause.cleanupOwner);
|
||||||
|
const output = cause.snapshot;
|
||||||
|
const capturedOutput =
|
||||||
|
output === null
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
stdout: output.stdout.text,
|
||||||
|
stderr: output.stderr.text,
|
||||||
|
truncated: output.diagnosticTruncated === true,
|
||||||
|
stdout_bytes: output.stdout.bytes,
|
||||||
|
stderr_bytes: output.stderr.bytes,
|
||||||
|
exit_code: output.exitCode ?? null,
|
||||||
|
signal: output.signal ?? null,
|
||||||
|
};
|
||||||
|
const cleanup =
|
||||||
|
cause.cleanupFailure === null
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
reason: cause.cleanupFailure,
|
||||||
|
resources: cause.resources,
|
||||||
|
};
|
||||||
|
if (cause.reason === "cancelled")
|
||||||
|
return err(
|
||||||
|
new AnalysisCancelledError(operation, {
|
||||||
|
cause,
|
||||||
|
...(cleanup === undefined ? {} : { cleanup }),
|
||||||
|
...(capturedOutput === undefined ? {} : { capturedOutput }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (cause.reason === "timeout")
|
||||||
|
return err(
|
||||||
|
new AnalysisTimeoutError(operation, limits.timeoutMs, {
|
||||||
|
cause,
|
||||||
|
...(cleanup === undefined ? {} : { cleanup }),
|
||||||
|
...(capturedOutput === undefined ? {} : { capturedOutput }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (cause.reason === "output-limit")
|
||||||
return err(
|
return err(
|
||||||
new AnalysisResourceConstraintError(
|
new AnalysisResourceConstraintError(
|
||||||
operation,
|
operation,
|
||||||
"memory",
|
"memory",
|
||||||
`apktool output exceeded the ${String(limits.maxOutputBytes)}-byte capture budget`,
|
`apktool output exceeded the ${String(limits.maxOutputBytes)}-byte capture budget`,
|
||||||
{ max_output_bytes: limits.maxOutputBytes },
|
{ max_output_bytes: limits.maxOutputBytes },
|
||||||
{ cause },
|
{
|
||||||
|
cause,
|
||||||
|
...(cleanup === undefined ? {} : { cleanup }),
|
||||||
|
...(capturedOutput === undefined ? {} : { capturedOutput }),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const excerpt = failure.stderr.trim().slice(0, STDERR_EXCERPT_BYTES);
|
|
||||||
if (failure.killed)
|
|
||||||
return err(
|
|
||||||
new AnalysisTimeoutError(operation, limits.timeoutMs, { cause }),
|
|
||||||
);
|
|
||||||
return err(
|
return err(
|
||||||
new AnalysisUnsupportedTargetError(
|
new AnalysisUnsupportedTargetError(
|
||||||
operation,
|
operation,
|
||||||
command,
|
command,
|
||||||
excerpt === ""
|
output?.stderr.text.trim().slice(0, STDERR_EXCERPT_BYTES) ||
|
||||||
? `apktool exited with code ${String(failure.code)} for ${operation}`
|
`apktool exited with code ${String(output?.exitCode ?? "unknown")} for ${operation}`,
|
||||||
: excerpt,
|
{
|
||||||
{ cause },
|
cause,
|
||||||
|
...(cleanup === undefined ? {} : { cleanup }),
|
||||||
|
...(capturedOutput === undefined ? {} : { capturedOutput }),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Count files in the decoded workspace with a hard ceiling. */
|
/** Count files while bounding streamed directory-entry traversal. */
|
||||||
const countDecodedFiles = async (
|
const countDecodedFiles = async (
|
||||||
root: string,
|
root: string,
|
||||||
): Promise<{ count: number; capped: boolean }> => {
|
signal: AbortSignal | undefined,
|
||||||
|
operation: string,
|
||||||
|
): Promise<{
|
||||||
|
count: number;
|
||||||
|
capped: boolean;
|
||||||
|
locales: readonly string[];
|
||||||
|
}> => {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
let capped = false;
|
let capped = false;
|
||||||
const walk = async (directory: string): Promise<void> => {
|
let visited = 0;
|
||||||
const entries = await readdir(directory, { withFileTypes: true });
|
const directories = [root];
|
||||||
for (const entry of entries) {
|
|
||||||
if (count >= MAX_DECODED_FILES_COUNTED) {
|
|
||||||
capped = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const path = join(directory, entry.name);
|
|
||||||
if (entry.isDirectory()) await walk(path);
|
|
||||||
else if (entry.isFile()) count += 1;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
await walk(root);
|
|
||||||
return { count, capped };
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Locale codes present as res/values-<locale>/strings.xml. */
|
|
||||||
const listStringLocales = async (
|
|
||||||
workspace: string,
|
|
||||||
): Promise<readonly string[]> => {
|
|
||||||
const res = join(workspace, "res");
|
|
||||||
const entries = await readdir(res, { withFileTypes: true }).catch(() => []);
|
|
||||||
const locales: string[] = [];
|
const locales: string[] = [];
|
||||||
for (const entry of entries) {
|
while (directories.length > 0 && !capped) {
|
||||||
if (!entry.isDirectory()) continue;
|
throwIfCancelled(operation, signal);
|
||||||
const match = /^values-(.+)$/u.exec(entry.name);
|
const directory = directories.pop()!;
|
||||||
if (match === null) continue;
|
for await (const entry of await opendir(directory)) {
|
||||||
const strings = join(res, entry.name, "strings.xml");
|
throwIfCancelled(operation, signal);
|
||||||
if (
|
if (visited >= MAX_DECODED_ENTRIES_VISITED) {
|
||||||
await access(strings).then(
|
capped = true;
|
||||||
() => true,
|
break;
|
||||||
() => false,
|
}
|
||||||
)
|
visited += 1;
|
||||||
)
|
const path = join(directory, entry.name);
|
||||||
locales.push(match[1]!);
|
if (entry.isDirectory()) {
|
||||||
|
directories.push(path);
|
||||||
|
if (directory === join(root, "res")) {
|
||||||
|
const match = /^values-(.+)$/u.exec(entry.name);
|
||||||
|
if (
|
||||||
|
match !== null &&
|
||||||
|
(await access(join(path, "strings.xml")).then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
))
|
||||||
|
)
|
||||||
|
locales.push(match[1]!);
|
||||||
|
}
|
||||||
|
} else if (entry.isFile()) count += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return locales.sort();
|
return { count, capped, locales: locales.sort() };
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ApktoolProviderOptions {
|
export interface ApktoolProviderOptions {
|
||||||
@@ -235,9 +294,14 @@ export class ApktoolProvider {
|
|||||||
|
|
||||||
private readonly workspaces = new Set<string>();
|
private readonly workspaces = new Set<string>();
|
||||||
private readonly removeWorkspace: (path: string) => Promise<void>;
|
private readonly removeWorkspace: (path: string) => Promise<void>;
|
||||||
|
readonly #pendingCommandOwners = new Map<ProviderProcessSupervisor, string>();
|
||||||
|
readonly #shutdown = new AbortController();
|
||||||
|
readonly #active = new Set<Promise<void>>();
|
||||||
|
#closing = false;
|
||||||
|
#closePromise: Promise<void> | undefined;
|
||||||
|
|
||||||
constructor(options: ApktoolProviderOptions = {}) {
|
constructor(options: ApktoolProviderOptions = {}) {
|
||||||
this.environment = options.environment ?? process.env;
|
this.environment = snapshotEnvironment(options.environment ?? process.env);
|
||||||
this.removeWorkspace =
|
this.removeWorkspace =
|
||||||
options.removeWorkspace ??
|
options.removeWorkspace ??
|
||||||
((path) => rm(path, { recursive: true, force: true }));
|
((path) => rm(path, { recursive: true, force: true }));
|
||||||
@@ -248,12 +312,25 @@ export class ApktoolProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Probe the selected apktool launcher; decodes nothing. */
|
/** Probe the selected apktool launcher; decodes nothing. */
|
||||||
async inspectAvailability(
|
inspectAvailability(signal?: AbortSignal): Promise<ProviderAvailability> {
|
||||||
signal?: AbortSignal,
|
if (this.#closing)
|
||||||
|
return Promise.reject(
|
||||||
|
new AnalysisCancelledError("inspect_apktool_client"),
|
||||||
|
);
|
||||||
|
const operationSignal =
|
||||||
|
signal === undefined
|
||||||
|
? this.#shutdown.signal
|
||||||
|
: AbortSignal.any([signal, this.#shutdown.signal]);
|
||||||
|
return this.#track(this.#inspectAvailability(operationSignal));
|
||||||
|
}
|
||||||
|
|
||||||
|
async #inspectAvailability(
|
||||||
|
signal: AbortSignal,
|
||||||
): Promise<ProviderAvailability> {
|
): Promise<ProviderAvailability> {
|
||||||
const selection = this.selection();
|
const selection = this.selection();
|
||||||
try {
|
try {
|
||||||
await inspectApktoolClient(selection, signal);
|
await inspectApktoolClient(selection, signal, this.environment);
|
||||||
|
throwIfCancelled("inspect_apktool_client", signal);
|
||||||
return {
|
return {
|
||||||
status: "available",
|
status: "available",
|
||||||
code: null,
|
code: null,
|
||||||
@@ -272,30 +349,44 @@ export class ApktoolProvider {
|
|||||||
|
|
||||||
/** Retain failed cleanup ownership and retry it on close. */
|
/** Retain failed cleanup ownership and retry it on close. */
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
const failures: unknown[] = [];
|
this.#closing = true;
|
||||||
for (const path of this.workspaces) {
|
this.#shutdown.abort();
|
||||||
try {
|
this.#closePromise ??= this.#drainAndClean().catch((cause: unknown) => {
|
||||||
await this.removeWorkspace(path);
|
this.#closePromise = undefined;
|
||||||
this.workspaces.delete(path);
|
throw cause;
|
||||||
} catch (cause) {
|
});
|
||||||
failures.push(cause);
|
return this.#closePromise;
|
||||||
}
|
|
||||||
}
|
|
||||||
if (failures.length > 0)
|
|
||||||
throw new AggregateError(failures, "Apktool workspace cleanup failed");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async execute(
|
execute(
|
||||||
request: ApktoolRequest,
|
request: ApktoolRequest,
|
||||||
options?: ExecutionOptions,
|
options?: ExecutionOptions,
|
||||||
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
||||||
const signal = options?.signal;
|
if (this.#closing)
|
||||||
|
return Promise.resolve(
|
||||||
|
err(new AnalysisCancelledError(request.operation)),
|
||||||
|
);
|
||||||
|
const signal =
|
||||||
|
options?.signal === undefined
|
||||||
|
? this.#shutdown.signal
|
||||||
|
: AbortSignal.any([options.signal, this.#shutdown.signal]);
|
||||||
|
return this.#track(this.#execute(request, signal));
|
||||||
|
}
|
||||||
|
|
||||||
|
async #execute(
|
||||||
|
request: ApktoolRequest,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
||||||
if (signal?.aborted === true)
|
if (signal?.aborted === true)
|
||||||
return err(new AnalysisCancelledError(request.operation));
|
return err(new AnalysisCancelledError(request.operation));
|
||||||
const selection = this.selection();
|
const selection = this.selection();
|
||||||
let identity: ApktoolClientIdentity;
|
let identity: ApktoolClientIdentity;
|
||||||
try {
|
try {
|
||||||
identity = await inspectApktoolClient(selection, signal);
|
identity = await inspectApktoolClient(
|
||||||
|
selection,
|
||||||
|
signal,
|
||||||
|
this.environment,
|
||||||
|
);
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (cause instanceof AnalysisCancelledError) return err(cause);
|
if (cause instanceof AnalysisCancelledError) return err(cause);
|
||||||
if (cause instanceof ApktoolConfigurationFailure)
|
if (cause instanceof ApktoolConfigurationFailure)
|
||||||
@@ -316,6 +407,8 @@ export class ApktoolProvider {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (signal.aborted)
|
||||||
|
return err(new AnalysisCancelledError(request.operation));
|
||||||
const client = {
|
const client = {
|
||||||
command: selection.command,
|
command: selection.command,
|
||||||
command_source: selection.commandSource,
|
command_source: selection.commandSource,
|
||||||
@@ -337,6 +430,47 @@ export class ApktoolProvider {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#track<T>(operation: Promise<T>): Promise<T> {
|
||||||
|
const settled = operation.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
this.#active.add(settled);
|
||||||
|
void settled.then(() => this.#active.delete(settled));
|
||||||
|
return operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #drainAndClean(): Promise<void> {
|
||||||
|
await Promise.all(this.#active);
|
||||||
|
const failures: unknown[] = [];
|
||||||
|
for (const [owner, root] of this.#pendingCommandOwners) {
|
||||||
|
try {
|
||||||
|
const stopped = await owner.stop();
|
||||||
|
if (stopped.status === "incomplete") throw new Error(stopped.reason);
|
||||||
|
this.#pendingCommandOwners.delete(owner);
|
||||||
|
} catch (cause) {
|
||||||
|
failures.push(
|
||||||
|
new Error(
|
||||||
|
`apktool process cleanup remains incomplete for ${root}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||||
|
{ cause },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failures.length > 0)
|
||||||
|
throw new AggregateError(failures, "Apktool process cleanup failed");
|
||||||
|
for (const path of this.workspaces) {
|
||||||
|
try {
|
||||||
|
await this.removeWorkspace(path);
|
||||||
|
this.workspaces.delete(path);
|
||||||
|
} catch (cause) {
|
||||||
|
failures.push(cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failures.length > 0)
|
||||||
|
throw new AggregateError(failures, "Apktool workspace cleanup failed");
|
||||||
|
}
|
||||||
|
|
||||||
private async decodeResources(
|
private async decodeResources(
|
||||||
operation: "decode_android_resources",
|
operation: "decode_android_resources",
|
||||||
selection: ApktoolCommandSelection,
|
selection: ApktoolCommandSelection,
|
||||||
@@ -353,28 +487,6 @@ export class ApktoolProvider {
|
|||||||
},
|
},
|
||||||
signal: AbortSignal | undefined,
|
signal: AbortSignal | undefined,
|
||||||
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
||||||
const info = await stat(input.path).then(
|
|
||||||
(value) => value,
|
|
||||||
() => null,
|
|
||||||
);
|
|
||||||
if (info === null || !info.isFile())
|
|
||||||
return err(
|
|
||||||
new AnalysisInputError(operation, {
|
|
||||||
cause: new Error(
|
|
||||||
`The selected APK is not a readable file: ${input.path}`,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const readable = await access(input.path, fsConstants.R_OK).then(
|
|
||||||
() => true,
|
|
||||||
() => false,
|
|
||||||
);
|
|
||||||
if (!readable)
|
|
||||||
return err(
|
|
||||||
new AnalysisInputError(operation, {
|
|
||||||
cause: new Error(`The selected APK is not readable: ${input.path}`),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const root = await mkdtemp(join(tmpdir(), "rea-apktool-"));
|
const root = await mkdtemp(join(tmpdir(), "rea-apktool-"));
|
||||||
this.workspaces.add(root);
|
this.workspaces.add(root);
|
||||||
const workspace = join(root, "decoded");
|
const workspace = join(root, "decoded");
|
||||||
@@ -384,6 +496,10 @@ export class ApktoolProvider {
|
|||||||
// The workspace is removed before the result is returned, so an
|
// The workspace is removed before the result is returned, so an
|
||||||
// unremovable workspace is reported instead of silently abandoned.
|
// unremovable workspace is reported instead of silently abandoned.
|
||||||
const removeWorkspace = async (): Promise<void> => {
|
const removeWorkspace = async (): Promise<void> => {
|
||||||
|
if ([...this.#pendingCommandOwners.values()].includes(root)) {
|
||||||
|
cleanupFailure = `The decode workspace is retained until its apktool process stops: ${root}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await this.removeWorkspace(root);
|
await this.removeWorkspace(root);
|
||||||
this.workspaces.delete(root);
|
this.workspaces.delete(root);
|
||||||
@@ -394,20 +510,78 @@ export class ApktoolProvider {
|
|||||||
const failureWithCleanup = (failure: AnalysisError): AnalysisError =>
|
const failureWithCleanup = (failure: AnalysisError): AnalysisError =>
|
||||||
cleanupFailure === null
|
cleanupFailure === null
|
||||||
? failure
|
? failure
|
||||||
: failure instanceof AnalysisCancelledError
|
: analysisErrorWithCleanupFailure(
|
||||||
? new AnalysisCancelledError(operation, {
|
failure,
|
||||||
cause: failure,
|
new ProviderCleanupError(
|
||||||
cleanup: { reason: cleanupFailure, resources: [root] },
|
APKTOOL_PROVIDER_IDENTITY.id,
|
||||||
})
|
[root],
|
||||||
: new AnalysisProtocolError(failure.message, {
|
{ reason: cleanupFailure },
|
||||||
cause: failure,
|
{ operation },
|
||||||
cleanup: { reason: cleanupFailure, resources: [root] },
|
),
|
||||||
});
|
operation,
|
||||||
|
);
|
||||||
|
let source: FileHandle | undefined;
|
||||||
|
let sourceAdmissionFailed = false;
|
||||||
try {
|
try {
|
||||||
await copyFile(input.path, snapshot, fsConstants.COPYFILE_EXCL);
|
throwIfCancelled(operation, signal);
|
||||||
|
sourceAdmissionFailed = true;
|
||||||
|
source = await openRegularFile(input.path, {
|
||||||
|
symlinks: "follow",
|
||||||
|
...(signal === undefined ? {} : { signal }),
|
||||||
|
});
|
||||||
|
const sourceInfo = await source.stat();
|
||||||
|
sourceAdmissionFailed = false;
|
||||||
|
const sourceSize = sourceInfo.size;
|
||||||
|
let copiedBytes = 0;
|
||||||
|
const sourceHash = createHash("sha256");
|
||||||
|
const boundedSource = new Transform({
|
||||||
|
transform(
|
||||||
|
chunk: Buffer,
|
||||||
|
_encoding: BufferEncoding,
|
||||||
|
callback: TransformCallback,
|
||||||
|
) {
|
||||||
|
copiedBytes += chunk.length;
|
||||||
|
if (copiedBytes > sourceSize) {
|
||||||
|
callback(
|
||||||
|
new AnalysisInputError(operation, {
|
||||||
|
cause: new Error(
|
||||||
|
`The selected APK changed while being copied: ${input.path}`,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sourceHash.update(chunk);
|
||||||
|
callback(null, chunk);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await pipeline(
|
||||||
|
source.createReadStream({
|
||||||
|
start: 0,
|
||||||
|
end: sourceSize,
|
||||||
|
autoClose: false,
|
||||||
|
...(signal === undefined ? {} : { signal }),
|
||||||
|
}),
|
||||||
|
boundedSource,
|
||||||
|
createWriteStream(snapshot, { flags: "wx", mode: 0o600 }),
|
||||||
|
signal === undefined ? {} : { signal },
|
||||||
|
);
|
||||||
|
const finalSourceInfo = await source.stat();
|
||||||
|
if (
|
||||||
|
copiedBytes !== sourceSize ||
|
||||||
|
!sameRegularFileState(sourceInfo, finalSourceInfo)
|
||||||
|
)
|
||||||
|
throw new AnalysisInputError(operation, {
|
||||||
|
cause: new Error(
|
||||||
|
`The selected APK changed while being copied: ${input.path}`,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const sha256 = sourceHash.digest("hex");
|
||||||
|
await source.close();
|
||||||
|
source = undefined;
|
||||||
await chmod(snapshot, 0o400);
|
await chmod(snapshot, 0o400);
|
||||||
|
throwIfCancelled(operation, signal);
|
||||||
const snapshotInfo = await stat(snapshot);
|
const snapshotInfo = await stat(snapshot);
|
||||||
const sha256 = await sha256File(snapshot);
|
|
||||||
await mkdir(workspace);
|
await mkdir(workspace);
|
||||||
await mkdir(framework);
|
await mkdir(framework);
|
||||||
const decoded = await runApktool(
|
const decoded = await runApktool(
|
||||||
@@ -428,14 +602,19 @@ export class ApktoolProvider {
|
|||||||
maxOutputBytes: DECODE_MAX_OUTPUT_BYTES,
|
maxOutputBytes: DECODE_MAX_OUTPUT_BYTES,
|
||||||
},
|
},
|
||||||
signal,
|
signal,
|
||||||
|
this.environment,
|
||||||
|
(owner) => this.#pendingCommandOwners.set(owner, root),
|
||||||
);
|
);
|
||||||
if (!decoded.ok) {
|
if (!decoded.ok) {
|
||||||
await removeWorkspace();
|
await removeWorkspace();
|
||||||
return err(failureWithCleanup(decoded.error));
|
return err(failureWithCleanup(decoded.error));
|
||||||
}
|
}
|
||||||
|
throwIfCancelled(operation, signal);
|
||||||
const yml = await readBoundedText(
|
const yml = await readBoundedText(
|
||||||
join(workspace, "apktool.yml"),
|
join(workspace, "apktool.yml"),
|
||||||
MANIFEST_MAX_BYTES,
|
MANIFEST_MAX_BYTES,
|
||||||
|
operation,
|
||||||
|
signal,
|
||||||
);
|
);
|
||||||
if (yml.text === null || yml.truncated) {
|
if (yml.text === null || yml.truncated) {
|
||||||
throw new AnalysisProtocolError(
|
throw new AnalysisProtocolError(
|
||||||
@@ -446,11 +625,12 @@ export class ApktoolProvider {
|
|||||||
const manifestRead = await readBoundedText(
|
const manifestRead = await readBoundedText(
|
||||||
join(workspace, "AndroidManifest.xml"),
|
join(workspace, "AndroidManifest.xml"),
|
||||||
MANIFEST_MAX_BYTES,
|
MANIFEST_MAX_BYTES,
|
||||||
|
operation,
|
||||||
|
signal,
|
||||||
);
|
);
|
||||||
const manifest = manifestRead.text;
|
const manifest = manifestRead.text;
|
||||||
const packageName =
|
const packageName =
|
||||||
manifest === null ? null : parseManifestPackage(manifest);
|
manifest === null ? null : parseManifestPackage(manifest);
|
||||||
const locales = await listStringLocales(workspace);
|
|
||||||
const stringsDirectory =
|
const stringsDirectory =
|
||||||
input.locale === undefined
|
input.locale === undefined
|
||||||
? "values"
|
? "values"
|
||||||
@@ -459,6 +639,8 @@ export class ApktoolProvider {
|
|||||||
? await readBoundedText(
|
? await readBoundedText(
|
||||||
join(workspace, "res", stringsDirectory, "strings.xml"),
|
join(workspace, "res", stringsDirectory, "strings.xml"),
|
||||||
STRINGS_XML_MAX_BYTES,
|
STRINGS_XML_MAX_BYTES,
|
||||||
|
operation,
|
||||||
|
signal,
|
||||||
)
|
)
|
||||||
: { text: null, truncated: false };
|
: { text: null, truncated: false };
|
||||||
// Do not parse an incomplete XML document as though its projection were complete.
|
// Do not parse an incomplete XML document as though its projection were complete.
|
||||||
@@ -468,7 +650,8 @@ export class ApktoolProvider {
|
|||||||
? { entries: [], unparsedCount: 0 }
|
? { entries: [], unparsedCount: 0 }
|
||||||
: parseDecodedStrings(stringsXml);
|
: parseDecodedStrings(stringsXml);
|
||||||
const bounded = parsedStrings.entries.slice(0, MAX_STRINGS);
|
const bounded = parsedStrings.entries.slice(0, MAX_STRINGS);
|
||||||
const files = await countDecodedFiles(workspace);
|
const files = await countDecodedFiles(workspace, signal, operation);
|
||||||
|
throwIfCancelled(operation, signal);
|
||||||
const coverage =
|
const coverage =
|
||||||
(stringsXml !== null && parsedStrings.entries.length > MAX_STRINGS) ||
|
(stringsXml !== null && parsedStrings.entries.length > MAX_STRINGS) ||
|
||||||
manifestRead.truncated ||
|
manifestRead.truncated ||
|
||||||
@@ -493,7 +676,7 @@ export class ApktoolProvider {
|
|||||||
manifest,
|
manifest,
|
||||||
strings: bounded,
|
strings: bounded,
|
||||||
locale: input.locale ?? null,
|
locale: input.locale ?? null,
|
||||||
locales,
|
locales: files.locales,
|
||||||
decoded_file_count: files.count,
|
decoded_file_count: files.count,
|
||||||
coverage,
|
coverage,
|
||||||
},
|
},
|
||||||
@@ -535,7 +718,7 @@ export class ApktoolProvider {
|
|||||||
: []),
|
: []),
|
||||||
...(files.capped
|
...(files.capped
|
||||||
? [
|
? [
|
||||||
`decoded_file_count is capped at ${String(MAX_DECODED_FILES_COUNTED)}`,
|
`Decoded workspace enumeration stopped after ${String(MAX_DECODED_ENTRIES_VISITED)} entries; decoded_file_count and locales may be partial`,
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(cleanupFailure === null ? [] : [cleanupFailure]),
|
...(cleanupFailure === null ? [] : [cleanupFailure]),
|
||||||
@@ -547,14 +730,25 @@ export class ApktoolProvider {
|
|||||||
await removeWorkspace();
|
await removeWorkspace();
|
||||||
return err(
|
return err(
|
||||||
failureWithCleanup(
|
failureWithCleanup(
|
||||||
cause instanceof AnalysisCancelledError ||
|
signal?.aborted === true
|
||||||
cause instanceof AnalysisProtocolError
|
? new AnalysisCancelledError(operation, { cause })
|
||||||
? cause
|
: sourceAdmissionFailed
|
||||||
: new AnalysisProtocolError("Apktool resource projection failed", {
|
? new AnalysisInputError(operation, { cause })
|
||||||
cause,
|
: cause instanceof AnalysisInputError
|
||||||
}),
|
? cause
|
||||||
|
: cause instanceof AnalysisCancelledError ||
|
||||||
|
cause instanceof AnalysisProtocolError
|
||||||
|
? cause
|
||||||
|
: new AnalysisProtocolError(
|
||||||
|
"Apktool resource projection failed",
|
||||||
|
{
|
||||||
|
cause,
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
await source?.close().catch(() => undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises";
|
import { lstat, opendir, readFile, realpath, stat } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
@@ -156,13 +156,14 @@ const wrappedBundle = async (bundle: string): Promise<string | undefined> => {
|
|||||||
if (isAbsence(cause)) return undefined;
|
if (isAbsence(cause)) return undefined;
|
||||||
throw cause;
|
throw cause;
|
||||||
}
|
}
|
||||||
const apps = (await readdir(wrapper, { withFileTypes: true })).filter(
|
let app: string | undefined;
|
||||||
(entry) => entry.isDirectory() && entry.name.toLowerCase().endsWith(".app"),
|
for await (const entry of await opendir(wrapper)) {
|
||||||
);
|
if (!entry.isDirectory() || !entry.name.toLowerCase().endsWith(".app"))
|
||||||
const [app] = apps;
|
continue;
|
||||||
return apps.length === 1 && app !== undefined
|
if (app !== undefined) return undefined;
|
||||||
? join(wrapper, app.name)
|
app = join(wrapper, entry.name);
|
||||||
: undefined;
|
}
|
||||||
|
return app;
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveLayoutExecutable = async (
|
const resolveLayoutExecutable = async (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { constants } from "node:fs";
|
import { constants, type Stats } from "node:fs";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
access,
|
access,
|
||||||
@@ -248,7 +248,7 @@ const isArchiveFormat = (
|
|||||||
const sha256Handle = async (
|
const sha256Handle = async (
|
||||||
handle: FileHandle,
|
handle: FileHandle,
|
||||||
path: string,
|
path: string,
|
||||||
initial: Awaited<ReturnType<FileHandle["stat"]>>,
|
initial: Stats,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
const hash = createHash("sha256");
|
const hash = createHash("sha256");
|
||||||
@@ -256,11 +256,18 @@ const sha256Handle = async (
|
|||||||
let position = 0;
|
let position = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
throwIfTargetResolutionCancelled(signal);
|
throwIfTargetResolutionCancelled(signal);
|
||||||
const observed = await handle.read(chunk, 0, chunk.length, position);
|
const observed = await handle.read(
|
||||||
|
chunk,
|
||||||
|
0,
|
||||||
|
Math.min(chunk.length, initial.size - position + 1),
|
||||||
|
position,
|
||||||
|
);
|
||||||
throwIfTargetResolutionCancelled(signal);
|
throwIfTargetResolutionCancelled(signal);
|
||||||
if (observed.bytesRead === 0) break;
|
if (observed.bytesRead === 0) break;
|
||||||
hash.update(chunk.subarray(0, observed.bytesRead));
|
hash.update(chunk.subarray(0, observed.bytesRead));
|
||||||
position += observed.bytesRead;
|
position += observed.bytesRead;
|
||||||
|
if (position > initial.size)
|
||||||
|
throw new BinaryTargetError(path, "target grew while being read");
|
||||||
}
|
}
|
||||||
throwIfTargetResolutionCancelled(signal);
|
throwIfTargetResolutionCancelled(signal);
|
||||||
const [opened, currentPath] = await Promise.all([handle.stat(), lstat(path)]);
|
const [opened, currentPath] = await Promise.all([handle.stat(), lstat(path)]);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { Result } from "../domain/result.js";
|
|||||||
|
|
||||||
/** Provider-neutral application boundary for controlled browser scenarios. */
|
/** Provider-neutral application boundary for controlled browser scenarios. */
|
||||||
export interface BrowserScenarioCapturePort {
|
export interface BrowserScenarioCapturePort {
|
||||||
|
close?(): Promise<void>;
|
||||||
identity(): ProviderIdentity;
|
identity(): ProviderIdentity;
|
||||||
captureScenario(
|
captureScenario(
|
||||||
scenario: BrowserScenario,
|
scenario: BrowserScenario,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
|||||||
import { err, type Result } from "../domain/result.js";
|
import { err, type Result } from "../domain/result.js";
|
||||||
import type { AnalysisExecution } from "./AnalysisProvider.js";
|
import type { AnalysisExecution } from "./AnalysisProvider.js";
|
||||||
import type { BinarySession } from "./binary/BinarySession.js";
|
import type { BinarySession } from "./binary/BinarySession.js";
|
||||||
import { analysisErrorWithCleanupFailure } from "./binary/AnalysisClientCleanup.js";
|
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||||
|
|
||||||
/** Await one-shot cleanup and retain either the outcome or both failures. */
|
/** Await one-shot cleanup and retain either the outcome or both failures. */
|
||||||
export const withSessionCleanup = async <Value>(
|
export const withSessionCleanup = async <Value>(
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/** Maximum admitted vendor response bytes, shared by Hopper setup adapters. */
|
||||||
|
export const HOPPER_DOWNLOAD_BYTES = 100_000_000;
|
||||||
|
|
||||||
|
/** Bound observed response bytes before retaining them, including chunked bodies. */
|
||||||
|
export const downloadHopperPackage = async (
|
||||||
|
url: string,
|
||||||
|
options: { readonly signal?: AbortSignal } = {},
|
||||||
|
): Promise<{ readonly ok: boolean; readonly bytes: Uint8Array }> => {
|
||||||
|
const deadline = AbortSignal.timeout(120_000);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { accept: "application/json", "user-agent": "rea-installer" },
|
||||||
|
signal:
|
||||||
|
options.signal === undefined
|
||||||
|
? deadline
|
||||||
|
: AbortSignal.any([options.signal, deadline]),
|
||||||
|
});
|
||||||
|
const refused = { ok: false, bytes: new Uint8Array() };
|
||||||
|
if (
|
||||||
|
!response.ok ||
|
||||||
|
Number(response.headers.get("content-length")) > HOPPER_DOWNLOAD_BYTES
|
||||||
|
) {
|
||||||
|
await response.body?.cancel();
|
||||||
|
return refused;
|
||||||
|
}
|
||||||
|
if (response.body === null) return { ok: true, bytes: new Uint8Array() };
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let bytes = 0;
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const chunk = await reader.read();
|
||||||
|
if (chunk.done) return { ok: true, bytes: Buffer.concat(chunks, bytes) };
|
||||||
|
if (chunk.value.byteLength > HOPPER_DOWNLOAD_BYTES - bytes) {
|
||||||
|
await reader.cancel();
|
||||||
|
return refused;
|
||||||
|
}
|
||||||
|
chunks.push(chunk.value);
|
||||||
|
bytes += chunk.value.byteLength;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -42,7 +42,7 @@ export const readJsonFile = async (
|
|||||||
if (!decoded.ok) {
|
if (!decoded.ok) {
|
||||||
return err(
|
return err(
|
||||||
new EvidenceFileError("read", "invalid-json", {
|
new EvidenceFileError("read", "invalid-json", {
|
||||||
cause: decoded.cause,
|
cause: decoded.cause ?? new SyntaxError(decoded.error),
|
||||||
path: requestedPath,
|
path: requestedPath,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ import { join } from "node:path";
|
|||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
import { execFileOutput } from "../process/ExecFileOutput.js";
|
import { execFileOutput } from "../process/ExecFileOutput.js";
|
||||||
|
import {
|
||||||
|
downloadHopperPackage,
|
||||||
|
HOPPER_DOWNLOAD_BYTES,
|
||||||
|
} from "./HopperDownload.js";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/";
|
const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/";
|
||||||
const MAX_PACKAGE_BYTES = 100_000_000;
|
|
||||||
const SUPPORTED_HOPPER_SHA256 = new Set([
|
const SUPPORTED_HOPPER_SHA256 = new Set([
|
||||||
"0294ced141cc373468ee22d8343e7dac41980cb05a937994ca81c9f09afe7ded",
|
"0294ced141cc373468ee22d8343e7dac41980cb05a937994ca81c9f09afe7ded",
|
||||||
"1339f9e58377442b0c6fcb0dfc3cec20d593cc557408521fad9a00dbc6b8da13",
|
"1339f9e58377442b0c6fcb0dfc3cec20d593cc557408521fad9a00dbc6b8da13",
|
||||||
@@ -231,16 +234,7 @@ export const systemLinuxHopperInstallHost = (
|
|||||||
environment: Readonly<NodeJS.ProcessEnv>,
|
environment: Readonly<NodeJS.ProcessEnv>,
|
||||||
): LinuxHopperInstallHost => ({
|
): LinuxHopperInstallHost => ({
|
||||||
distribution: readLinuxDistribution,
|
distribution: readLinuxDistribution,
|
||||||
async download(url, options) {
|
download: downloadHopperPackage,
|
||||||
const response = await fetch(url, {
|
|
||||||
headers: { accept: "application/json", "user-agent": "rea-installer" },
|
|
||||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
ok: response.ok,
|
|
||||||
bytes: new Uint8Array(await response.arrayBuffer()),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-")),
|
createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-")),
|
||||||
writeArchive: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
|
writeArchive: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
|
||||||
installPackage: (family, archive) =>
|
installPackage: (family, archive) =>
|
||||||
@@ -350,7 +344,7 @@ const packageIntegrityMatches = (
|
|||||||
release: LinuxHopperRelease,
|
release: LinuxHopperRelease,
|
||||||
): boolean =>
|
): boolean =>
|
||||||
bytes.byteLength === Number(release.file_length) &&
|
bytes.byteLength === Number(release.file_length) &&
|
||||||
bytes.byteLength <= MAX_PACKAGE_BYTES &&
|
bytes.byteLength <= HOPPER_DOWNLOAD_BYTES &&
|
||||||
createHash("sha1").update(bytes).digest("hex") === release.file_hash;
|
createHash("sha1").update(bytes).digest("hex") === release.file_hash;
|
||||||
|
|
||||||
const packageFamilyFor = (
|
const packageFamilyFor = (
|
||||||
|
|||||||
+213
-167
@@ -15,12 +15,15 @@ import { promisify } from "node:util";
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
|
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
|
||||||
|
import {
|
||||||
|
downloadHopperPackage,
|
||||||
|
HOPPER_DOWNLOAD_BYTES,
|
||||||
|
} from "./HopperDownload.js";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
const RELEASES_URL =
|
const RELEASES_URL =
|
||||||
"https://www.hopperapp.com/include/files-api.php?request=releases&public=true";
|
"https://www.hopperapp.com/include/files-api.php?request=releases&public=true";
|
||||||
const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/";
|
const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/";
|
||||||
const MAX_PACKAGE_BYTES = 100_000_000;
|
|
||||||
const BUNDLE_IDENTIFIER = "com.cryptic-apps.hopper-web-4";
|
const BUNDLE_IDENTIFIER = "com.cryptic-apps.hopper-web-4";
|
||||||
|
|
||||||
/** Stable network and command effects disclosed before macOS Hopper setup. */
|
/** Stable network and command effects disclosed before macOS Hopper setup. */
|
||||||
@@ -36,7 +39,11 @@ export const macHopperInstallDisclosure = {
|
|||||||
|
|
||||||
/** Result of installing the official Hopper macOS application. */
|
/** Result of installing the official Hopper macOS application. */
|
||||||
export type MacHopperInstallResult =
|
export type MacHopperInstallResult =
|
||||||
| { readonly status: "installed"; readonly launcherPath: string }
|
| {
|
||||||
|
readonly status: "installed";
|
||||||
|
readonly launcherPath: string;
|
||||||
|
readonly cleanupFailure?: string;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
readonly status: "failed";
|
readonly status: "failed";
|
||||||
readonly reason:
|
readonly reason:
|
||||||
@@ -48,6 +55,7 @@ export type MacHopperInstallResult =
|
|||||||
| "destination_exists"
|
| "destination_exists"
|
||||||
| "copy"
|
| "copy"
|
||||||
| "launcher_missing";
|
| "launcher_missing";
|
||||||
|
readonly cleanupFailure?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** External effects used by the bounded macOS Hopper installer. */
|
/** External effects used by the bounded macOS Hopper installer. */
|
||||||
@@ -72,6 +80,8 @@ export interface MacHopperInstallHost {
|
|||||||
launcherReady(path: string): Promise<boolean>;
|
launcherReady(path: string): Promise<boolean>;
|
||||||
openApplication(path: string): Promise<void>;
|
openApplication(path: string): Promise<void>;
|
||||||
cleanup(path: string): Promise<void>;
|
cleanup(path: string): Promise<void>;
|
||||||
|
/** Retain the cleanup capability until the owning setup host retries it. */
|
||||||
|
retainCleanup?(cleanup: () => Promise<void>): void;
|
||||||
destination(): string;
|
destination(): string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,187 +95,221 @@ export const installMacHopper = async (
|
|||||||
): Promise<MacHopperInstallResult> => {
|
): Promise<MacHopperInstallResult> => {
|
||||||
let temporary: string | undefined;
|
let temporary: string | undefined;
|
||||||
let mounted = false;
|
let mounted = false;
|
||||||
|
let result: MacHopperInstallResult;
|
||||||
try {
|
try {
|
||||||
const metadata = await host.download(RELEASES_URL);
|
result = await (async (): Promise<MacHopperInstallResult> => {
|
||||||
if (!metadata.ok) return { status: "failed", reason: "release_metadata" };
|
const metadata = await host.download(RELEASES_URL);
|
||||||
const parsed = releasesSchema.safeParse(parseJson(metadata.bytes));
|
if (!metadata.ok) return { status: "failed", reason: "release_metadata" };
|
||||||
if (!parsed.success)
|
const parsed = releasesSchema.safeParse(parseJson(metadata.bytes));
|
||||||
return { status: "failed", reason: "release_metadata" };
|
if (!parsed.success)
|
||||||
const release = parsed.data["OS X"];
|
return { status: "failed", reason: "release_metadata" };
|
||||||
if (!release.filename.startsWith(DOWNLOAD_PREFIX))
|
const release = parsed.data["OS X"];
|
||||||
return { status: "failed", reason: "release_metadata" };
|
if (!release.filename.startsWith(DOWNLOAD_PREFIX))
|
||||||
const archive = await host.download(release.filename);
|
return { status: "failed", reason: "release_metadata" };
|
||||||
if (!archive.ok) return { status: "failed", reason: "download" };
|
const archive = await host.download(release.filename);
|
||||||
if (!integrityMatches(archive.bytes, release))
|
if (!archive.ok) return { status: "failed", reason: "download" };
|
||||||
return { status: "failed", reason: "integrity" };
|
if (!integrityMatches(archive.bytes, release))
|
||||||
|
return { status: "failed", reason: "integrity" };
|
||||||
|
|
||||||
const destination = host.destination();
|
const destination = host.destination();
|
||||||
const destinationExists = await host.destinationExists(destination);
|
const destinationExists = await host.destinationExists(destination);
|
||||||
if (destinationExists && options.replaceExisting !== true)
|
if (destinationExists && options.replaceExisting !== true)
|
||||||
return { status: "failed", reason: "destination_exists" };
|
return { status: "failed", reason: "destination_exists" };
|
||||||
temporary = await host.createTemporaryDirectory();
|
temporary = await host.createTemporaryDirectory();
|
||||||
const packagePath = join(temporary, "hopper.dmg");
|
const packagePath = join(temporary, "hopper.dmg");
|
||||||
const mountPath = join(temporary, "mounted");
|
const mountPath = join(temporary, "mounted");
|
||||||
await host.createMountDirectory(mountPath);
|
await host.createMountDirectory(mountPath);
|
||||||
await host.writePackage(packagePath, archive.bytes);
|
await host.writePackage(packagePath, archive.bytes);
|
||||||
if (!(await host.mount(packagePath, mountPath)))
|
if (!(await host.mount(packagePath, mountPath)))
|
||||||
return { status: "failed", reason: "mount" };
|
return { status: "failed", reason: "mount" };
|
||||||
mounted = true;
|
mounted = true;
|
||||||
const source = await host.appBundle(mountPath);
|
const source = await host.appBundle(mountPath);
|
||||||
if (source === undefined || !(await host.validBundle(source)))
|
if (source === undefined || !(await host.validBundle(source)))
|
||||||
return { status: "failed", reason: "bundle" };
|
return { status: "failed", reason: "bundle" };
|
||||||
const stage = `${destination}.rea-stage`;
|
const stage = `${destination}.rea-stage`;
|
||||||
if (
|
if (
|
||||||
!(await host.installBundle(source, destination, stage, destinationExists))
|
!(await host.installBundle(
|
||||||
)
|
source,
|
||||||
return { status: "failed", reason: "copy" };
|
destination,
|
||||||
const launcherPath = join(destination, "Contents/MacOS/hopper");
|
stage,
|
||||||
if (!(await host.launcherReady(launcherPath)))
|
destinationExists,
|
||||||
return { status: "failed", reason: "launcher_missing" };
|
))
|
||||||
await host.openApplication(destination);
|
)
|
||||||
return { status: "installed", launcherPath };
|
return { status: "failed", reason: "copy" };
|
||||||
|
const launcherPath = join(destination, "Contents/MacOS/hopper");
|
||||||
|
if (!(await host.launcherReady(launcherPath)))
|
||||||
|
return { status: "failed", reason: "launcher_missing" };
|
||||||
|
await host.openApplication(destination);
|
||||||
|
return { status: "installed", launcherPath };
|
||||||
|
})();
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
// Install-stage failures share the download reason; the stage is cleaned below.
|
// Install-stage failures share the download reason; the stage is cleaned below.
|
||||||
void cause;
|
void cause;
|
||||||
return { status: "failed", reason: "download" };
|
result = { status: "failed", reason: "download" };
|
||||||
} finally {
|
}
|
||||||
if (temporary !== undefined) {
|
|
||||||
const mountPath = join(temporary, "mounted");
|
let cleanupFailure: string | undefined;
|
||||||
if (mounted) await host.unmount(mountPath);
|
if (temporary !== undefined) {
|
||||||
await host.cleanup(temporary);
|
const temporaryPath = temporary;
|
||||||
|
const mountPath = join(temporary, "mounted");
|
||||||
|
const cleanup = async (): Promise<void> => {
|
||||||
|
if (mounted) {
|
||||||
|
await host.unmount(mountPath);
|
||||||
|
mounted = false;
|
||||||
|
}
|
||||||
|
await host.cleanup(temporaryPath);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await cleanup();
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
cleanupFailure = `Hopper DMG cleanup failed: ${failureMessage(cause)}`;
|
||||||
|
host.retainCleanup?.(cleanup);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return cleanupFailure === undefined ? result : { ...result, cleanupFailure };
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Compose installer paths and subprocesses from one selected caller context. */
|
/** Compose installer paths and subprocesses from one selected caller context. */
|
||||||
export const systemMacHopperInstallHost = (
|
export const systemMacHopperInstallHost = (
|
||||||
homeDirectory: string,
|
homeDirectory: string,
|
||||||
environment: Readonly<NodeJS.ProcessEnv>,
|
environment: Readonly<NodeJS.ProcessEnv>,
|
||||||
): MacHopperInstallHost => ({
|
): MacHopperInstallHost & { close(): Promise<string | undefined> } => {
|
||||||
download: downloadPackage,
|
const pendingCleanup = new Set<() => Promise<void>>();
|
||||||
createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-mac-")),
|
return {
|
||||||
createMountDirectory: (path) => mkdir(path),
|
download: downloadHopperPackage,
|
||||||
writePackage: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
|
createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-mac-")),
|
||||||
async mount(packagePath, mountPath) {
|
createMountDirectory: (path) => mkdir(path),
|
||||||
return commandSucceeds(
|
writePackage: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
|
||||||
"hdiutil",
|
async mount(packagePath, mountPath) {
|
||||||
[
|
return commandSucceeds(
|
||||||
"attach",
|
"hdiutil",
|
||||||
"-readonly",
|
[
|
||||||
"-nobrowse",
|
"attach",
|
||||||
"-mountpoint",
|
"-readonly",
|
||||||
mountPath,
|
"-nobrowse",
|
||||||
packagePath,
|
"-mountpoint",
|
||||||
],
|
mountPath,
|
||||||
environment,
|
packagePath,
|
||||||
);
|
],
|
||||||
},
|
|
||||||
async unmount(mountPath) {
|
|
||||||
await commandSucceeds(
|
|
||||||
"hdiutil",
|
|
||||||
["detach", mountPath, "-force"],
|
|
||||||
environment,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
async appBundle(mountPath) {
|
|
||||||
const entries = await readdir(mountPath, { withFileTypes: true });
|
|
||||||
const app = entries.find(
|
|
||||||
(entry) => entry.isDirectory() && /^Hopper.*\.app$/iu.test(entry.name),
|
|
||||||
);
|
|
||||||
return app === undefined ? undefined : join(mountPath, app.name);
|
|
||||||
},
|
|
||||||
async validBundle(appPath) {
|
|
||||||
if (
|
|
||||||
!(await commandSucceeds(
|
|
||||||
"codesign",
|
|
||||||
["--verify", "--deep", "--strict", appPath],
|
|
||||||
environment,
|
environment,
|
||||||
))
|
);
|
||||||
)
|
},
|
||||||
return false;
|
async unmount(mountPath) {
|
||||||
try {
|
if (
|
||||||
const identifier = (
|
!(await commandSucceeds(
|
||||||
await execFileAsync(
|
"hdiutil",
|
||||||
"/usr/libexec/PlistBuddy",
|
["detach", mountPath, "-force"],
|
||||||
[
|
environment,
|
||||||
"-c",
|
))
|
||||||
"Print :CFBundleIdentifier",
|
)
|
||||||
join(appPath, "Contents/Info.plist"),
|
throw new Error(
|
||||||
],
|
`hdiutil detach failed for ${JSON.stringify(mountPath)}`,
|
||||||
{ env: environment },
|
);
|
||||||
)
|
},
|
||||||
).stdout.trim();
|
async appBundle(mountPath) {
|
||||||
return identifier === BUNDLE_IDENTIFIER;
|
const entries = await readdir(mountPath, { withFileTypes: true });
|
||||||
} catch (cause: unknown) {
|
const app = entries.find(
|
||||||
// best-effort cleanup: optional bundle probing; unreadable means invalid.
|
(entry) => entry.isDirectory() && /^Hopper.*\.app$/iu.test(entry.name),
|
||||||
void cause;
|
);
|
||||||
return false;
|
return app === undefined ? undefined : join(mountPath, app.name);
|
||||||
}
|
},
|
||||||
},
|
async validBundle(appPath) {
|
||||||
async destinationExists(path) {
|
if (
|
||||||
try {
|
!(await commandSucceeds(
|
||||||
await access(path);
|
"codesign",
|
||||||
return true;
|
["--verify", "--deep", "--strict", appPath],
|
||||||
} catch (cause: unknown) {
|
environment,
|
||||||
// best-effort cleanup: optional existence probing; absence means missing.
|
))
|
||||||
void cause;
|
)
|
||||||
return false;
|
return false;
|
||||||
}
|
try {
|
||||||
},
|
const identifier = (
|
||||||
async installBundle(source, destination, stage, replaceExisting) {
|
await execFileAsync(
|
||||||
const previous = `${stage}-previous`;
|
"/usr/libexec/PlistBuddy",
|
||||||
try {
|
[
|
||||||
await mkdir(join(homeDirectory, "Applications"), { recursive: true });
|
"-c",
|
||||||
await rm(stage, { recursive: true, force: true });
|
"Print :CFBundleIdentifier",
|
||||||
await rm(previous, { recursive: true, force: true });
|
join(appPath, "Contents/Info.plist"),
|
||||||
await execFileAsync("ditto", [source, stage], { env: environment });
|
],
|
||||||
await access(join(stage, "Contents/MacOS/hopper"));
|
{ env: environment },
|
||||||
if (replaceExisting) await rename(destination, previous);
|
)
|
||||||
await rename(stage, destination);
|
).stdout.trim();
|
||||||
if (replaceExisting) await rm(previous, { recursive: true, force: true });
|
return identifier === BUNDLE_IDENTIFIER;
|
||||||
return true;
|
} catch (cause: unknown) {
|
||||||
} catch (cause: unknown) {
|
// best-effort cleanup: optional bundle probing; unreadable means invalid.
|
||||||
// Install failure rolls back below; the false return reports it.
|
void cause;
|
||||||
void cause;
|
return false;
|
||||||
await rm(stage, { recursive: true, force: true });
|
}
|
||||||
if (replaceExisting) {
|
},
|
||||||
|
async destinationExists(path) {
|
||||||
|
try {
|
||||||
|
await access(path);
|
||||||
|
return true;
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
// best-effort cleanup: optional existence probing; absence means missing.
|
||||||
|
void cause;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async installBundle(source, destination, stage, replaceExisting) {
|
||||||
|
const previous = `${stage}-previous`;
|
||||||
|
try {
|
||||||
|
await mkdir(join(homeDirectory, "Applications"), { recursive: true });
|
||||||
|
await rm(stage, { recursive: true, force: true });
|
||||||
|
await rm(previous, { recursive: true, force: true });
|
||||||
|
await execFileAsync("ditto", [source, stage], { env: environment });
|
||||||
|
await access(join(stage, "Contents/MacOS/hopper"));
|
||||||
|
if (replaceExisting) await rename(destination, previous);
|
||||||
|
await rename(stage, destination);
|
||||||
|
if (replaceExisting)
|
||||||
|
await rm(previous, { recursive: true, force: true });
|
||||||
|
return true;
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
// Install failure rolls back below; the false return reports it.
|
||||||
|
void cause;
|
||||||
|
await rm(stage, { recursive: true, force: true });
|
||||||
|
if (replaceExisting) {
|
||||||
|
try {
|
||||||
|
await rename(previous, destination);
|
||||||
|
} catch (rollbackCause: unknown) {
|
||||||
|
// The original destination was not moved, or rollback is impossible.
|
||||||
|
void rollbackCause;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async launcherReady(path) {
|
||||||
|
try {
|
||||||
|
await access(path);
|
||||||
|
return true;
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
// best-effort cleanup: optional launcher probing; absence means not ready.
|
||||||
|
void cause;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async openApplication(path) {
|
||||||
|
await execFileAsync("open", [path], { env: environment });
|
||||||
|
},
|
||||||
|
cleanup: (path) => rm(path, { recursive: true, force: true }),
|
||||||
|
retainCleanup: (cleanup) => pendingCleanup.add(cleanup),
|
||||||
|
async close() {
|
||||||
|
let failure: string | undefined;
|
||||||
|
for (const cleanup of pendingCleanup) {
|
||||||
try {
|
try {
|
||||||
await rename(previous, destination);
|
await cleanup();
|
||||||
} catch (rollbackCause: unknown) {
|
pendingCleanup.delete(cleanup);
|
||||||
// The original destination was not moved, or rollback is impossible.
|
} catch (cause: unknown) {
|
||||||
void rollbackCause;
|
failure ??= `Hopper DMG cleanup remains incomplete: ${failureMessage(cause)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return failure;
|
||||||
}
|
},
|
||||||
},
|
destination: () =>
|
||||||
async launcherReady(path) {
|
join(homeDirectory, "Applications/Hopper Disassembler.app"),
|
||||||
try {
|
};
|
||||||
await access(path);
|
|
||||||
return true;
|
|
||||||
} catch (cause: unknown) {
|
|
||||||
// best-effort cleanup: optional launcher probing; absence means not ready.
|
|
||||||
void cause;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async openApplication(path) {
|
|
||||||
await execFileAsync("open", [path], { env: environment });
|
|
||||||
},
|
|
||||||
cleanup: (path) => rm(path, { recursive: true, force: true }),
|
|
||||||
destination: () =>
|
|
||||||
join(homeDirectory, "Applications/Hopper Disassembler.app"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const downloadPackage = async (url: string) => {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
headers: { accept: "application/json", "user-agent": "rea-installer" },
|
|
||||||
signal: AbortSignal.timeout(120_000),
|
|
||||||
});
|
|
||||||
const length = Number(response.headers.get("content-length") ?? "0");
|
|
||||||
if (length > MAX_PACKAGE_BYTES) return { ok: false, bytes: new Uint8Array() };
|
|
||||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
||||||
return { ok: response.ok && bytes.byteLength <= MAX_PACKAGE_BYTES, bytes };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseJson = (bytes: Uint8Array): unknown =>
|
const parseJson = (bytes: Uint8Array): unknown =>
|
||||||
@@ -275,7 +319,7 @@ const integrityMatches = (
|
|||||||
release: z.infer<typeof releaseSchema>,
|
release: z.infer<typeof releaseSchema>,
|
||||||
): boolean =>
|
): boolean =>
|
||||||
bytes.byteLength === Number(release.file_length) &&
|
bytes.byteLength === Number(release.file_length) &&
|
||||||
bytes.byteLength <= MAX_PACKAGE_BYTES &&
|
bytes.byteLength <= HOPPER_DOWNLOAD_BYTES &&
|
||||||
createHash("sha1").update(bytes).digest("hex") === release.file_hash;
|
createHash("sha1").update(bytes).digest("hex") === release.file_hash;
|
||||||
const commandSucceeds = async (
|
const commandSucceeds = async (
|
||||||
command: string,
|
command: string,
|
||||||
@@ -291,6 +335,8 @@ const commandSucceeds = async (
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const failureMessage = (cause: unknown): string =>
|
||||||
|
cause instanceof Error ? cause.message : String(cause);
|
||||||
const releaseSchema = z.object({
|
const releaseSchema = z.object({
|
||||||
filename: z.string().url(),
|
filename: z.string().url(),
|
||||||
file_length: z.string().regex(/^\d+$/u),
|
file_length: z.string().regex(/^\d+$/u),
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { posix } from "node:path";
|
import { posix } from "node:path";
|
||||||
|
|
||||||
|
import { logicalPathEscapesRoot } from "../domain/artifactIdentity.js";
|
||||||
import {
|
import {
|
||||||
logicalPathEscapesRoot,
|
createReferenceSourcePathLookup,
|
||||||
normalizeJoinedLogicalPath,
|
isPortableAbsoluteReferenceTarget,
|
||||||
} from "../domain/artifactIdentity.js";
|
type ReferenceSourcePathLookup,
|
||||||
|
} from "../domain/referenceSourcePathIdentity.js";
|
||||||
import {
|
import {
|
||||||
classifyReferenceSourcePath,
|
classifyReferenceSourcePath,
|
||||||
detectReferenceSourceLanguage,
|
detectReferenceSourceLanguage,
|
||||||
@@ -83,11 +85,11 @@ const failedEntry = (
|
|||||||
const resolveInternalSpecifier = (
|
const resolveInternalSpecifier = (
|
||||||
fromPath: string,
|
fromPath: string,
|
||||||
specifier: string,
|
specifier: string,
|
||||||
filePaths: ReadonlySet<string>,
|
lookupPath: ReferenceSourcePathLookup,
|
||||||
): { to: string; resolution: "internal" | "unresolved" } => {
|
): { to: string; resolution: "internal" | "unresolved" } => {
|
||||||
if (posix.isAbsolute(specifier))
|
if (posix.isAbsolute(specifier))
|
||||||
return { to: specifier, resolution: "unresolved" };
|
return { to: specifier, resolution: "unresolved" };
|
||||||
const normalized = normalizeJoinedLogicalPath(
|
const normalized = posix.normalize(
|
||||||
posix.join(posix.dirname(fromPath), specifier),
|
posix.join(posix.dirname(fromPath), specifier),
|
||||||
);
|
);
|
||||||
if (logicalPathEscapesRoot(normalized))
|
if (logicalPathEscapesRoot(normalized))
|
||||||
@@ -113,7 +115,7 @@ const resolveInternalSpecifier = (
|
|||||||
const stem = normalized.slice(0, -posix.extname(normalized).length);
|
const stem = normalized.slice(0, -posix.extname(normalized).length);
|
||||||
candidates.push(...sourceSuffixes.map((suffix) => `${stem}${suffix}`));
|
candidates.push(...sourceSuffixes.map((suffix) => `${stem}${suffix}`));
|
||||||
}
|
}
|
||||||
const match = candidates.find((candidate) => filePaths.has(candidate));
|
const match = lookupPath.resolveCandidates(candidates);
|
||||||
return match === undefined
|
return match === undefined
|
||||||
? { to: normalized, resolution: "unresolved" }
|
? { to: normalized, resolution: "unresolved" }
|
||||||
: { to: match, resolution: "internal" };
|
: { to: match, resolution: "internal" };
|
||||||
@@ -121,7 +123,7 @@ const resolveInternalSpecifier = (
|
|||||||
|
|
||||||
const appendFile = (
|
const appendFile = (
|
||||||
entry: Extract<ReferenceSourceEntry, { status: "read"; kind: "file" }>,
|
entry: Extract<ReferenceSourceEntry, { status: "read"; kind: "file" }>,
|
||||||
filePaths: ReadonlySet<string>,
|
lookupPath: ReferenceSourcePathLookup,
|
||||||
output: ParsedReferenceSourceEntries,
|
output: ParsedReferenceSourceEntries,
|
||||||
): void => {
|
): void => {
|
||||||
const language = detectReferenceSourceLanguage(entry.path);
|
const language = detectReferenceSourceLanguage(entry.path);
|
||||||
@@ -146,7 +148,7 @@ const appendFile = (
|
|||||||
const resolved = resolveInternalSpecifier(
|
const resolved = resolveInternalSpecifier(
|
||||||
relationship.from_path,
|
relationship.from_path,
|
||||||
relationship.to,
|
relationship.to,
|
||||||
filePaths,
|
lookupPath,
|
||||||
);
|
);
|
||||||
output.relationships.push({
|
output.relationships.push({
|
||||||
...relationship,
|
...relationship,
|
||||||
@@ -159,11 +161,11 @@ const appendFile = (
|
|||||||
|
|
||||||
const appendReadEntry = (
|
const appendReadEntry = (
|
||||||
entry: Extract<ReferenceSourceEntry, { status: "read" }>,
|
entry: Extract<ReferenceSourceEntry, { status: "read" }>,
|
||||||
filePaths: ReadonlySet<string>,
|
lookupPath: ReferenceSourcePathLookup,
|
||||||
output: ParsedReferenceSourceEntries,
|
output: ParsedReferenceSourceEntries,
|
||||||
): void => {
|
): void => {
|
||||||
if (entry.kind === "file") {
|
if (entry.kind === "file") {
|
||||||
appendFile(entry, filePaths, output);
|
appendFile(entry, lookupPath, output);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const classifications = classifyReferenceSourcePath(entry.path);
|
const classifications = classifyReferenceSourcePath(entry.path);
|
||||||
@@ -180,7 +182,14 @@ const appendReadEntry = (
|
|||||||
output.entries.push({
|
output.entries.push({
|
||||||
path: entry.path,
|
path: entry.path,
|
||||||
kind: "symlink",
|
kind: "symlink",
|
||||||
target: entry.target,
|
// The reader resolves inventory identities from its root; the persisted
|
||||||
|
// graph represents relative symlink targets from the link's directory.
|
||||||
|
target:
|
||||||
|
entry.targetState === "internal" ||
|
||||||
|
(entry.targetState === "missing" &&
|
||||||
|
!isPortableAbsoluteReferenceTarget(entry.target))
|
||||||
|
? posix.relative(posix.dirname(entry.path), entry.target) || "."
|
||||||
|
: entry.target,
|
||||||
target_state: entry.targetState,
|
target_state: entry.targetState,
|
||||||
classifications,
|
classifications,
|
||||||
limitations: [],
|
limitations: [],
|
||||||
@@ -199,10 +208,11 @@ export const parseReferenceSourceEntries = (
|
|||||||
parseFailures: [],
|
parseFailures: [],
|
||||||
limitations: [...read.limitations],
|
limitations: [...read.limitations],
|
||||||
};
|
};
|
||||||
|
const lookupPath = createReferenceSourcePathLookup(filePaths);
|
||||||
for (const entry of read.entries) {
|
for (const entry of read.entries) {
|
||||||
if (signal?.aborted === true) break;
|
if (signal?.aborted === true) break;
|
||||||
if (entry.status === "failed") output.entries.push(failedEntry(entry));
|
if (entry.status === "failed") output.entries.push(failedEntry(entry));
|
||||||
else appendReadEntry(entry, filePaths, output);
|
else appendReadEntry(entry, lookupPath, output);
|
||||||
}
|
}
|
||||||
return output;
|
return output;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,6 +29,49 @@ export const runSetup = async (
|
|||||||
options: SetupOptions,
|
options: SetupOptions,
|
||||||
host: SetupHost = systemSetupHost(),
|
host: SetupHost = systemSetupHost(),
|
||||||
confirm?: SetupConfirmation,
|
confirm?: SetupConfirmation,
|
||||||
|
): Promise<SetupResult> => {
|
||||||
|
let result: SetupResult | undefined;
|
||||||
|
let primaryFailure: unknown;
|
||||||
|
let threw = false;
|
||||||
|
try {
|
||||||
|
result = await runSetupWorkflow(options, host, confirm);
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
threw = true;
|
||||||
|
primaryFailure = cause;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cleanupFailure: string | undefined;
|
||||||
|
try {
|
||||||
|
cleanupFailure = await host.close?.();
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
cleanupFailure = `Setup resource cleanup failed: ${failureMessage(cause)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (threw) {
|
||||||
|
if (cleanupFailure !== undefined)
|
||||||
|
throw new AggregateError(
|
||||||
|
[primaryFailure, new Error(cleanupFailure)],
|
||||||
|
"Setup and resource cleanup failed.",
|
||||||
|
{ cause: primaryFailure },
|
||||||
|
);
|
||||||
|
throw primaryFailure;
|
||||||
|
}
|
||||||
|
if (result === undefined) throw new Error("Setup returned no result.");
|
||||||
|
if (cleanupFailure === undefined) return result;
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
status: "needs_human",
|
||||||
|
code: result.code ?? "cleanup_failed",
|
||||||
|
remediation: result.remediation
|
||||||
|
? `${result.remediation} ${cleanupFailure}`
|
||||||
|
: cleanupFailure,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const runSetupWorkflow = async (
|
||||||
|
options: SetupOptions,
|
||||||
|
host: SetupHost,
|
||||||
|
confirm?: SetupConfirmation,
|
||||||
): Promise<SetupResult> => {
|
): Promise<SetupResult> => {
|
||||||
const appliedActions: string[] = [];
|
const appliedActions: string[] = [];
|
||||||
let plannedActions: readonly SetupAction[] = [];
|
let plannedActions: readonly SetupAction[] = [];
|
||||||
@@ -59,6 +102,7 @@ export const runSetup = async (
|
|||||||
options.readinessScope?.clients === undefined &&
|
options.readinessScope?.clients === undefined &&
|
||||||
options.allDetectedClients !== true &&
|
options.allDetectedClients !== true &&
|
||||||
options.installHopper !== true &&
|
options.installHopper !== true &&
|
||||||
|
options.skillClientIds === undefined &&
|
||||||
options.installSkill === undefined;
|
options.installSkill === undefined;
|
||||||
const interactiveSelection =
|
const interactiveSelection =
|
||||||
confirm !== undefined && !options.structured && clientSelectionAllowed;
|
confirm !== undefined && !options.structured && clientSelectionAllowed;
|
||||||
@@ -67,10 +111,12 @@ export const runSetup = async (
|
|||||||
: (options.clientIds ??
|
: (options.clientIds ??
|
||||||
options.readinessScope?.clients ??
|
options.readinessScope?.clients ??
|
||||||
discovery.defaultClientIds);
|
discovery.defaultClientIds);
|
||||||
let skillNeedsInstall = await host.skillNeedsInstall(selectedClientIds);
|
let selectedSkillClientIds = options.skillClientIds ?? selectedClientIds;
|
||||||
|
let skillNeedsInstall = await host.skillNeedsInstall(selectedSkillClientIds);
|
||||||
let skillSelected =
|
let skillSelected =
|
||||||
options.installSkill === true ||
|
options.installSkill === true ||
|
||||||
(options.installSkill !== false && selectedClientIds.length > 0);
|
(options.installSkill !== false &&
|
||||||
|
(selectedClientIds.length > 0 || selectedSkillClientIds.length > 0));
|
||||||
let installSkill = skillSelected && skillNeedsInstall;
|
let installSkill = skillSelected && skillNeedsInstall;
|
||||||
let planDiscovery = discovery;
|
let planDiscovery = discovery;
|
||||||
if (interactiveSelection) {
|
if (interactiveSelection) {
|
||||||
@@ -138,6 +184,7 @@ export const runSetup = async (
|
|||||||
selectedClientIds.length === 0) ||
|
selectedClientIds.length === 0) ||
|
||||||
(options.installSkill !== false && selectedClientIds.length > 0);
|
(options.installSkill !== false && selectedClientIds.length > 0);
|
||||||
installSkill = skillSelected && skillNeedsInstall;
|
installSkill = skillSelected && skillNeedsInstall;
|
||||||
|
selectedSkillClientIds = selectedClientIds;
|
||||||
}
|
}
|
||||||
const planned = await planSetupActions({
|
const planned = await planSetupActions({
|
||||||
discovery: planDiscovery,
|
discovery: planDiscovery,
|
||||||
@@ -145,7 +192,7 @@ export const runSetup = async (
|
|||||||
providerEnvironment,
|
providerEnvironment,
|
||||||
command: host.registrationCommand,
|
command: host.registrationCommand,
|
||||||
clientIds: selectedClientIds,
|
clientIds: selectedClientIds,
|
||||||
skillClientIds: selectedClientIds,
|
skillClientIds: selectedSkillClientIds,
|
||||||
installSkill,
|
installSkill,
|
||||||
});
|
});
|
||||||
plannedActions = planned.plannedActions;
|
plannedActions = planned.plannedActions;
|
||||||
@@ -256,7 +303,7 @@ export const runSetup = async (
|
|||||||
host,
|
host,
|
||||||
options,
|
options,
|
||||||
appliedActions,
|
appliedActions,
|
||||||
selectedClientIds,
|
selectedSkillClientIds,
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
return fail(
|
return fail(
|
||||||
@@ -325,8 +372,11 @@ const installHopperAction = async (input: {
|
|||||||
emitProgress(input.options, {
|
emitProgress(input.options, {
|
||||||
actionId: "install_hopper",
|
actionId: "install_hopper",
|
||||||
label,
|
label,
|
||||||
state: "completed",
|
state: installed.cleanupFailure === undefined ? "completed" : "warning",
|
||||||
detail: installed.launcherPath,
|
detail:
|
||||||
|
installed.cleanupFailure === undefined
|
||||||
|
? installed.launcherPath
|
||||||
|
: `${installed.launcherPath}; ${installed.cleanupFailure}`,
|
||||||
});
|
});
|
||||||
const providerEnvironment = {
|
const providerEnvironment = {
|
||||||
...input.providerEnvironment,
|
...input.providerEnvironment,
|
||||||
@@ -492,3 +542,6 @@ const summarizeDoctor = (report: DoctorReport): SetupDoctorSummary => ({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const failureMessage = (cause: unknown): string =>
|
||||||
|
cause instanceof Error ? cause.message : String(cause);
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ export const systemSetupHost = (
|
|||||||
const doctorHost = selectedDoctorHost ?? systemDoctorHost({ environment });
|
const doctorHost = selectedDoctorHost ?? systemDoctorHost({ environment });
|
||||||
const platform = doctorHost.platform;
|
const platform = doctorHost.platform;
|
||||||
const { homeDirectory } = doctorHost;
|
const { homeDirectory } = doctorHost;
|
||||||
|
const macHopperHost =
|
||||||
|
platform === "darwin"
|
||||||
|
? systemMacHopperInstallHost(homeDirectory, environment)
|
||||||
|
: undefined;
|
||||||
return {
|
return {
|
||||||
platform,
|
platform,
|
||||||
homeDirectory,
|
homeDirectory,
|
||||||
@@ -106,6 +110,7 @@ export const systemSetupHost = (
|
|||||||
nodeVersion: process.versions.node,
|
nodeVersion: process.versions.node,
|
||||||
macosVersion: () => doctorHost.macosVersion(),
|
macosVersion: () => doctorHost.macosVersion(),
|
||||||
linuxDistribution: readLinuxDistribution,
|
linuxDistribution: readLinuxDistribution,
|
||||||
|
close: () => macHopperHost?.close() ?? Promise.resolve(undefined),
|
||||||
initialSetupState: async (
|
initialSetupState: async (
|
||||||
scope?: DoctorScope,
|
scope?: DoctorScope,
|
||||||
): Promise<SetupInitialState> => {
|
): Promise<SetupInitialState> => {
|
||||||
@@ -126,15 +131,33 @@ export const systemSetupHost = (
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
installHopper: async (replaceExisting) => {
|
installHopper: async (replaceExisting) => {
|
||||||
const result =
|
if (platform === "linux") {
|
||||||
platform === "linux"
|
const result = await installLinuxHopper(
|
||||||
? await installLinuxHopper(systemLinuxHopperInstallHost(environment))
|
systemLinuxHopperInstallHost(environment),
|
||||||
: await installMacHopper(
|
);
|
||||||
{ replaceExisting },
|
return result.status === "installed"
|
||||||
systemMacHopperInstallHost(homeDirectory, environment),
|
? result
|
||||||
);
|
: setupInstallFailure(result.reason);
|
||||||
if (result.status === "installed") return result;
|
}
|
||||||
return setupInstallFailure(result.reason);
|
if (macHopperHost === undefined)
|
||||||
|
return setupInstallFailure("unsupported_host");
|
||||||
|
const result = await installMacHopper({ replaceExisting }, macHopperHost);
|
||||||
|
if (result.status === "installed")
|
||||||
|
return {
|
||||||
|
status: "installed",
|
||||||
|
launcherPath: result.launcherPath,
|
||||||
|
...(result.cleanupFailure === undefined
|
||||||
|
? {}
|
||||||
|
: { cleanupFailure: result.cleanupFailure }),
|
||||||
|
};
|
||||||
|
const failure = setupInstallFailure(result.reason);
|
||||||
|
if (result.cleanupFailure === undefined) return failure;
|
||||||
|
return failure.status === "failed"
|
||||||
|
? {
|
||||||
|
...failure,
|
||||||
|
remediation: `${failure.remediation} ${result.cleanupFailure}`,
|
||||||
|
}
|
||||||
|
: failure;
|
||||||
},
|
},
|
||||||
detectedClients: () => detectClients(homeDirectory, platform, environment),
|
detectedClients: () => detectClients(homeDirectory, platform, environment),
|
||||||
supportedClients: () =>
|
supportedClients: () =>
|
||||||
|
|||||||
@@ -10,10 +10,15 @@ export type SetupFailureCode =
|
|||||||
| "destination_exists"
|
| "destination_exists"
|
||||||
| "mount_failed"
|
| "mount_failed"
|
||||||
| "bundle_invalid"
|
| "bundle_invalid"
|
||||||
| "copy_failed";
|
| "copy_failed"
|
||||||
|
| "cleanup_failed";
|
||||||
|
|
||||||
export type SetupHopperInstallResult =
|
export type SetupHopperInstallResult =
|
||||||
| { readonly status: "installed"; readonly launcherPath: string }
|
| {
|
||||||
|
readonly status: "installed";
|
||||||
|
readonly launcherPath: string;
|
||||||
|
readonly cleanupFailure?: string;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
readonly status: "failed";
|
readonly status: "failed";
|
||||||
readonly code: SetupFailureCode;
|
readonly code: SetupFailureCode;
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { mkdir, readFile, rm } from "node:fs/promises";
|
import { copyFile, lstat, mkdir, readFile, rm } from "node:fs/promises";
|
||||||
|
import { constants as fsConstants } from "node:fs";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import writeFileAtomic from "write-file-atomic";
|
import writeFileAtomic from "write-file-atomic";
|
||||||
|
|
||||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||||
|
import {
|
||||||
|
NonRegularFileReadError,
|
||||||
|
openRegularFile,
|
||||||
|
} from "../filesystem/RegularFile.js";
|
||||||
import { clientSkillDirectories } from "./SupportedClients.js";
|
import { clientSkillDirectories } from "./SupportedClients.js";
|
||||||
|
|
||||||
const SKILL_FILES = [
|
export const MANAGED_SKILL_FILES = [
|
||||||
"SKILL.md",
|
"SKILL.md",
|
||||||
"references/native-and-artifacts.md",
|
"references/native-and-artifacts.md",
|
||||||
"references/javascript-applications.md",
|
"references/javascript-applications.md",
|
||||||
@@ -14,6 +19,19 @@ const SKILL_FILES = [
|
|||||||
"references/evidence-workflows.md",
|
"references/evidence-workflows.md",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export const isManagedSkillManifest = (content: string): boolean =>
|
||||||
|
content.startsWith(`---\nname: ${PRODUCT_IDENTITY.skillName}\n`) ||
|
||||||
|
content.startsWith(`---\r\nname: ${PRODUCT_IDENTITY.skillName}\r\n`);
|
||||||
|
|
||||||
|
export const readSkillFile = async (path: string): Promise<string> => {
|
||||||
|
const handle = await openRegularFile(path, { symlinks: "reject" });
|
||||||
|
try {
|
||||||
|
return await handle.readFile("utf8");
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
interface CanonicalSkillFile {
|
interface CanonicalSkillFile {
|
||||||
readonly content: string;
|
readonly content: string;
|
||||||
readonly destination: string;
|
readonly destination: string;
|
||||||
@@ -47,9 +65,38 @@ export const skillDestinations = (
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Resolve existing REA-owned skill roots using setup's canonical destinations. */
|
||||||
|
export const existingSkillDestinations = async (
|
||||||
|
home: string,
|
||||||
|
environment: Readonly<NodeJS.ProcessEnv> = {},
|
||||||
|
platform: NodeJS.Platform = process.platform,
|
||||||
|
): Promise<readonly SkillDestination[]> => {
|
||||||
|
const existing: SkillDestination[] = [];
|
||||||
|
for (const destination of skillDestinations(
|
||||||
|
home,
|
||||||
|
undefined,
|
||||||
|
environment,
|
||||||
|
platform,
|
||||||
|
)) {
|
||||||
|
try {
|
||||||
|
const root = await lstat(destination.path);
|
||||||
|
if (root.isSymbolicLink() || !root.isDirectory()) continue;
|
||||||
|
const manifest = join(destination.path, "SKILL.md");
|
||||||
|
const content = await readSkillFile(manifest);
|
||||||
|
if (isManagedSkillManifest(content)) existing.push(destination);
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
||||||
|
continue;
|
||||||
|
if (cause instanceof NonRegularFileReadError) continue;
|
||||||
|
throw cause;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return existing;
|
||||||
|
};
|
||||||
|
|
||||||
const readOptionalText = async (path: string): Promise<string | undefined> => {
|
const readOptionalText = async (path: string): Promise<string | undefined> => {
|
||||||
try {
|
try {
|
||||||
return await readFile(path, "utf8");
|
return await readSkillFile(path);
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -66,7 +113,7 @@ const canonicalSkillFiles = async (
|
|||||||
Promise.all(
|
Promise.all(
|
||||||
skillDestinations(home, clientIds, environment, platform).flatMap(
|
skillDestinations(home, clientIds, environment, platform).flatMap(
|
||||||
({ path: root }) =>
|
({ path: root }) =>
|
||||||
SKILL_FILES.map(async (relativePath) => {
|
MANAGED_SKILL_FILES.map(async (relativePath) => {
|
||||||
const destination = join(root, relativePath);
|
const destination = join(root, relativePath);
|
||||||
return {
|
return {
|
||||||
destination,
|
destination,
|
||||||
@@ -104,6 +151,19 @@ export const canonicalSkillNeedsInstall = async (
|
|||||||
const writeText = (path: string, content: string): Promise<void> =>
|
const writeText = (path: string, content: string): Promise<void> =>
|
||||||
writeFileAtomic(path, content, { encoding: "utf8", mode: 0o600 });
|
writeFileAtomic(path, content, { encoding: "utf8", mode: 0o600 });
|
||||||
|
|
||||||
|
const preserveSkillBackup = async (
|
||||||
|
source: string,
|
||||||
|
destination: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await copyFile(source, destination, fsConstants.COPYFILE_EXCL);
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
if (cause instanceof Error && "code" in cause && cause.code === "EEXIST")
|
||||||
|
return;
|
||||||
|
throw cause;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const restoreSkillFiles = async (
|
const restoreSkillFiles = async (
|
||||||
changed: readonly CanonicalSkillFile[],
|
changed: readonly CanonicalSkillFile[],
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
@@ -134,7 +194,7 @@ export const installCanonicalSkill = async (
|
|||||||
for (const { destination, original } of changed) {
|
for (const { destination, original } of changed) {
|
||||||
await mkdir(dirname(destination), { recursive: true });
|
await mkdir(dirname(destination), { recursive: true });
|
||||||
if (original !== undefined)
|
if (original !== undefined)
|
||||||
await writeText(`${destination}.rea.backup`, original);
|
await preserveSkillBackup(destination, `${destination}.rea.backup`);
|
||||||
}
|
}
|
||||||
for (const { destination, content } of changed)
|
for (const { destination, content } of changed)
|
||||||
await writeText(destination, content);
|
await writeText(destination, content);
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ export interface SetupHost {
|
|||||||
linuxDistribution(): Promise<LinuxDistribution | undefined>;
|
linuxDistribution(): Promise<LinuxDistribution | undefined>;
|
||||||
initialSetupState(scope?: DoctorScope): Promise<SetupInitialState>;
|
initialSetupState(scope?: DoctorScope): Promise<SetupInitialState>;
|
||||||
installHopper(replaceExisting: boolean): Promise<SetupHopperInstallResult>;
|
installHopper(replaceExisting: boolean): Promise<SetupHopperInstallResult>;
|
||||||
|
/** Retry and report any cleanup resources still owned by this setup host. */
|
||||||
|
close?(): Promise<string | undefined>;
|
||||||
detectedClients(): Promise<readonly SetupClient[]>;
|
detectedClients(): Promise<readonly SetupClient[]>;
|
||||||
supportedClients(): Promise<readonly SetupClient[]>;
|
supportedClients(): Promise<readonly SetupClient[]>;
|
||||||
configureClient(
|
configureClient(
|
||||||
@@ -155,6 +157,8 @@ export interface SetupOptions {
|
|||||||
readonly structured: boolean;
|
readonly structured: boolean;
|
||||||
readonly proposeHopper?: boolean;
|
readonly proposeHopper?: boolean;
|
||||||
readonly clientIds?: readonly string[];
|
readonly clientIds?: readonly string[];
|
||||||
|
/** Skill roots to update independently of MCP registrations (maintenance). */
|
||||||
|
readonly skillClientIds?: readonly string[];
|
||||||
readonly installSkill?: boolean;
|
readonly installSkill?: boolean;
|
||||||
readonly readinessScope?: DoctorScope;
|
readonly readinessScope?: DoctorScope;
|
||||||
readonly dryRun?: boolean;
|
readonly dryRun?: boolean;
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ import writeFileAtomic from "write-file-atomic";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||||
import { skillDestinations } from "./SetupSkill.js";
|
import {
|
||||||
|
isManagedSkillManifest,
|
||||||
|
MANAGED_SKILL_FILES,
|
||||||
|
readSkillFile,
|
||||||
|
skillDestinations,
|
||||||
|
} from "./SetupSkill.js";
|
||||||
import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
|
import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
|
||||||
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
|
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
|
||||||
import {
|
import {
|
||||||
@@ -27,29 +32,32 @@ import {
|
|||||||
interface ManagedPathStats {
|
interface ManagedPathStats {
|
||||||
readonly uid?: number;
|
readonly uid?: number;
|
||||||
isFile?(): boolean;
|
isFile?(): boolean;
|
||||||
|
isDirectory?(): boolean;
|
||||||
isSymbolicLink(): boolean;
|
isSymbolicLink(): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Injectable filesystem operations used to prove uninstall failure recovery. */
|
/** Injectable filesystem operations used to prove uninstall failure recovery. */
|
||||||
export interface UninstallFileSystem {
|
export interface UninstallFileSystem {
|
||||||
readText(path: string): Promise<string>;
|
readText(path: string): Promise<string>;
|
||||||
|
readRegularText?(path: string): Promise<string>;
|
||||||
/** Preserve the first backup; reject an existing destination with EEXIST. */
|
/** Preserve the first backup; reject an existing destination with EEXIST. */
|
||||||
copy(source: string, destination: string): Promise<void>;
|
copy(source: string, destination: string): Promise<void>;
|
||||||
writeText(path: string, contents: string): Promise<void>;
|
writeText(path: string, contents: string): Promise<void>;
|
||||||
stat(path: string): Promise<ManagedPathStats>;
|
stat(path: string): Promise<ManagedPathStats>;
|
||||||
realpath?(path: string): Promise<string>;
|
realpath?(path: string): Promise<string>;
|
||||||
remove(path: string): Promise<void>;
|
remove(path: string, recursive?: boolean): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemFileSystem: UninstallFileSystem = {
|
const systemFileSystem: UninstallFileSystem = {
|
||||||
readText: (path) => readFile(path, "utf8"),
|
readText: (path) => readFile(path, "utf8"),
|
||||||
|
readRegularText: readSkillFile,
|
||||||
copy: (source, destination) =>
|
copy: (source, destination) =>
|
||||||
copyFile(source, destination, fsConstants.COPYFILE_EXCL),
|
copyFile(source, destination, fsConstants.COPYFILE_EXCL),
|
||||||
writeText: (path, contents) =>
|
writeText: (path, contents) =>
|
||||||
writeFileAtomic(path, contents, { encoding: "utf8" }),
|
writeFileAtomic(path, contents, { encoding: "utf8" }),
|
||||||
stat: (path) => lstat(path),
|
stat: (path) => lstat(path),
|
||||||
realpath: (path) => realpath(path),
|
realpath: (path) => realpath(path),
|
||||||
remove: (path) => rm(path, { recursive: true }),
|
remove: (path, recursive = true) => rm(path, { recursive }),
|
||||||
};
|
};
|
||||||
|
|
||||||
/** One explicitly classified uninstall action. */
|
/** One explicitly classified uninstall action. */
|
||||||
@@ -422,7 +430,7 @@ const removeManagedSkills = async (
|
|||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
skillDestinations(home, undefined, environment, platform).map(
|
skillDestinations(home, undefined, environment, platform).map(
|
||||||
({ client, path }) =>
|
({ client, path }) =>
|
||||||
removeManagedPath(
|
removeManagedSkill(
|
||||||
path,
|
path,
|
||||||
client === "claude_code" ? "Claude Code skill" : "skill",
|
client === "claude_code" ? "Claude Code skill" : "skill",
|
||||||
fileSystem,
|
fileSystem,
|
||||||
@@ -457,6 +465,146 @@ const removeManagedSkills = async (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const removeManagedSkill = async (
|
||||||
|
path: string,
|
||||||
|
name: string,
|
||||||
|
fileSystem: UninstallFileSystem,
|
||||||
|
): Promise<UninstallItem> => {
|
||||||
|
let root: ManagedPathStats;
|
||||||
|
try {
|
||||||
|
root = await fileSystem.stat(path);
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
return isMissing(cause)
|
||||||
|
? item(name, "skipped", "Skill directory does not exist.")
|
||||||
|
: item(name, "failed", "Skill directory could not be inspected.");
|
||||||
|
}
|
||||||
|
if (root.isSymbolicLink())
|
||||||
|
return item(name, "retained", "Skill directory is a symbolic link.");
|
||||||
|
if (root.isDirectory?.() !== true)
|
||||||
|
return item(name, "retained", "Skill path is not a directory.");
|
||||||
|
|
||||||
|
const manifestPath = join(path, "SKILL.md");
|
||||||
|
try {
|
||||||
|
const manifest = await fileSystem.stat(manifestPath);
|
||||||
|
if (manifest.isSymbolicLink())
|
||||||
|
return item(name, "retained", "Skill manifest is a symbolic link.");
|
||||||
|
if (manifest.isFile?.() !== true)
|
||||||
|
return item(name, "retained", "Skill manifest is not a regular file.");
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
return isMissing(cause)
|
||||||
|
? item(name, "retained", "Skill ownership could not be established.")
|
||||||
|
: item(name, "failed", "Skill manifest could not be inspected.");
|
||||||
|
}
|
||||||
|
let content: string;
|
||||||
|
try {
|
||||||
|
content = await (fileSystem.readRegularText?.(manifestPath) ??
|
||||||
|
fileSystem.readText(manifestPath));
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
return isMissing(cause)
|
||||||
|
? item(name, "retained", "Skill ownership could not be established.")
|
||||||
|
: item(name, "failed", "Skill manifest could not be read.");
|
||||||
|
}
|
||||||
|
if (!isManagedSkillManifest(content))
|
||||||
|
return item(name, "retained", "Skill manifest is not REA-owned.");
|
||||||
|
|
||||||
|
const manifestName = "SKILL.md";
|
||||||
|
const otherFiles = MANAGED_SKILL_FILES.filter(
|
||||||
|
(relativePath) => relativePath !== manifestName,
|
||||||
|
);
|
||||||
|
const results: UninstallItem[] = [];
|
||||||
|
for (const relativePath of otherFiles)
|
||||||
|
results.push(
|
||||||
|
await removeManagedSkillFile(path, relativePath, name, fileSystem),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
results.some(({ status }) => status === "failed" || status === "retained")
|
||||||
|
)
|
||||||
|
results.push(
|
||||||
|
item(
|
||||||
|
name,
|
||||||
|
"retained",
|
||||||
|
"REA ownership manifest was retained because a managed file could not be safely removed.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
else
|
||||||
|
results.push(
|
||||||
|
await removeManagedSkillFile(path, manifestName, name, fileSystem),
|
||||||
|
);
|
||||||
|
const failed = results.find(({ status }) => status === "failed");
|
||||||
|
if (failed !== undefined)
|
||||||
|
return item(name, "failed", results.map(({ detail }) => detail).join(" "));
|
||||||
|
const removed = results.some(({ status }) => status === "removed");
|
||||||
|
return item(
|
||||||
|
name,
|
||||||
|
removed ? "removed" : "skipped",
|
||||||
|
"REA-managed skill files were removed; other files were preserved.",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeManagedSkillFile = async (
|
||||||
|
root: string,
|
||||||
|
relativePath: string,
|
||||||
|
name: string,
|
||||||
|
fileSystem: UninstallFileSystem,
|
||||||
|
): Promise<UninstallItem> => {
|
||||||
|
const segments = relativePath.split(/[\\/]/u);
|
||||||
|
let parent = root;
|
||||||
|
for (const segment of segments.slice(0, -1)) {
|
||||||
|
parent = join(parent, segment);
|
||||||
|
try {
|
||||||
|
const stats = await fileSystem.stat(parent);
|
||||||
|
if (stats.isSymbolicLink() || stats.isDirectory?.() !== true)
|
||||||
|
return item(
|
||||||
|
name,
|
||||||
|
"retained",
|
||||||
|
`Managed path parent was retained: ${parent}.`,
|
||||||
|
);
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
return isMissing(cause)
|
||||||
|
? item(
|
||||||
|
name,
|
||||||
|
"skipped",
|
||||||
|
`${join(parent, segments.at(-1) ?? "")} does not exist.`,
|
||||||
|
)
|
||||||
|
: item(
|
||||||
|
name,
|
||||||
|
"failed",
|
||||||
|
`Managed path parent could not be inspected: ${parent}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = join(root, relativePath);
|
||||||
|
let stats: ManagedPathStats;
|
||||||
|
try {
|
||||||
|
stats = await fileSystem.stat(target);
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
return isMissing(cause)
|
||||||
|
? item(name, "skipped", `${target} does not exist.`)
|
||||||
|
: item(name, "failed", `Managed file could not be inspected: ${target}.`);
|
||||||
|
}
|
||||||
|
if (stats.isSymbolicLink())
|
||||||
|
return item(
|
||||||
|
name,
|
||||||
|
"retained",
|
||||||
|
`Managed file is a symbolic link: ${target}.`,
|
||||||
|
);
|
||||||
|
if (stats.isFile?.() !== true)
|
||||||
|
return item(
|
||||||
|
name,
|
||||||
|
"retained",
|
||||||
|
`Managed path is not a regular file: ${target}.`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await fileSystem.remove(target, false);
|
||||||
|
return item(name, "removed", `Removed ${target}.`);
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
return isMissing(cause)
|
||||||
|
? item(name, "skipped", `${target} does not exist.`)
|
||||||
|
: item(name, "failed", `Managed file could not be removed: ${target}.`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const item = (
|
const item = (
|
||||||
name: string,
|
name: string,
|
||||||
status: UninstallItem["status"],
|
status: UninstallItem["status"],
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||||
@@ -12,6 +11,7 @@ import {
|
|||||||
parseClientConfiguration,
|
parseClientConfiguration,
|
||||||
} from "./ClientConfigurationDocument.js";
|
} from "./ClientConfigurationDocument.js";
|
||||||
import { supportedClients } from "./SupportedClients.js";
|
import { supportedClients } from "./SupportedClients.js";
|
||||||
|
import { existingSkillDestinations } from "./SetupSkill.js";
|
||||||
import type { SetupAction } from "./SetupTypes.js";
|
import type { SetupAction } from "./SetupTypes.js";
|
||||||
import type { Result } from "../domain/result.js";
|
import type { Result } from "../domain/result.js";
|
||||||
|
|
||||||
@@ -19,6 +19,10 @@ import type { Result } from "../domain/result.js";
|
|||||||
export interface MaintenanceScope {
|
export interface MaintenanceScope {
|
||||||
readonly clients: readonly string[];
|
readonly clients: readonly string[];
|
||||||
readonly skill: boolean;
|
readonly skill: boolean;
|
||||||
|
readonly skillDestinations?: readonly {
|
||||||
|
readonly client: string;
|
||||||
|
readonly path: string;
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Unapplied maintenance evidence produced by the updated setup executable. */
|
/** Unapplied maintenance evidence produced by the updated setup executable. */
|
||||||
@@ -49,7 +53,7 @@ const setupPlanSchema = z.object({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Discover owned registrations and an existing REA skill without writes. */
|
/** Discover owned registrations and REA skills without writes. */
|
||||||
export const existingMaintenanceScope = async (
|
export const existingMaintenanceScope = async (
|
||||||
home: string,
|
home: string,
|
||||||
entryPoint: string,
|
entryPoint: string,
|
||||||
@@ -87,20 +91,12 @@ export const existingMaintenanceScope = async (
|
|||||||
)
|
)
|
||||||
clients.push(client.name);
|
clients.push(client.name);
|
||||||
}
|
}
|
||||||
let skill = false;
|
const skillDestinations = await existingSkillDestinations(home, environment);
|
||||||
try {
|
return {
|
||||||
const content = await readFile(
|
clients,
|
||||||
join(home, ".agents", "skills", PRODUCT_IDENTITY.skillName, "SKILL.md"),
|
skill: skillDestinations.length > 0,
|
||||||
"utf8",
|
skillDestinations,
|
||||||
);
|
};
|
||||||
skill =
|
|
||||||
content.startsWith(`---\nname: ${PRODUCT_IDENTITY.skillName}\n`) ||
|
|
||||||
content.startsWith(`---\r\nname: ${PRODUCT_IDENTITY.skillName}\r\n`);
|
|
||||||
} catch (cause: unknown) {
|
|
||||||
if (!(cause instanceof Error && "code" in cause && cause.code === "ENOENT"))
|
|
||||||
throw cause;
|
|
||||||
}
|
|
||||||
return { clients, skill };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Ask the verified new executable to plan only existing REA integrations. */
|
/** Ask the verified new executable to plan only existing REA integrations. */
|
||||||
@@ -112,6 +108,7 @@ export const planIntegrationMaintenance = async (
|
|||||||
): Promise<IntegrationMaintenance> => {
|
): Promise<IntegrationMaintenance> => {
|
||||||
try {
|
try {
|
||||||
const scope = await existingMaintenanceScope(home, entryPoint, environment);
|
const scope = await existingMaintenanceScope(home, entryPoint, environment);
|
||||||
|
const skillDestinations = scope.skillDestinations ?? [];
|
||||||
if (scope.clients.length === 0 && !scope.skill)
|
if (scope.clients.length === 0 && !scope.skill)
|
||||||
return { status: "current", plannedActions: [] };
|
return { status: "current", plannedActions: [] };
|
||||||
const command = [
|
const command = [
|
||||||
@@ -119,6 +116,7 @@ export const planIntegrationMaintenance = async (
|
|||||||
entryPoint,
|
entryPoint,
|
||||||
"setup",
|
"setup",
|
||||||
...scope.clients.flatMap((client) => ["--client", client]),
|
...scope.clients.flatMap((client) => ["--client", client]),
|
||||||
|
...skillDestinations.flatMap(({ client }) => ["--skill-client", client]),
|
||||||
`--skill=${String(scope.skill)}`,
|
`--skill=${String(scope.skill)}`,
|
||||||
];
|
];
|
||||||
const execution = await execute([...command, "--dry-run", "--json"]);
|
const execution = await execute([...command, "--dry-run", "--json"]);
|
||||||
@@ -132,11 +130,17 @@ export const planIntegrationMaintenance = async (
|
|||||||
};
|
};
|
||||||
if (plan.data.plannedActions.length === 0)
|
if (plan.data.plannedActions.length === 0)
|
||||||
return { status: "current", plannedActions: [] };
|
return { status: "current", plannedActions: [] };
|
||||||
const allowedIds = new Set([
|
const allowedIds = new Set(
|
||||||
...scope.clients.map((client) => `configure_client:${client}`),
|
scope.clients.map((client) => `configure_client:${client}`),
|
||||||
...(scope.skill ? ["install_skill"] : []),
|
);
|
||||||
]);
|
const skillTargets = new Set(skillDestinations.map(({ path }) => path));
|
||||||
if (plan.data.plannedActions.some(({ id }) => !allowedIds.has(id)))
|
if (
|
||||||
|
plan.data.plannedActions.some((action) =>
|
||||||
|
action.kind === "install_skill"
|
||||||
|
? !skillTargets.has(action.target)
|
||||||
|
: !allowedIds.has(action.id),
|
||||||
|
)
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
status: "unavailable",
|
status: "unavailable",
|
||||||
remediation:
|
remediation:
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface WebSourceLocationArtifactPort {
|
|||||||
|
|
||||||
/** Replaceable codec receives map data and one explicit generated point. */
|
/** Replaceable codec receives map data and one explicit generated point. */
|
||||||
export interface WebSourceMapPort {
|
export interface WebSourceMapPort {
|
||||||
|
close?(): Promise<void>;
|
||||||
trace(
|
trace(
|
||||||
input: {
|
input: {
|
||||||
readonly text: string;
|
readonly text: string;
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ export class WebSourceLocationService {
|
|||||||
readonly decoder: WebSourceMapPort,
|
readonly decoder: WebSourceMapPort,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
close(): Promise<void> {
|
||||||
|
return this.decoder.close?.() ?? Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
/** Shared CLI/MCP workflow preserves raw declarations and every equal-position match. */
|
/** Shared CLI/MCP workflow preserves raw declarations and every equal-position match. */
|
||||||
async trace(
|
async trace(
|
||||||
rawInput: unknown,
|
rawInput: unknown,
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
|||||||
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
|
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
|
||||||
import { err } from "../../domain/result.js";
|
import { err } from "../../domain/result.js";
|
||||||
import type { AnalysisClient } from "../AnalysisProvider.js";
|
import type { AnalysisClient } from "../AnalysisProvider.js";
|
||||||
import {
|
import { closeAnalysisClient } from "./AnalysisClientCleanup.js";
|
||||||
analysisErrorWithCleanupFailure,
|
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
|
||||||
closeAnalysisClient,
|
|
||||||
} from "./AnalysisClientCleanup.js";
|
|
||||||
|
|
||||||
const clientClosingWith = (cause: unknown): AnalysisClient => ({
|
const clientClosingWith = (cause: unknown): AnalysisClient => ({
|
||||||
execute: () => Promise.resolve(err(new ProviderCleanupError("x", [], {}))),
|
execute: () => Promise.resolve(err(new ProviderCleanupError("x", [], {}))),
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
|
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
|
||||||
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
|
||||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
|
||||||
import { err, type Result } from "../../domain/result.js";
|
import { err, type Result } from "../../domain/result.js";
|
||||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||||
import type { AnalysisClient, ExecutionOptions } from "../AnalysisProvider.js";
|
import type { AnalysisClient, ExecutionOptions } from "../AnalysisProvider.js";
|
||||||
@@ -29,34 +27,3 @@ export const closeAnalysisClient = async (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Preserve the original lifecycle failure alongside incomplete cleanup. */
|
|
||||||
export const analysisErrorWithCleanupFailure = (
|
|
||||||
primary: AnalysisError,
|
|
||||||
cleanup: AnalysisError,
|
|
||||||
operation = "close_binary",
|
|
||||||
): AnalysisError => {
|
|
||||||
const projected = projectAnalysisError(cleanup);
|
|
||||||
return new ProviderAdapterError(
|
|
||||||
cleanup instanceof ProviderAdapterError ? cleanup.providerId : "rea",
|
|
||||||
operation,
|
|
||||||
{
|
|
||||||
cause: primary,
|
|
||||||
userMessage: projectAnalysisError(primary).message,
|
|
||||||
...(primary.capturedOutput === undefined
|
|
||||||
? {}
|
|
||||||
: { capturedOutput: primary.capturedOutput }),
|
|
||||||
...(primary.partialObservation === undefined
|
|
||||||
? {}
|
|
||||||
: { partialObservation: primary.partialObservation }),
|
|
||||||
cleanup: {
|
|
||||||
reason: projected.message,
|
|
||||||
resources: cleanup.cleanupResources,
|
|
||||||
},
|
|
||||||
diagnostics: {
|
|
||||||
primary_error: projectAnalysisError(primary),
|
|
||||||
cleanup_error: projected,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -44,10 +44,8 @@ import {
|
|||||||
commitExecutionProfile,
|
commitExecutionProfile,
|
||||||
prepareSessionExecution,
|
prepareSessionExecution,
|
||||||
} from "./BinarySessionExecution.js";
|
} from "./BinarySessionExecution.js";
|
||||||
import {
|
import { closeAnalysisClient } from "./AnalysisClientCleanup.js";
|
||||||
analysisErrorWithCleanupFailure,
|
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
|
||||||
closeAnalysisClient,
|
|
||||||
} from "./AnalysisClientCleanup.js";
|
|
||||||
const OFFICIAL_OPERATIONS: ReadonlySet<string> = new Set(
|
const OFFICIAL_OPERATIONS: ReadonlySet<string> = new Set(
|
||||||
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name),
|
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { Result } from "../../domain/result.js";
|
|||||||
|
|
||||||
/** Replaceable offline interface-recovery engine, independent of wallets and chain clients. */
|
/** Replaceable offline interface-recovery engine, independent of wallets and chain clients. */
|
||||||
export interface EvmInterfacePort {
|
export interface EvmInterfacePort {
|
||||||
|
close?(): Promise<void>;
|
||||||
inspect(
|
inspect(
|
||||||
input: InspectEvmInterfaceInput,
|
input: InspectEvmInterfaceInput,
|
||||||
options?: ExecutionOptions,
|
options?: ExecutionOptions,
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ const diagnosticsSchema = z.object({
|
|||||||
export class EvmInterfaceService {
|
export class EvmInterfaceService {
|
||||||
constructor(readonly provider: EvmInterfacePort) {}
|
constructor(readonly provider: EvmInterfacePort) {}
|
||||||
|
|
||||||
|
close(): Promise<void> {
|
||||||
|
return this.provider.close?.() ?? Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
/** Preserve selected byte identity while clearly labeling recovered ABI candidates as inferences. */
|
/** Preserve selected byte identity while clearly labeling recovered ABI candidates as inferences. */
|
||||||
async inspect(
|
async inspect(
|
||||||
rawInput: unknown,
|
rawInput: unknown,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { Result } from "../../domain/result.js";
|
|||||||
|
|
||||||
/** File-oriented firmware engines; executable admission remains a separate operation. */
|
/** File-oriented firmware engines; executable admission remains a separate operation. */
|
||||||
export interface FirmwareAnalysisPort {
|
export interface FirmwareAnalysisPort {
|
||||||
|
close?(): Promise<void>;
|
||||||
execute(
|
execute(
|
||||||
request: FirmwareRequest,
|
request: FirmwareRequest,
|
||||||
options?: ExecutionOptions,
|
options?: ExecutionOptions,
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ import { err, ok, type Result } from "../../domain/result.js";
|
|||||||
export class FirmwareAnalysisService {
|
export class FirmwareAnalysisService {
|
||||||
constructor(readonly provider: FirmwareAnalysisPort) {}
|
constructor(readonly provider: FirmwareAnalysisPort) {}
|
||||||
|
|
||||||
|
close(): Promise<void> {
|
||||||
|
return this.provider.close?.() ?? Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
/** Execute an explicit inspection or extraction and preserve its producing identity. */
|
/** Execute an explicit inspection or extraction and preserve its producing identity. */
|
||||||
async execute(
|
async execute(
|
||||||
operation: FirmwareOperation,
|
operation: FirmwareOperation,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "./JavaScriptArtifactGraphContext.js";
|
} from "./JavaScriptArtifactGraphContext.js";
|
||||||
import { astObservationEvidence } from "./JavaScriptArtifactGraphEvidence.js";
|
import { astObservationEvidence } from "./JavaScriptArtifactGraphEvidence.js";
|
||||||
import { compositeKey } from "../../domain/unicodeCodePointOrder.js";
|
import { compositeKey } from "../../domain/unicodeCodePointOrder.js";
|
||||||
|
import { javascriptDisplayText } from "../../domain/javascript/javascriptAstValues.js";
|
||||||
import {
|
import {
|
||||||
addElectronInferenceEdge,
|
addElectronInferenceEdge,
|
||||||
electronFindingSourceNode,
|
electronFindingSourceNode,
|
||||||
@@ -51,17 +52,27 @@ const addNativeBinding = (input: NativeBindingInput): void => {
|
|||||||
identity: electronObservationIdentity(
|
identity: electronObservationIdentity(
|
||||||
context,
|
context,
|
||||||
"javascript-requested-native-export",
|
"javascript-requested-native-export",
|
||||||
// Members are a variable-length source-derived list: compositeKey keeps
|
compositeKey([
|
||||||
// ["a\0b"] distinct from ["a", "b"] inside the digested identity.
|
file.path,
|
||||||
`${file.path}:${value.specifier}:${value.binding_kind}:${compositeKey(value.members)}:${electronRangeKey(value.location)}`,
|
value.specifier,
|
||||||
|
value.binding_kind,
|
||||||
|
value.members,
|
||||||
|
value.namespace_access ?? null,
|
||||||
|
value.dynamic_member_access ?? null,
|
||||||
|
electronRangeKey(value.location),
|
||||||
|
]),
|
||||||
),
|
),
|
||||||
observations: [
|
observations: [
|
||||||
{
|
{
|
||||||
label: value.members.join(", "),
|
label:
|
||||||
|
value.members.map(javascriptDisplayText).join(", ") ||
|
||||||
|
value.specifier,
|
||||||
properties: {
|
properties: {
|
||||||
specifier: value.specifier,
|
specifier: value.specifier,
|
||||||
binding_kind: value.binding_kind,
|
binding_kind: value.binding_kind,
|
||||||
requested_members: value.members,
|
requested_members: value.members,
|
||||||
|
namespace_access: value.namespace_access ?? null,
|
||||||
|
dynamic_member_access: value.dynamic_member_access ?? null,
|
||||||
resolved_path: resolved,
|
resolved_path: resolved,
|
||||||
native_export_verification: "not-performed",
|
native_export_verification: "not-performed",
|
||||||
},
|
},
|
||||||
@@ -90,6 +101,8 @@ const addNativeBinding = (input: NativeBindingInput): void => {
|
|||||||
specifier: value.specifier,
|
specifier: value.specifier,
|
||||||
binding_kind: value.binding_kind,
|
binding_kind: value.binding_kind,
|
||||||
requested_members: value.members,
|
requested_members: value.members,
|
||||||
|
namespace_access: value.namespace_access ?? null,
|
||||||
|
dynamic_member_access: value.dynamic_member_access ?? null,
|
||||||
},
|
},
|
||||||
limitations: [
|
limitations: [
|
||||||
"Requested JavaScript members are not verified native exports.",
|
"Requested JavaScript members are not verified native exports.",
|
||||||
@@ -107,6 +120,8 @@ const addNativeBinding = (input: NativeBindingInput): void => {
|
|||||||
properties: {
|
properties: {
|
||||||
resolved_path: resolved,
|
resolved_path: resolved,
|
||||||
requested_members: value.members,
|
requested_members: value.members,
|
||||||
|
namespace_access: value.namespace_access ?? null,
|
||||||
|
dynamic_member_access: value.dynamic_member_access ?? null,
|
||||||
verified: false,
|
verified: false,
|
||||||
},
|
},
|
||||||
confidence: "medium",
|
confidence: "medium",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js";
|
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js";
|
||||||
import type { JavaScriptBundlerRegistration } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
|
import type { JavaScriptBundlerRegistration } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
|
||||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||||
|
import { compositeKey } from "../../domain/unicodeCodePointOrder.js";
|
||||||
import {
|
import {
|
||||||
addAstContainsEdge,
|
addAstContainsEdge,
|
||||||
artifactLocalIdentity,
|
artifactLocalIdentity,
|
||||||
@@ -80,7 +81,11 @@ const createBundlerChunkNode = (
|
|||||||
const chunkKey = `${registration.runtime}:${registration.chunk_keys.join(",")}`;
|
const chunkKey = `${registration.runtime}:${registration.chunk_keys.join(",")}`;
|
||||||
return context.accumulator.addNode({
|
return context.accumulator.addNode({
|
||||||
kind: "javascript-chunk",
|
kind: "javascript-chunk",
|
||||||
identity: artifactLocalIdentity(file.sha256, "bundler-chunk", chunkKey),
|
identity: artifactLocalIdentity(
|
||||||
|
file.sha256,
|
||||||
|
"bundler-chunk",
|
||||||
|
compositeKey([registration.runtime, registration.chunk_keys]),
|
||||||
|
),
|
||||||
observations: [
|
observations: [
|
||||||
{
|
{
|
||||||
label: chunkKey,
|
label: chunkKey,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
|
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
|
||||||
|
import { compositeKey } from "../../domain/unicodeCodePointOrder.js";
|
||||||
import {
|
import {
|
||||||
completeApplicationCoverage,
|
completeApplicationCoverage,
|
||||||
partialApplicationCoverage,
|
partialApplicationCoverage,
|
||||||
@@ -330,7 +331,7 @@ export const chunkLookupKey = (
|
|||||||
path: string,
|
path: string,
|
||||||
runtime: string,
|
runtime: string,
|
||||||
chunkKey: string,
|
chunkKey: string,
|
||||||
): string => `${path}\0${runtime}\0${chunkKey}`;
|
): string => compositeKey([path, runtime, chunkKey]);
|
||||||
|
|
||||||
/** Resolve a relative static specifier without escaping the artifact root. */
|
/** Resolve a relative static specifier without escaping the artifact root. */
|
||||||
export const resolveArtifactPath = (
|
export const resolveArtifactPath = (
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { posix } from "node:path";
|
import { posix } from "node:path";
|
||||||
|
|
||||||
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
|
import {
|
||||||
|
compareUnicodeCodePoints,
|
||||||
|
compositeKey,
|
||||||
|
} from "../../domain/unicodeCodePointOrder.js";
|
||||||
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js";
|
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js";
|
||||||
import {
|
import {
|
||||||
completeApplicationCoverage,
|
completeApplicationCoverage,
|
||||||
@@ -177,7 +180,7 @@ const addExportRelationship = (
|
|||||||
identity: artifactLocalIdentity(
|
identity: artifactLocalIdentity(
|
||||||
file.sha256,
|
file.sha256,
|
||||||
"module-export",
|
"module-export",
|
||||||
`${file.container_sha256}:${file.path}:${link.exportedName}`,
|
compositeKey([file.container_sha256, file.path, link.exportedName]),
|
||||||
),
|
),
|
||||||
observations: [
|
observations: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
|
|
||||||
/** Typed boundary for upstream source recovery without exposing its protocol. */
|
/** Typed boundary for upstream source recovery without exposing its protocol. */
|
||||||
export interface JavaScriptRecoveryPort {
|
export interface JavaScriptRecoveryPort {
|
||||||
|
close?(): Promise<void>;
|
||||||
recover(
|
recover(
|
||||||
input: JavaScriptRecoveryInput,
|
input: JavaScriptRecoveryInput,
|
||||||
options?: ExecutionOptions,
|
options?: ExecutionOptions,
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ const OPERATION = "recover_javascript_sources";
|
|||||||
export class JavaScriptRecoveryService {
|
export class JavaScriptRecoveryService {
|
||||||
constructor(readonly provider: JavaScriptRecoveryPort) {}
|
constructor(readonly provider: JavaScriptRecoveryPort) {}
|
||||||
|
|
||||||
|
close(): Promise<void> {
|
||||||
|
return this.provider.close?.() ?? Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
/** Recover sources and bind the result to its original immutable input. */
|
/** Recover sources and bind the result to its original immutable input. */
|
||||||
async recover(
|
async recover(
|
||||||
input: unknown,
|
input: unknown,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||||
@@ -17,9 +16,10 @@ import { compareProcessCaptures } from "../../domain/process/processComparison.j
|
|||||||
import { parseProcessCapture } from "../../domain/process/processCaptureParsing.js";
|
import { parseProcessCapture } from "../../domain/process/processCaptureParsing.js";
|
||||||
import { createProgressReporter } from "../ProgressReporter.js";
|
import { createProgressReporter } from "../ProgressReporter.js";
|
||||||
import { captureProcessScenario } from "../../process/capture/ProcessHarness.js";
|
import { captureProcessScenario } from "../../process/capture/ProcessHarness.js";
|
||||||
|
import { ProcessCaptureResourceScope } from "../../process/capture/ProcessCaptureLifecycle.js";
|
||||||
import { PROCESS_PROVIDER } from "../../domain/process/processEvidenceProvider.js";
|
import { PROCESS_PROVIDER } from "../../domain/process/processEvidenceProvider.js";
|
||||||
import { createProcessCaptureEvidence } from "./ProcessEvidence.js";
|
import { createProcessCaptureEvidence } from "./ProcessEvidence.js";
|
||||||
import { JSON_BYTE_ORDER_MARK_MESSAGE } from "../Utf8JsonInput.js";
|
import { readJsonFile } from "../JsonFiles.js";
|
||||||
|
|
||||||
/** Safe process-command failure returned to the CLI adapter. */
|
/** Safe process-command failure returned to the CLI adapter. */
|
||||||
export interface ProcessCliErrorOutput {
|
export interface ProcessCliErrorOutput {
|
||||||
@@ -63,6 +63,7 @@ export const captureProcessScenarioFile = async (
|
|||||||
},
|
},
|
||||||
{ minimumIntervalMs: 100 },
|
{ minimumIntervalMs: 100 },
|
||||||
);
|
);
|
||||||
|
const resourceScope = new ProcessCaptureResourceScope();
|
||||||
const captured = await captureProcessScenario(
|
const captured = await captureProcessScenario(
|
||||||
parsed.data,
|
parsed.data,
|
||||||
signal,
|
signal,
|
||||||
@@ -71,7 +72,14 @@ export const captureProcessScenarioFile = async (
|
|||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
progress,
|
progress,
|
||||||
|
resourceScope,
|
||||||
);
|
);
|
||||||
|
try {
|
||||||
|
await resourceScope.close();
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
if (!captured.ok) return cliAnalysisError(captured.error);
|
||||||
|
throw cause;
|
||||||
|
}
|
||||||
if (!captured.ok) return cliAnalysisError(captured.error);
|
if (!captured.ok) return cliAnalysisError(captured.error);
|
||||||
return createProcessCaptureEvidence(parsed.data, captured.value);
|
return createProcessCaptureEvidence(parsed.data, captured.value);
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
@@ -178,30 +186,16 @@ const invalidCaptureEvidence = (cause: unknown): ProcessCliFailure =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const readJson = async (path: string): Promise<unknown> => {
|
const readJson = async (path: string): Promise<unknown> => {
|
||||||
let bytes;
|
const loaded = await readJsonFile(path);
|
||||||
try {
|
if (loaded.ok) return loaded.value;
|
||||||
bytes = await readFile(path);
|
if (loaded.error._tag !== "EvidenceFileError") throw loaded.error;
|
||||||
} catch (cause: unknown) {
|
const detail = describeValidationFailure(loaded.error.cause);
|
||||||
throw new ProcessCliFailure(
|
throw new ProcessCliFailure(
|
||||||
"invalid_input",
|
"invalid_input",
|
||||||
`Process input file could not be read: ${path} (${describeValidationFailure(cause)}). Check the reported filesystem constraint and retry.`,
|
loaded.error.reason === "invalid-json"
|
||||||
);
|
? `Process input file is not valid UTF-8 JSON: ${path} (${detail}). Repair the file, then try again.`
|
||||||
}
|
: `Process input file could not be read: ${path} (${detail}). Check the reported filesystem constraint and retry.`,
|
||||||
try {
|
);
|
||||||
const text = new TextDecoder("utf-8", {
|
|
||||||
fatal: true,
|
|
||||||
ignoreBOM: true,
|
|
||||||
}).decode(bytes);
|
|
||||||
if (text.startsWith("\uFEFF"))
|
|
||||||
throw new Error(JSON_BYTE_ORDER_MARK_MESSAGE);
|
|
||||||
const parsed: unknown = JSON.parse(text);
|
|
||||||
return parsed;
|
|
||||||
} catch (cause: unknown) {
|
|
||||||
throw new ProcessCliFailure(
|
|
||||||
"invalid_input",
|
|
||||||
`Process input file is not valid UTF-8 JSON: ${path} (${describeValidationFailure(cause)}). Repair the file, then try again.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
class ProcessCliFailure extends Error {
|
class ProcessCliFailure extends Error {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
type ArtifactReader,
|
type ArtifactReader,
|
||||||
} from "./ArtifactReader.js";
|
} from "./ArtifactReader.js";
|
||||||
import { closeAsarHandle, readValidatedAsarEntry } from "./AsarEntryStream.js";
|
import { closeAsarHandle, readValidatedAsarEntry } from "./AsarEntryStream.js";
|
||||||
|
import { admitAsarHeader } from "./AsarHeader.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Official Electron ASAR adapter with range-streamed member reads.
|
* Official Electron ASAR adapter with range-streamed member reads.
|
||||||
@@ -57,6 +58,17 @@ export class AsarArtifactReader implements ArtifactReader {
|
|||||||
const snapshotPath = this.#snapshotPath();
|
const snapshotPath = this.#snapshotPath();
|
||||||
this.#resetArchiveState();
|
this.#resetArchiveState();
|
||||||
this.#entries.clear();
|
this.#entries.clear();
|
||||||
|
const headerHandle = await open(
|
||||||
|
snapshotPath,
|
||||||
|
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const admitted = await admitAsarHeader(headerHandle);
|
||||||
|
this.#archiveSize = admitted.archiveBytes;
|
||||||
|
} finally {
|
||||||
|
await headerHandle.close();
|
||||||
|
}
|
||||||
|
abortIfNeeded(signal);
|
||||||
paths = listPackage(snapshotPath, { isPack: false }).sort((left, right) =>
|
paths = listPackage(snapshotPath, { isPack: false }).sort((left, right) =>
|
||||||
left.localeCompare(right, "en"),
|
left.localeCompare(right, "en"),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { FileHandle } from "node:fs/promises";
|
||||||
|
|
||||||
|
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||||
|
|
||||||
|
/** Shared resource budget for the eagerly decoded ASAR header, not member bytes. */
|
||||||
|
export const MAX_ASAR_HEADER_BYTES = 16 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** Admit both Chromium pickle extents before an upstream decoder allocates them. */
|
||||||
|
export const admitAsarHeader = async (
|
||||||
|
handle: FileHandle,
|
||||||
|
): Promise<{
|
||||||
|
readonly archiveBytes: number;
|
||||||
|
readonly headerBytes: number;
|
||||||
|
readonly jsonBytes: number;
|
||||||
|
}> => {
|
||||||
|
const observed = await handle.stat();
|
||||||
|
if (!observed.isFile())
|
||||||
|
throw new ArtifactReaderFailure(
|
||||||
|
"format",
|
||||||
|
"ASAR container is not a regular file",
|
||||||
|
);
|
||||||
|
const prefix = Buffer.alloc(16);
|
||||||
|
const read = await handle.read(prefix, 0, prefix.length, 0);
|
||||||
|
if (read.bytesRead !== prefix.length || prefix.readUInt32LE(0) !== 4)
|
||||||
|
throw new ArtifactReaderFailure(
|
||||||
|
"format",
|
||||||
|
"Truncated or invalid ASAR size pickle",
|
||||||
|
);
|
||||||
|
const headerBytes = prefix.readUInt32LE(4);
|
||||||
|
const payloadBytes = prefix.readUInt32LE(8);
|
||||||
|
const jsonBytes = prefix.readUInt32LE(12);
|
||||||
|
if (
|
||||||
|
headerBytes < 8 ||
|
||||||
|
headerBytes > observed.size - 8 ||
|
||||||
|
payloadBytes !== headerBytes - 4 ||
|
||||||
|
jsonBytes > payloadBytes - 4
|
||||||
|
)
|
||||||
|
throw new ArtifactReaderFailure(
|
||||||
|
"format",
|
||||||
|
"ASAR header leaves its container or has invalid pickle extents",
|
||||||
|
);
|
||||||
|
if (headerBytes > MAX_ASAR_HEADER_BYTES)
|
||||||
|
throw new ArtifactReaderFailure(
|
||||||
|
"limit",
|
||||||
|
`ASAR header exceeds the ${String(MAX_ASAR_HEADER_BYTES)} byte decode budget`,
|
||||||
|
);
|
||||||
|
return { archiveBytes: observed.size, headerBytes, jsonBytes };
|
||||||
|
};
|
||||||
@@ -3,15 +3,13 @@ import { lstat, open, opendir, realpath } from "node:fs/promises";
|
|||||||
import { isAbsolute, join, relative, sep } from "node:path";
|
import { isAbsolute, join, relative, sep } from "node:path";
|
||||||
import type { Readable } from "node:stream";
|
import type { Readable } from "node:stream";
|
||||||
|
|
||||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ArtifactReaderFailure,
|
ArtifactReaderFailure,
|
||||||
type ArtifactEntry,
|
type ArtifactEntry,
|
||||||
type ArtifactReader,
|
type ArtifactReader,
|
||||||
} from "./ArtifactReader.js";
|
} from "./ArtifactReader.js";
|
||||||
|
|
||||||
/** Deterministic, non-symlink-following directory reader. */
|
/** Non-symlink-following directory reader. */
|
||||||
export class DirectoryArtifactReader implements ArtifactReader {
|
export class DirectoryArtifactReader implements ArtifactReader {
|
||||||
readonly format = "directory" as const;
|
readonly format = "directory" as const;
|
||||||
readonly #rootPromise: Promise<string>;
|
readonly #rootPromise: Promise<string>;
|
||||||
@@ -38,13 +36,52 @@ export class DirectoryArtifactReader implements ArtifactReader {
|
|||||||
await withDirectoryIoContext("inspect directory", directory, () =>
|
await withDirectoryIoContext("inspect directory", directory, () =>
|
||||||
assertContainedDirectory(root, directory),
|
assertContainedDirectory(root, directory),
|
||||||
);
|
);
|
||||||
const children: string[] = [];
|
|
||||||
const handle = await opendir(directory).catch((cause: unknown) => {
|
const handle = await opendir(directory).catch((cause: unknown) => {
|
||||||
throw directoryIoFailure("open directory", directory, cause);
|
throw directoryIoFailure("open directory", directory, cause);
|
||||||
});
|
});
|
||||||
|
const directories: string[] = [];
|
||||||
try {
|
try {
|
||||||
for await (const child of handle) children.push(child.name);
|
for await (const child of handle) {
|
||||||
|
abortIfNeeded(signal);
|
||||||
|
const absolute = join(directory, child.name);
|
||||||
|
const metadata = await lstat(absolute).catch((cause: unknown) => {
|
||||||
|
throw directoryIoFailure("inspect entry", absolute, cause);
|
||||||
|
});
|
||||||
|
const path = relative(root, absolute).split(sep).join("/");
|
||||||
|
const kind = metadata.isSymbolicLink()
|
||||||
|
? "symlink"
|
||||||
|
: metadata.isDirectory()
|
||||||
|
? "directory"
|
||||||
|
: metadata.isFile()
|
||||||
|
? "file"
|
||||||
|
: undefined;
|
||||||
|
if (kind === undefined) continue;
|
||||||
|
yield {
|
||||||
|
path,
|
||||||
|
kind,
|
||||||
|
declaredSize: kind === "file" ? metadata.size : null,
|
||||||
|
compressedSize: null,
|
||||||
|
executable: (metadata.mode & 0o111) !== 0,
|
||||||
|
encrypted: false,
|
||||||
|
byteOffset: null,
|
||||||
|
declaredSha256: null,
|
||||||
|
unpacked: false,
|
||||||
|
limitations:
|
||||||
|
kind === "symlink"
|
||||||
|
? ["Symlink target was not followed or disclosed."]
|
||||||
|
: [],
|
||||||
|
adapterKey: absolute,
|
||||||
|
...(kind === "file"
|
||||||
|
? {
|
||||||
|
sourceIdentity: { device: metadata.dev, inode: metadata.ino },
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
if (kind === "directory" && (descendInto?.(path) ?? true))
|
||||||
|
directories.push(absolute);
|
||||||
|
}
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
|
if (cause instanceof ArtifactReaderFailure) throw cause;
|
||||||
throw directoryIoFailure("read directory", directory, cause);
|
throw directoryIoFailure("read directory", directory, cause);
|
||||||
} finally {
|
} finally {
|
||||||
// best-effort cleanup: directory-handle close must not mask traversal.
|
// best-effort cleanup: directory-handle close must not mask traversal.
|
||||||
@@ -53,45 +90,6 @@ export class DirectoryArtifactReader implements ArtifactReader {
|
|||||||
await withDirectoryIoContext("inspect directory", directory, () =>
|
await withDirectoryIoContext("inspect directory", directory, () =>
|
||||||
assertContainedDirectory(root, directory),
|
assertContainedDirectory(root, directory),
|
||||||
);
|
);
|
||||||
children.sort(compareUnicodeCodePoints);
|
|
||||||
const directories: string[] = [];
|
|
||||||
for (const name of children) {
|
|
||||||
abortIfNeeded(signal);
|
|
||||||
const absolute = join(directory, name);
|
|
||||||
const metadata = await lstat(absolute).catch((cause: unknown) => {
|
|
||||||
throw directoryIoFailure("inspect entry", absolute, cause);
|
|
||||||
});
|
|
||||||
const path = relative(root, absolute).split(sep).join("/");
|
|
||||||
const kind = metadata.isSymbolicLink()
|
|
||||||
? "symlink"
|
|
||||||
: metadata.isDirectory()
|
|
||||||
? "directory"
|
|
||||||
: metadata.isFile()
|
|
||||||
? "file"
|
|
||||||
: undefined;
|
|
||||||
if (kind === undefined) continue;
|
|
||||||
if (kind === "directory" && (descendInto?.(path) ?? true))
|
|
||||||
directories.push(absolute);
|
|
||||||
yield {
|
|
||||||
path,
|
|
||||||
kind,
|
|
||||||
declaredSize: kind === "file" ? metadata.size : null,
|
|
||||||
compressedSize: null,
|
|
||||||
executable: (metadata.mode & 0o111) !== 0,
|
|
||||||
encrypted: false,
|
|
||||||
byteOffset: null,
|
|
||||||
declaredSha256: null,
|
|
||||||
unpacked: false,
|
|
||||||
limitations:
|
|
||||||
kind === "symlink"
|
|
||||||
? ["Symlink target was not followed or disclosed."]
|
|
||||||
: [],
|
|
||||||
adapterKey: absolute,
|
|
||||||
...(kind === "file"
|
|
||||||
? { sourceIdentity: { device: metadata.dev, inode: metadata.ino } }
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
directories.reverse();
|
directories.reverse();
|
||||||
pending.push(...directories);
|
pending.push(...directories);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,26 @@ export class SafeOutputTree {
|
|||||||
}
|
}
|
||||||
return new SafeOutputTree(canonicalOutput, platform);
|
return new SafeOutputTree(canonicalOutput, platform);
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
await rm(canonicalOutput, { recursive: true, force: true });
|
let removalFailure: unknown;
|
||||||
|
try {
|
||||||
|
await rm(canonicalOutput, { recursive: true, force: true });
|
||||||
|
} catch (cleanupCause: unknown) {
|
||||||
|
removalFailure = cleanupCause;
|
||||||
|
}
|
||||||
|
let absent = false;
|
||||||
|
try {
|
||||||
|
absent = await isAbsent(canonicalOutput);
|
||||||
|
} catch (cleanupCause: unknown) {
|
||||||
|
removalFailure ??= cleanupCause;
|
||||||
|
}
|
||||||
|
if (!absent)
|
||||||
|
throw ArtifactReaderFailure.withCleanup(cause, {
|
||||||
|
reason:
|
||||||
|
removalFailure instanceof Error
|
||||||
|
? removalFailure.message
|
||||||
|
: "Extraction output root remains after setup failure",
|
||||||
|
resources: [canonicalOutput],
|
||||||
|
});
|
||||||
throw cause;
|
throw cause;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,56 +141,84 @@ export class SafeOutputTree {
|
|||||||
async write(
|
async write(
|
||||||
relativePath: string,
|
relativePath: string,
|
||||||
source: Readable,
|
source: Readable,
|
||||||
expectedSha256: string,
|
expected: { readonly sha256: string; readonly bytes: number },
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<SafeOutputFile> {
|
): Promise<SafeOutputFile> {
|
||||||
this.#assertWritable();
|
|
||||||
const path = normalizeArtifactPath(relativePath);
|
|
||||||
this.#registry.add(path, "file");
|
|
||||||
const destination = await this.#prepareParent(path);
|
|
||||||
const handle = await open(
|
|
||||||
destination,
|
|
||||||
constants.O_CREAT |
|
|
||||||
constants.O_EXCL |
|
|
||||||
constants.O_WRONLY |
|
|
||||||
constants.O_NOFOLLOW,
|
|
||||||
0o600,
|
|
||||||
).catch(async (cause: unknown) => {
|
|
||||||
await throwIfDestinationCaseCollision(dirname(destination), path, cause);
|
|
||||||
throw new ArtifactReaderFailure(
|
|
||||||
"path",
|
|
||||||
`Could not exclusively create extraction path: ${path}`,
|
|
||||||
{ cause },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
const hash = createHash("sha256");
|
|
||||||
let bytes = 0;
|
|
||||||
try {
|
try {
|
||||||
for await (const raw of source) {
|
this.#assertWritable();
|
||||||
abortIfNeeded(signal);
|
if (!Number.isSafeInteger(expected.bytes) || expected.bytes < 0)
|
||||||
const chunk = streamChunkToBuffer(raw);
|
throw new ArtifactReaderFailure(
|
||||||
bytes += chunk.length;
|
"format",
|
||||||
hash.update(chunk);
|
`Invalid expected extraction size for ${relativePath}`,
|
||||||
await writeAll(handle, chunk);
|
);
|
||||||
|
const path = normalizeArtifactPath(relativePath);
|
||||||
|
this.#registry.add(path, "file");
|
||||||
|
const destination = await this.#prepareParent(path);
|
||||||
|
const handle = await open(
|
||||||
|
destination,
|
||||||
|
constants.O_CREAT |
|
||||||
|
constants.O_EXCL |
|
||||||
|
constants.O_WRONLY |
|
||||||
|
constants.O_NOFOLLOW,
|
||||||
|
0o600,
|
||||||
|
).catch(async (cause: unknown) => {
|
||||||
|
await throwIfDestinationCaseCollision(
|
||||||
|
dirname(destination),
|
||||||
|
path,
|
||||||
|
cause,
|
||||||
|
);
|
||||||
|
throw new ArtifactReaderFailure(
|
||||||
|
"path",
|
||||||
|
`Could not exclusively create extraction path: ${path}`,
|
||||||
|
{ cause },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const hash = createHash("sha256");
|
||||||
|
let bytes = 0;
|
||||||
|
try {
|
||||||
|
for await (const raw of source) {
|
||||||
|
abortIfNeeded(signal);
|
||||||
|
const chunk = streamChunkToBuffer(raw);
|
||||||
|
if (chunk.length > expected.bytes - bytes)
|
||||||
|
throw new ArtifactReaderFailure(
|
||||||
|
"integrity",
|
||||||
|
`Extracted content exceeds the inventoried size: ${path}`,
|
||||||
|
);
|
||||||
|
bytes += chunk.length;
|
||||||
|
hash.update(chunk);
|
||||||
|
await writeAll(handle, chunk);
|
||||||
|
}
|
||||||
|
if (bytes !== expected.bytes)
|
||||||
|
throw new ArtifactReaderFailure(
|
||||||
|
"integrity",
|
||||||
|
`Extracted content size disagrees with inventory: ${path}`,
|
||||||
|
);
|
||||||
|
const sha256 = hash.digest("hex");
|
||||||
|
if (sha256 !== expected.sha256)
|
||||||
|
throw new ArtifactReaderFailure(
|
||||||
|
"integrity",
|
||||||
|
`Extracted content disagrees with inventory: ${path}`,
|
||||||
|
);
|
||||||
|
await handle.sync();
|
||||||
|
await handle.close();
|
||||||
|
const readback = await hashFile(destination, bytes, signal);
|
||||||
|
if (readback.sha256 !== sha256 || readback.bytes !== bytes)
|
||||||
|
throw new ArtifactReaderFailure(
|
||||||
|
"integrity",
|
||||||
|
`Durable readback verification failed: ${path}`,
|
||||||
|
);
|
||||||
|
return { relativePath: path, sha256, bytesWritten: bytes };
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
// best-effort cleanup: file-handle close must not mask the write failure.
|
||||||
|
await handle.close().catch(() => undefined);
|
||||||
|
throw cause;
|
||||||
}
|
}
|
||||||
const sha256 = hash.digest("hex");
|
|
||||||
if (sha256 !== expectedSha256)
|
|
||||||
throw new ArtifactReaderFailure(
|
|
||||||
"integrity",
|
|
||||||
`Extracted content disagrees with inventory: ${path}`,
|
|
||||||
);
|
|
||||||
await handle.sync();
|
|
||||||
await handle.close();
|
|
||||||
const readback = await hashFile(destination, bytes, signal);
|
|
||||||
if (readback.sha256 !== sha256 || readback.bytes !== bytes)
|
|
||||||
throw new ArtifactReaderFailure(
|
|
||||||
"integrity",
|
|
||||||
`Durable readback verification failed: ${path}`,
|
|
||||||
);
|
|
||||||
return { relativePath: path, sha256, bytesWritten: bytes };
|
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
// best-effort cleanup: file-handle close must not mask the write failure.
|
try {
|
||||||
await handle.close().catch(() => undefined);
|
source.destroy();
|
||||||
|
} catch {
|
||||||
|
// Preserve the write refusal or failure as the caller-visible error.
|
||||||
|
}
|
||||||
throw cause;
|
throw cause;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { open, type FileHandle } from "node:fs/promises";
|
import type { FileHandle } from "node:fs/promises";
|
||||||
import { PassThrough, type Readable } from "node:stream";
|
import { PassThrough, type Readable } from "node:stream";
|
||||||
import { once } from "node:events";
|
import { once } from "node:events";
|
||||||
|
|
||||||
@@ -10,20 +10,57 @@ import {
|
|||||||
type ArtifactReader,
|
type ArtifactReader,
|
||||||
} from "./ArtifactReader.js";
|
} from "./ArtifactReader.js";
|
||||||
import type { ZipPackageFormat } from "../domain/zipPackageFormat.js";
|
import type { ZipPackageFormat } from "../domain/zipPackageFormat.js";
|
||||||
|
import {
|
||||||
|
NonRegularFileReadError,
|
||||||
|
openRegularFile,
|
||||||
|
sameRegularFileState,
|
||||||
|
type StableRegularFileDescriptor,
|
||||||
|
} from "../filesystem/RegularFile.js";
|
||||||
|
|
||||||
class NodeFileReader extends Reader<string> {
|
class NodeFileReader extends Reader<string> {
|
||||||
#handle: FileHandle | undefined;
|
#handle: FileHandle | undefined;
|
||||||
|
#initPromise: Promise<void> | undefined;
|
||||||
|
readonly #ownsHandle: boolean;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly path: string,
|
private readonly path: string,
|
||||||
private readonly maximumReadBytes?: number,
|
private readonly maximumReadBytes?: number,
|
||||||
|
private readonly admitted?: StableRegularFileDescriptor,
|
||||||
) {
|
) {
|
||||||
super(path);
|
super(path);
|
||||||
|
this.#ownsHandle = admitted === undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
override async init(): Promise<void> {
|
override init(): Promise<void> {
|
||||||
this.#handle = await open(this.path, "r");
|
this.#initPromise ??= this.#initialize();
|
||||||
this.size = (await this.#handle.stat()).size;
|
return this.#initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #initialize(): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.#handle =
|
||||||
|
this.admitted?.handle ??
|
||||||
|
(await openRegularFile(this.path, { symlinks: "reject" }));
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
if (cause instanceof NonRegularFileReadError)
|
||||||
|
throw new ArtifactReaderFailure(
|
||||||
|
"format",
|
||||||
|
`ZIP source is not a regular file: ${this.path}`,
|
||||||
|
{ cause },
|
||||||
|
);
|
||||||
|
throw cause;
|
||||||
|
}
|
||||||
|
const metadata = await this.#handle.stat();
|
||||||
|
if (
|
||||||
|
!metadata.isFile() ||
|
||||||
|
(this.admitted !== undefined &&
|
||||||
|
!sameRegularFileState(this.admitted.initial, metadata))
|
||||||
|
)
|
||||||
|
throw new ArtifactReaderFailure(
|
||||||
|
"integrity",
|
||||||
|
`ZIP source identity changed before reading: ${this.path}`,
|
||||||
|
);
|
||||||
|
this.size = metadata.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
override async readUint8Array(
|
override async readUint8Array(
|
||||||
@@ -47,7 +84,10 @@ class NodeFileReader extends Reader<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async closeHandle(): Promise<void> {
|
async closeHandle(): Promise<void> {
|
||||||
await this.#handle?.close();
|
if (!this.#ownsHandle) return;
|
||||||
|
await this.#initPromise?.catch(() => undefined);
|
||||||
|
if (this.#handle === undefined) return;
|
||||||
|
await this.#handle.close();
|
||||||
this.#handle = undefined;
|
this.#handle = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,9 +104,10 @@ export class ZipArtifactReader implements ArtifactReader {
|
|||||||
path: string,
|
path: string,
|
||||||
format: ZipPackageFormat,
|
format: ZipPackageFormat,
|
||||||
maximumMetadataReadBytes?: number,
|
maximumMetadataReadBytes?: number,
|
||||||
|
admitted?: StableRegularFileDescriptor,
|
||||||
) {
|
) {
|
||||||
this.format = format;
|
this.format = format;
|
||||||
this.#source = new NodeFileReader(path, maximumMetadataReadBytes);
|
this.#source = new NodeFileReader(path, maximumMetadataReadBytes, admitted);
|
||||||
this.#reader = new ZipReader(this.#source, {
|
this.#reader = new ZipReader(this.#source, {
|
||||||
checkSignature: true,
|
checkSignature: true,
|
||||||
checkOverlappingEntry: true,
|
checkOverlappingEntry: true,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
type NibHierarchyNode,
|
type NibHierarchyNode,
|
||||||
} from "./NibViewHierarchy.js";
|
} from "./NibViewHierarchy.js";
|
||||||
import { jsonValueSchema, type JsonValue } from "../../domain/jsonValue.js";
|
import { jsonValueSchema, type JsonValue } from "../../domain/jsonValue.js";
|
||||||
|
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
|
||||||
|
|
||||||
import { decodeXmlPlistText } from "../../domain/propertyListXmlText.js";
|
import { decodeXmlPlistText } from "../../domain/propertyListXmlText.js";
|
||||||
|
|
||||||
@@ -49,7 +50,6 @@ export const analyzeInterfaceBuilderBundle = async (input: {
|
|||||||
const prototypeKeyOmissions = new Map<string, number>();
|
const prototypeKeyOmissions = new Map<string, number>();
|
||||||
let omitted = 0;
|
let omitted = 0;
|
||||||
let attempted = 0;
|
let attempted = 0;
|
||||||
let candidates = 0;
|
|
||||||
let aggregateInputBytes = 0;
|
let aggregateInputBytes = 0;
|
||||||
const decodeBudget = new InterfaceBuilderDecodeBudget(
|
const decodeBudget = new InterfaceBuilderDecodeBudget(
|
||||||
MAX_AGGREGATE_DECODE_BYTES,
|
MAX_AGGREGATE_DECODE_BYTES,
|
||||||
@@ -60,15 +60,16 @@ export const analyzeInterfaceBuilderBundle = async (input: {
|
|||||||
else unreportedOmittedArchives += 1;
|
else unreportedOmittedArchives += 1;
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
for await (const entry of reader.entries(input.signal)) {
|
const selection = await selectInterfaceBuilderCandidates(
|
||||||
if (entry.kind !== "file" || !isInterfaceBuilderArchive(entry.path))
|
reader,
|
||||||
continue;
|
input.signal,
|
||||||
candidates += 1;
|
limits.max_documents,
|
||||||
if (candidates > limits.max_documents) {
|
);
|
||||||
omitted += 1;
|
omitted = selection.omitted;
|
||||||
recordOmittedArchive(entry.path, "max_documents");
|
for (const entry of selection.omittedSamples)
|
||||||
continue;
|
recordOmittedArchive(entry.path, "max_documents");
|
||||||
}
|
unreportedOmittedArchives += selection.unreportedOmitted;
|
||||||
|
for (const entry of selection.selected) {
|
||||||
try {
|
try {
|
||||||
const remainingInputBytes =
|
const remainingInputBytes =
|
||||||
MAX_AGGREGATE_INPUT_BYTES - aggregateInputBytes;
|
MAX_AGGREGATE_INPUT_BYTES - aggregateInputBytes;
|
||||||
@@ -167,6 +168,60 @@ export const analyzeInterfaceBuilderBundle = async (input: {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Select lexically earliest document paths independent of host traversal order. */
|
||||||
|
const selectInterfaceBuilderCandidates = async (
|
||||||
|
reader: DirectoryArtifactReader,
|
||||||
|
signal: AbortSignal | undefined,
|
||||||
|
maximumDocuments: number,
|
||||||
|
): Promise<{
|
||||||
|
readonly selected: readonly ArtifactEntry[];
|
||||||
|
readonly omittedSamples: readonly ArtifactEntry[];
|
||||||
|
readonly omitted: number;
|
||||||
|
readonly unreportedOmitted: number;
|
||||||
|
}> => {
|
||||||
|
const capacity = maximumDocuments + MAX_REPORTED_OMITTED_ARCHIVES;
|
||||||
|
const candidates: ArtifactEntry[] = [];
|
||||||
|
let total = 0;
|
||||||
|
for await (const entry of reader.entries(signal)) {
|
||||||
|
if (entry.kind !== "file" || !isInterfaceBuilderArchive(entry.path))
|
||||||
|
continue;
|
||||||
|
total += 1;
|
||||||
|
retainCandidate(candidates, entry, capacity);
|
||||||
|
}
|
||||||
|
const omittedSamples = candidates.slice(maximumDocuments, capacity);
|
||||||
|
return {
|
||||||
|
selected: candidates.slice(0, maximumDocuments),
|
||||||
|
omittedSamples,
|
||||||
|
omitted: Math.max(0, total - maximumDocuments),
|
||||||
|
unreportedOmitted: Math.max(
|
||||||
|
0,
|
||||||
|
total - maximumDocuments - omittedSamples.length,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Keep the earliest bounded path set; directory iteration order is host-defined. */
|
||||||
|
const retainCandidate = (
|
||||||
|
candidates: ArtifactEntry[],
|
||||||
|
entry: ArtifactEntry,
|
||||||
|
maximum: number,
|
||||||
|
): void => {
|
||||||
|
let low = 0;
|
||||||
|
let high = candidates.length;
|
||||||
|
while (low < high) {
|
||||||
|
const middle = Math.floor((low + high) / 2);
|
||||||
|
const existing = candidates[middle];
|
||||||
|
if (
|
||||||
|
existing !== undefined &&
|
||||||
|
compareUnicodeCodePoints(existing.path, entry.path) < 0
|
||||||
|
)
|
||||||
|
low = middle + 1;
|
||||||
|
else high = middle;
|
||||||
|
}
|
||||||
|
candidates.splice(low, 0, entry);
|
||||||
|
if (candidates.length > maximum) candidates.pop();
|
||||||
|
};
|
||||||
|
|
||||||
const finalizeHierarchyCoverage = (
|
const finalizeHierarchyCoverage = (
|
||||||
result: ReturnType<typeof buildInterfaceBuilderAnalysis>,
|
result: ReturnType<typeof buildInterfaceBuilderAnalysis>,
|
||||||
counts: {
|
counts: {
|
||||||
|
|||||||
@@ -147,29 +147,28 @@ export const inspectBundleKeyedArchive = async (input: {
|
|||||||
try {
|
try {
|
||||||
// Match inventory spelling while retaining the raw path for I/O and evidence.
|
// Match inventory spelling while retaining the raw path for I/O and evidence.
|
||||||
const wanted = selected.path.normalize("NFC");
|
const wanted = selected.path.normalize("NFC");
|
||||||
const matches: ArtifactEntry[] = [];
|
let entry: ArtifactEntry | undefined;
|
||||||
for await (const entry of reader.entries(input.signal, (directory) =>
|
for await (const candidate of reader.entries(input.signal, (directory) =>
|
||||||
wanted.startsWith(`${directory.normalize("NFC")}/`),
|
wanted.startsWith(`${directory.normalize("NFC")}/`),
|
||||||
)) {
|
)) {
|
||||||
if (entry.path.normalize("NFC") !== wanted) continue;
|
if (candidate.path.normalize("NFC") !== wanted) continue;
|
||||||
if (entry.kind !== "file")
|
if (candidate.kind !== "file")
|
||||||
throw archivePathError(
|
throw archivePathError(
|
||||||
"invalid_value",
|
"invalid_value",
|
||||||
`Archive path selects a ${entry.kind}, not a regular file: ${selected.path}`,
|
`Archive path selects a ${candidate.kind}, not a regular file: ${selected.path}`,
|
||||||
);
|
);
|
||||||
if ((entry.declaredSize ?? 0) > MAX_BYTES)
|
if ((candidate.declaredSize ?? 0) > MAX_BYTES)
|
||||||
throw new ArtifactReaderFailure(
|
throw new ArtifactReaderFailure(
|
||||||
"limit",
|
"limit",
|
||||||
"Keyed archive exceeds 64 MiB",
|
"Keyed archive exceeds 64 MiB",
|
||||||
);
|
);
|
||||||
matches.push(entry);
|
if (entry !== undefined)
|
||||||
|
throw archivePathError(
|
||||||
|
"invalid_value",
|
||||||
|
`Archive path matches multiple Unicode-equivalent bundle entries; selection is ambiguous: ${selected.path}`,
|
||||||
|
);
|
||||||
|
entry = candidate;
|
||||||
}
|
}
|
||||||
if (matches.length > 1)
|
|
||||||
throw archivePathError(
|
|
||||||
"invalid_value",
|
|
||||||
`Archive path matches ${String(matches.length)} Unicode-equivalent bundle entries; selection is ambiguous: ${selected.path}`,
|
|
||||||
);
|
|
||||||
const entry = matches[0];
|
|
||||||
if (entry === undefined)
|
if (entry === undefined)
|
||||||
throw archivePathError(
|
throw archivePathError(
|
||||||
"invalid_value",
|
"invalid_value",
|
||||||
|
|||||||
@@ -215,7 +215,10 @@ const materializeSelection = async ({
|
|||||||
const written = await output.write(
|
const written = await output.write(
|
||||||
path,
|
path,
|
||||||
stream,
|
stream,
|
||||||
selectedItem.node.sha256,
|
{
|
||||||
|
sha256: selectedItem.node.sha256,
|
||||||
|
bytes: selectedItem.node.size,
|
||||||
|
},
|
||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
extracted.push({
|
extracted.push({
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from "../../filesystem/RegularFile.js";
|
} from "../../filesystem/RegularFile.js";
|
||||||
import type { FileHandle } from "node:fs/promises";
|
import type { FileHandle } from "node:fs/promises";
|
||||||
import type { Stats } from "node:fs";
|
import type { Stats } from "node:fs";
|
||||||
|
import type { StableRegularFileDescriptor } from "../../filesystem/RegularFile.js";
|
||||||
|
|
||||||
import { classifyArtifactContent } from "./ArtifactGraphConstruction.js";
|
import { classifyArtifactContent } from "./ArtifactGraphConstruction.js";
|
||||||
import { ARTIFACT_CLASSIFICATION_PREFIX_BYTES } from "../ArtifactHash.js";
|
import { ARTIFACT_CLASSIFICATION_PREFIX_BYTES } from "../ArtifactHash.js";
|
||||||
@@ -17,18 +18,46 @@ import { ArtifactReaderFailure } from "../ArtifactReader.js";
|
|||||||
import type { HashResult } from "../ArtifactHash.js";
|
import type { HashResult } from "../ArtifactHash.js";
|
||||||
import { hashStableRootArtifactHandle } from "./hashStableRootArtifact.js";
|
import { hashStableRootArtifactHandle } from "./hashStableRootArtifact.js";
|
||||||
|
|
||||||
|
interface RootClassification {
|
||||||
|
readonly format: ArtifactOccurrence["artifact_format"];
|
||||||
|
readonly digest: HashResult | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RootInventoryClassification = RootClassification & {
|
||||||
|
readonly zipSource: StableRegularFileDescriptor | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
/** Classify and hash one file root through the same stable open descriptor. */
|
/** Classify and hash one file root through the same stable open descriptor. */
|
||||||
export const classifyAndHashRoot = async (
|
export const classifyAndHashRoot = async (
|
||||||
path: string,
|
path: string,
|
||||||
directory: boolean,
|
directory: boolean,
|
||||||
expectedMetadata: Stats,
|
expectedMetadata: Stats,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<{
|
): Promise<RootClassification> => {
|
||||||
readonly format: ArtifactOccurrence["artifact_format"];
|
const result = await classifyAndHashRootForInventory(
|
||||||
readonly digest: HashResult | null;
|
path,
|
||||||
}> => {
|
directory,
|
||||||
if (directory) return { format: "directory", digest: null };
|
expectedMetadata,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
return { format: result.format, digest: result.digest };
|
||||||
|
} finally {
|
||||||
|
await result.zipSource?.handle.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Classify a root and retain its admitted ZIP descriptor for child inventory. */
|
||||||
|
export const classifyAndHashRootForInventory = async (
|
||||||
|
path: string,
|
||||||
|
directory: boolean,
|
||||||
|
expectedMetadata: Stats,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<RootInventoryClassification> => {
|
||||||
|
if (directory)
|
||||||
|
return { format: "directory", digest: null, zipSource: undefined };
|
||||||
const handle = await openRootFile(path, signal);
|
const handle = await openRootFile(path, signal);
|
||||||
|
let transferHandle = false;
|
||||||
try {
|
try {
|
||||||
const initial = await handle.stat();
|
const initial = await handle.stat();
|
||||||
if (!sameRegularFileState(expectedMetadata, initial))
|
if (!sameRegularFileState(expectedMetadata, initial))
|
||||||
@@ -43,12 +72,25 @@ export const classifyAndHashRoot = async (
|
|||||||
initial,
|
initial,
|
||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
return { format, digest };
|
const zipSource = isZipFormat(format);
|
||||||
|
if (zipSource) transferHandle = true;
|
||||||
|
return {
|
||||||
|
format,
|
||||||
|
digest,
|
||||||
|
zipSource: zipSource ? { handle, initial } : undefined,
|
||||||
|
};
|
||||||
} finally {
|
} finally {
|
||||||
await handle.close();
|
if (!transferHandle) await handle.close();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isZipFormat = (format: ArtifactOccurrence["artifact_format"]): boolean =>
|
||||||
|
format === "zip" ||
|
||||||
|
format === "ipa" ||
|
||||||
|
format === "apk" ||
|
||||||
|
format === "msix" ||
|
||||||
|
format === "appx";
|
||||||
|
|
||||||
const openRootFile = async (
|
const openRootFile = async (
|
||||||
path: string,
|
path: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { lstat } from "node:fs/promises";
|
import { lstat } from "node:fs/promises";
|
||||||
|
import type { Stats } from "node:fs";
|
||||||
import type { FileHandle } from "node:fs/promises";
|
import type { FileHandle } from "node:fs/promises";
|
||||||
|
|
||||||
import { ArtifactReaderFailure } from "../ArtifactReader.js";
|
import { ArtifactReaderFailure } from "../ArtifactReader.js";
|
||||||
@@ -45,11 +46,13 @@ export const hashStableRootArtifact = async (
|
|||||||
export const hashStableRootArtifactHandle = async (
|
export const hashStableRootArtifactHandle = async (
|
||||||
path: string,
|
path: string,
|
||||||
handle: FileHandle,
|
handle: FileHandle,
|
||||||
initial: Awaited<ReturnType<FileHandle["stat"]>>,
|
initial: Stats,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<HashResult> => {
|
): Promise<HashResult> => {
|
||||||
const digest = await hashReadable(
|
const digest = await hashReadable(
|
||||||
handle.createReadStream({ autoClose: false }),
|
// Read at most the admitted extent plus one byte to detect growth without
|
||||||
|
// following an actively appended file indefinitely.
|
||||||
|
handle.createReadStream({ autoClose: false, start: 0, end: initial.size }),
|
||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
abortIfNeeded(signal);
|
abortIfNeeded(signal);
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import { NativeDmgArtifactReader } from "../NativeDmgArtifactReader.js";
|
|||||||
import { ZipArtifactReader } from "../ZipArtifactReader.js";
|
import { ZipArtifactReader } from "../ZipArtifactReader.js";
|
||||||
import type { ArtifactOccurrence } from "../../domain/artifactGraph.js";
|
import type { ArtifactOccurrence } from "../../domain/artifactGraph.js";
|
||||||
import { ZIP_NON_ENTRY_TAIL_LIMITATION } from "../../domain/zipPackageFormat.js";
|
import { ZIP_NON_ENTRY_TAIL_LIMITATION } from "../../domain/zipPackageFormat.js";
|
||||||
|
import type { StableRegularFileDescriptor } from "../../filesystem/RegularFile.js";
|
||||||
|
|
||||||
export const createReader = async (
|
export const createReader = async (
|
||||||
path: string,
|
path: string,
|
||||||
format: ArtifactOccurrence["artifact_format"],
|
format: ArtifactOccurrence["artifact_format"],
|
||||||
environment: Readonly<NodeJS.ProcessEnv>,
|
environment: Readonly<NodeJS.ProcessEnv>,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
zipSource?: StableRegularFileDescriptor,
|
||||||
): Promise<ArtifactReader | undefined> => {
|
): Promise<ArtifactReader | undefined> => {
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case "directory":
|
case "directory":
|
||||||
@@ -21,7 +23,7 @@ export const createReader = async (
|
|||||||
case "apk":
|
case "apk":
|
||||||
case "msix":
|
case "msix":
|
||||||
case "appx":
|
case "appx":
|
||||||
return new ZipArtifactReader(path, format);
|
return new ZipArtifactReader(path, format, undefined, zipSource);
|
||||||
case "asar":
|
case "asar":
|
||||||
return new AsarArtifactReader(path);
|
return new AsarArtifactReader(path);
|
||||||
case "mach-o-universal":
|
case "mach-o-universal":
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { AsarArtifactReader } from "../AsarArtifactReader.js";
|
|||||||
|
|
||||||
import { lstat } from "node:fs/promises";
|
import { lstat } from "node:fs/promises";
|
||||||
import type { Stats } from "node:fs";
|
import type { Stats } from "node:fs";
|
||||||
|
import type { StableRegularFileDescriptor } from "../../filesystem/RegularFile.js";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ArtifactReaderFailure,
|
ArtifactReaderFailure,
|
||||||
@@ -31,9 +32,12 @@ import {
|
|||||||
artifactGraphDigest,
|
artifactGraphDigest,
|
||||||
artifactManifestId,
|
artifactManifestId,
|
||||||
} from "../../domain/artifactIdentity.js";
|
} from "../../domain/artifactIdentity.js";
|
||||||
import { classifyAndHashRoot } from "./classify.js";
|
import { classifyAndHashRootForInventory } from "./classify.js";
|
||||||
import { type HashResult } from "../ArtifactHash.js";
|
import { type HashResult } from "../ArtifactHash.js";
|
||||||
import { hashStableRootArtifact } from "./hashStableRootArtifact.js";
|
import {
|
||||||
|
hashStableRootArtifact,
|
||||||
|
hashStableRootArtifactHandle,
|
||||||
|
} from "./hashStableRootArtifact.js";
|
||||||
import { createReader, inventoryLimitations } from "./reader.js";
|
import { createReader, inventoryLimitations } from "./reader.js";
|
||||||
import {
|
import {
|
||||||
scanReader,
|
scanReader,
|
||||||
@@ -51,18 +55,29 @@ export const scanCanonicalArtifactInventory = async (
|
|||||||
): Promise<ArtifactInventorySnapshot> => {
|
): Promise<ArtifactInventorySnapshot> => {
|
||||||
const integrity = options.integrity ?? STRICT_INTEGRITY_POLICY;
|
const integrity = options.integrity ?? STRICT_INTEGRITY_POLICY;
|
||||||
const metadata = await lstat(path);
|
const metadata = await lstat(path);
|
||||||
const { format: rootFormat, digest: rootDigest } = await classifyAndHashRoot(
|
const {
|
||||||
|
format: rootFormat,
|
||||||
|
digest: rootDigest,
|
||||||
|
zipSource,
|
||||||
|
} = await classifyAndHashRootForInventory(
|
||||||
path,
|
path,
|
||||||
metadata.isDirectory(),
|
metadata.isDirectory(),
|
||||||
metadata,
|
metadata,
|
||||||
options.signal,
|
options.signal,
|
||||||
);
|
);
|
||||||
const reader = await readerFactory(
|
let reader: ArtifactReader | undefined;
|
||||||
path,
|
let readerCreationFailure: { readonly cause: unknown } | undefined;
|
||||||
rootFormat,
|
try {
|
||||||
options.environment ?? {},
|
reader = await readerFactory(
|
||||||
options.signal,
|
path,
|
||||||
);
|
rootFormat,
|
||||||
|
options.environment ?? {},
|
||||||
|
options.signal,
|
||||||
|
zipSource,
|
||||||
|
);
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
readerCreationFailure = { cause };
|
||||||
|
}
|
||||||
const ownedReaders: ArtifactReader[] = reader === undefined ? [] : [reader];
|
const ownedReaders: ArtifactReader[] = reader === undefined ? [] : [reader];
|
||||||
let outcome:
|
let outcome:
|
||||||
| {
|
| {
|
||||||
@@ -71,6 +86,7 @@ export const scanCanonicalArtifactInventory = async (
|
|||||||
}
|
}
|
||||||
| { readonly kind: "failed"; readonly cause: unknown };
|
| { readonly kind: "failed"; readonly cause: unknown };
|
||||||
try {
|
try {
|
||||||
|
if (readerCreationFailure !== undefined) throw readerCreationFailure.cause;
|
||||||
if (reader instanceof AsarArtifactReader)
|
if (reader instanceof AsarArtifactReader)
|
||||||
await reader.prepareContainer(rootDigest?.sha256, options.signal);
|
await reader.prepareContainer(rootDigest?.sha256, options.signal);
|
||||||
const {
|
const {
|
||||||
@@ -86,6 +102,7 @@ export const scanCanonicalArtifactInventory = async (
|
|||||||
metadata,
|
metadata,
|
||||||
rootFormat,
|
rootFormat,
|
||||||
rootDigest,
|
rootDigest,
|
||||||
|
zipSource,
|
||||||
reader,
|
reader,
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
nodes,
|
nodes,
|
||||||
@@ -116,6 +133,24 @@ export const scanCanonicalArtifactInventory = async (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (zipSource !== undefined) {
|
||||||
|
try {
|
||||||
|
await zipSource.handle.close();
|
||||||
|
} catch (cleanupCause: unknown) {
|
||||||
|
const cleanup = ArtifactReaderFailure.cleanupObservation(
|
||||||
|
cleanupCause,
|
||||||
|
`root ZIP descriptor for ${path}`,
|
||||||
|
);
|
||||||
|
cleanupFailure = ArtifactReaderFailure.withCleanup(
|
||||||
|
cleanupFailure ??
|
||||||
|
(outcome.kind === "failed" ? outcome.cause : cleanupCause),
|
||||||
|
cleanup,
|
||||||
|
outcome.kind === "completed"
|
||||||
|
? { kind: "artifact-inventory", inventory: outcome.snapshot }
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (cleanupFailure !== undefined) throw cleanupFailure;
|
if (cleanupFailure !== undefined) throw cleanupFailure;
|
||||||
if (outcome.kind === "failed") throw outcome.cause;
|
if (outcome.kind === "failed") throw outcome.cause;
|
||||||
return outcome.snapshot;
|
return outcome.snapshot;
|
||||||
@@ -126,6 +161,7 @@ interface SnapshotBuildInput {
|
|||||||
readonly metadata: Stats;
|
readonly metadata: Stats;
|
||||||
readonly rootFormat: ArtifactOccurrence["artifact_format"];
|
readonly rootFormat: ArtifactOccurrence["artifact_format"];
|
||||||
readonly rootDigest: HashResult | null;
|
readonly rootDigest: HashResult | null;
|
||||||
|
readonly zipSource: StableRegularFileDescriptor | undefined;
|
||||||
readonly reader: ArtifactReader | undefined;
|
readonly reader: ArtifactReader | undefined;
|
||||||
readonly signal: AbortSignal | undefined;
|
readonly signal: AbortSignal | undefined;
|
||||||
readonly nodes: Map<string, ArtifactNode>;
|
readonly nodes: Map<string, ArtifactNode>;
|
||||||
@@ -137,7 +173,8 @@ interface SnapshotBuildInput {
|
|||||||
const buildInventorySnapshot = async (
|
const buildInventorySnapshot = async (
|
||||||
input: SnapshotBuildInput,
|
input: SnapshotBuildInput,
|
||||||
): Promise<ArtifactInventorySnapshot> => {
|
): Promise<ArtifactInventorySnapshot> => {
|
||||||
const { path, metadata, rootFormat, rootDigest, reader, signal } = input;
|
const { path, metadata, rootFormat, rootDigest, zipSource, reader, signal } =
|
||||||
|
input;
|
||||||
materializeDirectoryNodes(input.occurrences, input.nodes);
|
materializeDirectoryNodes(input.occurrences, input.nodes);
|
||||||
const rootNode = createRootNode({
|
const rootNode = createRootNode({
|
||||||
digest: rootDigest,
|
digest: rootDigest,
|
||||||
@@ -181,7 +218,7 @@ const buildInventorySnapshot = async (
|
|||||||
ordinal,
|
ordinal,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await verifyRootDigest(path, rootDigest, signal);
|
await verifyRootDigest(path, rootDigest, zipSource, signal);
|
||||||
|
|
||||||
const graphSha256 = artifactGraphDigest({
|
const graphSha256 = artifactGraphDigest({
|
||||||
nodes: orderedNodes,
|
nodes: orderedNodes,
|
||||||
@@ -256,10 +293,19 @@ const buildIntegrityContradictions = (
|
|||||||
const verifyRootDigest = async (
|
const verifyRootDigest = async (
|
||||||
path: string,
|
path: string,
|
||||||
rootDigest: HashResult | null,
|
rootDigest: HashResult | null,
|
||||||
|
zipSource: StableRegularFileDescriptor | undefined,
|
||||||
signal: AbortSignal | undefined,
|
signal: AbortSignal | undefined,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
if (rootDigest === null) return;
|
if (rootDigest === null) return;
|
||||||
const verified = await hashStableRootArtifact(path, signal);
|
const verified =
|
||||||
|
zipSource === undefined
|
||||||
|
? await hashStableRootArtifact(path, signal)
|
||||||
|
: await hashStableRootArtifactHandle(
|
||||||
|
path,
|
||||||
|
zipSource.handle,
|
||||||
|
zipSource.initial,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
verified.sha256 !== rootDigest.sha256 ||
|
verified.sha256 !== rootDigest.sha256 ||
|
||||||
verified.bytes !== rootDigest.bytes
|
verified.bytes !== rootDigest.bytes
|
||||||
|
|||||||
@@ -38,5 +38,8 @@ export interface BrowserScenarioSessionPort {
|
|||||||
/** Acquire one scenario session whose lifecycle the caller owns. */
|
/** Acquire one scenario session whose lifecycle the caller owns. */
|
||||||
export type BrowserScenarioSessionOpener = (
|
export type BrowserScenarioSessionOpener = (
|
||||||
scenario: BrowserScenario,
|
scenario: BrowserScenario,
|
||||||
options: { readonly signal?: AbortSignal },
|
options: {
|
||||||
|
readonly signal?: AbortSignal;
|
||||||
|
readonly retainCleanup?: (close: () => Promise<unknown>) => void;
|
||||||
|
},
|
||||||
) => Promise<BrowserScenarioSessionPort>;
|
) => Promise<BrowserScenarioSessionPort>;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export class CdpConnection {
|
|||||||
>();
|
>();
|
||||||
#nextId = 1;
|
#nextId = 1;
|
||||||
#closed = false;
|
#closed = false;
|
||||||
|
#closePromise: Promise<void> | undefined;
|
||||||
#protocolFailed = false;
|
#protocolFailed = false;
|
||||||
#transportFailure: "disconnected" | "payload_limit" = "disconnected";
|
#transportFailure: "disconnected" | "payload_limit" = "disconnected";
|
||||||
|
|
||||||
@@ -151,8 +152,15 @@ export class CdpConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Close only REA's socket; never close the browser or selected page. */
|
/** Close only REA's socket; never close the browser or selected page. */
|
||||||
async close(): Promise<void> {
|
close(): Promise<void> {
|
||||||
if (this.#closed) return;
|
this.#closePromise ??= this.#closeTransport().catch((cause: unknown) => {
|
||||||
|
this.#closePromise = undefined;
|
||||||
|
throw cause;
|
||||||
|
});
|
||||||
|
return this.#closePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #closeTransport(): Promise<void> {
|
||||||
this.#closed = true;
|
this.#closed = true;
|
||||||
this.#failPending("disconnected");
|
this.#failPending("disconnected");
|
||||||
if (this.socket.readyState === WebSocket.CLOSED) return;
|
if (this.socket.readyState === WebSocket.CLOSED) return;
|
||||||
|
|||||||
@@ -50,54 +50,40 @@ export const closeCdpTargetSession = async (
|
|||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const { connection, sessionId } = targetSession;
|
const { connection, sessionId } = targetSession;
|
||||||
for (const [index, domain] of enabledDomains.entries()) {
|
const cleanup = new AbortController();
|
||||||
if (signal?.aborted === true) {
|
const timeout = setTimeout(() => cleanup.abort(), 1_000);
|
||||||
await closeCancelledTargetSession(
|
timeout.unref();
|
||||||
targetSession,
|
|
||||||
enabledDomains.slice(index),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await connection.send(`${domain}.disable`, {}, sessionId, signal);
|
|
||||||
} catch (cause: unknown) {
|
|
||||||
// best-effort cleanup: domain disable continues to detach/close boundary.
|
|
||||||
void cause;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (signal?.aborted === true) {
|
|
||||||
await closeCancelledTargetSession(targetSession, []);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (sessionId !== undefined)
|
|
||||||
try {
|
|
||||||
await connection.send(
|
|
||||||
"Target.detachFromTarget",
|
|
||||||
{ sessionId },
|
|
||||||
undefined,
|
|
||||||
signal,
|
|
||||||
);
|
|
||||||
} catch (cause: unknown) {
|
|
||||||
// best-effort cleanup: closing REA's socket is the final cleanup boundary.
|
|
||||||
void cause;
|
|
||||||
}
|
|
||||||
await connection.close();
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeCancelledTargetSession = async (
|
|
||||||
{ connection, sessionId }: CdpTargetSession,
|
|
||||||
enabledDomains: readonly string[],
|
|
||||||
): Promise<void> => {
|
|
||||||
const commands = enabledDomains.map((domain) =>
|
|
||||||
connection.send(`${domain}.disable`, {}, sessionId),
|
|
||||||
);
|
|
||||||
if (sessionId !== undefined)
|
|
||||||
commands.push(connection.send("Target.detachFromTarget", { sessionId }));
|
|
||||||
const settled = Promise.allSettled(commands);
|
|
||||||
try {
|
try {
|
||||||
await connection.close();
|
if (signal?.aborted !== true) {
|
||||||
|
for (const domain of enabledDomains) {
|
||||||
|
if (cleanup.signal.aborted) break;
|
||||||
|
try {
|
||||||
|
await connection.send(
|
||||||
|
`${domain}.disable`,
|
||||||
|
{},
|
||||||
|
sessionId,
|
||||||
|
cleanup.signal,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Best-effort domain cleanup; transport close is the definitive boundary.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!cleanup.signal.aborted && sessionId !== undefined)
|
||||||
|
try {
|
||||||
|
await connection.send(
|
||||||
|
"Target.detachFromTarget",
|
||||||
|
{ sessionId },
|
||||||
|
undefined,
|
||||||
|
cleanup.signal,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Best-effort detach; transport close is the definitive boundary.
|
||||||
|
}
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await settled;
|
clearTimeout(timeout);
|
||||||
|
cleanup.abort();
|
||||||
|
await connection.close();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import { open, realpath, stat } from "node:fs/promises";
|
|||||||
import { isAbsolute, join, sep } from "node:path";
|
import { isAbsolute, join, sep } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import {
|
||||||
|
admitAsarHeader,
|
||||||
|
MAX_ASAR_HEADER_BYTES,
|
||||||
|
} from "../artifacts/AsarHeader.js";
|
||||||
|
|
||||||
/** Resolve one local file URL while rejecting remote hosts and encoded separators. */
|
/** Resolve one local file URL while rejecting remote hosts and encoded separators. */
|
||||||
export const authorizedElectronFile = async (
|
export const authorizedElectronFile = async (
|
||||||
value: string,
|
value: string,
|
||||||
@@ -77,7 +82,6 @@ const authorizedAsarMember = async (
|
|||||||
type ArchiveIdentity = Pick<Stats, "dev" | "ino" | "size" | "mtimeMs">;
|
type ArchiveIdentity = Pick<Stats, "dev" | "ino" | "size" | "mtimeMs">;
|
||||||
|
|
||||||
// This is a retained-memory budget for JSON headers, shared across all archives.
|
// This is a retained-memory budget for JSON headers, shared across all archives.
|
||||||
const MAX_HEADER_BYTES = 16 * 1024 * 1024;
|
|
||||||
const MAX_CACHED_HEADERS = 8;
|
const MAX_CACHED_HEADERS = 8;
|
||||||
const headerCache = new Map<
|
const headerCache = new Map<
|
||||||
string,
|
string,
|
||||||
@@ -105,23 +109,8 @@ const asarFiles = async (
|
|||||||
const opened = await handle.stat();
|
const opened = await handle.stat();
|
||||||
if (!opened.isFile() || archiveIdentity(opened) !== identity)
|
if (!opened.isFile() || archiveIdentity(opened) !== identity)
|
||||||
throw new Error("ASAR identity changed before header read");
|
throw new Error("ASAR identity changed before header read");
|
||||||
const prefix = Buffer.alloc(16);
|
const admitted = await admitAsarHeader(handle);
|
||||||
const read = await handle.read(prefix, 0, prefix.length, 0);
|
jsonBytes = admitted.jsonBytes;
|
||||||
if (read.bytesRead !== prefix.length || prefix.readUInt32LE(0) !== 4)
|
|
||||||
throw new Error("Truncated or invalid ASAR size pickle");
|
|
||||||
const headerBytes = prefix.readUInt32LE(4);
|
|
||||||
const payloadBytes = prefix.readUInt32LE(8);
|
|
||||||
jsonBytes = prefix.readUInt32LE(12);
|
|
||||||
if (
|
|
||||||
headerBytes < 8 ||
|
|
||||||
headerBytes > opened.size - 8 ||
|
|
||||||
payloadBytes !== headerBytes - 4 ||
|
|
||||||
jsonBytes > payloadBytes - 4 ||
|
|
||||||
jsonBytes > MAX_HEADER_BYTES
|
|
||||||
)
|
|
||||||
throw new Error(
|
|
||||||
"ASAR JSON header leaves the file or exceeds its memory budget",
|
|
||||||
);
|
|
||||||
const json = Buffer.alloc(jsonBytes);
|
const json = Buffer.alloc(jsonBytes);
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
while (offset < json.length) {
|
while (offset < json.length) {
|
||||||
@@ -151,7 +140,7 @@ const asarFiles = async (
|
|||||||
[...headerCache.values()].reduce((sum, entry) => sum + entry.bytes, 0);
|
[...headerCache.values()].reduce((sum, entry) => sum + entry.bytes, 0);
|
||||||
while (
|
while (
|
||||||
headerCache.size > MAX_CACHED_HEADERS ||
|
headerCache.size > MAX_CACHED_HEADERS ||
|
||||||
retainedBytes() > MAX_HEADER_BYTES
|
retainedBytes() > MAX_ASAR_HEADER_BYTES
|
||||||
)
|
)
|
||||||
headerCache.delete(headerCache.keys().next().value ?? archive);
|
headerCache.delete(headerCache.keys().next().value ?? archive);
|
||||||
return files;
|
return files;
|
||||||
|
|||||||
@@ -229,14 +229,15 @@ const runScenario = async (
|
|||||||
openSession: BrowserScenarioSessionOpener,
|
openSession: BrowserScenarioSessionOpener,
|
||||||
scenario: BrowserScenario,
|
scenario: BrowserScenario,
|
||||||
options: ExecutionOptions,
|
options: ExecutionOptions,
|
||||||
|
retainCleanup: (close: () => Promise<unknown>) => void,
|
||||||
): Promise<BrowserScenarioCapture> => {
|
): Promise<BrowserScenarioCapture> => {
|
||||||
if (options.signal?.aborted === true)
|
if (options.signal?.aborted === true)
|
||||||
throw new BrowserObservationError(OPERATION, "cancelled");
|
throw new BrowserObservationError(OPERATION, "cancelled");
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const session = await openSession(
|
const session = await openSession(scenario, {
|
||||||
scenario,
|
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||||
options.signal === undefined ? {} : { signal: options.signal },
|
retainCleanup,
|
||||||
);
|
});
|
||||||
const steps: BrowserScenarioStep[] = [];
|
const steps: BrowserScenarioStep[] = [];
|
||||||
let operationOutcome:
|
let operationOutcome:
|
||||||
| { readonly ok: true }
|
| { readonly ok: true }
|
||||||
@@ -279,6 +280,7 @@ const runScenario = async (
|
|||||||
cleanupOutcome = { ok: true, cleanup: await session.close() };
|
cleanupOutcome = { ok: true, cleanup: await session.close() };
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
cleanupOutcome = { ok: false, cause };
|
cleanupOutcome = { ok: false, cause };
|
||||||
|
retainCleanup(() => session.close());
|
||||||
}
|
}
|
||||||
if (!cleanupOutcome.ok) {
|
if (!cleanupOutcome.ok) {
|
||||||
const cleanup = browserScenarioCleanupObservation(cleanupOutcome.cause);
|
const cleanup = browserScenarioCleanupObservation(cleanupOutcome.cause);
|
||||||
@@ -337,18 +339,46 @@ const runScenario = async (
|
|||||||
|
|
||||||
/** Controlled Playwright/CDP scenario driver with exact process ownership. */
|
/** Controlled Playwright/CDP scenario driver with exact process ownership. */
|
||||||
export class PlaywrightBrowserScenarioProvider implements BrowserScenarioCapturePort {
|
export class PlaywrightBrowserScenarioProvider implements BrowserScenarioCapturePort {
|
||||||
|
#tail: Promise<void> = Promise.resolve();
|
||||||
|
#closing = false;
|
||||||
|
#closePromise: Promise<void> | undefined;
|
||||||
|
readonly #pendingCleanup = new Set<() => Promise<unknown>>();
|
||||||
|
|
||||||
constructor(private readonly openSession: BrowserScenarioSessionOpener) {}
|
constructor(private readonly openSession: BrowserScenarioSessionOpener) {}
|
||||||
|
|
||||||
identity(): ProviderIdentity {
|
identity(): ProviderIdentity {
|
||||||
return PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY;
|
return PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY;
|
||||||
}
|
}
|
||||||
|
|
||||||
async captureScenario(
|
captureScenario(
|
||||||
scenario: BrowserScenario,
|
scenario: BrowserScenario,
|
||||||
options: ExecutionOptions = {},
|
options: ExecutionOptions = {},
|
||||||
): Promise<Result<BrowserScenarioCapture, AnalysisError>> {
|
): Promise<Result<BrowserScenarioCapture, AnalysisError>> {
|
||||||
|
if (this.#closing) return Promise.resolve(err(this.#closedError()));
|
||||||
|
const operation = this.#tail.then(() =>
|
||||||
|
this.#closing
|
||||||
|
? err(this.#closedError())
|
||||||
|
: this.#captureScenario(scenario, options),
|
||||||
|
);
|
||||||
|
this.#tail = operation.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
return operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #captureScenario(
|
||||||
|
scenario: BrowserScenario,
|
||||||
|
options: ExecutionOptions,
|
||||||
|
): Promise<Result<BrowserScenarioCapture, AnalysisError>> {
|
||||||
|
const pending = await this.#retryCleanup();
|
||||||
|
if (pending !== undefined) return err(pending);
|
||||||
try {
|
try {
|
||||||
return ok(await runScenario(this.openSession, scenario, options));
|
return ok(
|
||||||
|
await runScenario(this.openSession, scenario, options, (close) => {
|
||||||
|
this.#pendingCleanup.add(close);
|
||||||
|
}),
|
||||||
|
);
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
if (cause instanceof AnalysisError) return err(cause);
|
if (cause instanceof AnalysisError) return err(cause);
|
||||||
return err(
|
return err(
|
||||||
@@ -360,4 +390,43 @@ export class PlaywrightBrowserScenarioProvider implements BrowserScenarioCapture
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
this.#closing = true;
|
||||||
|
this.#closePromise ??= this.#tail
|
||||||
|
.then(async () => {
|
||||||
|
const failure = await this.#retryCleanup();
|
||||||
|
if (failure !== undefined) throw failure;
|
||||||
|
})
|
||||||
|
.catch((cause: unknown) => {
|
||||||
|
this.#closePromise = undefined;
|
||||||
|
throw cause;
|
||||||
|
});
|
||||||
|
return this.#closePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
#closedError(): AnalysisError {
|
||||||
|
return new BrowserObservationError(OPERATION, "cleanup_failed", {
|
||||||
|
detail: "Browser scenario provider is closing.",
|
||||||
|
cleanup: { reason: "Provider is closing", resources: [] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async #retryCleanup(): Promise<AnalysisError | undefined> {
|
||||||
|
for (const pending of this.#pendingCleanup) {
|
||||||
|
try {
|
||||||
|
await pending();
|
||||||
|
this.#pendingCleanup.delete(pending);
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
return cause instanceof AnalysisError
|
||||||
|
? cause
|
||||||
|
: new ProviderAdapterError(
|
||||||
|
PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY.id,
|
||||||
|
OPERATION,
|
||||||
|
{ cause },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export class PlaywrightScenarioBrowserCleanupOwner {
|
|||||||
#profileRemoved: boolean;
|
#profileRemoved: boolean;
|
||||||
#closePromise: Promise<void> | undefined;
|
#closePromise: Promise<void> | undefined;
|
||||||
#eventFinalizationPromise: Promise<void> | undefined;
|
#eventFinalizationPromise: Promise<void> | undefined;
|
||||||
|
#eventFinalizationFailureReported = false;
|
||||||
|
|
||||||
/** Keep the narrow provider actions needed to release one opened browser. */
|
/** Keep the narrow provider actions needed to release one opened browser. */
|
||||||
constructor(private readonly resources: PlaywrightScenarioCleanupResources) {
|
constructor(private readonly resources: PlaywrightScenarioCleanupResources) {
|
||||||
@@ -97,7 +98,10 @@ export class PlaywrightScenarioBrowserCleanupOwner {
|
|||||||
try {
|
try {
|
||||||
await eventFinalization;
|
await eventFinalization;
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
failures.push(cause);
|
if (!this.#eventFinalizationFailureReported) {
|
||||||
|
failures.push(cause);
|
||||||
|
this.#eventFinalizationFailureReported = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!this.#browserClosed) {
|
if (!this.#browserClosed) {
|
||||||
try {
|
try {
|
||||||
@@ -380,6 +384,7 @@ const openAttachedScenarioBrowser = async (
|
|||||||
environment: BrowserScenario["environment"],
|
environment: BrowserScenario["environment"],
|
||||||
launcher: ScenarioBrowserLauncher,
|
launcher: ScenarioBrowserLauncher,
|
||||||
signal: AbortSignal | undefined,
|
signal: AbortSignal | undefined,
|
||||||
|
retainCleanup: ((close: () => Promise<unknown>) => void) | undefined,
|
||||||
): Promise<OpenedScenarioBrowser> => {
|
): Promise<OpenedScenarioBrowser> => {
|
||||||
const lease = await acquireAttachedScenario(browserRequest, signal);
|
const lease = await acquireAttachedScenario(browserRequest, signal);
|
||||||
let cleanupOwner: PlaywrightScenarioBrowserCleanupOwner | undefined;
|
let cleanupOwner: PlaywrightScenarioBrowserCleanupOwner | undefined;
|
||||||
@@ -431,7 +436,13 @@ const openAttachedScenarioBrowser = async (
|
|||||||
throw cause;
|
throw cause;
|
||||||
}
|
}
|
||||||
const cleanup = cleanupOwner;
|
const cleanup = cleanupOwner;
|
||||||
return failBrowserScenarioOperation(() => cleanup.close(), cause);
|
const close = () => cleanup.close();
|
||||||
|
try {
|
||||||
|
return await failBrowserScenarioOperation(close, cause);
|
||||||
|
} catch (failure: unknown) {
|
||||||
|
if (failure !== cause) retainCleanup?.(close);
|
||||||
|
throw failure;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -442,6 +453,7 @@ export const openPlaywrightScenarioBrowser = async (
|
|||||||
options: {
|
options: {
|
||||||
readonly launcher?: ScenarioBrowserLauncher;
|
readonly launcher?: ScenarioBrowserLauncher;
|
||||||
readonly signal?: AbortSignal | undefined;
|
readonly signal?: AbortSignal | undefined;
|
||||||
|
readonly retainCleanup?: (close: () => Promise<unknown>) => void;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<OpenedScenarioBrowser> => {
|
): Promise<OpenedScenarioBrowser> => {
|
||||||
const launcher = options.launcher ?? chromium;
|
const launcher = options.launcher ?? chromium;
|
||||||
@@ -451,6 +463,7 @@ export const openPlaywrightScenarioBrowser = async (
|
|||||||
scenario.environment,
|
scenario.environment,
|
||||||
launcher,
|
launcher,
|
||||||
options.signal,
|
options.signal,
|
||||||
|
options.retainCleanup,
|
||||||
);
|
);
|
||||||
|
|
||||||
const profilePath = await mkdtemp(join(tmpdir(), "rea-browser-scenario-"));
|
const profilePath = await mkdtemp(join(tmpdir(), "rea-browser-scenario-"));
|
||||||
@@ -478,11 +491,16 @@ export const openPlaywrightScenarioBrowser = async (
|
|||||||
timeout: 0,
|
timeout: 0,
|
||||||
});
|
});
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
return failBrowserScenarioOperation(
|
const cleanup = () =>
|
||||||
() => rm(profilePath, { recursive: true, force: true, maxRetries: 3 }),
|
rm(profilePath, { recursive: true, force: true, maxRetries: 3 });
|
||||||
cause,
|
try {
|
||||||
["browser_profile"],
|
return await failBrowserScenarioOperation(cleanup, cause, [
|
||||||
);
|
"browser_profile",
|
||||||
|
]);
|
||||||
|
} catch (failure: unknown) {
|
||||||
|
if (failure !== cause) options.retainCleanup?.(cleanup);
|
||||||
|
throw failure;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const cleanup = new PlaywrightScenarioBrowserCleanupOwner({
|
const cleanup = new PlaywrightScenarioBrowserCleanupOwner({
|
||||||
closeBrowser: () => context.close(),
|
closeBrowser: () => context.close(),
|
||||||
@@ -502,6 +520,12 @@ export const openPlaywrightScenarioBrowser = async (
|
|||||||
cleanup,
|
cleanup,
|
||||||
};
|
};
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
return failBrowserScenarioOperation(() => cleanup.close(), cause);
|
const close = () => cleanup.close();
|
||||||
|
try {
|
||||||
|
return await failBrowserScenarioOperation(close, cause);
|
||||||
|
} catch (failure: unknown) {
|
||||||
|
if (failure !== cause) options.retainCleanup?.(close);
|
||||||
|
throw failure;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
|
|||||||
environment: Readonly<Record<string, string | undefined>>,
|
environment: Readonly<Record<string, string | undefined>>,
|
||||||
options: {
|
options: {
|
||||||
readonly signal?: AbortSignal;
|
readonly signal?: AbortSignal;
|
||||||
|
readonly retainCleanup?: (close: () => Promise<unknown>) => void;
|
||||||
},
|
},
|
||||||
): Promise<PlaywrightScenarioSession> {
|
): Promise<PlaywrightScenarioSession> {
|
||||||
if (options.signal?.aborted === true)
|
if (options.signal?.aborted === true)
|
||||||
@@ -137,6 +138,9 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
|
|||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const opening = openPlaywrightScenarioBrowser(scenario, environment, {
|
const opening = openPlaywrightScenarioBrowser(scenario, environment, {
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
|
...(options.retainCleanup === undefined
|
||||||
|
? {}
|
||||||
|
: { retainCleanup: options.retainCleanup }),
|
||||||
});
|
});
|
||||||
let opened: OpenedScenarioBrowser;
|
let opened: OpenedScenarioBrowser;
|
||||||
let events: PlaywrightScenarioEvents | undefined;
|
let events: PlaywrightScenarioEvents | undefined;
|
||||||
@@ -147,14 +151,18 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
|
|||||||
options.signal,
|
options.signal,
|
||||||
);
|
);
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
void opening
|
const lateCleanup = () =>
|
||||||
.then((lateOpened) =>
|
opening.then(
|
||||||
lateOpened.cleanup.close(undefined, options.signal),
|
(lateOpened) => lateOpened.cleanup.close(undefined, options.signal),
|
||||||
)
|
() => undefined,
|
||||||
.catch((cause: unknown) => {
|
);
|
||||||
// best-effort cleanup: late-open cleanup must not mask the boundary failure.
|
options.retainCleanup?.(() =>
|
||||||
void cause;
|
withPlaywrightExecutionBoundary(lateCleanup, 1_000),
|
||||||
});
|
);
|
||||||
|
void lateCleanup().catch((cleanupCause: unknown) => {
|
||||||
|
// best-effort cleanup: retainCleanup keeps the retry capability.
|
||||||
|
void cleanupCause;
|
||||||
|
});
|
||||||
throw cause;
|
throw cause;
|
||||||
}
|
}
|
||||||
let session: PlaywrightScenarioSession;
|
let session: PlaywrightScenarioSession;
|
||||||
@@ -179,16 +187,19 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
|
|||||||
eventCapture: events,
|
eventCapture: events,
|
||||||
});
|
});
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
return failBrowserScenarioOperation(
|
const cleanup = () =>
|
||||||
() =>
|
opened.cleanup.close(
|
||||||
opened.cleanup.close(
|
events === undefined
|
||||||
events === undefined
|
? undefined
|
||||||
? undefined
|
: () => events?.finish() ?? Promise.resolve(),
|
||||||
: () => events?.finish() ?? Promise.resolve(),
|
options.signal,
|
||||||
options.signal,
|
);
|
||||||
),
|
try {
|
||||||
cause,
|
return await failBrowserScenarioOperation(cleanup, cause);
|
||||||
);
|
} catch (failure: unknown) {
|
||||||
|
if (failure !== cause) options.retainCleanup?.(cleanup);
|
||||||
|
throw failure;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await withPlaywrightExecutionBoundary(
|
await withPlaywrightExecutionBoundary(
|
||||||
@@ -212,6 +223,7 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
|
|||||||
} catch (failure: unknown) {
|
} catch (failure: unknown) {
|
||||||
cleanup = "incomplete";
|
cleanup = "incomplete";
|
||||||
cleanupFailure = { cause: failure };
|
cleanupFailure = { cause: failure };
|
||||||
|
options.retainCleanup?.(() => session.close());
|
||||||
}
|
}
|
||||||
throw browserScenarioOperationFailure(
|
throw browserScenarioOperationFailure(
|
||||||
cause,
|
cause,
|
||||||
|
|||||||
@@ -76,7 +76,12 @@ export const publishWebScripts = async (
|
|||||||
bytes: bytes.length,
|
bytes: bytes.length,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await tree.write("manifest.json", Readable.from([bytes]), digest, signal);
|
await tree.write(
|
||||||
|
"manifest.json",
|
||||||
|
Readable.from([bytes]),
|
||||||
|
{ sha256: digest, bytes: bytes.byteLength },
|
||||||
|
signal,
|
||||||
|
);
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
await tree.commit();
|
await tree.commit();
|
||||||
return result;
|
return result;
|
||||||
@@ -109,7 +114,7 @@ const publishPlannedScript = async (
|
|||||||
tree.write(
|
tree.write(
|
||||||
`files/${relativePath}`,
|
`files/${relativePath}`,
|
||||||
Readable.from([content.bytes]),
|
Readable.from([content.bytes]),
|
||||||
content.sha256,
|
{ sha256: content.sha256, bytes: content.bytes.byteLength },
|
||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
|
|||||||
+18
-8
@@ -24,14 +24,24 @@ export const registerEvmCommands = (
|
|||||||
.describe("Selected carrier representation"),
|
.describe("Selected carrier representation"),
|
||||||
}),
|
}),
|
||||||
run: ({ args }) =>
|
run: ({ args }) =>
|
||||||
withCommandCancellation((signal) =>
|
withCommandCancellation(async (signal) => {
|
||||||
logCliCommand(logger, CLI_COMMANDS.inspectEvmInterface, async () => {
|
try {
|
||||||
const result = await service.inspect(
|
return await logCliCommand(
|
||||||
{ path: resolve(args.path), encoding: args.encoding },
|
logger,
|
||||||
{ signal },
|
CLI_COMMANDS.inspectEvmInterface,
|
||||||
|
async () => {
|
||||||
|
const result = await service.inspect(
|
||||||
|
{ path: resolve(args.path), encoding: args.encoding },
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
return result.ok
|
||||||
|
? result.value
|
||||||
|
: projectAnalysisError(result.error);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
return result.ok ? result.value : projectAnalysisError(result.error);
|
} finally {
|
||||||
}),
|
await service.close();
|
||||||
),
|
}
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,8 +26,12 @@ export const registerFirmwareCommands = (
|
|||||||
) =>
|
) =>
|
||||||
withCommandCancellation((signal) =>
|
withCommandCancellation((signal) =>
|
||||||
logCliCommand(logger, name, async () => {
|
logCliCommand(logger, name, async () => {
|
||||||
const result = await service.execute(operation, input, { signal });
|
try {
|
||||||
return result.ok ? result.value : projectAnalysisError(result.error);
|
const result = await service.execute(operation, input, { signal });
|
||||||
|
return result.ok ? result.value : projectAnalysisError(result.error);
|
||||||
|
} finally {
|
||||||
|
await service.close();
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const path = z.string().describe("Local firmware path; never executed");
|
const path = z.string().describe("Local firmware path; never executed");
|
||||||
|
|||||||
@@ -41,29 +41,33 @@ export const registerJavaScriptRecoveryCommands = (
|
|||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
run: ({ args, options }) =>
|
run: ({ args, options }) =>
|
||||||
withCommandCancellation((signal) =>
|
withCommandCancellation(async (signal) => {
|
||||||
logCliCommand(
|
try {
|
||||||
logger,
|
return await logCliCommand(
|
||||||
CLI_COMMANDS.recoverJavaScriptSources,
|
logger,
|
||||||
async () => {
|
CLI_COMMANDS.recoverJavaScriptSources,
|
||||||
const result = await service.recover(
|
async () => {
|
||||||
{
|
const result = await service.recover(
|
||||||
path: args.path,
|
{
|
||||||
output_directory: args.outputDirectory,
|
path: args.path,
|
||||||
...(options.extractionMode === undefined
|
output_directory: args.outputDirectory,
|
||||||
? {}
|
...(options.extractionMode === undefined
|
||||||
: { extraction_mode: options.extractionMode }),
|
? {}
|
||||||
...(options.rewriteLevel === undefined
|
: { extraction_mode: options.extractionMode }),
|
||||||
? {}
|
...(options.rewriteLevel === undefined
|
||||||
: { rewrite_level: options.rewriteLevel }),
|
? {}
|
||||||
},
|
: { rewrite_level: options.rewriteLevel }),
|
||||||
{ signal },
|
},
|
||||||
);
|
{ signal },
|
||||||
return result.ok
|
);
|
||||||
? result.value
|
return result.ok
|
||||||
: projectAnalysisError(result.error);
|
? result.value
|
||||||
},
|
: projectAnalysisError(result.error);
|
||||||
),
|
},
|
||||||
),
|
);
|
||||||
|
} finally {
|
||||||
|
await service.close();
|
||||||
|
}
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const supportedClientSchema = z
|
|||||||
(candidate) => supportedClientIds.some((name) => name === candidate),
|
(candidate) => supportedClientIds.some((name) => name === candidate),
|
||||||
"Unsupported agent integration",
|
"Unsupported agent integration",
|
||||||
);
|
);
|
||||||
|
const skillClientSchema = z.union([z.literal("shared"), supportedClientSchema]);
|
||||||
|
|
||||||
/** Register setup, doctor, uninstall, and update CLI commands. */
|
/** Register setup, doctor, uninstall, and update CLI commands. */
|
||||||
export const registerSetupCommands = (
|
export const registerSetupCommands = (
|
||||||
@@ -72,6 +73,10 @@ const registerSetupCommand = (
|
|||||||
.array(supportedClientSchema)
|
.array(supportedClientSchema)
|
||||||
.default([])
|
.default([])
|
||||||
.describe("Agent integration to configure; repeat for multiple agents"),
|
.describe("Agent integration to configure; repeat for multiple agents"),
|
||||||
|
skillClient: z
|
||||||
|
.array(skillClientSchema)
|
||||||
|
.default([])
|
||||||
|
.describe("Existing client skill destination to update; repeatable"),
|
||||||
allDetected: z
|
allDetected: z
|
||||||
.boolean()
|
.boolean()
|
||||||
.default(false)
|
.default(false)
|
||||||
@@ -186,6 +191,7 @@ interface SetupCommandOptions {
|
|||||||
readonly yes: boolean;
|
readonly yes: boolean;
|
||||||
readonly installHopper: boolean;
|
readonly installHopper: boolean;
|
||||||
readonly client: readonly string[];
|
readonly client: readonly string[];
|
||||||
|
readonly skillClient: readonly string[];
|
||||||
readonly allDetected: boolean;
|
readonly allDetected: boolean;
|
||||||
readonly skill?: boolean | undefined;
|
readonly skill?: boolean | undefined;
|
||||||
readonly dryRun: boolean;
|
readonly dryRun: boolean;
|
||||||
@@ -224,8 +230,13 @@ const setupRunOptions = (
|
|||||||
): SetupOptions => {
|
): SetupOptions => {
|
||||||
const { options } = input;
|
const { options } = input;
|
||||||
const agentIntegrationSelected =
|
const agentIntegrationSelected =
|
||||||
options.allDetected || options.client.length > 0;
|
options.allDetected ||
|
||||||
const hasSelectedClients = hasExplicitScope && !options.allDetected;
|
options.client.length > 0 ||
|
||||||
|
options.skillClient.length > 0;
|
||||||
|
const hasSelectedClients =
|
||||||
|
hasExplicitScope &&
|
||||||
|
!options.allDetected &&
|
||||||
|
(options.client.length > 0 || options.skillClient.length > 0);
|
||||||
return {
|
return {
|
||||||
approved: options.yes && !options.dryRun,
|
approved: options.yes && !options.dryRun,
|
||||||
installHopper: options.installHopper,
|
installHopper: options.installHopper,
|
||||||
@@ -234,6 +245,9 @@ const setupRunOptions = (
|
|||||||
allDetectedClients: options.allDetected,
|
allDetectedClients: options.allDetected,
|
||||||
proposeHopper: interactive || options.installHopper,
|
proposeHopper: interactive || options.installHopper,
|
||||||
...(hasSelectedClients ? { clientIds: options.client } : {}),
|
...(hasSelectedClients ? { clientIds: options.client } : {}),
|
||||||
|
...(options.skillClient.length > 0
|
||||||
|
? { skillClientIds: options.skillClient }
|
||||||
|
: {}),
|
||||||
...(hasExplicitScope
|
...(hasExplicitScope
|
||||||
? { installSkill: options.skill ?? agentIntegrationSelected }
|
? { installSkill: options.skill ?? agentIntegrationSelected }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -264,6 +278,7 @@ const setupIsInteractive = (
|
|||||||
const setupHasExplicitScope = (options: SetupCommandOptions): boolean =>
|
const setupHasExplicitScope = (options: SetupCommandOptions): boolean =>
|
||||||
options.allDetected ||
|
options.allDetected ||
|
||||||
options.client.length > 0 ||
|
options.client.length > 0 ||
|
||||||
|
options.skillClient.length > 0 ||
|
||||||
options.skill !== undefined ||
|
options.skill !== undefined ||
|
||||||
options.installHopper;
|
options.installHopper;
|
||||||
|
|
||||||
|
|||||||
@@ -38,19 +38,32 @@ export const registerWebSourceLocationCommands = (
|
|||||||
.describe("Zero-based UTF-16 generated column"),
|
.describe("Zero-based UTF-16 generated column"),
|
||||||
}),
|
}),
|
||||||
run: ({ args }) =>
|
run: ({ args }) =>
|
||||||
withCommandCancellation((signal) =>
|
withCommandCancellation(async (signal) => {
|
||||||
logCliCommand(logger, CLI_COMMANDS.traceWebSourceLocation, async () => {
|
try {
|
||||||
const result = await service.trace(
|
return await logCliCommand(
|
||||||
{
|
logger,
|
||||||
manifest_path: args.manifestPath,
|
CLI_COMMANDS.traceWebSourceLocation,
|
||||||
script_index: args.scriptIndex,
|
async () => {
|
||||||
source_map: { path: args.sourceMapPath, url: args.sourceMapUrl },
|
const result = await service.trace(
|
||||||
generated_position: { line: args.line, column: args.column },
|
{
|
||||||
|
manifest_path: args.manifestPath,
|
||||||
|
script_index: args.scriptIndex,
|
||||||
|
source_map: {
|
||||||
|
path: args.sourceMapPath,
|
||||||
|
url: args.sourceMapUrl,
|
||||||
|
},
|
||||||
|
generated_position: { line: args.line, column: args.column },
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
return result.ok
|
||||||
|
? result.value
|
||||||
|
: projectAnalysisError(result.error);
|
||||||
},
|
},
|
||||||
{ signal },
|
|
||||||
);
|
);
|
||||||
return result.ok ? result.value : projectAnalysisError(result.error);
|
} finally {
|
||||||
}),
|
await service.close();
|
||||||
),
|
}
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { adbInputSchemas } from "./adbDeviceAnalysis.js";
|
||||||
|
|
||||||
|
it("preserves ADB key, action and component admission when making advertised patterns portable", () => {
|
||||||
|
const serial = "emulator-5554";
|
||||||
|
const variants = [
|
||||||
|
{
|
||||||
|
previous: /^[A-Za-z0-9._-]+$/u,
|
||||||
|
max: 256,
|
||||||
|
accepts: (key: string) =>
|
||||||
|
adbInputSchemas.read_adb_setting.safeParse({
|
||||||
|
serial,
|
||||||
|
namespace: "global",
|
||||||
|
key,
|
||||||
|
}).success,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
previous: /^[A-Za-z0-9._-]+$/u,
|
||||||
|
max: 256,
|
||||||
|
accepts: (key: string) =>
|
||||||
|
adbInputSchemas.start_adb_activity.safeParse({
|
||||||
|
serial,
|
||||||
|
action: "android.intent.action.VIEW",
|
||||||
|
extras: [{ key, type: "string", value: "payload" }],
|
||||||
|
}).success,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
previous: /^[A-Za-z][A-Za-z0-9._-]*$/u,
|
||||||
|
max: 256,
|
||||||
|
accepts: (action: string) =>
|
||||||
|
adbInputSchemas.start_adb_activity.safeParse({ serial, action })
|
||||||
|
.success,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
previous: /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]*)?$/u,
|
||||||
|
max: 512,
|
||||||
|
accepts: (component: string) =>
|
||||||
|
adbInputSchemas.start_adb_activity.safeParse({
|
||||||
|
serial,
|
||||||
|
action: "android.intent.action.VIEW",
|
||||||
|
component,
|
||||||
|
}).success,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const samples = [
|
||||||
|
"",
|
||||||
|
"screen_brightness",
|
||||||
|
"0",
|
||||||
|
"_",
|
||||||
|
".",
|
||||||
|
"-",
|
||||||
|
"android.intent.action.VIEW",
|
||||||
|
"com.example/.MainActivity",
|
||||||
|
"com.example/",
|
||||||
|
"com.example/a/b",
|
||||||
|
"a\n",
|
||||||
|
"a\r\n",
|
||||||
|
"é",
|
||||||
|
"aé",
|
||||||
|
"a b",
|
||||||
|
"a\\b",
|
||||||
|
"a".repeat(256),
|
||||||
|
"a".repeat(257),
|
||||||
|
"a".repeat(512),
|
||||||
|
"a".repeat(513),
|
||||||
|
];
|
||||||
|
for (let code = 0; code < 128; code += 1) {
|
||||||
|
const character = String.fromCharCode(code);
|
||||||
|
samples.push(character, `a${character}`, `a${character}b`);
|
||||||
|
}
|
||||||
|
for (const variant of variants)
|
||||||
|
for (const sample of samples)
|
||||||
|
expect(
|
||||||
|
variant.accepts(sample),
|
||||||
|
JSON.stringify({ pattern: variant.previous.source, sample }),
|
||||||
|
).toBe(
|
||||||
|
sample.length > 0 &&
|
||||||
|
sample.length <= variant.max &&
|
||||||
|
variant.previous.test(sample),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { localPathStringSchema } from "../localPath.js";
|
import { localPathStringSchema } from "../localPath.js";
|
||||||
|
import { IDENTIFIER_PATTERN } from "../stringPatterns.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Device serials as adb prints them: emulator-5554, USB identifiers, and
|
* Device serials as adb prints them: emulator-5554, USB identifiers, and
|
||||||
@@ -29,6 +30,9 @@ const packageNameSchema = z
|
|||||||
"Package name must be dot-separated Java identifiers",
|
"Package name must be dot-separated Java identifiers",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const keyStringSchema = z.string().min(1).max(256);
|
||||||
|
const keyPattern = /^[A-Za-z0-9._\x2d]+$/u;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Caller intent for ADB-backed device inspection and acquisition.
|
* Caller intent for ADB-backed device inspection and acquisition.
|
||||||
*
|
*
|
||||||
@@ -174,14 +178,8 @@ export const adbInputSchemas = {
|
|||||||
namespace: z
|
namespace: z
|
||||||
.enum(["system", "secure", "global"])
|
.enum(["system", "secure", "global"])
|
||||||
.describe("Settings namespace to read"),
|
.describe("Settings namespace to read"),
|
||||||
key: z
|
key: keyStringSchema
|
||||||
.string()
|
.regex(keyPattern, "Setting keys are restricted to identifiers")
|
||||||
.min(1)
|
|
||||||
.max(256)
|
|
||||||
.regex(
|
|
||||||
/^[A-Za-z0-9._\-]+$/u,
|
|
||||||
"Setting keys are restricted to identifiers",
|
|
||||||
)
|
|
||||||
.describe("Setting key to read"),
|
.describe("Setting key to read"),
|
||||||
}),
|
}),
|
||||||
collect_adb_bugreport: z.strictObject({
|
collect_adb_bugreport: z.strictObject({
|
||||||
@@ -241,7 +239,7 @@ export const adbInputSchemas = {
|
|||||||
.min(1)
|
.min(1)
|
||||||
.max(256)
|
.max(256)
|
||||||
.regex(
|
.regex(
|
||||||
/^[A-Za-z][A-Za-z0-9._\-]*$/u,
|
IDENTIFIER_PATTERN,
|
||||||
"Intent action must be a dotted identifier such as android.intent.action.VIEW",
|
"Intent action must be a dotted identifier such as android.intent.action.VIEW",
|
||||||
)
|
)
|
||||||
.describe("Intent action to start"),
|
.describe("Intent action to start"),
|
||||||
@@ -257,7 +255,7 @@ export const adbInputSchemas = {
|
|||||||
.min(1)
|
.min(1)
|
||||||
.max(512)
|
.max(512)
|
||||||
.regex(
|
.regex(
|
||||||
/^[A-Za-z0-9._\-]+(\/[A-Za-z0-9._\-]*)?$/u,
|
/^[A-Za-z0-9._\x2d]+(\/[A-Za-z0-9._\x2d]*)?$/u,
|
||||||
"Component must be package/activity form",
|
"Component must be package/activity form",
|
||||||
)
|
)
|
||||||
.optional()
|
.optional()
|
||||||
@@ -265,11 +263,8 @@ export const adbInputSchemas = {
|
|||||||
extras: z
|
extras: z
|
||||||
.array(
|
.array(
|
||||||
z.strictObject({
|
z.strictObject({
|
||||||
key: z
|
key: keyStringSchema
|
||||||
.string()
|
.regex(keyPattern, "Extra keys are identifiers")
|
||||||
.min(1)
|
|
||||||
.max(256)
|
|
||||||
.regex(/^[A-Za-z0-9._\-]+$/u, "Extra keys are identifiers")
|
|
||||||
.describe("Extra key"),
|
.describe("Extra key"),
|
||||||
type: z
|
type: z
|
||||||
.enum(["string", "boolean", "int", "long", "float"])
|
.enum(["string", "boolean", "int", "long", "float"])
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ import type { BrowserScenarioPartialObservation } from "./browserScenarioCapture
|
|||||||
import type { Evidence } from "./evidence.js";
|
import type { Evidence } from "./evidence.js";
|
||||||
import type { AndroidPartialObservation } from "./android/androidPartialObservation.js";
|
import type { AndroidPartialObservation } from "./android/androidPartialObservation.js";
|
||||||
import type { FirmwarePartialObservation } from "./firmware/firmwareAnalysis.js";
|
import type { FirmwarePartialObservation } from "./firmware/firmwareAnalysis.js";
|
||||||
|
import type { NativeUiObservationResult } from "./native/nativeUiObservation.js";
|
||||||
|
|
||||||
/** Provider-neutral evidence collected before a typed analysis failure. */
|
/** Provider-neutral evidence collected before a typed analysis failure. */
|
||||||
export type AnalysisPartialObservation =
|
export type AnalysisPartialObservation =
|
||||||
@@ -111,4 +112,5 @@ export type AnalysisPartialObservation =
|
|||||||
| ArtifactInventoryPartialObservation
|
| ArtifactInventoryPartialObservation
|
||||||
| Evidence
|
| Evidence
|
||||||
| AndroidPartialObservation
|
| AndroidPartialObservation
|
||||||
| FirmwarePartialObservation;
|
| FirmwarePartialObservation
|
||||||
|
| NativeUiObservationResult;
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { ProviderAdapterError } from "./providerAdapterError.js";
|
||||||
|
import { projectAnalysisError } from "./analysisErrorProjection.js";
|
||||||
|
import type { AnalysisError } from "./analysisErrorBase.js";
|
||||||
|
|
||||||
|
/** Preserve the original lifecycle failure alongside incomplete cleanup. */
|
||||||
|
export const analysisErrorWithCleanupFailure = (
|
||||||
|
primary: AnalysisError,
|
||||||
|
cleanup: AnalysisError,
|
||||||
|
operation = "close_binary",
|
||||||
|
): AnalysisError => {
|
||||||
|
const projected = projectAnalysisError(cleanup);
|
||||||
|
return new ProviderAdapterError(
|
||||||
|
cleanup instanceof ProviderAdapterError ? cleanup.providerId : "rea",
|
||||||
|
operation,
|
||||||
|
{
|
||||||
|
cause: primary,
|
||||||
|
userMessage: projectAnalysisError(primary).message,
|
||||||
|
...(primary.capturedOutput === undefined
|
||||||
|
? {}
|
||||||
|
: { capturedOutput: primary.capturedOutput }),
|
||||||
|
...(primary.partialObservation === undefined
|
||||||
|
? {}
|
||||||
|
: { partialObservation: primary.partialObservation }),
|
||||||
|
cleanup: {
|
||||||
|
reason: projected.message,
|
||||||
|
resources: [
|
||||||
|
...new Set([
|
||||||
|
...primary.cleanupResources,
|
||||||
|
...cleanup.cleanupResources,
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
diagnostics: {
|
||||||
|
primary_error: projectAnalysisError(primary),
|
||||||
|
cleanup_error: projected,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -66,7 +66,9 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
|||||||
let omittedHierarchyReferences = 0;
|
let omittedHierarchyReferences = 0;
|
||||||
for (const document of input.documents.slice(0, input.limits.max_documents)) {
|
for (const document of input.documents.slice(0, input.limits.max_documents)) {
|
||||||
const parsed = parseInterfaceBuilderRecords(document.raw);
|
const parsed = parseInterfaceBuilderRecords(document.raw);
|
||||||
const prefix = `ib:${document.relativePath}:`;
|
const prefix = `ib:${JSON.stringify(document.relativePath)}:`;
|
||||||
|
const graphId = (kind: string, parts: readonly unknown[] = []): string =>
|
||||||
|
`${prefix}${kind}:${JSON.stringify(parts)}`;
|
||||||
const rootId = `${prefix}root`;
|
const rootId = `${prefix}root`;
|
||||||
const objectIds = new Set(parsed.objects.map(({ id }) => id));
|
const objectIds = new Set(parsed.objects.map(({ id }) => id));
|
||||||
const evidenceFor = (description: string) => [
|
const evidenceFor = (description: string) => [
|
||||||
@@ -104,7 +106,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
|||||||
addNode(rootNode);
|
addNode(rootNode);
|
||||||
let documentObjectCount = 0;
|
let documentObjectCount = 0;
|
||||||
for (const object of parsed.objects.slice(0, input.limits.max_objects)) {
|
for (const object of parsed.objects.slice(0, input.limits.max_objects)) {
|
||||||
const id = `${prefix}object:${object.id}`;
|
const id = graphId("object", [object.id]);
|
||||||
const kind = object.kind === "other" ? "unknown" : object.kind;
|
const kind = object.kind === "other" ? "unknown" : object.kind;
|
||||||
if (
|
if (
|
||||||
!addNode({
|
!addNode({
|
||||||
@@ -129,7 +131,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
|||||||
let documentOmittedConnections = 0;
|
let documentOmittedConnections = 0;
|
||||||
let documentOmittedHierarchyEdges = 0;
|
let documentOmittedHierarchyEdges = 0;
|
||||||
const objectNodeId = (objectId: string): string =>
|
const objectNodeId = (objectId: string): string =>
|
||||||
`${prefix}object:${objectId}`;
|
graphId("object", [objectId]);
|
||||||
const addEdge = (
|
const addEdge = (
|
||||||
edge: (typeof edges)[number],
|
edge: (typeof edges)[number],
|
||||||
isHierarchy: boolean,
|
isHierarchy: boolean,
|
||||||
@@ -238,7 +240,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
|||||||
const known = objectIds.has(objectId);
|
const known = objectIds.has(objectId);
|
||||||
const childId = known
|
const childId = known
|
||||||
? objectNodeId(objectId)
|
? objectNodeId(objectId)
|
||||||
: `${prefix}unknown:${objectId}`;
|
: graphId("unknown", [objectId]);
|
||||||
if (
|
if (
|
||||||
!known &&
|
!known &&
|
||||||
!addNode({
|
!addNode({
|
||||||
@@ -252,7 +254,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
|||||||
)
|
)
|
||||||
hierarchyTruncated = true;
|
hierarchyTruncated = true;
|
||||||
connect(
|
connect(
|
||||||
`${prefix}hierarchy:${parentId}:${childId}`,
|
graphId("hierarchy", [parentId, childId]),
|
||||||
parentId,
|
parentId,
|
||||||
childId,
|
childId,
|
||||||
"contains",
|
"contains",
|
||||||
@@ -281,11 +283,11 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
|||||||
const destinationId =
|
const destinationId =
|
||||||
item.destination_id === null ? null : objectNodeId(item.destination_id);
|
item.destination_id === null ? null : objectNodeId(item.destination_id);
|
||||||
if (item.kind === "action") {
|
if (item.kind === "action") {
|
||||||
const actionId = `${prefix}action:${item.id}`;
|
const actionId = graphId("action", [item.id]);
|
||||||
const selectorId =
|
const selectorId =
|
||||||
item.label === null
|
item.label === null
|
||||||
? null
|
? null
|
||||||
: `${prefix}selector:${item.destination_id ?? "unknown"}:${item.label}`;
|
: graphId("selector", [item.destination_id, item.label]);
|
||||||
addNode({
|
addNode({
|
||||||
id: actionId,
|
id: actionId,
|
||||||
kind: "action",
|
kind: "action",
|
||||||
@@ -299,7 +301,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
|||||||
evidence: evidenceFor(`action ${item.id}`),
|
evidence: evidenceFor(`action ${item.id}`),
|
||||||
});
|
});
|
||||||
connect(
|
connect(
|
||||||
`${actionId}:source`,
|
graphId("action-edge", [item.id, "source"]),
|
||||||
sourceId,
|
sourceId,
|
||||||
actionId,
|
actionId,
|
||||||
"target_action",
|
"target_action",
|
||||||
@@ -315,7 +317,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
|||||||
evidence: evidenceFor(`selector ${item.label}`),
|
evidence: evidenceFor(`selector ${item.label}`),
|
||||||
});
|
});
|
||||||
connect(
|
connect(
|
||||||
`${actionId}:selector`,
|
graphId("action-edge", [item.id, "selector"]),
|
||||||
actionId,
|
actionId,
|
||||||
selectorId,
|
selectorId,
|
||||||
"target_action",
|
"target_action",
|
||||||
@@ -324,7 +326,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
|||||||
}
|
}
|
||||||
if (destinationId === null || !nodes.has(destinationId))
|
if (destinationId === null || !nodes.has(destinationId))
|
||||||
connect(
|
connect(
|
||||||
`${actionId}:destination`,
|
graphId("action-edge", [item.id, "destination"]),
|
||||||
actionId,
|
actionId,
|
||||||
null,
|
null,
|
||||||
"target_action",
|
"target_action",
|
||||||
@@ -333,14 +335,14 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
|||||||
);
|
);
|
||||||
else
|
else
|
||||||
connect(
|
connect(
|
||||||
`${actionId}:destination`,
|
graphId("action-edge", [item.id, "destination"]),
|
||||||
actionId,
|
actionId,
|
||||||
destinationId,
|
destinationId,
|
||||||
"target_action",
|
"target_action",
|
||||||
`action destination ${item.destination_id}`,
|
`action destination ${item.destination_id}`,
|
||||||
);
|
);
|
||||||
} else if (item.kind === "outlet") {
|
} else if (item.kind === "outlet") {
|
||||||
const outletId = `${prefix}outlet:${item.id}`;
|
const outletId = graphId("outlet", [item.id]);
|
||||||
addNode({
|
addNode({
|
||||||
id: outletId,
|
id: outletId,
|
||||||
kind: "outlet",
|
kind: "outlet",
|
||||||
@@ -350,7 +352,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
|||||||
evidence: evidenceFor(`outlet ${item.id}`),
|
evidence: evidenceFor(`outlet ${item.id}`),
|
||||||
});
|
});
|
||||||
connect(
|
connect(
|
||||||
`${outletId}:source`,
|
graphId("outlet-edge", [item.id, "source"]),
|
||||||
sourceId,
|
sourceId,
|
||||||
outletId,
|
outletId,
|
||||||
"contains",
|
"contains",
|
||||||
@@ -358,7 +360,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
|||||||
);
|
);
|
||||||
if (destinationId === null || !nodes.has(destinationId))
|
if (destinationId === null || !nodes.has(destinationId))
|
||||||
connect(
|
connect(
|
||||||
`${outletId}:destination`,
|
graphId("outlet-edge", [item.id, "destination"]),
|
||||||
outletId,
|
outletId,
|
||||||
null,
|
null,
|
||||||
"outlet_to",
|
"outlet_to",
|
||||||
@@ -367,7 +369,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
|||||||
);
|
);
|
||||||
else
|
else
|
||||||
connect(
|
connect(
|
||||||
`${outletId}:destination`,
|
graphId("outlet-edge", [item.id, "destination"]),
|
||||||
outletId,
|
outletId,
|
||||||
destinationId,
|
destinationId,
|
||||||
"outlet_to",
|
"outlet_to",
|
||||||
@@ -376,7 +378,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
|||||||
} else if (item.kind === "segue") {
|
} else if (item.kind === "segue") {
|
||||||
if (destinationId === null || !nodes.has(destinationId))
|
if (destinationId === null || !nodes.has(destinationId))
|
||||||
connect(
|
connect(
|
||||||
`${prefix}segue:${index}`,
|
graphId("segue", [index]),
|
||||||
sourceId,
|
sourceId,
|
||||||
null,
|
null,
|
||||||
"segue_to",
|
"segue_to",
|
||||||
@@ -385,7 +387,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
|||||||
);
|
);
|
||||||
else
|
else
|
||||||
connect(
|
connect(
|
||||||
`${prefix}segue:${index}`,
|
graphId("segue", [index]),
|
||||||
sourceId,
|
sourceId,
|
||||||
destinationId,
|
destinationId,
|
||||||
"segue_to",
|
"segue_to",
|
||||||
@@ -393,7 +395,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
connect(
|
connect(
|
||||||
`${prefix}unknown-connection:${index}`,
|
graphId("unknown-connection", [index]),
|
||||||
sourceId,
|
sourceId,
|
||||||
null,
|
null,
|
||||||
"contains",
|
"contains",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
} from "./javascriptStaticAnalysisHelpers.js";
|
} from "./javascriptStaticAnalysisHelpers.js";
|
||||||
import {
|
import {
|
||||||
propertyName,
|
propertyName,
|
||||||
semanticStaticPropertyName,
|
semanticStaticPropertyKey,
|
||||||
} from "./javascriptAstValues.js";
|
} from "./javascriptAstValues.js";
|
||||||
import type { JavaScriptFindingContext } from "./javascriptStaticAnalysisState.js";
|
import type { JavaScriptFindingContext } from "./javascriptStaticAnalysisState.js";
|
||||||
|
|
||||||
@@ -26,6 +26,8 @@ interface NativeBindingInput {
|
|||||||
readonly kind: ElectronNativeAddonBindingFinding["binding_kind"];
|
readonly kind: ElectronNativeAddonBindingFinding["binding_kind"];
|
||||||
readonly moduleKind: ElectronNativeAddonBindingFinding["module_kind"];
|
readonly moduleKind: ElectronNativeAddonBindingFinding["module_kind"];
|
||||||
readonly members: readonly string[];
|
readonly members: readonly string[];
|
||||||
|
readonly namespaceAccess: boolean;
|
||||||
|
readonly dynamicMemberAccess: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Inspect JavaScript-side imports and re-exports of native .node addons. */
|
/** Inspect JavaScript-side imports and re-exports of native .node addons. */
|
||||||
@@ -45,10 +47,10 @@ const inspectImport = (
|
|||||||
context: JavaScriptFindingContext,
|
context: JavaScriptFindingContext,
|
||||||
): void => {
|
): void => {
|
||||||
if (!isNativeSpecifier(node.source.value, "import")) return;
|
if (!isNativeSpecifier(node.source.value, "import")) return;
|
||||||
const members = node.specifiers.map((specifier) => {
|
const members = node.specifiers.flatMap((specifier) => {
|
||||||
if (t.isImportDefaultSpecifier(specifier)) return "default";
|
if (t.isImportDefaultSpecifier(specifier)) return ["default"];
|
||||||
if (t.isImportNamespaceSpecifier(specifier)) return "*";
|
if (t.isImportNamespaceSpecifier(specifier)) return [];
|
||||||
return propertyName(specifier.imported) || "[dynamic-import]";
|
return [propertyName(specifier.imported)];
|
||||||
});
|
});
|
||||||
addBinding({
|
addBinding({
|
||||||
context,
|
context,
|
||||||
@@ -57,6 +59,10 @@ const inspectImport = (
|
|||||||
kind: "import",
|
kind: "import",
|
||||||
moduleKind: "import",
|
moduleKind: "import",
|
||||||
members,
|
members,
|
||||||
|
namespaceAccess: node.specifiers.some((value) =>
|
||||||
|
t.isImportNamespaceSpecifier(value),
|
||||||
|
),
|
||||||
|
dynamicMemberAccess: false,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,9 +76,10 @@ const inspectNamedExport = (
|
|||||||
!isNativeSpecifier(node.source.value, "import")
|
!isNativeSpecifier(node.source.value, "import")
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
const members = node.specifiers.map((specifier) => {
|
const members = node.specifiers.flatMap((specifier) => {
|
||||||
if (t.isExportSpecifier(specifier)) return propertyName(specifier.local);
|
if (t.isExportSpecifier(specifier)) return [propertyName(specifier.local)];
|
||||||
return "*";
|
if (t.isExportDefaultSpecifier(specifier)) return ["default"];
|
||||||
|
return [];
|
||||||
});
|
});
|
||||||
addBinding({
|
addBinding({
|
||||||
context,
|
context,
|
||||||
@@ -81,6 +88,10 @@ const inspectNamedExport = (
|
|||||||
kind: "re-export",
|
kind: "re-export",
|
||||||
moduleKind: "import",
|
moduleKind: "import",
|
||||||
members,
|
members,
|
||||||
|
namespaceAccess: node.specifiers.some((value) =>
|
||||||
|
t.isExportNamespaceSpecifier(value),
|
||||||
|
),
|
||||||
|
dynamicMemberAccess: false,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -99,7 +110,9 @@ const inspectExportAll = (
|
|||||||
specifier: node.source.value,
|
specifier: node.source.value,
|
||||||
kind: "re-export",
|
kind: "re-export",
|
||||||
moduleKind: "import",
|
moduleKind: "import",
|
||||||
members: ["*"],
|
members: [],
|
||||||
|
namespaceAccess: true,
|
||||||
|
dynamicMemberAccess: false,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -115,8 +128,7 @@ const inspectRequireBinding = (
|
|||||||
specifier: required.specifier,
|
specifier: required.specifier,
|
||||||
kind: "require",
|
kind: "require",
|
||||||
moduleKind: "require",
|
moduleKind: "require",
|
||||||
members:
|
...(required.hasMemberAccess ? required.access : bindingMembers(node.id)),
|
||||||
required.member === null ? bindingMembers(node.id) : [required.member],
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -132,27 +144,33 @@ const inspectReExport = (
|
|||||||
specifier: required.specifier,
|
specifier: required.specifier,
|
||||||
kind: "re-export",
|
kind: "re-export",
|
||||||
moduleKind: "require",
|
moduleKind: "require",
|
||||||
members: [required.member ?? exportedMember(node.left) ?? "*"],
|
...required.access,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const addBinding = (input: NativeBindingInput): void => {
|
const addBinding = (input: NativeBindingInput): void => {
|
||||||
const { context, node, specifier, kind } = input;
|
const { context, node, specifier, kind } = input;
|
||||||
const unique = [
|
const members = [...new Set(input.members)].sort(compareUnicodeCodePoints);
|
||||||
...new Set(input.members.filter((member) => member !== "")),
|
|
||||||
].sort(compareUnicodeCodePoints);
|
|
||||||
const members = unique.length === 0 ? ["*"] : unique;
|
|
||||||
addLocatedFinding(context, {
|
addLocatedFinding(context, {
|
||||||
collection: context.accumulator.nativeAddonBindings,
|
collection: context.accumulator.nativeAddonBindings,
|
||||||
// Members are a variable-length source-derived list: compositeKey keeps
|
// Members are a variable-length source-derived list: compositeKey keeps
|
||||||
// ["a\0b"] distinct from ["a", "b"] instead of collapsing one binding.
|
// ["a\0b"] distinct from ["a", "b"] instead of collapsing one binding.
|
||||||
key: compositeKey(["native-addon-binding", kind, specifier, members]),
|
key: compositeKey([
|
||||||
|
"native-addon-binding",
|
||||||
|
kind,
|
||||||
|
specifier,
|
||||||
|
members,
|
||||||
|
input.namespaceAccess,
|
||||||
|
input.dynamicMemberAccess,
|
||||||
|
]),
|
||||||
node,
|
node,
|
||||||
value: {
|
value: {
|
||||||
specifier,
|
specifier,
|
||||||
binding_kind: kind,
|
binding_kind: kind,
|
||||||
module_kind: input.moduleKind,
|
module_kind: input.moduleKind,
|
||||||
members,
|
members,
|
||||||
|
namespace_access: input.namespaceAccess,
|
||||||
|
dynamic_member_access: input.dynamicMemberAccess,
|
||||||
module_key: null,
|
module_key: null,
|
||||||
location: range(node),
|
location: range(node),
|
||||||
},
|
},
|
||||||
@@ -162,13 +180,20 @@ const addBinding = (input: NativeBindingInput): void => {
|
|||||||
const nativeRequire = (
|
const nativeRequire = (
|
||||||
node: t.Node | null | undefined,
|
node: t.Node | null | undefined,
|
||||||
):
|
):
|
||||||
| { readonly specifier: string; readonly member: string | null }
|
| {
|
||||||
|
readonly specifier: string;
|
||||||
|
readonly hasMemberAccess: boolean;
|
||||||
|
readonly access: NativeMemberAccess;
|
||||||
|
}
|
||||||
| undefined => {
|
| undefined => {
|
||||||
let member: string | null = null;
|
let member: string | null = null;
|
||||||
|
let hasMemberAccess = false;
|
||||||
while (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) {
|
while (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) {
|
||||||
if (!t.isNode(node.object)) return undefined;
|
if (!t.isNode(node.object)) return undefined;
|
||||||
const property = semanticStaticPropertyName(node.property, node.computed);
|
const property = semanticStaticPropertyKey(node.property, node.computed);
|
||||||
if (member === null && property !== "") member = property;
|
// Walking inward leaves the first member consumed from the addon itself.
|
||||||
|
member = property;
|
||||||
|
hasMemberAccess = true;
|
||||||
node = node.object;
|
node = node.object;
|
||||||
}
|
}
|
||||||
if (!t.isCallExpression(node)) return undefined;
|
if (!t.isCallExpression(node)) return undefined;
|
||||||
@@ -181,34 +206,52 @@ const nativeRequire = (
|
|||||||
return undefined;
|
return undefined;
|
||||||
const specifier = stringValue(argumentNode(node.arguments[0]));
|
const specifier = stringValue(argumentNode(node.arguments[0]));
|
||||||
return specifier !== undefined && isNativeSpecifier(specifier, "require")
|
return specifier !== undefined && isNativeSpecifier(specifier, "require")
|
||||||
? { specifier, member }
|
? {
|
||||||
|
specifier,
|
||||||
|
hasMemberAccess,
|
||||||
|
access: {
|
||||||
|
members: member === null ? [] : [member],
|
||||||
|
namespaceAccess: !hasMemberAccess,
|
||||||
|
dynamicMemberAccess: hasMemberAccess && member === null,
|
||||||
|
},
|
||||||
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const bindingMembers = (pattern: t.Node): string[] => {
|
interface NativeMemberAccess {
|
||||||
if (t.isIdentifier(pattern)) return ["*"];
|
readonly members: readonly string[];
|
||||||
if (!t.isObjectPattern(pattern)) return ["*"];
|
readonly namespaceAccess: boolean;
|
||||||
return pattern.properties.map((property) => {
|
readonly dynamicMemberAccess: boolean;
|
||||||
if (t.isRestElement(property)) return "*";
|
}
|
||||||
return propertyName(property.key) || "[dynamic-import]";
|
|
||||||
});
|
const bindingMembers = (pattern: t.Node): NativeMemberAccess => {
|
||||||
|
if (!t.isObjectPattern(pattern))
|
||||||
|
return { members: [], namespaceAccess: true, dynamicMemberAccess: false };
|
||||||
|
const members: string[] = [];
|
||||||
|
let namespaceAccess = false;
|
||||||
|
let dynamicMemberAccess = false;
|
||||||
|
for (const property of pattern.properties) {
|
||||||
|
if (t.isRestElement(property)) {
|
||||||
|
namespaceAccess = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = semanticStaticPropertyKey(property.key, property.computed);
|
||||||
|
if (key === null) dynamicMemberAccess = true;
|
||||||
|
else members.push(key);
|
||||||
|
}
|
||||||
|
return { members, namespaceAccess, dynamicMemberAccess };
|
||||||
};
|
};
|
||||||
|
|
||||||
const isModuleExport = (node: t.Node): boolean => {
|
const isModuleExport = (node: t.Node): boolean => {
|
||||||
const name = calleeName(node);
|
const keys: (string | null)[] = [];
|
||||||
return (
|
while (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) {
|
||||||
name === "module.exports" ||
|
keys.unshift(semanticStaticPropertyKey(node.property, node.computed));
|
||||||
name.startsWith("module.exports.") ||
|
node = node.object;
|
||||||
name.startsWith("exports.")
|
}
|
||||||
);
|
if (!t.isIdentifier(node)) return false;
|
||||||
};
|
return node.name === "exports"
|
||||||
|
? keys.length > 0
|
||||||
const exportedMember = (node: t.Node): string | undefined => {
|
: node.name === "module" && keys[0] === "exports";
|
||||||
const name = calleeName(node);
|
|
||||||
if (name.startsWith("module.exports."))
|
|
||||||
return name.slice("module.exports.".length);
|
|
||||||
if (name.startsWith("exports.")) return name.slice("exports.".length);
|
|
||||||
return undefined;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isNativeSpecifier = (
|
const isNativeSpecifier = (
|
||||||
|
|||||||
@@ -163,7 +163,11 @@ export interface ElectronNativeAddonBindingFinding {
|
|||||||
readonly specifier: string;
|
readonly specifier: string;
|
||||||
readonly binding_kind: "import" | "require" | "re-export";
|
readonly binding_kind: "import" | "require" | "re-export";
|
||||||
readonly module_kind: "import" | "require";
|
readonly module_kind: "import" | "require";
|
||||||
|
/** Exact requested addon member keys; namespace and dynamic access are separate. */
|
||||||
readonly members: readonly string[];
|
readonly members: readonly string[];
|
||||||
|
/** Omitted in legacy findings; omission means unknown, not false. */
|
||||||
|
readonly namespace_access?: boolean;
|
||||||
|
readonly dynamic_member_access?: boolean;
|
||||||
readonly module_key: string | null;
|
readonly module_key: string | null;
|
||||||
readonly location: JavaScriptSourceRange;
|
readonly location: JavaScriptSourceRange;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -257,7 +257,15 @@ const semanticValue = (
|
|||||||
return (
|
return (
|
||||||
joined(
|
joined(
|
||||||
properties.specifier,
|
properties.specifier,
|
||||||
stringList(properties.requested_members).join(","),
|
compositeKey([
|
||||||
|
stringList(properties.requested_members),
|
||||||
|
typeof properties.namespace_access === "boolean"
|
||||||
|
? properties.namespace_access
|
||||||
|
: null,
|
||||||
|
typeof properties.dynamic_member_access === "boolean"
|
||||||
|
? properties.dynamic_member_access
|
||||||
|
: null,
|
||||||
|
]),
|
||||||
) ?? firstLabel(node)
|
) ?? firstLabel(node)
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { parse } from "@babel/parser";
|
||||||
|
import { expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { collectJavaScriptExports } from "./javascriptAstFingerprint.js";
|
||||||
|
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"exports[key] = 1;",
|
||||||
|
"module[key].value = 1;",
|
||||||
|
'module["exports.value"] = 1;',
|
||||||
|
"module.exports = { [key]: 1 };",
|
||||||
|
'Object[method](exports, "value", {});',
|
||||||
|
"function d() {} d(exports, { invented: 1 });",
|
||||||
|
])("does not invent an export name for %s", (source) => {
|
||||||
|
expect(collectJavaScriptExports(parse(source)).values).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'exports[""] = 1;',
|
||||||
|
'module.exports = { "": 1 };',
|
||||||
|
'Object.defineProperty(exports, "", {});',
|
||||||
|
])("preserves the exact empty export name for %s", (source) => {
|
||||||
|
expect(collectJavaScriptExports(parse(source)).values).toEqual([""]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains static computed and ordinary names", () => {
|
||||||
|
expect(
|
||||||
|
collectJavaScriptExports(
|
||||||
|
parse('module["exports"]["ready"] = 1; exports.done = 2;'),
|
||||||
|
).values,
|
||||||
|
).toEqual(["done", "ready"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains member helper exports on this", () => {
|
||||||
|
expect(
|
||||||
|
collectJavaScriptExports(parse("this.d(exports, { ready: () => 1 });"))
|
||||||
|
.values,
|
||||||
|
).toEqual(["ready"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not expose invented names in bundler module analysis", () => {
|
||||||
|
const analysis = analyzeJavaScriptStaticSource(`
|
||||||
|
globalThis.webpackChunkApp.push([[1], { 1: function(module, exports) {
|
||||||
|
exports[key] = 1;
|
||||||
|
module["exports.value"] = 2;
|
||||||
|
module.exports = { [key]: 3, ready: 4 };
|
||||||
|
} }]);
|
||||||
|
`);
|
||||||
|
expect(analysis.parse_status).toBe("complete");
|
||||||
|
expect(analysis.bundler_registrations[0]?.modules[0]?.exports).toEqual([
|
||||||
|
"ready",
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -5,7 +5,10 @@ import * as t from "@babel/types";
|
|||||||
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
|
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
|
||||||
|
|
||||||
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
|
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
|
||||||
import { propertyName } from "./javascriptAstValues.js";
|
import {
|
||||||
|
propertyName,
|
||||||
|
semanticStaticPropertyKey,
|
||||||
|
} from "./javascriptAstValues.js";
|
||||||
import { stringValue } from "./javascriptStaticAnalysisHelpers.js";
|
import { stringValue } from "./javascriptStaticAnalysisHelpers.js";
|
||||||
|
|
||||||
/** Static CommonJS export names. */
|
/** Static CommonJS export names. */
|
||||||
@@ -71,15 +74,20 @@ const collectAssignmentExports = (
|
|||||||
output: Set<string>,
|
output: Set<string>,
|
||||||
): void => {
|
): void => {
|
||||||
const path = memberPath(left);
|
const path = memberPath(left);
|
||||||
if (path === "exports" || path === "module.exports") {
|
if (path === null) return;
|
||||||
|
const exportOffset =
|
||||||
|
path[0] === "exports"
|
||||||
|
? 1
|
||||||
|
: path[0] === "module" && path[1] === "exports"
|
||||||
|
? 2
|
||||||
|
: null;
|
||||||
|
if (exportOffset === null) return;
|
||||||
|
if (path.length === exportOffset) {
|
||||||
if (t.isObjectExpression(right)) collectObjectKeys(right, output);
|
if (t.isObjectExpression(right)) collectObjectKeys(right, output);
|
||||||
else addExport(output, "default");
|
else addExport(output, "default");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (path.startsWith("exports."))
|
addExport(output, path.slice(exportOffset).join("."));
|
||||||
addExport(output, path.slice("exports.".length));
|
|
||||||
if (path.startsWith("module.exports."))
|
|
||||||
addExport(output, path.slice("module.exports.".length));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const collectCallExports = (
|
const collectCallExports = (
|
||||||
@@ -87,19 +95,28 @@ const collectCallExports = (
|
|||||||
output: Set<string>,
|
output: Set<string>,
|
||||||
): void => {
|
): void => {
|
||||||
const callee = memberPath(call.callee);
|
const callee = memberPath(call.callee);
|
||||||
if (callee === "Object.defineProperty") {
|
if (callee === null) return;
|
||||||
const target = memberPath(call.arguments[0]);
|
const definePropertyCall =
|
||||||
const name = stringValue(call.arguments[1]);
|
callee.length === 2 &&
|
||||||
if ((target === "exports" || target === "module.exports") && name)
|
callee[0] === "Object" &&
|
||||||
addExport(output, name);
|
callee[1] === "defineProperty";
|
||||||
}
|
const declarationCall = callee.length >= 2 && callee.at(-1) === "d";
|
||||||
if (!callee.endsWith(".d")) return;
|
if (!definePropertyCall && !declarationCall) return;
|
||||||
const target = memberPath(call.arguments[0]);
|
const target = memberPath(call.arguments[0]);
|
||||||
|
const exportTarget =
|
||||||
|
target !== null &&
|
||||||
|
((target.length === 1 && target[0] === "exports") ||
|
||||||
|
(target.length === 2 &&
|
||||||
|
target[0] === "module" &&
|
||||||
|
target[1] === "exports"));
|
||||||
|
if (!exportTarget) return;
|
||||||
|
if (definePropertyCall) {
|
||||||
|
const name = stringValue(call.arguments[1]);
|
||||||
|
if (name !== undefined) addExport(output, name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const declarations = call.arguments[1];
|
const declarations = call.arguments[1];
|
||||||
if (
|
if (t.isObjectExpression(declarations))
|
||||||
(target === "exports" || target === "module.exports") &&
|
|
||||||
t.isObjectExpression(declarations)
|
|
||||||
)
|
|
||||||
collectObjectKeys(declarations, output);
|
collectObjectKeys(declarations, output);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -109,8 +126,8 @@ const collectObjectKeys = (
|
|||||||
): void => {
|
): void => {
|
||||||
for (const property of object.properties) {
|
for (const property of object.properties) {
|
||||||
if (!t.isObjectProperty(property) && !t.isObjectMethod(property)) continue;
|
if (!t.isObjectProperty(property) && !t.isObjectMethod(property)) continue;
|
||||||
const name = propertyName(property.key);
|
const name = semanticStaticPropertyKey(property.key, property.computed);
|
||||||
if (name !== "") addExport(output, name);
|
if (name !== null) addExport(output, name);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -118,7 +135,9 @@ const addExport = (output: Set<string>, name: string): void => {
|
|||||||
output.add(name);
|
output.add(name);
|
||||||
};
|
};
|
||||||
|
|
||||||
const memberPath = (node: t.Node | null | undefined): string => {
|
const memberPath = (
|
||||||
|
node: t.Node | null | undefined,
|
||||||
|
): readonly string[] | null => {
|
||||||
const properties: string[] = [];
|
const properties: string[] = [];
|
||||||
let current = node;
|
let current = node;
|
||||||
while (
|
while (
|
||||||
@@ -126,23 +145,19 @@ const memberPath = (node: t.Node | null | undefined): string => {
|
|||||||
current !== null &&
|
current !== null &&
|
||||||
(t.isMemberExpression(current) || t.isOptionalMemberExpression(current))
|
(t.isMemberExpression(current) || t.isOptionalMemberExpression(current))
|
||||||
) {
|
) {
|
||||||
properties.push(propertyName(current.property));
|
const property = semanticStaticPropertyKey(
|
||||||
current = t.isNode(current.object) ? current.object : undefined;
|
current.property,
|
||||||
|
current.computed,
|
||||||
|
);
|
||||||
|
if (property === null) return null;
|
||||||
|
properties.push(property);
|
||||||
|
current = current.object;
|
||||||
}
|
}
|
||||||
|
if (current === undefined || current === null) return null;
|
||||||
const root =
|
const root = t.isIdentifier(current)
|
||||||
current !== undefined && t.isIdentifier(current)
|
? current.name
|
||||||
? current.name
|
: t.isThisExpression(current)
|
||||||
: current !== undefined && t.isThisExpression(current)
|
? "this"
|
||||||
? "this"
|
: null;
|
||||||
: "";
|
return root === null ? null : [root, ...properties.reverse()];
|
||||||
if (properties.length === 0) return root;
|
|
||||||
|
|
||||||
properties.reverse();
|
|
||||||
const path = root === "" ? [] : [root];
|
|
||||||
for (const property of properties) {
|
|
||||||
if (path.length === 0 && property === "") continue;
|
|
||||||
path.push(property);
|
|
||||||
}
|
|
||||||
return path.join(".");
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -103,7 +103,12 @@ const nativeExportNodes = (
|
|||||||
const requestedExports = (nodes: readonly ApplicationNode[]): string[] => {
|
const requestedExports = (nodes: readonly ApplicationNode[]): string[] => {
|
||||||
const values = nodes.flatMap((node) =>
|
const values = nodes.flatMap((node) =>
|
||||||
node.observations.flatMap((observation) => [
|
node.observations.flatMap((observation) => [
|
||||||
...(observation.label === null ? [] : [observation.label]),
|
// Display labels can summarize several names or namespace access.
|
||||||
|
// Only legacy observations without structured member facts need them.
|
||||||
|
...(observation.label === null ||
|
||||||
|
Array.isArray(observation.properties.requested_members)
|
||||||
|
? []
|
||||||
|
: [observation.label]),
|
||||||
...stringArray(observation.properties.requested_members),
|
...stringArray(observation.properties.requested_members),
|
||||||
...stringArray(observation.properties.members),
|
...stringArray(observation.properties.members),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ it("collects a 12,000-member CommonJS export path without recursion", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves empty, dynamic, this, and private member path behavior", () => {
|
it("preserves empty keys without inventing dynamic, this, or private exports", () => {
|
||||||
const file = parseJavaScriptSource(`
|
const file = parseJavaScriptSource(`
|
||||||
exports[""].value = 1;
|
exports[""].value = 1;
|
||||||
exports[""] = 0;
|
exports[""] = 0;
|
||||||
@@ -24,11 +24,7 @@ it("preserves empty, dynamic, this, and private member path behavior", () => {
|
|||||||
class Example { #private = 0; read() { this.#private = 1; } }
|
class Example { #private = 0; read() { this.#private = 1; } }
|
||||||
`);
|
`);
|
||||||
if (file === null) throw new Error("Expected valid JavaScript");
|
if (file === null) throw new Error("Expected valid JavaScript");
|
||||||
expect(collectJavaScriptExports(file).values).toEqual([
|
expect(collectJavaScriptExports(file).values).toEqual(["", ".value"]);
|
||||||
"",
|
|
||||||
".value",
|
|
||||||
"key.value",
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps left-first and outer-member-first bundler runtime matches", () => {
|
it("keeps left-first and outer-member-first bundler runtime matches", () => {
|
||||||
|
|||||||
@@ -282,6 +282,7 @@ const managedFieldAccessSchema = z.object({
|
|||||||
|
|
||||||
const managedParseIssueSchema = z.object({
|
const managedParseIssueSchema = z.object({
|
||||||
code: z.enum([
|
code: z.enum([
|
||||||
|
"resource-limit",
|
||||||
"invalid-cli-header",
|
"invalid-cli-header",
|
||||||
"invalid-directory",
|
"invalid-directory",
|
||||||
"invalid-metadata-root",
|
"invalid-metadata-root",
|
||||||
|
|||||||
@@ -156,3 +156,5 @@ export const nativeUiResultSchema = z.strictObject({
|
|||||||
restore: z.literal("leave-as-is"),
|
restore: z.literal("leave-as-is"),
|
||||||
limitations: z.array(z.string()),
|
limitations: z.array(z.string()),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type NativeUiObservationResult = z.infer<typeof nativeUiResultSchema>;
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import { canonicalJson } from "./comparisonSemantics.js";
|
import { canonicalJson } from "./comparisonSemantics.js";
|
||||||
import { digestCanonicalValue } from "./canonicalDigest.js";
|
import { digestCanonicalValue } from "./canonicalDigest.js";
|
||||||
import { normalizeJoinedLogicalPath } from "./artifactIdentity.js";
|
import {
|
||||||
|
createReferenceSourcePathLookup,
|
||||||
|
isPortableAbsoluteReferenceTarget,
|
||||||
|
} from "./referenceSourcePathIdentity.js";
|
||||||
import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js";
|
import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js";
|
||||||
import { digestSchema } from "./../domain/digests.js";
|
import { digestSchema } from "./../domain/digests.js";
|
||||||
import { prefixedDigestSchema } from "./../domain/digests.js";
|
import { prefixedDigestSchema } from "./../domain/digests.js";
|
||||||
@@ -82,18 +85,13 @@ const symlinkTargetSchema = z
|
|||||||
.min(1)
|
.min(1)
|
||||||
.refine(
|
.refine(
|
||||||
(target) =>
|
(target) =>
|
||||||
isPortableAbsoluteSymlinkTarget(target) ||
|
isPortableAbsoluteReferenceTarget(target) ||
|
||||||
(!target.startsWith("/") &&
|
(!target.startsWith("/") &&
|
||||||
!target.includes("\\") &&
|
!target.includes("\\") &&
|
||||||
posix.normalize(target) === target),
|
posix.normalize(target) === target),
|
||||||
"Expected a normalized POSIX symlink target",
|
"Expected a normalized POSIX symlink target",
|
||||||
);
|
);
|
||||||
|
|
||||||
const isPortableAbsoluteSymlinkTarget = (target: string): boolean =>
|
|
||||||
target.startsWith("/") ||
|
|
||||||
/^[A-Za-z]:[\\/]/u.test(target) ||
|
|
||||||
/^\\\\[^\\/]+[\\/][^\\/]+/u.test(target);
|
|
||||||
|
|
||||||
const sourceSymlinkSchema = z.union([
|
const sourceSymlinkSchema = z.union([
|
||||||
z.strictObject({
|
z.strictObject({
|
||||||
...entryBaseShape,
|
...entryBaseShape,
|
||||||
@@ -314,18 +312,11 @@ const checkSymlinks = (
|
|||||||
paths: ReadonlySet<string>,
|
paths: ReadonlySet<string>,
|
||||||
context: z.RefinementCtx,
|
context: z.RefinementCtx,
|
||||||
): void => {
|
): void => {
|
||||||
const normalizedPathCounts = new Map<string, number>();
|
const lookupPath = createReferenceSourcePathLookup(paths);
|
||||||
for (const path of paths) {
|
|
||||||
const identity = normalizeJoinedLogicalPath(path);
|
|
||||||
normalizedPathCounts.set(
|
|
||||||
identity,
|
|
||||||
(normalizedPathCounts.get(identity) ?? 0) + 1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const [index, entry] of graph.entries.entries()) {
|
for (const [index, entry] of graph.entries.entries()) {
|
||||||
if (entry.kind !== "symlink") continue;
|
if (entry.kind !== "symlink") continue;
|
||||||
if (entry.target === null) continue;
|
if (entry.target === null) continue;
|
||||||
const absolute = isPortableAbsoluteSymlinkTarget(entry.target);
|
const absolute = isPortableAbsoluteReferenceTarget(entry.target);
|
||||||
if (
|
if (
|
||||||
(entry.target_state === "external" && !absolute) ||
|
(entry.target_state === "external" && !absolute) ||
|
||||||
(entry.target_state === "internal" && absolute)
|
(entry.target_state === "internal" && absolute)
|
||||||
@@ -339,11 +330,11 @@ const checkSymlinks = (
|
|||||||
const resolved = posix.normalize(
|
const resolved = posix.normalize(
|
||||||
posix.join(posix.dirname(entry.path), entry.target),
|
posix.join(posix.dirname(entry.path), entry.target),
|
||||||
);
|
);
|
||||||
const normalized = normalizeJoinedLogicalPath(resolved);
|
|
||||||
if (
|
if (
|
||||||
resolved.startsWith("../") ||
|
resolved.startsWith("../") ||
|
||||||
resolved === ".." ||
|
resolved === ".." ||
|
||||||
(!paths.has(resolved) && normalizedPathCounts.get(normalized) !== 1)
|
// The inventory root is implicit rather than an entry with path ".".
|
||||||
|
(resolved !== "." && lookupPath.resolve(resolved) === undefined)
|
||||||
)
|
)
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
export interface ReferenceSourcePathLookup {
|
||||||
|
readonly resolve: (path: string) => string | undefined;
|
||||||
|
readonly resolveCandidates: (paths: readonly string[]) => string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference inventories retain filesystem spelling. Prefer that exact identity;
|
||||||
|
* canonical Unicode equivalence is a fallback only when it identifies one entry.
|
||||||
|
*/
|
||||||
|
export const createReferenceSourcePathLookup = (
|
||||||
|
paths: Iterable<string>,
|
||||||
|
): ReferenceSourcePathLookup => {
|
||||||
|
const exact = new Set(paths);
|
||||||
|
const canonical = new Map<string, string | null>();
|
||||||
|
for (const path of exact) {
|
||||||
|
const key = path.normalize("NFC");
|
||||||
|
canonical.set(key, canonical.has(key) ? null : path);
|
||||||
|
}
|
||||||
|
const equivalent = (path: string): string | undefined =>
|
||||||
|
canonical.get(path.normalize("NFC")) ?? undefined;
|
||||||
|
return {
|
||||||
|
resolve: (path) => (exact.has(path) ? path : equivalent(path)),
|
||||||
|
resolveCandidates: (candidates) =>
|
||||||
|
candidates.find((path) => exact.has(path)) ??
|
||||||
|
candidates.map(equivalent).find((path) => path !== undefined),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Symlink evidence can originate on a different OS from the graph reader. */
|
||||||
|
export const isPortableAbsoluteReferenceTarget = (target: string): boolean =>
|
||||||
|
target.startsWith("/") ||
|
||||||
|
/^[A-Za-z]:[\\/]/u.test(target) ||
|
||||||
|
/^\\\\[^\\/]+[\\/][^\\/]+/u.test(target);
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
admitManagedProjection,
|
||||||
|
managedDecodeBudget,
|
||||||
|
} from "./ManagedDecodeBudget.js";
|
||||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||||
import {
|
import {
|
||||||
managedArtifactInspectionSchema,
|
managedArtifactInspectionSchema,
|
||||||
@@ -107,40 +111,44 @@ const unavailableResult = (
|
|||||||
: [
|
: [
|
||||||
"The PE has no CLI data directory; managed metadata and CIL are unavailable.",
|
"The PE has no CLI data directory; managed metadata and CIL are unavailable.",
|
||||||
];
|
];
|
||||||
return managedArtifactInspectionSchema.parse({
|
return managedArtifactInspectionSchema.parse(
|
||||||
...base(target, bytes, layout),
|
admitManagedProjection({
|
||||||
classification: {
|
...base(target, bytes, layout),
|
||||||
status: malformed ? "malformed" : "not-managed",
|
classification: {
|
||||||
container: "pe",
|
status: malformed ? "malformed" : "not-managed",
|
||||||
runtime_family: "unknown",
|
container: "pe",
|
||||||
implementation: malformed ? "unknown" : "not-managed",
|
runtime_family: "unknown",
|
||||||
managed_architecture: "unknown",
|
implementation: malformed ? "unknown" : "not-managed",
|
||||||
evidence: [
|
managed_architecture: "unknown",
|
||||||
{
|
evidence: [
|
||||||
code: malformed ? "cli-directory-malformed" : "cli-directory-absent",
|
{
|
||||||
detail: limitations[0],
|
code: malformed
|
||||||
file_offset: layout.cliIssue?.offset ?? null,
|
? "cli-directory-malformed"
|
||||||
},
|
: "cli-directory-absent",
|
||||||
],
|
detail: limitations[0],
|
||||||
},
|
file_offset: layout.cliIssue?.offset ?? null,
|
||||||
metadata: {
|
},
|
||||||
status: malformed ? "malformed" : "absent",
|
],
|
||||||
version: null,
|
},
|
||||||
stream_names: [],
|
metadata: {
|
||||||
table_row_counts: {},
|
status: malformed ? "malformed" : "absent",
|
||||||
},
|
version: null,
|
||||||
module: null,
|
stream_names: [],
|
||||||
assembly: null,
|
table_row_counts: {},
|
||||||
target_frameworks: [],
|
},
|
||||||
references: [],
|
module: null,
|
||||||
resources: [],
|
assembly: null,
|
||||||
attributes: [],
|
target_frameworks: [],
|
||||||
coverage: {
|
references: [],
|
||||||
state: malformed ? "partial" : "unavailable",
|
resources: [],
|
||||||
issues,
|
attributes: [],
|
||||||
},
|
coverage: {
|
||||||
limitations,
|
state: malformed ? "partial" : "unavailable",
|
||||||
});
|
issues,
|
||||||
|
},
|
||||||
|
limitations,
|
||||||
|
}),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface RuntimeClassificationInput {
|
interface RuntimeClassificationInput {
|
||||||
@@ -288,7 +296,7 @@ const partialMetadataResult = ({
|
|||||||
}: PartialMetadataContext): ManagedArtifactInspection => ({
|
}: PartialMetadataContext): ManagedArtifactInspection => ({
|
||||||
...base(target, bytes, layout),
|
...base(target, bytes, layout),
|
||||||
classification: {
|
classification: {
|
||||||
status: "malformed",
|
status: issue.code === "resource-limit" ? "managed" : "malformed",
|
||||||
container: "pe",
|
container: "pe",
|
||||||
runtime_family:
|
runtime_family:
|
||||||
(layout.cli?.flags ?? 1) & 1 ? "unknown" : "mixed-clr-native",
|
(layout.cli?.flags ?? 1) & 1 ? "unknown" : "mixed-clr-native",
|
||||||
@@ -303,7 +311,7 @@ const partialMetadataResult = ({
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
status: "malformed",
|
status: issue.code === "resource-limit" ? "partial" : "malformed",
|
||||||
version: null,
|
version: null,
|
||||||
stream_names: [],
|
stream_names: [],
|
||||||
table_row_counts: {},
|
table_row_counts: {},
|
||||||
@@ -316,7 +324,9 @@ const partialMetadataResult = ({
|
|||||||
attributes: [],
|
attributes: [],
|
||||||
coverage: { state: "partial", issues: [issue] },
|
coverage: { state: "partial", issues: [issue] },
|
||||||
limitations: [
|
limitations: [
|
||||||
"CLI metadata could not be read because the PE/CLI structure is malformed or unsupported.",
|
issue.code === "resource-limit"
|
||||||
|
? "CLI metadata inspection stopped at its representation budget; omitted facts are unknown."
|
||||||
|
: "CLI metadata could not be read because the PE/CLI structure is malformed or unsupported.",
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -343,12 +353,14 @@ export const inspectManagedArtifactBytes = (
|
|||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
if (!(cause instanceof ManagedReaderFailure)) throw cause;
|
if (!(cause instanceof ManagedReaderFailure)) throw cause;
|
||||||
return managedArtifactInspectionSchema.parse(
|
return managedArtifactInspectionSchema.parse(
|
||||||
partialMetadataResult({
|
admitManagedProjection(
|
||||||
target,
|
partialMetadataResult({
|
||||||
bytes,
|
target,
|
||||||
layout,
|
bytes,
|
||||||
issue: cause.issue,
|
layout,
|
||||||
}),
|
issue: cause.issue,
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const resourceIssues: ManagedParseIssue[] = [];
|
const resourceIssues: ManagedParseIssue[] = [];
|
||||||
@@ -356,43 +368,57 @@ export const inspectManagedArtifactBytes = (
|
|||||||
layout,
|
layout,
|
||||||
resourceIssues,
|
resourceIssues,
|
||||||
);
|
);
|
||||||
const inventory = readManagedMetadataInventory(
|
try {
|
||||||
bytes,
|
const inventory = readManagedMetadataInventory(
|
||||||
metadata,
|
bytes,
|
||||||
resourceDirectory,
|
metadata,
|
||||||
);
|
resourceDirectory,
|
||||||
const issues = [...resourceIssues, ...inventory.issues];
|
);
|
||||||
const limitations = [
|
const issues = [...resourceIssues, ...inventory.issues];
|
||||||
...(issues.length === 0
|
const limitations = [
|
||||||
? []
|
...(issues.length === 0
|
||||||
: [
|
? []
|
||||||
"At least one metadata or resource item could not be read; coverage is partial.",
|
: [
|
||||||
]),
|
"At least one metadata or resource item could not be read; coverage is partial.",
|
||||||
"Static inspection did not load the assembly, resolve dependencies through a CLR, decompile C#, or execute target code.",
|
]),
|
||||||
];
|
"Static inspection did not load the assembly, resolve dependencies through a CLR, decompile C#, or execute target code.",
|
||||||
return managedArtifactInspectionSchema.parse({
|
];
|
||||||
...base(target, bytes, layout),
|
return managedArtifactInspectionSchema.parse(
|
||||||
classification: {
|
admitManagedProjection(
|
||||||
status: "managed",
|
{
|
||||||
container: "pe",
|
...base(target, bytes, layout),
|
||||||
...runtimeClassification({ layout, metadata, inventory }),
|
classification: {
|
||||||
},
|
status: "managed",
|
||||||
metadata: {
|
container: "pe",
|
||||||
status: issues.length === 0 ? "complete" : "partial",
|
...runtimeClassification({ layout, metadata, inventory }),
|
||||||
version: metadata.version,
|
},
|
||||||
stream_names: metadata.streamNames,
|
metadata: {
|
||||||
table_row_counts: managedTableRowCounts(metadata),
|
status: issues.length === 0 ? "complete" : "partial",
|
||||||
},
|
version: metadata.version,
|
||||||
module: inventory.module,
|
stream_names: metadata.streamNames,
|
||||||
assembly: inventory.assembly,
|
table_row_counts: managedTableRowCounts(metadata),
|
||||||
target_frameworks: inventory.targetFrameworks,
|
},
|
||||||
references: inventory.references,
|
module: inventory.module,
|
||||||
resources: inventory.resources,
|
assembly: inventory.assembly,
|
||||||
attributes: inventory.attributes,
|
target_frameworks: inventory.targetFrameworks,
|
||||||
coverage: {
|
references: inventory.references,
|
||||||
state: issues.length === 0 ? "complete" : "partial",
|
resources: inventory.resources,
|
||||||
issues,
|
attributes: inventory.attributes,
|
||||||
},
|
coverage: {
|
||||||
limitations,
|
state: issues.length === 0 ? "complete" : "partial",
|
||||||
});
|
issues,
|
||||||
|
},
|
||||||
|
limitations,
|
||||||
|
},
|
||||||
|
managedDecodeBudget(metadata),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
if (!(cause instanceof ManagedReaderFailure)) throw cause;
|
||||||
|
return managedArtifactInspectionSchema.parse(
|
||||||
|
admitManagedProjection(
|
||||||
|
partialMetadataResult({ target, bytes, layout, issue: cause.issue }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js";
|
||||||
|
import { managedFailure } from "./ManagedReaderFailure.js";
|
||||||
|
|
||||||
|
/** Per-inspection capacity for eagerly retained metadata and its JSON projection. */
|
||||||
|
const MAX_METADATA_REPRESENTATION_BYTES = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
interface CachedString {
|
||||||
|
readonly value: string;
|
||||||
|
readonly encodedBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The layout owns decoding capacity and heap identity for all of its consumers. */
|
||||||
|
export class ManagedDecodeBudget {
|
||||||
|
#remaining = MAX_METADATA_REPRESENTATION_BYTES;
|
||||||
|
readonly #strings = new Map<number, CachedString>();
|
||||||
|
readonly #digests = new WeakMap<ArrayBufferLike, Map<string, string>>();
|
||||||
|
|
||||||
|
reserve(bytes: number, offset: number | null): void {
|
||||||
|
if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.#remaining)
|
||||||
|
throw managedFailure(
|
||||||
|
"resource-limit",
|
||||||
|
"metadata.representation",
|
||||||
|
`Managed metadata exceeds the ${String(MAX_METADATA_REPRESENTATION_BYTES)} byte representation budget`,
|
||||||
|
offset,
|
||||||
|
);
|
||||||
|
this.#remaining -= bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UTF-8 cannot decode to more UTF-16 units than bytes; JSON uses at most six bytes per unit. */
|
||||||
|
reserveString(encodedBytes: number, offset: number | null): void {
|
||||||
|
this.reserve(encodedBytes * 6 + 2, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snapshot buffers are immutable within a layout; cache exact backing-buffer ranges. */
|
||||||
|
digest(bytes: Buffer, algorithm: "sha256" | "sha1"): string {
|
||||||
|
let ranges = this.#digests.get(bytes.buffer);
|
||||||
|
if (ranges === undefined) {
|
||||||
|
ranges = new Map();
|
||||||
|
this.#digests.set(bytes.buffer, ranges);
|
||||||
|
}
|
||||||
|
const key = `${algorithm}:${String(bytes.byteOffset)}:${String(bytes.byteLength)}`;
|
||||||
|
const cached = ranges.get(key);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
this.reserve(256, null);
|
||||||
|
const digest = createHash(algorithm).update(bytes).digest("hex");
|
||||||
|
ranges.set(key, digest);
|
||||||
|
return digest;
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedString(index: number): CachedString | undefined {
|
||||||
|
return this.#strings.get(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheString(index: number, value: string, encodedBytes: number): void {
|
||||||
|
this.#strings.set(index, { value, encodedBytes });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const budgets = new WeakMap<ManagedMetadataLayout, ManagedDecodeBudget>();
|
||||||
|
|
||||||
|
/** Transfer metadata-root admission capacity to its completed immutable layout. */
|
||||||
|
export const attachManagedDecodeBudget = (
|
||||||
|
layout: ManagedMetadataLayout,
|
||||||
|
budget: ManagedDecodeBudget,
|
||||||
|
): void => {
|
||||||
|
budgets.set(layout, budget);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Share one owner for the lifetime of the admitted layout, including all inspection facets. */
|
||||||
|
export const managedDecodeBudget = (
|
||||||
|
layout: ManagedMetadataLayout,
|
||||||
|
): ManagedDecodeBudget => {
|
||||||
|
let budget = budgets.get(layout);
|
||||||
|
if (budget === undefined) {
|
||||||
|
budget = new ManagedDecodeBudget();
|
||||||
|
budgets.set(layout, budget);
|
||||||
|
}
|
||||||
|
return budget;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Admit repeated values too: shared heap strings still expand at the JSON boundary. */
|
||||||
|
export const admitManagedProjection = <Value>(
|
||||||
|
value: Value,
|
||||||
|
budget: ManagedDecodeBudget = new ManagedDecodeBudget(),
|
||||||
|
): Value => {
|
||||||
|
const visit = (item: unknown): void => {
|
||||||
|
if (typeof item === "string") {
|
||||||
|
budget.reserveString(item.length, null);
|
||||||
|
} else if (Array.isArray(item)) {
|
||||||
|
budget.reserve(item.length + 2, null);
|
||||||
|
for (const child of item) visit(child);
|
||||||
|
} else if (item !== null && typeof item === "object") {
|
||||||
|
budget.reserve(2, null);
|
||||||
|
for (const key of Object.keys(item)) {
|
||||||
|
budget.reserve(key.length * 6 + 4, null);
|
||||||
|
visit(Reflect.get(item, key));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
budget.reserve(32, null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(value);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
admitManagedProjection,
|
||||||
|
managedDecodeBudget,
|
||||||
|
} from "./ManagedDecodeBudget.js";
|
||||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||||
import {
|
import {
|
||||||
managedMemberInspectionSchema,
|
managedMemberInspectionSchema,
|
||||||
@@ -31,40 +35,47 @@ const unavailable = (
|
|||||||
bytes: Buffer,
|
bytes: Buffer,
|
||||||
issue: ManagedParseIssue | null,
|
issue: ManagedParseIssue | null,
|
||||||
): ManagedMemberInspection =>
|
): ManagedMemberInspection =>
|
||||||
managedMemberInspectionSchema.parse({
|
managedMemberInspectionSchema.parse(
|
||||||
artifact: {
|
admitManagedProjection({
|
||||||
path: target.path,
|
artifact: {
|
||||||
sha256: target.sha256,
|
path: target.path,
|
||||||
byte_length: bytes.length,
|
sha256: target.sha256,
|
||||||
format: "pe",
|
byte_length: bytes.length,
|
||||||
},
|
format: "pe",
|
||||||
module: null,
|
},
|
||||||
metadata: {
|
module: null,
|
||||||
status: issue === null ? "absent" : "malformed",
|
metadata: {
|
||||||
version: null,
|
status:
|
||||||
table_row_counts: {},
|
issue === null
|
||||||
},
|
? "absent"
|
||||||
identity_scope: {
|
: issue.code === "resource-limit"
|
||||||
token_identity: "build-local",
|
? "partial"
|
||||||
requires_artifact_sha256: target.sha256,
|
: "malformed",
|
||||||
requires_mvid: null,
|
version: null,
|
||||||
},
|
table_row_counts: {},
|
||||||
types: [],
|
},
|
||||||
fields: [],
|
identity_scope: {
|
||||||
methods: [],
|
token_identity: "build-local",
|
||||||
member_refs: [],
|
requires_artifact_sha256: target.sha256,
|
||||||
call_edges: [],
|
requires_mvid: null,
|
||||||
field_accesses: [],
|
},
|
||||||
coverage: {
|
types: [],
|
||||||
state: "unavailable",
|
fields: [],
|
||||||
issues: issue === null ? [] : [issue],
|
methods: [],
|
||||||
},
|
member_refs: [],
|
||||||
limitations: [
|
call_edges: [],
|
||||||
issue === null
|
field_accesses: [],
|
||||||
? "The PE has no admitted CLI metadata; managed member inspection is unavailable."
|
coverage: {
|
||||||
: "The CLI metadata could not be admitted; managed member inspection is unavailable.",
|
state: "unavailable",
|
||||||
],
|
issues: issue === null ? [] : [issue],
|
||||||
});
|
},
|
||||||
|
limitations: [
|
||||||
|
issue === null
|
||||||
|
? "The PE has no admitted CLI metadata; managed member inspection is unavailable."
|
||||||
|
: "The CLI metadata could not be admitted; managed member inspection is unavailable.",
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const readMemberInventory = (
|
const readMemberInventory = (
|
||||||
bytes: Buffer,
|
bytes: Buffer,
|
||||||
@@ -132,41 +143,48 @@ export const inspectManagedMembersBytes = (
|
|||||||
fields.core,
|
fields.core,
|
||||||
memberRefs.core,
|
memberRefs.core,
|
||||||
);
|
);
|
||||||
return managedMemberInspectionSchema.parse({
|
return managedMemberInspectionSchema.parse(
|
||||||
artifact: {
|
admitManagedProjection(
|
||||||
path: target.path,
|
{
|
||||||
sha256: target.sha256,
|
artifact: {
|
||||||
byte_length: bytes.length,
|
path: target.path,
|
||||||
format: "pe",
|
sha256: target.sha256,
|
||||||
},
|
byte_length: bytes.length,
|
||||||
module: inventory.module,
|
format: "pe",
|
||||||
metadata: {
|
},
|
||||||
status: issues.length === 0 ? "complete" : "partial",
|
module: inventory.module,
|
||||||
version: layout.version,
|
metadata: {
|
||||||
table_row_counts: managedTableRowCounts(layout),
|
status: issues.length === 0 ? "complete" : "partial",
|
||||||
},
|
version: layout.version,
|
||||||
identity_scope: {
|
table_row_counts: managedTableRowCounts(layout),
|
||||||
token_identity: "build-local",
|
},
|
||||||
requires_artifact_sha256: target.sha256,
|
identity_scope: {
|
||||||
requires_mvid: inventory.module?.mvid ?? null,
|
token_identity: "build-local",
|
||||||
},
|
requires_artifact_sha256: target.sha256,
|
||||||
types: parsedTypes.types,
|
requires_mvid: inventory.module?.mvid ?? null,
|
||||||
fields: fields.fields,
|
},
|
||||||
methods: methods.methods,
|
types: parsedTypes.types,
|
||||||
member_refs: memberRefs.refs,
|
fields: fields.fields,
|
||||||
call_edges: related.callEdges,
|
methods: methods.methods,
|
||||||
field_accesses: related.fieldAccesses,
|
member_refs: memberRefs.refs,
|
||||||
coverage: {
|
call_edges: related.callEdges,
|
||||||
state:
|
field_accesses: related.fieldAccesses,
|
||||||
issues.length === 0 && !incompleteMembers ? "complete" : "partial",
|
coverage: {
|
||||||
issues: coverageIssues,
|
state:
|
||||||
},
|
issues.length === 0 && !incompleteMembers
|
||||||
limitations: [
|
? "complete"
|
||||||
"Metadata tokens are build-local coordinates and are only meaningful with the reported artifact SHA-256 and MVID.",
|
: "partial",
|
||||||
"CIL instruction anchors are decoded from file-backed method bodies only; no target assembly is loaded or executed.",
|
issues: coverageIssues,
|
||||||
"Signatures are decoded for common ECMA-335 primitive, class, valuetype, pointer, byref, array, and generic variable forms; unsupported forms retain raw signature hashes.",
|
},
|
||||||
],
|
limitations: [
|
||||||
});
|
"Metadata tokens are build-local coordinates and are only meaningful with the reported artifact SHA-256 and MVID.",
|
||||||
|
"CIL instruction anchors are decoded from file-backed method bodies only; no target assembly is loaded or executed.",
|
||||||
|
"Signatures are decoded for common ECMA-335 primitive, class, valuetype, pointer, byref, array, and generic variable forms; unsupported forms retain raw signature hashes.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
managedDecodeBudget(layout),
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
if (cause instanceof ManagedReaderFailure)
|
if (cause instanceof ManagedReaderFailure)
|
||||||
return unavailable(target, bytes, cause.issue);
|
return unavailable(target, bytes, cause.issue);
|
||||||
|
|||||||
@@ -405,10 +405,13 @@ const callingConvention = (value: number): string => {
|
|||||||
return flags.length === 0 ? name : `${name} ${flags.join(" ")}`;
|
return flags.length === 0 ? name : `${name} ${flags.join(" ")}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const signature = (blob: Buffer): ManagedSignature => {
|
export const signature = (
|
||||||
|
blob: Buffer,
|
||||||
|
rawSha256?: string,
|
||||||
|
): ManagedSignature => {
|
||||||
const raw = {
|
const raw = {
|
||||||
raw_length: blob.length,
|
raw_length: blob.length,
|
||||||
raw_sha256: sha256Bytes(blob),
|
raw_sha256: rawSha256 ?? sha256Bytes(blob),
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
if (blob.length === 0)
|
if (blob.length === 0)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ManagedDecodeBudget } from "./ManagedDecodeBudget.js";
|
||||||
import type {
|
import type {
|
||||||
ManagedExceptionRegion,
|
ManagedExceptionRegion,
|
||||||
ManagedInstructionAnchor,
|
ManagedInstructionAnchor,
|
||||||
@@ -434,6 +435,7 @@ export const parseExceptionRegions = (
|
|||||||
bytes: Buffer,
|
bytes: Buffer,
|
||||||
offset: number,
|
offset: number,
|
||||||
methodEnd: number,
|
methodEnd: number,
|
||||||
|
budget?: ManagedDecodeBudget,
|
||||||
): ExceptionRegionParseResult => {
|
): ExceptionRegionParseResult => {
|
||||||
const regions: ManagedExceptionRegion[] = [];
|
const regions: ManagedExceptionRegion[] = [];
|
||||||
let sectionOffset = offset;
|
let sectionOffset = offset;
|
||||||
@@ -487,7 +489,14 @@ export const parseExceptionRegions = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const count = (size - 4) / clauseSize;
|
const count = (size - 4) / clauseSize;
|
||||||
regions.push(...readExceptionClauses(bytes, sectionOffset + 4, count, fat));
|
budget?.reserve(count * 256, sectionOffset);
|
||||||
|
for (const region of readExceptionClauses(
|
||||||
|
bytes,
|
||||||
|
sectionOffset + 4,
|
||||||
|
count,
|
||||||
|
fat,
|
||||||
|
))
|
||||||
|
regions.push(region);
|
||||||
|
|
||||||
hasMoreSections = (kind & 0x80) !== 0;
|
hasMoreSections = (kind & 0x80) !== 0;
|
||||||
sectionOffset += size;
|
sectionOffset += size;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { managedDecodeBudget } from "./ManagedDecodeBudget.js";
|
||||||
import type { ManagedPeLayout } from "./ManagedPeReader.js";
|
import type { ManagedPeLayout } from "./ManagedPeReader.js";
|
||||||
import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js";
|
import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js";
|
||||||
import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js";
|
import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js";
|
||||||
@@ -64,7 +65,7 @@ export const parseFields = (
|
|||||||
declaring_type: declared?.fullName ?? null,
|
declaring_type: declared?.fullName ?? null,
|
||||||
name,
|
name,
|
||||||
flags,
|
flags,
|
||||||
signature: signature(sig),
|
signature: budgetedSignature(sig, layout),
|
||||||
});
|
});
|
||||||
core.set(token, { token, name });
|
core.set(token, { token, name });
|
||||||
}, issues);
|
}, issues);
|
||||||
@@ -129,7 +130,7 @@ export const parseMemberRefs = (
|
|||||||
layout.rowCounts,
|
layout.rowCounts,
|
||||||
),
|
),
|
||||||
name,
|
name,
|
||||||
signature: signature(sig),
|
signature: budgetedSignature(sig, layout),
|
||||||
});
|
});
|
||||||
core.set(token, { token, name });
|
core.set(token, { token, name });
|
||||||
}, issues);
|
}, issues);
|
||||||
@@ -189,8 +190,14 @@ export const parseMethods = ({
|
|||||||
rva,
|
rva,
|
||||||
impl_flags: implFlags,
|
impl_flags: implFlags,
|
||||||
flags,
|
flags,
|
||||||
signature: signature(sig),
|
signature: budgetedSignature(sig, layout),
|
||||||
body: methodBody(bytes, pe, rva, { implFlags, flags }),
|
body: methodBody(
|
||||||
|
bytes,
|
||||||
|
pe,
|
||||||
|
rva,
|
||||||
|
{ implFlags, flags },
|
||||||
|
managedDecodeBudget(layout),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
core.set(token, {
|
core.set(token, {
|
||||||
token,
|
token,
|
||||||
@@ -258,3 +265,11 @@ export const edges = (
|
|||||||
}
|
}
|
||||||
return { callEdges, fieldAccesses };
|
return { callEdges, fieldAccesses };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const budgetedSignature = (
|
||||||
|
bytes: Buffer,
|
||||||
|
layout: ManagedMetadataLayout,
|
||||||
|
): ReturnType<typeof signature> => {
|
||||||
|
managedDecodeBudget(layout).reserve(bytes.length * 256, layout.blob.offset);
|
||||||
|
return signature(bytes, managedDecodeBudget(layout).digest(bytes, "sha256"));
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
import { managedDecodeBudget } from "./ManagedDecodeBudget.js";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
metadataRowOffset,
|
metadataRowOffset,
|
||||||
type ManagedMetadataLayout,
|
type ManagedMetadataLayout,
|
||||||
@@ -117,6 +119,11 @@ export const readMetadataString = (
|
|||||||
layout.strings.offset + Math.max(index, 0),
|
layout.strings.offset + Math.max(index, 0),
|
||||||
);
|
);
|
||||||
const start = layout.strings.offset + index;
|
const start = layout.strings.offset + index;
|
||||||
|
const cached = managedDecodeBudget(layout).cachedString(index);
|
||||||
|
if (cached !== undefined && cached.encodedBytes <= heapExtent) {
|
||||||
|
managedDecodeBudget(layout).reserveString(cached.encodedBytes, start);
|
||||||
|
return cached.value;
|
||||||
|
}
|
||||||
const maximum = Math.min(
|
const maximum = Math.min(
|
||||||
layout.strings.offset + layout.strings.size,
|
layout.strings.offset + layout.strings.size,
|
||||||
start + heapExtent + 1,
|
start + heapExtent + 1,
|
||||||
@@ -130,10 +137,14 @@ export const readMetadataString = (
|
|||||||
"String heap item has no terminator within #Strings",
|
"String heap item has no terminator within #Strings",
|
||||||
start,
|
start,
|
||||||
);
|
);
|
||||||
|
managedDecodeBudget(layout).reserveString(end - start, start);
|
||||||
try {
|
try {
|
||||||
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
|
const value = new TextDecoder("utf-8", {
|
||||||
bytes.subarray(start, end),
|
fatal: true,
|
||||||
);
|
ignoreBOM: true,
|
||||||
|
}).decode(bytes.subarray(start, end));
|
||||||
|
managedDecodeBudget(layout).cacheString(index, value, end - start);
|
||||||
|
return value;
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
void cause;
|
void cause;
|
||||||
throw managedFailure(
|
throw managedFailure(
|
||||||
@@ -218,12 +229,6 @@ export const metadataToken = (table: number, row: number): string =>
|
|||||||
export const sha256Bytes = (bytes: Buffer): string =>
|
export const sha256Bytes = (bytes: Buffer): string =>
|
||||||
createHash("sha256").update(bytes).digest("hex");
|
createHash("sha256").update(bytes).digest("hex");
|
||||||
|
|
||||||
/** Derive the conventional strong-name token from a full public key. */
|
|
||||||
export const strongNameToken = (publicKey: Buffer): string =>
|
|
||||||
Buffer.from(createHash("sha1").update(publicKey).digest().subarray(-8))
|
|
||||||
.reverse()
|
|
||||||
.toString("hex");
|
|
||||||
|
|
||||||
/** Create a cursor bounded to one admitted metadata table row. */
|
/** Create a cursor bounded to one admitted metadata table row. */
|
||||||
export const metadataRowCursor = (
|
export const metadataRowCursor = (
|
||||||
bytes: Buffer,
|
bytes: Buffer,
|
||||||
@@ -240,6 +245,8 @@ export const metadataRowCursor = (
|
|||||||
"Metadata table is absent",
|
"Metadata table is absent",
|
||||||
start,
|
start,
|
||||||
);
|
);
|
||||||
|
// Reserve per-row object/index bookkeeping before readers retain decoded facts.
|
||||||
|
managedDecodeBudget(layout).reserve(256, start);
|
||||||
return new MetadataRowCursor(
|
return new MetadataRowCursor(
|
||||||
bytes,
|
bytes,
|
||||||
start,
|
start,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { managedDecodeBudget } from "./ManagedDecodeBudget.js";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ManagedArtifactInspection,
|
ManagedArtifactInspection,
|
||||||
ManagedParseIssue,
|
ManagedParseIssue,
|
||||||
@@ -14,8 +16,6 @@ import {
|
|||||||
readMetadataBlob,
|
readMetadataBlob,
|
||||||
readMetadataGuid,
|
readMetadataGuid,
|
||||||
readMetadataString,
|
readMetadataString,
|
||||||
sha256Bytes,
|
|
||||||
strongNameToken,
|
|
||||||
} from "./ManagedMetadataHeaps.js";
|
} from "./ManagedMetadataHeaps.js";
|
||||||
import { managedFailure } from "./ManagedReaderFailure.js";
|
import { managedFailure } from "./ManagedReaderFailure.js";
|
||||||
import type { ManagedResourceDirectory } from "./ManagedMetadataInventory.js";
|
import type { ManagedResourceDirectory } from "./ManagedMetadataInventory.js";
|
||||||
@@ -29,15 +29,22 @@ type CustomAttribute = ManagedArtifactInspection["attributes"][number];
|
|||||||
const publicKeyIdentity = (
|
const publicKeyIdentity = (
|
||||||
bytes: Buffer,
|
bytes: Buffer,
|
||||||
kind: "public-key" | "public-key-token",
|
kind: "public-key" | "public-key-token",
|
||||||
|
layout: ManagedMetadataLayout,
|
||||||
): AssemblyIdentity["public_key"] => ({
|
): AssemblyIdentity["public_key"] => ({
|
||||||
kind: bytes.length === 0 ? "none" : kind,
|
kind: bytes.length === 0 ? "none" : kind,
|
||||||
byte_length: bytes.length,
|
byte_length: bytes.length,
|
||||||
sha256: bytes.length === 0 ? null : sha256Bytes(bytes),
|
sha256:
|
||||||
|
bytes.length === 0
|
||||||
|
? null
|
||||||
|
: managedDecodeBudget(layout).digest(bytes, "sha256"),
|
||||||
token:
|
token:
|
||||||
bytes.length === 0
|
bytes.length === 0
|
||||||
? null
|
? null
|
||||||
: kind === "public-key"
|
: kind === "public-key"
|
||||||
? strongNameToken(bytes)
|
? Buffer.from(managedDecodeBudget(layout).digest(bytes, "sha1"), "hex")
|
||||||
|
.subarray(-8)
|
||||||
|
.reverse()
|
||||||
|
.toString("hex")
|
||||||
: bytes.length === 8
|
: bytes.length === 8
|
||||||
? bytes.toString("hex")
|
? bytes.toString("hex")
|
||||||
: null,
|
: null,
|
||||||
@@ -127,6 +134,7 @@ export const readAssembly = (
|
|||||||
public_key: publicKeyIdentity(
|
public_key: publicKeyIdentity(
|
||||||
key,
|
key,
|
||||||
(flags & 1) === 0 ? "public-key-token" : "public-key",
|
(flags & 1) === 0 ? "public-key-token" : "public-key",
|
||||||
|
layout,
|
||||||
),
|
),
|
||||||
token: metadataToken(32, 1),
|
token: metadataToken(32, 1),
|
||||||
row_offset: table.offset,
|
row_offset: table.offset,
|
||||||
@@ -179,8 +187,12 @@ export const readAssemblyReference = (
|
|||||||
public_key_or_token: publicKeyIdentity(
|
public_key_or_token: publicKeyIdentity(
|
||||||
keyOrToken,
|
keyOrToken,
|
||||||
(flags & 1) === 0 ? "public-key-token" : "public-key",
|
(flags & 1) === 0 ? "public-key-token" : "public-key",
|
||||||
|
layout,
|
||||||
),
|
),
|
||||||
hash_value_sha256: hashValue.length === 0 ? null : sha256Bytes(hashValue),
|
hash_value_sha256:
|
||||||
|
hashValue.length === 0
|
||||||
|
? null
|
||||||
|
: managedDecodeBudget(layout).digest(hashValue, "sha256"),
|
||||||
hash_value_length: hashValue.length,
|
hash_value_length: hashValue.length,
|
||||||
token: metadataToken(35, row),
|
token: metadataToken(35, row),
|
||||||
row_offset: metadataRowOffset(layout, 35, row),
|
row_offset: metadataRowOffset(layout, 35, row),
|
||||||
@@ -338,13 +350,17 @@ const decodeCompressedLength = (
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const decodeFixedString = (blob: Buffer): string | null => {
|
const decodeFixedString = (
|
||||||
|
blob: Buffer,
|
||||||
|
layout: ManagedMetadataLayout,
|
||||||
|
): string | null => {
|
||||||
if (blob.length < 3 || blob.readUInt16LE(0) !== 1 || blob[2] === 0xff)
|
if (blob.length < 3 || blob.readUInt16LE(0) !== 1 || blob[2] === 0xff)
|
||||||
return null;
|
return null;
|
||||||
const length = decodeCompressedLength(blob, 2);
|
const length = decodeCompressedLength(blob, 2);
|
||||||
if (length === undefined) return null;
|
if (length === undefined) return null;
|
||||||
const start = 2 + length.prefix;
|
const start = 2 + length.prefix;
|
||||||
if (start > blob.length - length.length) return null;
|
if (start > blob.length - length.length) return null;
|
||||||
|
managedDecodeBudget(layout).reserveString(length.length, layout.blob.offset);
|
||||||
try {
|
try {
|
||||||
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
|
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
|
||||||
blob.subarray(start, start + length.length),
|
blob.subarray(start, start + length.length),
|
||||||
@@ -427,10 +443,10 @@ export const readCustomAttribute = (
|
|||||||
),
|
),
|
||||||
type_name: typeName,
|
type_name: typeName,
|
||||||
value_length: value.length,
|
value_length: value.length,
|
||||||
value_sha256: sha256Bytes(value),
|
value_sha256: managedDecodeBudget(layout).digest(value, "sha256"),
|
||||||
decoded_fixed_string:
|
decoded_fixed_string:
|
||||||
typeName === "System.Runtime.Versioning.TargetFrameworkAttribute"
|
typeName === "System.Runtime.Versioning.TargetFrameworkAttribute"
|
||||||
? decodeFixedString(value)
|
? decodeFixedString(value, layout)
|
||||||
: null,
|
: null,
|
||||||
token: metadataToken(12, row),
|
token: metadataToken(12, row),
|
||||||
row_offset: cursor.start,
|
row_offset: cursor.start,
|
||||||
@@ -505,8 +521,9 @@ export const readResource = ({
|
|||||||
detail: "Embedded resource length leaves the CLI resources directory",
|
detail: "Embedded resource length leaves the CLI resources directory",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
dataSha256 = sha256Bytes(
|
dataSha256 = managedDecodeBudget(layout).digest(
|
||||||
bytes.subarray(start + 4, start + 4 + dataLength),
|
bytes.subarray(start + 4, start + 4 + dataLength),
|
||||||
|
"sha256",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
attachManagedDecodeBudget,
|
||||||
|
ManagedDecodeBudget,
|
||||||
|
} from "./ManagedDecodeBudget.js";
|
||||||
import { managedFailure } from "./ManagedReaderFailure.js";
|
import { managedFailure } from "./ManagedReaderFailure.js";
|
||||||
import {
|
import {
|
||||||
computeRowSize,
|
computeRowSize,
|
||||||
@@ -208,6 +212,7 @@ interface ParsedStreams {
|
|||||||
const readMetadataStreams = (
|
const readMetadataStreams = (
|
||||||
bytes: Buffer,
|
bytes: Buffer,
|
||||||
rootOffset: number,
|
rootOffset: number,
|
||||||
|
budget: ManagedDecodeBudget,
|
||||||
): ParsedStreams => {
|
): ParsedStreams => {
|
||||||
if (bytes.readUInt32LE(0) !== 0x424a_5342)
|
if (bytes.readUInt32LE(0) !== 0x424a_5342)
|
||||||
throw managedFailure(
|
throw managedFailure(
|
||||||
@@ -220,8 +225,10 @@ const readMetadataStreams = (
|
|||||||
requireRange(bytes, 16, versionLength, "metadata.version");
|
requireRange(bytes, 16, versionLength, "metadata.version");
|
||||||
const versionBytes = bytes.subarray(16, 16 + versionLength);
|
const versionBytes = bytes.subarray(16, 16 + versionLength);
|
||||||
const zero = versionBytes.indexOf(0);
|
const zero = versionBytes.indexOf(0);
|
||||||
|
const decodedVersionLength = zero < 0 ? versionBytes.length : zero;
|
||||||
|
budget.reserveString(decodedVersionLength, rootOffset + 16);
|
||||||
const version = versionBytes
|
const version = versionBytes
|
||||||
.subarray(0, zero < 0 ? versionBytes.length : zero)
|
.subarray(0, decodedVersionLength)
|
||||||
.toString("utf8");
|
.toString("utf8");
|
||||||
let cursor = align4(16 + versionLength);
|
let cursor = align4(16 + versionLength);
|
||||||
requireRange(bytes, cursor, 4, "metadata.stream-count");
|
requireRange(bytes, cursor, 4, "metadata.stream-count");
|
||||||
@@ -390,7 +397,8 @@ export const readManagedMetadataLayout = (
|
|||||||
rootOffset,
|
rootOffset,
|
||||||
);
|
);
|
||||||
const bytes = artifact.subarray(rootOffset, rootOffset + size);
|
const bytes = artifact.subarray(rootOffset, rootOffset + size);
|
||||||
const { streams, version } = readMetadataStreams(bytes, rootOffset);
|
const budget = new ManagedDecodeBudget();
|
||||||
|
const { streams, version } = readMetadataStreams(bytes, rootOffset, budget);
|
||||||
const { tableBytes, tablesOffset, heaps, valid } = readTablesHeader(
|
const { tableBytes, tablesOffset, heaps, valid } = readTablesHeader(
|
||||||
artifact,
|
artifact,
|
||||||
streams,
|
streams,
|
||||||
@@ -403,7 +411,7 @@ export const readManagedMetadataLayout = (
|
|||||||
valid,
|
valid,
|
||||||
tablesOffset,
|
tablesOffset,
|
||||||
});
|
});
|
||||||
return {
|
const layout: ManagedMetadataLayout = {
|
||||||
rootOffset,
|
rootOffset,
|
||||||
size,
|
size,
|
||||||
version,
|
version,
|
||||||
@@ -420,6 +428,8 @@ export const readManagedMetadataLayout = (
|
|||||||
tableIndexSize: (index) => tableSize(index, rowCounts),
|
tableIndexSize: (index) => tableSize(index, rowCounts),
|
||||||
codedIndexSize: (name) => codedSize(name, rowCounts),
|
codedIndexSize: (name) => codedSize(name, rowCounts),
|
||||||
};
|
};
|
||||||
|
attachManagedDecodeBudget(layout, budget);
|
||||||
|
return layout;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Resolve a one-based metadata row to its exact file offset. */
|
/** Resolve a one-based metadata row to its exact file offset. */
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ManagedDecodeBudget } from "./ManagedDecodeBudget.js";
|
||||||
import type { ManagedPeLayout } from "./ManagedPeReader.js";
|
import type { ManagedPeLayout } from "./ManagedPeReader.js";
|
||||||
import type { ManagedMethodBody } from "./ManagedMemberInspectorCore.js";
|
import type { ManagedMethodBody } from "./ManagedMemberInspectorCore.js";
|
||||||
import {
|
import {
|
||||||
@@ -62,19 +63,6 @@ const readMethodBodyHeader = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const readExceptionRegions = (
|
|
||||||
bytes: Buffer,
|
|
||||||
sectionOffset: number,
|
|
||||||
methodEnd: number,
|
|
||||||
ilSize: number,
|
|
||||||
instructionOffsets: readonly number[],
|
|
||||||
): ReturnType<typeof parseExceptionRegions> =>
|
|
||||||
validateExceptionRegionRanges(
|
|
||||||
parseExceptionRegions(bytes, sectionOffset, methodEnd),
|
|
||||||
ilSize,
|
|
||||||
instructionOffsets,
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Decode admitted managed CIL, retaining unavailable implementation metadata as partial. */
|
/** Decode admitted managed CIL, retaining unavailable implementation metadata as partial. */
|
||||||
export const methodBody = (
|
export const methodBody = (
|
||||||
bytes: Buffer,
|
bytes: Buffer,
|
||||||
@@ -84,6 +72,7 @@ export const methodBody = (
|
|||||||
implFlags: 0,
|
implFlags: 0,
|
||||||
flags: 0,
|
flags: 0,
|
||||||
},
|
},
|
||||||
|
budget?: ManagedDecodeBudget,
|
||||||
): ManagedMethodBody => {
|
): ManagedMethodBody => {
|
||||||
if (rva === 0) return emptyMethodBody(rva, "absent", null);
|
if (rva === 0) return emptyMethodBody(rva, "absent", null);
|
||||||
if (
|
if (
|
||||||
@@ -108,6 +97,9 @@ export const methodBody = (
|
|||||||
if (ilOffset > bytes.length - header.ilSize)
|
if (ilOffset > bytes.length - header.ilSize)
|
||||||
throw new RangeError("Method IL bytes leave artifact");
|
throw new RangeError("Method IL bytes leave artifact");
|
||||||
const il = bytes.subarray(ilOffset, ilOffset + header.ilSize);
|
const il = bytes.subarray(ilOffset, ilOffset + header.ilSize);
|
||||||
|
// One byte can produce an instruction record, start index, normalized tuple,
|
||||||
|
// and anchor. Reserve conservative bookkeeping capacity before decoding.
|
||||||
|
budget?.reserve(il.length * 256, ilOffset);
|
||||||
const decoded = decodeInstructions(il);
|
const decoded = decodeInstructions(il);
|
||||||
const opcodeCounts: Record<string, number> = {};
|
const opcodeCounts: Record<string, number> = {};
|
||||||
for (const instruction of decoded.parsed)
|
for (const instruction of decoded.parsed)
|
||||||
@@ -135,10 +127,13 @@ export const methodBody = (
|
|||||||
const sectionOffset = (methodEnd + 3) & ~3;
|
const sectionOffset = (methodEnd + 3) & ~3;
|
||||||
const exceptionRegions =
|
const exceptionRegions =
|
||||||
header.format === "fat" && (header.flags & 8) !== 0
|
header.format === "fat" && (header.flags & 8) !== 0
|
||||||
? readExceptionRegions(
|
? validateExceptionRegionRanges(
|
||||||
bytes,
|
parseExceptionRegions(
|
||||||
sectionOffset,
|
bytes,
|
||||||
offset + methodExtent,
|
sectionOffset,
|
||||||
|
offset + methodExtent,
|
||||||
|
budget,
|
||||||
|
),
|
||||||
header.ilSize,
|
header.ilSize,
|
||||||
decoded.instructionStarts,
|
decoded.instructionStarts,
|
||||||
)
|
)
|
||||||
@@ -179,6 +174,11 @@ export const methodBody = (
|
|||||||
: null),
|
: null),
|
||||||
};
|
};
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
|
if (
|
||||||
|
cause instanceof ManagedReaderFailure &&
|
||||||
|
cause.issue.code === "resource-limit"
|
||||||
|
)
|
||||||
|
throw cause;
|
||||||
if (
|
if (
|
||||||
!(cause instanceof ManagedReaderFailure) &&
|
!(cause instanceof ManagedReaderFailure) &&
|
||||||
!(cause instanceof RangeError)
|
!(cause instanceof RangeError)
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
admitManagedProjection,
|
||||||
|
managedDecodeBudget,
|
||||||
|
} from "./ManagedDecodeBudget.js";
|
||||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||||
import {
|
import {
|
||||||
managedNativeBoundaryInspectionSchema,
|
managedNativeBoundaryInspectionSchema,
|
||||||
@@ -427,40 +431,45 @@ export const buildNativeBoundaryInspection = ({
|
|||||||
native,
|
native,
|
||||||
issues,
|
issues,
|
||||||
}: BoundaryInspectionContext): ManagedNativeBoundaryInspection =>
|
}: BoundaryInspectionContext): ManagedNativeBoundaryInspection =>
|
||||||
managedNativeBoundaryInspectionSchema.parse({
|
managedNativeBoundaryInspectionSchema.parse(
|
||||||
artifact: {
|
admitManagedProjection(
|
||||||
path: target.path,
|
{
|
||||||
sha256: target.sha256,
|
artifact: {
|
||||||
byte_length: bytes.length,
|
path: target.path,
|
||||||
format: "pe",
|
sha256: target.sha256,
|
||||||
},
|
byte_length: bytes.length,
|
||||||
module: inventory.module,
|
format: "pe",
|
||||||
metadata: {
|
},
|
||||||
status: issues.length === 0 ? "complete" : "partial",
|
module: inventory.module,
|
||||||
version: layout.version,
|
metadata: {
|
||||||
table_row_counts: managedTableRowCounts(layout),
|
status: issues.length === 0 ? "complete" : "partial",
|
||||||
},
|
version: layout.version,
|
||||||
identity_scope: {
|
table_row_counts: managedTableRowCounts(layout),
|
||||||
token_identity: "build-local",
|
},
|
||||||
requires_artifact_sha256: target.sha256,
|
identity_scope: {
|
||||||
requires_mvid: inventory.module?.mvid ?? null,
|
token_identity: "build-local",
|
||||||
},
|
requires_artifact_sha256: target.sha256,
|
||||||
cli_native: native,
|
requires_mvid: inventory.module?.mvid ?? null,
|
||||||
module_refs: moduleRefs,
|
},
|
||||||
pinvoke_imports: imports,
|
cli_native: native,
|
||||||
native_implementations: implementations,
|
module_refs: moduleRefs,
|
||||||
summary: nativeBoundarySummary(native, {
|
pinvoke_imports: imports,
|
||||||
module_ref_count: moduleRefs.length,
|
native_implementations: implementations,
|
||||||
pinvoke_import_count: imports.length,
|
summary: nativeBoundarySummary(native, {
|
||||||
native_implementation_count: implementations.length,
|
module_ref_count: moduleRefs.length,
|
||||||
}),
|
pinvoke_import_count: imports.length,
|
||||||
coverage: {
|
native_implementation_count: implementations.length,
|
||||||
state: issues.length === 0 ? "complete" : "partial",
|
}),
|
||||||
issues,
|
coverage: {
|
||||||
},
|
state: issues.length === 0 ? "complete" : "partial",
|
||||||
limitations: [
|
issues,
|
||||||
"P/Invoke rows prove managed import declarations only; this inspection does not verify that a native library, export, thunk, or provider-qualified function exists.",
|
},
|
||||||
"Managed metadata tokens are build-local coordinates and are only meaningful with the reported artifact SHA-256 and MVID.",
|
limitations: [
|
||||||
"ReadyToRun, NativeAOT, C++/CLI, and IL2CPP native semantics require separately selected native-provider evidence; this tool does not translate managed tokens into native addresses.",
|
"P/Invoke rows prove managed import declarations only; this inspection does not verify that a native library, export, thunk, or provider-qualified function exists.",
|
||||||
],
|
"Managed metadata tokens are build-local coordinates and are only meaningful with the reported artifact SHA-256 and MVID.",
|
||||||
});
|
"ReadyToRun, NativeAOT, C++/CLI, and IL2CPP native semantics require separately selected native-provider evidence; this tool does not translate managed tokens into native addresses.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
managedDecodeBudget(layout),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { admitManagedProjection } from "./ManagedDecodeBudget.js";
|
||||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||||
import {
|
import {
|
||||||
managedNativeBoundaryInspectionSchema,
|
managedNativeBoundaryInspectionSchema,
|
||||||
@@ -47,39 +48,45 @@ const emptyInspection = (
|
|||||||
readonly issues?: readonly ManagedParseIssue[];
|
readonly issues?: readonly ManagedParseIssue[];
|
||||||
} = {},
|
} = {},
|
||||||
): ManagedNativeBoundaryInspection => {
|
): ManagedNativeBoundaryInspection => {
|
||||||
return managedNativeBoundaryInspectionSchema.parse({
|
return managedNativeBoundaryInspectionSchema.parse(
|
||||||
artifact: {
|
admitManagedProjection({
|
||||||
path: target.path,
|
artifact: {
|
||||||
sha256: target.sha256,
|
path: target.path,
|
||||||
byte_length: bytes.length,
|
sha256: target.sha256,
|
||||||
format: "pe",
|
byte_length: bytes.length,
|
||||||
},
|
format: "pe",
|
||||||
module: null,
|
},
|
||||||
metadata: {
|
module: null,
|
||||||
status: classification === "malformed" ? "malformed" : "absent",
|
metadata: {
|
||||||
version: null,
|
status: issues.some((issue) => issue.code === "resource-limit")
|
||||||
table_row_counts: {},
|
? "partial"
|
||||||
},
|
: classification === "malformed"
|
||||||
identity_scope: {
|
? "malformed"
|
||||||
token_identity: "build-local",
|
: "absent",
|
||||||
requires_artifact_sha256: target.sha256,
|
version: null,
|
||||||
requires_mvid: null,
|
table_row_counts: {},
|
||||||
},
|
},
|
||||||
cli_native: native,
|
identity_scope: {
|
||||||
module_refs: [],
|
token_identity: "build-local",
|
||||||
pinvoke_imports: [],
|
requires_artifact_sha256: target.sha256,
|
||||||
native_implementations: [],
|
requires_mvid: null,
|
||||||
summary: nativeBoundarySummary(native, {
|
},
|
||||||
module_ref_count: 0,
|
cli_native: native,
|
||||||
pinvoke_import_count: 0,
|
module_refs: [],
|
||||||
native_implementation_count: 0,
|
pinvoke_imports: [],
|
||||||
|
native_implementations: [],
|
||||||
|
summary: nativeBoundarySummary(native, {
|
||||||
|
module_ref_count: 0,
|
||||||
|
pinvoke_import_count: 0,
|
||||||
|
native_implementation_count: 0,
|
||||||
|
}),
|
||||||
|
coverage: { state: "unavailable", issues },
|
||||||
|
limitations: [
|
||||||
|
"No CLI metadata was admitted; native boundary declarations are unavailable.",
|
||||||
|
"Static inspection does not load or execute target code, so native export resolution is not performed.",
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
coverage: { state: "unavailable", issues },
|
);
|
||||||
limitations: [
|
|
||||||
"No CLI metadata was admitted; native boundary declarations are unavailable.",
|
|
||||||
"Static inspection does not load or execute target code, so native export resolution is not performed.",
|
|
||||||
],
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const readBoundaryInventory = (
|
const readBoundaryInventory = (
|
||||||
@@ -142,40 +149,48 @@ export const inspectManagedNativeBoundariesBytes = (
|
|||||||
issues: [cause.issue],
|
issues: [cause.issue],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const heapExtent = Math.max(layout.strings.size, layout.blob.size);
|
try {
|
||||||
issues.push(...inventory.issues);
|
const heapExtent = Math.max(layout.strings.size, layout.blob.size);
|
||||||
const moduleRefs = parseModuleRefs(bytes, layout, heapExtent, issues);
|
issues.push(...inventory.issues);
|
||||||
const members = new Map([
|
const moduleRefs = parseModuleRefs(bytes, layout, heapExtent, issues);
|
||||||
...parseFields(bytes, layout, heapExtent, issues),
|
const members = new Map([
|
||||||
...parseMethods(bytes, layout, heapExtent, issues),
|
...parseFields(bytes, layout, heapExtent, issues),
|
||||||
]);
|
...parseMethods(bytes, layout, heapExtent, issues),
|
||||||
const imports = parseImplMaps({
|
]);
|
||||||
bytes,
|
const imports = parseImplMaps({
|
||||||
layout,
|
bytes,
|
||||||
heapExtent,
|
layout,
|
||||||
modules: moduleRefs,
|
heapExtent,
|
||||||
members,
|
modules: moduleRefs,
|
||||||
issues,
|
members,
|
||||||
});
|
issues,
|
||||||
const pinvokeTokens = new Set(
|
});
|
||||||
imports
|
const pinvokeTokens = new Set(
|
||||||
.map(({ member_token }) => member_token)
|
imports
|
||||||
.filter((token): token is string => token !== null),
|
.map(({ member_token }) => member_token)
|
||||||
);
|
.filter((token): token is string => token !== null),
|
||||||
const implementations = nativeImplementations(
|
);
|
||||||
members.values(),
|
const implementations = nativeImplementations(
|
||||||
pinvokeTokens,
|
members.values(),
|
||||||
);
|
pinvokeTokens,
|
||||||
return buildNativeBoundaryInspection({
|
);
|
||||||
target,
|
return buildNativeBoundaryInspection({
|
||||||
bytes,
|
target,
|
||||||
pe,
|
bytes,
|
||||||
layout,
|
pe,
|
||||||
inventory,
|
layout,
|
||||||
moduleRefs,
|
inventory,
|
||||||
imports,
|
moduleRefs,
|
||||||
implementations,
|
imports,
|
||||||
native: cliNative(pe),
|
implementations,
|
||||||
issues,
|
native: cliNative(pe),
|
||||||
});
|
issues,
|
||||||
|
});
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
if (!(cause instanceof ManagedReaderFailure)) throw cause;
|
||||||
|
return emptyInspection(target, bytes, "malformed", {
|
||||||
|
native: cliNative(pe),
|
||||||
|
issues: [...issues, cause.issue],
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -121,31 +121,87 @@ const architectureFor = (machine: number): ManagedPeLayout["architecture"] => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapRva = (mapping: RvaMapping): number => {
|
/** Resolve one section identity for both exact reads and available-byte queries. */
|
||||||
|
const resolveRva = (
|
||||||
|
mapping: RvaMapping,
|
||||||
|
): { readonly offset: number; readonly available: number } => {
|
||||||
const { bytes, sections, rva, size, scope } = mapping;
|
const { bytes, sections, rva, size, scope } = mapping;
|
||||||
if (rva === 0 && size === 0) return 0;
|
if (
|
||||||
for (const section of sections) {
|
!Number.isSafeInteger(rva) ||
|
||||||
const mappedSize = Math.max(section.virtualSize, section.rawSize);
|
!Number.isSafeInteger(size) ||
|
||||||
if (rva < section.virtualAddress) continue;
|
rva < 0 ||
|
||||||
|
size < 0 ||
|
||||||
|
rva > 0xffff_ffff ||
|
||||||
|
(size > 0 && size > 0x1_0000_0000 - rva)
|
||||||
|
)
|
||||||
|
throw managedFailure(
|
||||||
|
"invalid-directory",
|
||||||
|
scope,
|
||||||
|
`${scope} has an invalid RVA range`,
|
||||||
|
);
|
||||||
|
const selected = sections.find((section) => {
|
||||||
const within = rva - section.virtualAddress;
|
const within = rva - section.virtualAddress;
|
||||||
if (within > mappedSize || size > mappedSize - within) continue;
|
const mappedSize = Math.max(section.virtualSize, section.rawSize);
|
||||||
if (within > section.rawSize || size > section.rawSize - within)
|
return (
|
||||||
|
mappedSize > 0 &&
|
||||||
|
within >= 0 &&
|
||||||
|
within <= mappedSize &&
|
||||||
|
size <= mappedSize - within
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (selected === undefined)
|
||||||
|
throw managedFailure(
|
||||||
|
"invalid-directory",
|
||||||
|
scope,
|
||||||
|
`${scope} RVA is not covered by one PE section`,
|
||||||
|
);
|
||||||
|
for (const section of sections) {
|
||||||
|
if (
|
||||||
|
section === selected ||
|
||||||
|
Math.max(section.virtualSize, section.rawSize) === 0
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
const end =
|
||||||
|
section.virtualAddress + Math.max(section.virtualSize, section.rawSize);
|
||||||
|
const intersects =
|
||||||
|
size === 0
|
||||||
|
? rva >= section.virtualAddress && rva < end
|
||||||
|
: rva < end && rva + size > section.virtualAddress;
|
||||||
|
if (intersects)
|
||||||
throw managedFailure(
|
throw managedFailure(
|
||||||
"invalid-directory",
|
"invalid-directory",
|
||||||
scope,
|
scope,
|
||||||
`${scope} occupies virtual bytes without file-backed data`,
|
`${scope} RVA range has ambiguous overlapping PE section mappings`,
|
||||||
);
|
);
|
||||||
const offset = add(section.rawOffset, within, scope);
|
|
||||||
requireRange(bytes, offset, size, scope);
|
|
||||||
return offset;
|
|
||||||
}
|
}
|
||||||
throw managedFailure(
|
const within = rva - selected.virtualAddress;
|
||||||
"invalid-directory",
|
if (within > selected.rawSize || size > selected.rawSize - within)
|
||||||
scope,
|
throw managedFailure(
|
||||||
`${scope} RVA is not covered by a PE section`,
|
"invalid-directory",
|
||||||
|
scope,
|
||||||
|
`${scope} occupies virtual bytes without file-backed data`,
|
||||||
|
);
|
||||||
|
const offset = add(selected.rawOffset, within, scope);
|
||||||
|
requireRange(bytes, offset, size, scope);
|
||||||
|
let available = Math.min(
|
||||||
|
selected.rawSize - within,
|
||||||
|
bytes.length - offset,
|
||||||
|
0x1_0000_0000 - rva,
|
||||||
);
|
);
|
||||||
|
// A future overlap ends the uniquely mapped extent even if the current byte is unique.
|
||||||
|
for (const section of sections)
|
||||||
|
if (
|
||||||
|
section !== selected &&
|
||||||
|
section.virtualAddress > rva &&
|
||||||
|
Math.max(section.virtualSize, section.rawSize) > 0
|
||||||
|
)
|
||||||
|
available = Math.min(available, section.virtualAddress - rva);
|
||||||
|
return { offset, available };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mapRva = (mapping: RvaMapping): number =>
|
||||||
|
mapping.rva === 0 && mapping.size === 0 ? 0 : resolveRva(mapping).offset;
|
||||||
|
|
||||||
const readDirectory = (bytes: Buffer, offset: number): PeDataDirectory => {
|
const readDirectory = (bytes: Buffer, offset: number): PeDataDirectory => {
|
||||||
requireRange(bytes, offset, 8, "pe.data-directory");
|
requireRange(bytes, offset, 8, "pe.data-directory");
|
||||||
return {
|
return {
|
||||||
@@ -356,22 +412,7 @@ export const readManagedPeLayout = (bytes: Buffer): ManagedPeLayout => {
|
|||||||
cliIssue,
|
cliIssue,
|
||||||
rvaToOffset: (rva, size, scope) =>
|
rvaToOffset: (rva, size, scope) =>
|
||||||
mapRva({ bytes, sections, rva, size, scope }),
|
mapRva({ bytes, sections, rva, size, scope }),
|
||||||
rvaAvailableBytes: (rva, scope) => {
|
rvaAvailableBytes: (rva, scope) =>
|
||||||
mapRva({ bytes, sections, rva, size: 1, scope });
|
resolveRva({ bytes, sections, rva, size: 1, scope }).available,
|
||||||
for (const section of sections) {
|
|
||||||
if (rva < section.virtualAddress) continue;
|
|
||||||
const within = rva - section.virtualAddress;
|
|
||||||
if (within < section.rawSize)
|
|
||||||
return Math.min(
|
|
||||||
section.rawSize - within,
|
|
||||||
bytes.length - section.rawOffset - within,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw managedFailure(
|
|
||||||
"invalid-directory",
|
|
||||||
scope,
|
|
||||||
`${scope} RVA is not covered by file-backed PE section data`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ export const readManagedValue = <Value>(
|
|||||||
try {
|
try {
|
||||||
return operation();
|
return operation();
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
if (!(cause instanceof ManagedReaderFailure)) throw cause;
|
if (
|
||||||
|
!(cause instanceof ManagedReaderFailure) ||
|
||||||
|
cause.issue.code === "resource-limit"
|
||||||
|
)
|
||||||
|
throw cause;
|
||||||
issues.push(cause.issue);
|
issues.push(cause.issue);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { createReadStream } from "node:fs";
|
|
||||||
import { readFile } from "node:fs/promises";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createAnalysisExecution,
|
createAnalysisExecution,
|
||||||
@@ -18,11 +16,12 @@ import {
|
|||||||
managedTargetInputSchema,
|
managedTargetInputSchema,
|
||||||
type ManagedToolName,
|
type ManagedToolName,
|
||||||
} from "../contracts/managed/managedToolContracts.js";
|
} from "../contracts/managed/managedToolContracts.js";
|
||||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||||
import {
|
import {
|
||||||
AnalysisCancelledError,
|
AnalysisCancelledError,
|
||||||
AnalysisCapabilityUnavailableError,
|
AnalysisCapabilityUnavailableError,
|
||||||
|
AnalysisResourceConstraintError,
|
||||||
} from "../domain/analysisErrorCore.js";
|
} from "../domain/analysisErrorCore.js";
|
||||||
import { EvidenceIntegrityError } from "../domain/evidenceErrors.js";
|
import { EvidenceIntegrityError } from "../domain/evidenceErrors.js";
|
||||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||||
@@ -38,6 +37,23 @@ import { inspectManagedArtifactBytes } from "./ManagedArtifactInspector.js";
|
|||||||
import { inspectManagedMembersBytes } from "./ManagedMemberInspector.js";
|
import { inspectManagedMembersBytes } from "./ManagedMemberInspector.js";
|
||||||
import { inspectManagedNativeBoundariesBytes } from "./ManagedNativeBoundaryInspector.js";
|
import { inspectManagedNativeBoundariesBytes } from "./ManagedNativeBoundaryInspector.js";
|
||||||
|
|
||||||
|
import { openRegularFile } from "../filesystem/RegularFile.js";
|
||||||
|
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
|
||||||
|
|
||||||
|
// This provider eagerly retains the full PE snapshot. Keep its input resource
|
||||||
|
// policy separate from decoded metadata/output capacity; it is not a PE format limit.
|
||||||
|
const MAX_MANAGED_SNAPSHOT_BYTES = 128 * 1024 * 1024;
|
||||||
|
|
||||||
|
const snapshotCapacityError = (
|
||||||
|
operation: string,
|
||||||
|
): AnalysisResourceConstraintError =>
|
||||||
|
new AnalysisResourceConstraintError(
|
||||||
|
operation,
|
||||||
|
"memory",
|
||||||
|
"Managed static inspection requires an eagerly retained PE snapshot within its input byte budget.",
|
||||||
|
{ max_snapshot_bytes: MAX_MANAGED_SNAPSHOT_BYTES },
|
||||||
|
);
|
||||||
|
|
||||||
/** Execution-free managed PE/CLI auxiliary provider. */
|
/** Execution-free managed PE/CLI auxiliary provider. */
|
||||||
export class ManagedStaticProvider implements AnalysisProvider {
|
export class ManagedStaticProvider implements AnalysisProvider {
|
||||||
readonly #capabilities: readonly CapabilityDescriptor[] =
|
readonly #capabilities: readonly CapabilityDescriptor[] =
|
||||||
@@ -90,8 +106,16 @@ class ManagedStaticClient implements AnalysisClient {
|
|||||||
const snapshot = this.#snapshotBytes;
|
const snapshot = this.#snapshotBytes;
|
||||||
const observed =
|
const observed =
|
||||||
snapshot === undefined
|
snapshot === undefined
|
||||||
? await readManagedSnapshot(this.target.path, options?.signal)
|
? await readManagedSnapshot(
|
||||||
: await hashManagedSource(this.target.path, options?.signal);
|
this.target.path,
|
||||||
|
operation,
|
||||||
|
options?.signal,
|
||||||
|
)
|
||||||
|
: await hashManagedSource(
|
||||||
|
this.target.path,
|
||||||
|
operation,
|
||||||
|
options?.signal,
|
||||||
|
);
|
||||||
if (observed.sha256 !== this.target.sha256)
|
if (observed.sha256 !== this.target.sha256)
|
||||||
return err(
|
return err(
|
||||||
new EvidenceIntegrityError(
|
new EvidenceIntegrityError(
|
||||||
@@ -119,6 +143,7 @@ class ManagedStaticClient implements AnalysisClient {
|
|||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
if (options?.signal?.aborted === true)
|
if (options?.signal?.aborted === true)
|
||||||
return err(new AnalysisCancelledError(operation));
|
return err(new AnalysisCancelledError(operation));
|
||||||
|
if (cause instanceof AnalysisError) return err(cause);
|
||||||
return err(
|
return err(
|
||||||
new ProviderAdapterError(MANAGED_STATIC_PROVIDER.id, operation, {
|
new ProviderAdapterError(MANAGED_STATIC_PROVIDER.id, operation, {
|
||||||
cause,
|
cause,
|
||||||
@@ -141,38 +166,59 @@ interface ManagedSourceObservation {
|
|||||||
|
|
||||||
const readManagedSnapshot = async (
|
const readManagedSnapshot = async (
|
||||||
path: string,
|
path: string,
|
||||||
|
operation: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<ManagedSourceObservation> => {
|
): Promise<ManagedSourceObservation> => {
|
||||||
const bytes = await readFile(
|
const handle = await openRegularFile(path, { symlinks: "follow", signal });
|
||||||
path,
|
try {
|
||||||
signal === undefined ? undefined : { signal },
|
const metadata = await handle.stat();
|
||||||
);
|
if (metadata.size > MAX_MANAGED_SNAPSHOT_BYTES)
|
||||||
return {
|
throw snapshotCapacityError(operation);
|
||||||
byteLength: bytes.length,
|
const bytes = await readBoundedFileBytes(
|
||||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
handle,
|
||||||
bytes,
|
MAX_MANAGED_SNAPSHOT_BYTES,
|
||||||
};
|
signal,
|
||||||
|
);
|
||||||
|
if (bytes === undefined) throw snapshotCapacityError(operation);
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
return {
|
||||||
|
byteLength: bytes.length,
|
||||||
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||||
|
bytes,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const hashManagedSource = async (
|
const hashManagedSource = async (
|
||||||
path: string,
|
path: string,
|
||||||
|
operation: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<ManagedSourceObservation> => {
|
): Promise<ManagedSourceObservation> => {
|
||||||
const digest = createHash("sha256");
|
const handle = await openRegularFile(path, { symlinks: "follow", signal });
|
||||||
let byteLength = 0;
|
try {
|
||||||
const stream = createReadStream(
|
if ((await handle.stat()).size > MAX_MANAGED_SNAPSHOT_BYTES)
|
||||||
path,
|
throw snapshotCapacityError(operation);
|
||||||
signal === undefined ? undefined : { signal },
|
const digest = createHash("sha256");
|
||||||
);
|
let byteLength = 0;
|
||||||
for await (const chunk of stream) {
|
const stream = handle.createReadStream({
|
||||||
if (!Buffer.isBuffer(chunk))
|
autoClose: false,
|
||||||
throw new TypeError("Managed source stream returned non-buffer data");
|
...(signal === undefined ? {} : { signal }),
|
||||||
if (chunk.length > Number.MAX_SAFE_INTEGER - byteLength)
|
});
|
||||||
throw new RangeError("Managed source byte length overflowed");
|
for await (const chunk of stream) {
|
||||||
byteLength += chunk.length;
|
signal?.throwIfAborted();
|
||||||
digest.update(chunk);
|
if (!Buffer.isBuffer(chunk))
|
||||||
|
throw new TypeError("Managed source stream returned non-buffer data");
|
||||||
|
if (chunk.length > MAX_MANAGED_SNAPSHOT_BYTES - byteLength)
|
||||||
|
throw snapshotCapacityError(operation);
|
||||||
|
byteLength += chunk.length;
|
||||||
|
digest.update(chunk);
|
||||||
|
}
|
||||||
|
return { byteLength, sha256: digest.digest("hex") };
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
}
|
}
|
||||||
return { byteLength, sha256: digest.digest("hex") };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isManagedOperation = (
|
const isManagedOperation = (
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
|||||||
import { err, ok, type Result } from "../domain/result.js";
|
import { err, ok, type Result } from "../domain/result.js";
|
||||||
import { runOwnedCommand } from "../process/OwnedCommand.js";
|
import { runOwnedCommand } from "../process/OwnedCommand.js";
|
||||||
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
|
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
|
||||||
|
import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js";
|
||||||
import {
|
import {
|
||||||
evmInterfaceFailure,
|
evmInterfaceFailure,
|
||||||
capturedEvmOutput,
|
capturedEvmOutput,
|
||||||
@@ -75,6 +76,16 @@ type Launcher = NonNullable<Parameters<typeof runOwnedCommand>[2]>["launcher"];
|
|||||||
|
|
||||||
/** Run unchanged EVMole/WASM in an owned worker; no target, chain or network execution. */
|
/** Run unchanged EVMole/WASM in an owned worker; no target, chain or network execution. */
|
||||||
export class EvmoleInterfaceProvider implements EvmInterfacePort {
|
export class EvmoleInterfaceProvider implements EvmInterfacePort {
|
||||||
|
#tail: Promise<void> = Promise.resolve();
|
||||||
|
#closing = false;
|
||||||
|
#closePromise: Promise<void> | undefined;
|
||||||
|
#pendingCleanup:
|
||||||
|
| {
|
||||||
|
root: Pick<PrivateRuntimeRoot, "path" | "close"> | undefined;
|
||||||
|
process: ProviderProcessSupervisor | undefined;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
readonly environment: Readonly<NodeJS.ProcessEnv> = process.env,
|
readonly environment: Readonly<NodeJS.ProcessEnv> = process.env,
|
||||||
readonly launcher?: Launcher,
|
readonly launcher?: Launcher,
|
||||||
@@ -84,11 +95,29 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Bind recovered interface candidates to both original carrier and decoded byte identity. */
|
/** Bind recovered interface candidates to both original carrier and decoded byte identity. */
|
||||||
async inspect(
|
inspect(
|
||||||
input: InspectEvmInterfaceInput,
|
input: InspectEvmInterfaceInput,
|
||||||
options?: ExecutionOptions,
|
options?: ExecutionOptions,
|
||||||
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
||||||
|
if (this.#closing) return Promise.resolve(err(this.#closedError()));
|
||||||
|
const operation = this.#tail.then(() =>
|
||||||
|
this.#closing ? err(this.#closedError()) : this.#inspect(input, options),
|
||||||
|
);
|
||||||
|
this.#tail = operation.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
return operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #inspect(
|
||||||
|
input: InspectEvmInterfaceInput,
|
||||||
|
options?: ExecutionOptions,
|
||||||
|
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
||||||
|
const pending = await this.#retire();
|
||||||
|
if (pending !== undefined) return err(pending);
|
||||||
let root: Pick<PrivateRuntimeRoot, "path" | "close"> | undefined;
|
let root: Pick<PrivateRuntimeRoot, "path" | "close"> | undefined;
|
||||||
|
let processOwner: ProviderProcessSupervisor | undefined;
|
||||||
let retainedOutput: AnalysisCapturedOutput | undefined;
|
let retainedOutput: AnalysisCapturedOutput | undefined;
|
||||||
let phase: "configuration" | "artifact-read" | "worker" = "configuration";
|
let phase: "configuration" | "artifact-read" | "worker" = "configuration";
|
||||||
let workerLimits: EvmWorkerLimits | undefined;
|
let workerLimits: EvmWorkerLimits | undefined;
|
||||||
@@ -321,6 +350,8 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
|
if (cause instanceof OwnedCommandFailure)
|
||||||
|
processOwner = cause.cleanupOwner;
|
||||||
let fileFailure: EvmFileSizeFailureEvidence | undefined;
|
let fileFailure: EvmFileSizeFailureEvidence | undefined;
|
||||||
if (
|
if (
|
||||||
cause instanceof OwnedCommandFailure &&
|
cause instanceof OwnedCommandFailure &&
|
||||||
@@ -368,14 +399,42 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (processOwner !== undefined) {
|
||||||
|
this.#pendingCleanup = { root, process: processOwner };
|
||||||
|
root = undefined;
|
||||||
|
return err(
|
||||||
|
new ProviderCleanupError(
|
||||||
|
EVMOLE_PROVIDER_IDENTITY.id,
|
||||||
|
[
|
||||||
|
processOwner.launch.ownership?.runId ?? "owned-worker",
|
||||||
|
...(this.#pendingCleanup.root === undefined
|
||||||
|
? []
|
||||||
|
: [this.#pendingCleanup.root.path]),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
reason: "Owned EVM worker cleanup could not be confirmed",
|
||||||
|
previous_error: result.ok
|
||||||
|
? null
|
||||||
|
: projectAnalysisError(result.error),
|
||||||
|
...(retainedOutput === undefined
|
||||||
|
? {}
|
||||||
|
: { captured_output: { ...retainedOutput } }),
|
||||||
|
},
|
||||||
|
{ operation: OPERATION },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (root !== undefined) {
|
if (root !== undefined) {
|
||||||
try {
|
try {
|
||||||
await root.close();
|
await root.close();
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
|
const retainedRoot = root;
|
||||||
|
this.#pendingCleanup = { root: retainedRoot, process: undefined };
|
||||||
|
root = undefined;
|
||||||
return err(
|
return err(
|
||||||
new ProviderCleanupError(
|
new ProviderCleanupError(
|
||||||
EVMOLE_PROVIDER_IDENTITY.id,
|
EVMOLE_PROVIDER_IDENTITY.id,
|
||||||
[root.path],
|
[retainedRoot.path],
|
||||||
{
|
{
|
||||||
reason: cause instanceof Error ? cause.message : String(cause),
|
reason: cause instanceof Error ? cause.message : String(cause),
|
||||||
previous_error: result.ok
|
previous_error: result.ok
|
||||||
@@ -401,4 +460,59 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort {
|
|||||||
)
|
)
|
||||||
: result;
|
: result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
this.#closing = true;
|
||||||
|
this.#closePromise ??= this.#tail
|
||||||
|
.then(async () => {
|
||||||
|
const failure = await this.#retire();
|
||||||
|
if (failure !== undefined) throw failure;
|
||||||
|
})
|
||||||
|
.catch((cause: unknown) => {
|
||||||
|
this.#closePromise = undefined;
|
||||||
|
throw cause;
|
||||||
|
});
|
||||||
|
return this.#closePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
#closedError(): ProviderCleanupError {
|
||||||
|
return new ProviderCleanupError(
|
||||||
|
EVMOLE_PROVIDER_IDENTITY.id,
|
||||||
|
[],
|
||||||
|
{ reason: "Provider is closing" },
|
||||||
|
{ operation: OPERATION },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async #retire(): Promise<ProviderCleanupError | undefined> {
|
||||||
|
const pending = this.#pendingCleanup;
|
||||||
|
if (pending === undefined) return undefined;
|
||||||
|
if (pending.process !== undefined) {
|
||||||
|
const stopped = await pending.process.stop();
|
||||||
|
if (stopped.status === "incomplete")
|
||||||
|
return new ProviderCleanupError(
|
||||||
|
EVMOLE_PROVIDER_IDENTITY.id,
|
||||||
|
[pending.process.launch.ownership?.runId ?? "owned-worker"],
|
||||||
|
{ reason: stopped.reason },
|
||||||
|
{ operation: OPERATION },
|
||||||
|
);
|
||||||
|
pending.process = undefined;
|
||||||
|
}
|
||||||
|
if (pending.root !== undefined) {
|
||||||
|
const root = pending.root;
|
||||||
|
try {
|
||||||
|
await root.close();
|
||||||
|
pending.root = undefined;
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
return new ProviderCleanupError(
|
||||||
|
EVMOLE_PROVIDER_IDENTITY.id,
|
||||||
|
[root.path],
|
||||||
|
{ reason: cause instanceof Error ? cause.message : String(cause) },
|
||||||
|
{ operation: OPERATION, cause },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.#pendingCleanup = undefined;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ export class NonRegularFileReadError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One admitted file descriptor and the metadata that identified its contents. */
|
||||||
|
export interface StableRegularFileDescriptor {
|
||||||
|
readonly handle: FileHandle;
|
||||||
|
readonly initial: Stats;
|
||||||
|
}
|
||||||
|
|
||||||
/** Open and verify the selected file descriptor without blocking on pipes. */
|
/** Open and verify the selected file descriptor without blocking on pipes. */
|
||||||
export const openRegularFile = async (
|
export const openRegularFile = async (
|
||||||
path: string,
|
path: string,
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export const runFirmwareCommand = async (context: {
|
|||||||
launcher?: FirmwareLauncher;
|
launcher?: FirmwareLauncher;
|
||||||
outputBudget?: { root: string; bytes: number; entries: number };
|
outputBudget?: { root: string; bytes: number; entries: number };
|
||||||
acceptReportedExtractionFailure?: boolean;
|
acceptReportedExtractionFailure?: boolean;
|
||||||
|
retainCleanup(supervisor: ProviderProcessSupervisor): void;
|
||||||
}) => {
|
}) => {
|
||||||
const { engine, operation, signal } = context;
|
const { engine, operation, signal } = context;
|
||||||
signal.throwIfAborted();
|
signal.throwIfAborted();
|
||||||
@@ -141,16 +142,43 @@ export const runFirmwareCommand = async (context: {
|
|||||||
NO_COLOR: "1",
|
NO_COLOR: "1",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const supervisor = new ProviderProcessSupervisor({
|
let diagnosticTruncated = false;
|
||||||
...spawned,
|
const supervisor = new ProviderProcessSupervisor(
|
||||||
ownsProcessLifetime: true,
|
{
|
||||||
cleanup:
|
...spawned,
|
||||||
spawned.cleanup ?? (() => cleanupOwnedProcessGroup(spawned.ownership)),
|
ownsProcessLifetime: true,
|
||||||
});
|
cleanup:
|
||||||
|
spawned.cleanup ?? (() => cleanupOwnedProcessGroup(spawned.ownership)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maxDiagnosticBytes: FIRMWARE_LIMITS.diagnosticBytes,
|
||||||
|
onDiagnostic: (event) => {
|
||||||
|
if (event.type === "output" && event.truncated === true)
|
||||||
|
diagnosticTruncated = true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const checkDiagnostics = () => {
|
||||||
|
if (diagnosticTruncated) {
|
||||||
|
const snapshot = supervisor.snapshot();
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
operation,
|
||||||
|
`Tool diagnostics exceeded the ${FIRMWARE_LIMITS.diagnosticBytes}-byte capture budget`,
|
||||||
|
{
|
||||||
|
capturedOutput: {
|
||||||
|
stdout: snapshot.stdout.text,
|
||||||
|
stderr: snapshot.stderr.text,
|
||||||
|
truncated: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
let failure: unknown;
|
let failure: unknown;
|
||||||
try {
|
try {
|
||||||
const deadline = Date.now() + FIRMWARE_LIMITS.timeoutMs;
|
const deadline = Date.now() + FIRMWARE_LIMITS.timeoutMs;
|
||||||
while (!(await supervisor.waitForExit(100))) {
|
while (!(await supervisor.waitForOutputClose(100))) {
|
||||||
|
checkDiagnostics();
|
||||||
if (signal.aborted) throw new AnalysisCancelledError(operation);
|
if (signal.aborted) throw new AnalysisCancelledError(operation);
|
||||||
if (Date.now() >= deadline)
|
if (Date.now() >= deadline)
|
||||||
throw new AnalysisTimeoutError(operation, FIRMWARE_LIMITS.timeoutMs);
|
throw new AnalysisTimeoutError(operation, FIRMWARE_LIMITS.timeoutMs);
|
||||||
@@ -163,14 +191,15 @@ export const runFirmwareCommand = async (context: {
|
|||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
checkDiagnostics();
|
||||||
if (signal.aborted) throw new AnalysisCancelledError(operation);
|
if (signal.aborted) throw new AnalysisCancelledError(operation);
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
failure = cause;
|
failure = cause;
|
||||||
}
|
}
|
||||||
const stopped = await supervisor.stop();
|
const stopped = await supervisor.stop();
|
||||||
const snapshot = supervisor.snapshot();
|
const snapshot = supervisor.snapshot();
|
||||||
supervisor.dispose();
|
if (stopped.status === "incomplete") {
|
||||||
if (stopped.status === "incomplete")
|
context.retainCleanup(supervisor);
|
||||||
throw new ProviderCleanupError(engine, [runId, context.cwd], {
|
throw new ProviderCleanupError(engine, [runId, context.cwd], {
|
||||||
reason: stopped.reason,
|
reason: stopped.reason,
|
||||||
leader_pid: spawned.ownership.leaderPid,
|
leader_pid: spawned.ownership.leaderPid,
|
||||||
@@ -183,6 +212,7 @@ export const runFirmwareCommand = async (context: {
|
|||||||
stderr_bytes: snapshot.stderr.bytes,
|
stderr_bytes: snapshot.stderr.bytes,
|
||||||
previous_error: failure instanceof Error ? failure.message : null,
|
previous_error: failure instanceof Error ? failure.message : null,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
if (failure !== undefined) throw failure;
|
if (failure !== undefined) throw failure;
|
||||||
if (
|
if (
|
||||||
snapshot.exitCode !== 0 &&
|
snapshot.exitCode !== 0 &&
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { constants, createReadStream, createWriteStream } from "node:fs";
|
|||||||
import {
|
import {
|
||||||
chmod,
|
chmod,
|
||||||
lstat,
|
lstat,
|
||||||
|
opendir,
|
||||||
open,
|
open,
|
||||||
readdir,
|
|
||||||
readlink,
|
readlink,
|
||||||
realpath,
|
realpath,
|
||||||
} from "node:fs/promises";
|
} from "node:fs/promises";
|
||||||
@@ -29,6 +29,13 @@ export interface FirmwareEntry {
|
|||||||
readonly linkTarget: string | null;
|
readonly linkTarget: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const throwIfFirmwareInventoryCancelled = (
|
||||||
|
signal: AbortSignal | undefined,
|
||||||
|
operation: string,
|
||||||
|
): void => {
|
||||||
|
if (signal?.aborted === true) throw new AnalysisCancelledError(operation);
|
||||||
|
};
|
||||||
|
|
||||||
/** Hash a regular tool or extracted file without retaining its bytes in memory. */
|
/** Hash a regular tool or extracted file without retaining its bytes in memory. */
|
||||||
export const hashFirmwareFile = async (
|
export const hashFirmwareFile = async (
|
||||||
path: string,
|
path: string,
|
||||||
@@ -184,7 +191,7 @@ export const inventoryFirmwareOutput = async (
|
|||||||
let bytes = 0;
|
let bytes = 0;
|
||||||
let count = 0;
|
let count = 0;
|
||||||
while (pending.length > 0) {
|
while (pending.length > 0) {
|
||||||
if (signal?.aborted === true) throw new AnalysisCancelledError(operation);
|
throwIfFirmwareInventoryCancelled(signal, operation);
|
||||||
const directory = pending.pop();
|
const directory = pending.pop();
|
||||||
if (directory === undefined) break;
|
if (directory === undefined) break;
|
||||||
const directoryStat = await lstat(directory).catch((cause: unknown) => {
|
const directoryStat = await lstat(directory).catch((cause: unknown) => {
|
||||||
@@ -198,18 +205,28 @@ export const inventoryFirmwareOutput = async (
|
|||||||
directoryStat.isSymbolicLink()
|
directoryStat.isSymbolicLink()
|
||||||
)
|
)
|
||||||
continue;
|
continue;
|
||||||
const names = await readdir(directory).catch((cause: unknown) => {
|
const handle = await opendir(directory).catch((cause: unknown) => {
|
||||||
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
||||||
return [];
|
return undefined;
|
||||||
throw cause;
|
throw cause;
|
||||||
});
|
});
|
||||||
|
if (handle === undefined) continue;
|
||||||
|
const names: string[] = [];
|
||||||
|
try {
|
||||||
|
for await (const entry of handle) {
|
||||||
|
throwIfFirmwareInventoryCancelled(signal, operation);
|
||||||
|
count++;
|
||||||
|
if (count > maxEntries)
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
operation,
|
||||||
|
`Staging entry budget exceeded (${maxEntries})`,
|
||||||
|
);
|
||||||
|
names.push(entry.name);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await handle.close().catch(() => undefined);
|
||||||
|
}
|
||||||
for (const name of names.sort()) {
|
for (const name of names.sort()) {
|
||||||
count++;
|
|
||||||
if (count > maxEntries)
|
|
||||||
throw new AnalysisOutputError(
|
|
||||||
operation,
|
|
||||||
`Staging entry budget exceeded (${maxEntries})`,
|
|
||||||
);
|
|
||||||
const path = join(directory, name);
|
const path = join(directory, name);
|
||||||
const stat = await lstat(path).catch((cause: unknown) => {
|
const stat = await lstat(path).catch((cause: unknown) => {
|
||||||
if (
|
if (
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user