chore(ci): refresh Rizin fixture audit against current main
Integrate main 9d6651e5b before publishing the shared fixture lifetime fix. The incoming Apple, Kuna and WABT changes do not alter the Rizin or process supervision boundaries.
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
This commit is contained in:
commit
5d85cfe4eb
17 files changed
+317
-78
No files matched your search
@@ -0,0 +1,4 @@
|
||||
// A single inspection can include native admission plus several WABT commands.
|
||||
// The aggregate verifier covers seven complete fixtures and their failure paths.
|
||||
export const WABT_OPERATION_TIMEOUT_MS = 4 * 60_000;
|
||||
export const WABT_WORKFLOW_TIMEOUT_MS = 15 * 60_000;
|
||||
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { PrivateRuntimeRoot } from "../dist/process/PrivateRuntimeRoot.js";
|
||||
import { exec } from "./lib/verify-package-core.mjs";
|
||||
import { WABT_WORKFLOW_TIMEOUT_MS } from "./lib/wabt-verifier-timeouts.mjs";
|
||||
import { verifyPackagePack } from "./verify-package-pack.mjs";
|
||||
|
||||
const root = await PrivateRuntimeRoot.create({
|
||||
@@ -49,7 +50,11 @@ try {
|
||||
const verified = await exec(
|
||||
process.execPath,
|
||||
["scripts/verify-wasm-artifact.mjs", entrypoint, "--require-tools"],
|
||||
{ env: environment, timeout: 180_000, maxBuffer: 4 * 1024 * 1024 },
|
||||
{
|
||||
env: environment,
|
||||
timeout: WABT_WORKFLOW_TIMEOUT_MS,
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
process.stdout.write(verified.stdout);
|
||||
} finally {
|
||||
|
||||
@@ -83,8 +83,11 @@ const FIXTURE_SOURCE = String.raw`
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
static const char rea_kuna_utf8[] __attribute__((used)) = "_φ( °-°)/ REA unicode marker";
|
||||
/* The leading NUL unit ends any UTF-16 run that bytes before this array would
|
||||
start: the UTF-8 marker's last byte and NUL decode as the unit 'r', so on
|
||||
layouts where it precedes this array Kuna reports "rREA wide marker". */
|
||||
static const unsigned short rea_kuna_wide[] __attribute__((used)) = {
|
||||
'R','E','A',' ','w','i','d','e',' ','m','a','r','k','e','r',0
|
||||
0,'R','E','A',' ','w','i','d','e',' ','m','a','r','k','e','r',0
|
||||
};
|
||||
#include <string.h>
|
||||
__attribute__((noinline)) unsigned rea_checksum(const unsigned char *p, size_t n) {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
requireMcpToolError,
|
||||
} from "./lib/mcp-verifier-results.mjs";
|
||||
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
||||
import { WABT_OPERATION_TIMEOUT_MS } from "./lib/wabt-verifier-timeouts.mjs";
|
||||
|
||||
const directory = process.env.REA_WABT_BIN_DIRECTORY;
|
||||
if (!isAbsolute(directory ?? "")) {
|
||||
@@ -76,6 +77,8 @@ try {
|
||||
throw cause;
|
||||
}
|
||||
const client = new Client({ name: "wabt-artifact-verifier", version: "1" });
|
||||
const callTool = (request) =>
|
||||
client.callTool(request, { timeout: WABT_OPERATION_TIMEOUT_MS });
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
@@ -128,7 +131,7 @@ try {
|
||||
TMP: childTempRoot,
|
||||
TEMP: childTempRoot,
|
||||
},
|
||||
timeout: 120_000,
|
||||
timeout: WABT_OPERATION_TIMEOUT_MS,
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
},
|
||||
)
|
||||
@@ -238,7 +241,7 @@ try {
|
||||
const cliValue = await inspectCli(input);
|
||||
assert.equal(cliValue.category, undefined, JSON.stringify(cliValue));
|
||||
const cli = parseEvidence(cliValue);
|
||||
const reply = await client.callTool({
|
||||
const reply = await callTool({
|
||||
name: "inspect_wasm_artifact",
|
||||
arguments: input,
|
||||
});
|
||||
@@ -332,7 +335,7 @@ try {
|
||||
},
|
||||
);
|
||||
}
|
||||
const bundleReply = await client.callTool({
|
||||
const bundleReply = await callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: {},
|
||||
});
|
||||
@@ -362,7 +365,7 @@ try {
|
||||
JSON.stringify(cliError),
|
||||
);
|
||||
const error = requireMcpToolError(
|
||||
await client.callTool({
|
||||
await callTool({
|
||||
name: "inspect_wasm_artifact",
|
||||
arguments: invalidInput,
|
||||
}),
|
||||
@@ -370,7 +373,7 @@ try {
|
||||
assert.equal(error.category, "invalid_input");
|
||||
const partial = error.details.partial_observation;
|
||||
assert.equal(partial.kind, "retained-evidence");
|
||||
const failedBundleReply = await client.callTool({
|
||||
const failedBundleReply = await callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: {},
|
||||
});
|
||||
|
||||
@@ -84,6 +84,8 @@ export const analyzeInterfaceBuilderBundle = async (input: {
|
||||
recordOmittedArchive(entry.path, "max_documents");
|
||||
unreportedOmittedArchives += selection.unreportedOmitted;
|
||||
for (const entry of selection.selected) {
|
||||
// Plist representation reserved for this document before decoding.
|
||||
let plistReservation = 0;
|
||||
try {
|
||||
const remainingInputBytes =
|
||||
MAX_AGGREGATE_INPUT_BYTES - aggregateInputBytes;
|
||||
@@ -132,6 +134,7 @@ export const analyzeInterfaceBuilderBundle = async (input: {
|
||||
decodedReservation.estimatedBytes,
|
||||
"plist representation exceeds the aggregate Interface Builder decode budget",
|
||||
);
|
||||
plistReservation = decodedReservation.estimatedBytes;
|
||||
binaryPrototypeKeyObserved =
|
||||
decodedReservation.binaryPrototypeKeyObserved;
|
||||
}
|
||||
@@ -165,6 +168,9 @@ export const analyzeInterfaceBuilderBundle = async (input: {
|
||||
} catch (cause: unknown) {
|
||||
if (input.signal?.aborted === true) throw cause;
|
||||
if (cause instanceof InterfaceBuilderDecodeBudgetExceeded) {
|
||||
// A document rejected while decoding, e.g. beyond the shared depth
|
||||
// limit, retains no representation; later documents keep its share.
|
||||
decodeBudget.release(plistReservation);
|
||||
omitted += 1;
|
||||
recordOmittedArchive(entry.path, cause.reason);
|
||||
continue;
|
||||
@@ -700,6 +706,24 @@ const readEntry = async (
|
||||
return Buffer.concat(chunks, length);
|
||||
};
|
||||
|
||||
/**
|
||||
* The shared parser bounds plist value nesting before its recursive decode and
|
||||
* reports the limit as a RangeError; that is an omitted archive, not a
|
||||
* malformed one.
|
||||
*/
|
||||
const parseXmlWithinDecodeBudget = (text: string) => {
|
||||
try {
|
||||
return parseXmlPropertyList(text);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RangeError)
|
||||
throw new InterfaceBuilderDecodeBudgetExceeded(
|
||||
"aggregate_decode_budget_exhausted",
|
||||
cause.message,
|
||||
);
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
/** Project decoded plist data and dates while retaining omitted-key coverage. */
|
||||
const decodePlist = (
|
||||
bytes: Buffer,
|
||||
@@ -716,7 +740,7 @@ const decodePlist = (
|
||||
value: parseBinary(bytes),
|
||||
omittedPrototypeKeys: binaryPrototypeKeyObserved ? null : 0,
|
||||
}
|
||||
: parseXmlPropertyList(xmlText ?? decodeXmlPlistText(bytes));
|
||||
: parseXmlWithinDecodeBudget(xmlText ?? decodeXmlPlistText(bytes));
|
||||
const projected = projectPlistValue(value);
|
||||
return {
|
||||
value: projected.value,
|
||||
|
||||
@@ -49,6 +49,14 @@ export class InterfaceBuilderDecodeBudget {
|
||||
this.#remainingBytes -= bytes;
|
||||
}
|
||||
|
||||
/** Return a reservation whose representation was rejected and not retained. */
|
||||
release(bytes: number): void {
|
||||
this.#remainingBytes = Math.min(
|
||||
this.#initialBytes,
|
||||
this.#remainingBytes + bytes,
|
||||
);
|
||||
}
|
||||
|
||||
/** Reserve an upper bound for one JSON-serialized string occurrence. */
|
||||
reserveJsonStringOccurrence(value: string): void {
|
||||
// JSON escaping uses at most six ASCII bytes per UTF-16 code unit, plus
|
||||
@@ -117,7 +125,8 @@ const estimateXmlPlistExpansion = (
|
||||
const xml = xmlText ?? decodeXmlPlistText(bytes);
|
||||
const decodedSourceBytes = xml.length * 2;
|
||||
let elementCount = 0;
|
||||
let xmlDepth = 0;
|
||||
// Value nesting is bounded by the shared plist parser, which counts plist
|
||||
// value depth; raw element depth would also count the envelope and keys.
|
||||
for (const token of xmlPreflightTokens(xml)) {
|
||||
if (token.doctype) {
|
||||
// Only a declaration outside opaque text can introduce an internal
|
||||
@@ -130,15 +139,6 @@ const estimateXmlPlistExpansion = (
|
||||
continue;
|
||||
}
|
||||
elementCount += 1;
|
||||
if (token.closing) xmlDepth -= 1;
|
||||
else if (!token.selfClosing) {
|
||||
xmlDepth += 1;
|
||||
if (xmlDepth > 128)
|
||||
throw new InterfaceBuilderDecodeBudgetExceeded(
|
||||
"aggregate_decode_budget_exhausted",
|
||||
"XML plist nesting exceeds the aggregate Interface Builder decode budget",
|
||||
);
|
||||
}
|
||||
if (decodedSourceBytes * 2 + elementCount * 128 > maxBytes)
|
||||
return maxBytes + 1;
|
||||
}
|
||||
@@ -179,13 +179,7 @@ function* xmlPreflightTokens(xml: string) {
|
||||
}
|
||||
if (end === xml.length) return;
|
||||
cursor = end + 1;
|
||||
if (doctype || element)
|
||||
yield {
|
||||
doctype,
|
||||
internalSubset,
|
||||
closing: xml[start + 1] === "/",
|
||||
selfClosing: xml[end - 1] === "/",
|
||||
};
|
||||
if (doctype || element) yield { doctype, internalSubset };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +121,43 @@ describe("source-map artifact relationships", () => {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps a directory named like a source map contained by its parent", () => {
|
||||
const archive = createOccurrence(entry("app", "directory"), "app", null);
|
||||
archive.artifact_id = contentId("1");
|
||||
const script = createOccurrence(
|
||||
entry("app/main.js", "file"),
|
||||
"app/main.js",
|
||||
archive.occurrence_id,
|
||||
);
|
||||
script.artifact_id = contentId("2");
|
||||
const directory = createOccurrence(
|
||||
entry("app/main.js.map", "directory"),
|
||||
"app/main.js.map",
|
||||
archive.occurrence_id,
|
||||
);
|
||||
directory.artifact_id = contentId("3");
|
||||
|
||||
const { edges, limitations } = createArtifactEdges(contentId("4"), [
|
||||
archive,
|
||||
script,
|
||||
directory,
|
||||
]);
|
||||
|
||||
expect(directory).toMatchObject({
|
||||
artifact_kind: "container",
|
||||
artifact_format: "directory",
|
||||
});
|
||||
expect(
|
||||
edges.find(
|
||||
({ occurrence_id }) => occurrence_id === directory.occurrence_id,
|
||||
),
|
||||
).toMatchObject({
|
||||
relation: "contains",
|
||||
parent_artifact_id: contentId("1"),
|
||||
});
|
||||
expect(limitations).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
const fat = (wide = false): Buffer =>
|
||||
|
||||
@@ -295,7 +295,9 @@ export const createArtifactEdges = (
|
||||
const edges: ArtifactEdge[] = [];
|
||||
const limitations: string[] = [];
|
||||
for (const occurrence of occurrences) {
|
||||
const isSourceMap = occurrence.logical_path.toLowerCase().endsWith(".map");
|
||||
// Use the occurrence's own classification: a directory named `x.map` is
|
||||
// a container, not a source map of its sibling.
|
||||
const isSourceMap = occurrence.artifact_kind === "source-map";
|
||||
const sourcePath = isSourceMap
|
||||
? occurrence.logical_path.slice(0, -".map".length)
|
||||
: undefined;
|
||||
@@ -337,8 +339,10 @@ export const createArtifactEdges = (
|
||||
relation:
|
||||
occurrence.entry_kind === "slice"
|
||||
? ("slice-of" as const)
|
||||
: isSourceMap && mappedSource === undefined
|
||||
? ("contains" as const)
|
||||
: isSourceMap
|
||||
? mappedSource === undefined
|
||||
? ("contains" as const)
|
||||
: ("maps-source" as const)
|
||||
: relationFor(occurrence.logical_path),
|
||||
occurrence_id: occurrence.occurrence_id,
|
||||
};
|
||||
@@ -587,11 +591,8 @@ const classifyFatArtifact = (
|
||||
return identity.state === "mach-o" ? "mach-o-universal" : "unknown";
|
||||
};
|
||||
|
||||
const relationFor = (path: string): ArtifactEdge["relation"] => {
|
||||
const lower = path.toLowerCase();
|
||||
if (lower.endsWith(".map")) return "maps-source";
|
||||
if (lower.includes(".framework/")) return "embeds";
|
||||
return "contains";
|
||||
};
|
||||
/** Relation of a non-source-map occurrence to its parent. */
|
||||
const relationFor = (path: string): ArtifactEdge["relation"] =>
|
||||
path.toLowerCase().includes(".framework/") ? "embeds" : "contains";
|
||||
|
||||
const depth = (path: string): number => path.split("/").length;
|
||||
@@ -156,6 +156,90 @@ describe("compiled Interface Builder graph projection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyed archive objects with unresolved classes", () => {
|
||||
it("keeps an archived object whose class reference cannot be resolved", () => {
|
||||
const parsed = parseInterfaceBuilderRecords({
|
||||
$archiver: "NSKeyedArchiver",
|
||||
$version: 100000,
|
||||
$objects: [
|
||||
"$null",
|
||||
{ $class: { UID: 99 }, label: "Unresolved object" },
|
||||
{ $classname: "UIView", $classes: ["UIView", "NSObject"] },
|
||||
{ $class: { UID: 2 }, "NS.string": "ok" },
|
||||
"plain scalar",
|
||||
],
|
||||
$top: { root: { UID: 3 } },
|
||||
});
|
||||
// The class descriptor and the scalar are not archived objects.
|
||||
expect(parsed.objects).toMatchObject([
|
||||
{
|
||||
id: "1",
|
||||
kind: "other",
|
||||
class_name: null,
|
||||
name: "Unresolved object",
|
||||
attributes: { label: "Unresolved object", $class: { UID: 99 } },
|
||||
},
|
||||
{ id: "3", kind: "view", class_name: "UIView" },
|
||||
]);
|
||||
expect(parsed.objects[1]?.attributes).not.toHaveProperty("$class");
|
||||
expect(parsed.objectCount).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps an unresolved hierarchy root's label and coverage in the graph", () => {
|
||||
const result = buildInterfaceBuilderAnalysis({
|
||||
targetSha256: hash,
|
||||
toolVersion: "test",
|
||||
documents: [
|
||||
{
|
||||
relativePath: "Main.nib",
|
||||
archiveSha256: hash,
|
||||
documentKind: "nib",
|
||||
raw: {
|
||||
$archiver: "NSKeyedArchiver",
|
||||
$version: 100000,
|
||||
$objects: [
|
||||
"$null",
|
||||
{ $class: { UID: 99 }, label: "Unresolved object" },
|
||||
{ $classname: "UIView", $classes: ["UIView", "NSObject"] },
|
||||
{ $class: { UID: 2 }, "NS.string": "ok" },
|
||||
],
|
||||
$top: { root: { UID: 1 } },
|
||||
},
|
||||
},
|
||||
],
|
||||
limits: interfaceBuilderLimitsSchema.parse({}),
|
||||
});
|
||||
const objectNodes = result.graph.nodes.filter(
|
||||
({ attributes }) => attributes.interface_builder_object_id !== undefined,
|
||||
);
|
||||
expect(objectNodes).toMatchObject([
|
||||
{
|
||||
kind: "unknown",
|
||||
name: "Unresolved object",
|
||||
attributes: { interface_builder_object_id: "1", class_name: null },
|
||||
},
|
||||
{ kind: "view", attributes: { interface_builder_object_id: "3" } },
|
||||
]);
|
||||
expect(result.documents[0]?.object_count).toBe(2);
|
||||
expect(
|
||||
result.graph.edges.some(
|
||||
({ relation, to, resolution }) =>
|
||||
relation === "contains" &&
|
||||
to === objectNodes[0]?.id &&
|
||||
resolution === "observed",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(result.graph.coverage).toContainEqual(
|
||||
expect.objectContaining({
|
||||
facet: "objects:Main.nib",
|
||||
status: "complete",
|
||||
examined: 2,
|
||||
omitted: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Interface Builder parser bounds", () => {
|
||||
it("reports connections discarded by the parser's hard ceiling", () => {
|
||||
const connections = Array.from({ length: 40_001 }, (_, index) => ({
|
||||
|
||||
@@ -327,11 +327,16 @@ const parseKeyedArchive = (
|
||||
if (index === 0) continue;
|
||||
const resolved = record(resolve(raw));
|
||||
const className = firstString(resolved.className);
|
||||
if (className === null) continue;
|
||||
// An archived object names its class with its own `$class` reference;
|
||||
// when that reference cannot be resolved the object is still observed,
|
||||
// with an unknown class. Scalars and class descriptors are not objects.
|
||||
if (className === null && !Object.hasOwn(record(raw), "$class")) continue;
|
||||
objectCount += 1;
|
||||
const fields = Object.fromEntries(
|
||||
Object.entries(resolved).filter(([key]) => key !== "className"),
|
||||
);
|
||||
// Keep the unresolvable class reference itself, e.g. a dangling UID.
|
||||
if (className === null) fields.$class = record(raw).$class;
|
||||
const authoredId = firstString(fields.objectID, fields["object-id"]);
|
||||
const objectId =
|
||||
authoredId ??
|
||||
@@ -357,6 +362,7 @@ const parseKeyedArchive = (
|
||||
}),
|
||||
);
|
||||
if (
|
||||
className !== null &&
|
||||
/(?:Outlet|Connection|Segue|ActionConnection|ControlConnector)/iu.test(
|
||||
className,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,6 @@ import { join } from "node:path";
|
||||
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisCancelledError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
} from "./KunaProjectFiles.js";
|
||||
import { KUNA_PROVIDER_ID } from "./KunaRelease.js";
|
||||
import { KUNA_FUNCTION_TEXT_BYTES } from "./KunaLimits.js";
|
||||
import { kunaUnavailable } from "./KunaUnavailable.js";
|
||||
|
||||
/** The exact producing range for one facet, including a failed C block. */
|
||||
export interface KunaProjectFileSpan {
|
||||
@@ -134,8 +134,7 @@ export class KunaProjectReader {
|
||||
const span = index.value.code.get(address);
|
||||
if (span === undefined)
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
KUNA_PROVIDER_ID,
|
||||
kunaUnavailable(
|
||||
operation,
|
||||
`Kuna's export lists no code at ${address}; import slots and data entries have no listing.`,
|
||||
),
|
||||
|
||||
@@ -8,10 +8,7 @@ import type {
|
||||
ProviderAvailability,
|
||||
ProviderTargetSupport,
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
createAnalysisProfile,
|
||||
type AnalysisProfileCommitment,
|
||||
@@ -33,6 +30,7 @@ import {
|
||||
import { KUNA_COMMAND_REMEDIATION, KUNA_PROVIDER_ID } from "./KunaRelease.js";
|
||||
import { KunaRunSlots } from "./KunaRunSlots.js";
|
||||
import { KunaSessionClient } from "./KunaSessionClient.js";
|
||||
import { kunaUnavailable } from "./KunaUnavailable.js";
|
||||
|
||||
/** Architectures REA verified Kuna on: x86-64 and AArch64 ELF and Mach-O, plus ARM Thumb. */
|
||||
const VERIFIED_ARCHITECTURES: ReadonlySet<string> = new Set([
|
||||
@@ -169,14 +167,7 @@ export class KunaProvider implements AnalysisProviderCandidate {
|
||||
...(options?.signal === undefined ? {} : { signal: options.signal }),
|
||||
});
|
||||
if (!installation.ok)
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
KUNA_PROVIDER_ID,
|
||||
"open_binary",
|
||||
installation.error.reason,
|
||||
{ userMessage: installation.error.reason },
|
||||
),
|
||||
);
|
||||
return err(kunaUnavailable("open_binary", installation.error.reason));
|
||||
const { value } = installation;
|
||||
return ok({
|
||||
profile: createAnalysisProfile(
|
||||
@@ -218,13 +209,7 @@ export class KunaProvider implements AnalysisProviderCandidate {
|
||||
if (!configuration.ok)
|
||||
return {
|
||||
execute: async (operation) =>
|
||||
err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
KUNA_PROVIDER_ID,
|
||||
operation,
|
||||
configuration.error.reason,
|
||||
),
|
||||
),
|
||||
err(kunaUnavailable(operation, configuration.error.reason)),
|
||||
close: async () => ok(null),
|
||||
};
|
||||
return new KunaSessionClient(target, profile, {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
} from "./KunaReferenceFiles.js";
|
||||
import { KUNA_PROVIDER_ID } from "./KunaRelease.js";
|
||||
import type { KunaPendingQuery, KunaQueryWork } from "./KunaQueryWork.js";
|
||||
import { kunaUnavailable } from "./KunaUnavailable.js";
|
||||
|
||||
/** Charge raw JSON bytes conservatively; retained projections are smaller. */
|
||||
const XREF_CACHE_BYTES = 16 * 1024 * 1024;
|
||||
@@ -158,8 +158,7 @@ export class KunaReferenceQueries {
|
||||
const node = graph.nodes.get(address);
|
||||
if (node === undefined)
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
KUNA_PROVIDER_ID,
|
||||
kunaUnavailable(
|
||||
operation,
|
||||
`Kuna's graph has no function node at ${address}; its relationships are unknown.`,
|
||||
),
|
||||
|
||||
@@ -69,6 +69,7 @@ import {
|
||||
KunaProjectReader,
|
||||
type KunaProjectFileSpan,
|
||||
} from "./KunaProjectReader.js";
|
||||
import { kunaUnavailable } from "./KunaUnavailable.js";
|
||||
|
||||
/** How often a request waiting on the project export reports that it still waits. */
|
||||
const WAIT_PROGRESS_INTERVAL_MS = 10_000;
|
||||
@@ -278,13 +279,7 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
||||
if (this.#closing)
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
KUNA_PROVIDER_ID,
|
||||
operation,
|
||||
"This Kuna session is closed.",
|
||||
),
|
||||
);
|
||||
return err(kunaUnavailable(operation, "This Kuna session is closed."));
|
||||
return this.#own(
|
||||
this.#execute(operation, parameters, {
|
||||
...options,
|
||||
@@ -312,8 +307,7 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
if (operation !== "health" && !isKunaOperation(operation))
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
KUNA_PROVIDER_ID,
|
||||
kunaUnavailable(
|
||||
operation,
|
||||
"Kuna answers procedure and string queries, pseudocode, assembly and references; this operation needs another provider.",
|
||||
),
|
||||
@@ -1229,14 +1223,7 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
this.#installation ??= inspectKunaInstallation(this.#environment).then(
|
||||
(inspected): Result<KunaInstallation, AnalysisError> => {
|
||||
if (!inspected.ok)
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
KUNA_PROVIDER_ID,
|
||||
"health",
|
||||
inspected.error.reason,
|
||||
{ userMessage: inspected.error.reason },
|
||||
),
|
||||
);
|
||||
return err(kunaUnavailable("health", inspected.error.reason));
|
||||
const committed = this.#profile?.parameters["kuna_sha256"];
|
||||
if (committed !== undefined && committed !== inspected.value.sha256)
|
||||
return err(
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisErrorOptions } from "../domain/analysisErrorBase.js";
|
||||
import { KUNA_PROVIDER_ID } from "./KunaRelease.js";
|
||||
|
||||
/**
|
||||
* A Kuna capability gap whose reason is what the caller needs to read. Without
|
||||
* a user message the projection shows only "choose another analysis or
|
||||
* target", which hides facts such as "no code at this address".
|
||||
*/
|
||||
export const kunaUnavailable = (
|
||||
operation: string,
|
||||
reason: string,
|
||||
options: AnalysisErrorOptions = {},
|
||||
): AnalysisCapabilityUnavailableError =>
|
||||
new AnalysisCapabilityUnavailableError(KUNA_PROVIDER_ID, operation, reason, {
|
||||
...options,
|
||||
userMessage: reason,
|
||||
});
|
||||
@@ -4,9 +4,10 @@ import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { buildBinary } from "plist";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { analyzeInterfaceBuilderBundle } from "../../../src/artifacts/apple/InterfaceBuilderAnalysis.js";
|
||||
import { InterfaceBuilderDecodeBudget } from "../../../src/artifacts/apple/InterfaceBuilderDecodeBudget.js";
|
||||
import { encodeNibArchiveFixture } from "../../../src/artifacts/apple/NibArchive.fixture.js";
|
||||
import { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
|
||||
import { xcodeIbtoolAvailable } from "../../fixtures/ibtool.js";
|
||||
@@ -907,6 +908,57 @@ describe("aggregate Interface Builder archive retention budget", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Interface Builder XML plist depth", () => {
|
||||
it("bounds XML archives by plist value depth, not element depth", async () => {
|
||||
const root = await createTestTempDirectory("rea-ib-depth-test-");
|
||||
const bundle = join(root, "Example.app");
|
||||
const resources = join(bundle, "Contents", "Resources");
|
||||
await mkdir(resources, { recursive: true });
|
||||
// The root dict is value depth 0, so the leaf sits at `nested + 1`. The
|
||||
// plist envelope and keys add XML elements but not value depth.
|
||||
const document = (nested: number) =>
|
||||
`<?xml version="1.0"?><plist version="1.0"><dict>${"<key>child</key><dict>".repeat(nested)}<key>leaf</key><integer>1</integer>${"</dict>".repeat(nested)}</dict></plist>`;
|
||||
await writeFile(join(resources, "AtLimit.nib"), document(127));
|
||||
await writeFile(join(resources, "BeyondLimit.nib"), document(128));
|
||||
|
||||
const release = vi.spyOn(InterfaceBuilderDecodeBudget.prototype, "release");
|
||||
const result = await analyzeInterfaceBuilderBundle({
|
||||
bundlePath: bundle,
|
||||
targetSha256: "e".repeat(64),
|
||||
});
|
||||
const released = release.mock.calls.map(([bytes]) => bytes);
|
||||
release.mockRestore();
|
||||
|
||||
// The rejected archive returns its reservation to later documents.
|
||||
expect(released).toHaveLength(1);
|
||||
expect(released[0]).toBeGreaterThan(0);
|
||||
expect(result.documents.map(({ relative_path }) => relative_path)).toEqual([
|
||||
"Contents/Resources/AtLimit.nib",
|
||||
]);
|
||||
expect(result.graph.coverage).toContainEqual(
|
||||
expect.objectContaining({
|
||||
facet: "archive_decode",
|
||||
status: "partial",
|
||||
reason: "aggregate_decode_budget_exhausted",
|
||||
examined: 2,
|
||||
omitted: 1,
|
||||
}),
|
||||
);
|
||||
expect(result.limitations).toContain(
|
||||
"Contents/Resources/BeyondLimit.nib: omitted because aggregate decode budget exhausted.",
|
||||
);
|
||||
});
|
||||
|
||||
it("restores released representation bytes up to the initial budget", () => {
|
||||
const budget = new InterfaceBuilderDecodeBudget(100);
|
||||
budget.reserve(60, "reserve");
|
||||
budget.release(60);
|
||||
expect(budget.remainingBytes).toBe(100);
|
||||
budget.release(10);
|
||||
expect(budget.remainingBytes).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bounded Interface Builder archive decoding", () => {
|
||||
it("counts malformed archives against the document limit", async () => {
|
||||
const root = await createTestTempDirectory("rea-ib-test-");
|
||||
|
||||
@@ -80,3 +80,41 @@ it("reports a graph beyond REA's buffer once per session instead of rebuilding i
|
||||
expect(await client.close()).toEqual({ ok: true, value: null });
|
||||
}
|
||||
});
|
||||
|
||||
it("says why Kuna cannot answer instead of a generic unavailable message", async () => {
|
||||
const { open } = await session("rea-kuna-unavailable-reasons-");
|
||||
const client = open();
|
||||
try {
|
||||
const cases = [
|
||||
{
|
||||
operation: "procedure_assembly",
|
||||
parameters: { procedure: "0x2000" },
|
||||
message:
|
||||
"Kuna's export lists no code at 0x2000; import slots and data entries have no listing.",
|
||||
},
|
||||
{
|
||||
operation: "procedure_callers",
|
||||
parameters: { procedure: "0x1004" },
|
||||
message:
|
||||
"Kuna's graph has no function node at 0x1004; its relationships are unknown.",
|
||||
},
|
||||
{
|
||||
operation: "list_segments",
|
||||
parameters: {},
|
||||
message:
|
||||
"Kuna answers procedure and string queries, pseudocode, assembly and references; this operation needs another provider.",
|
||||
},
|
||||
] as const;
|
||||
for (const { operation, parameters, message } of cases) {
|
||||
const result = await client.execute(operation, parameters);
|
||||
if (result.ok) throw new Error(`${operation} unexpectedly succeeded`);
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "capability_unavailable",
|
||||
message,
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
expect(await client.close()).toEqual({ ok: true, value: null });
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user