Compare commits

..
325 changed files with 1615 additions and 3115 deletions

No files matched your search

+1 -3
View File
@@ -464,9 +464,7 @@ class ReaJadxBridge {
if (element.getTagName().equals("uses-sdk")) {
minSdk = element.getAttributeNS(android, "minSdkVersion");
targetSdk = element.getAttributeNS(android, "targetSdkVersion");
} else if (element.getTagName().equals("uses-permission")
|| element.getTagName().equals("uses-permission-sdk-23")
|| element.getTagName().equals("uses-permission-sdk-m")) {
} else if (element.getTagName().equals("uses-permission")) {
String permission = element.getAttributeNS(android, "name");
if (!permission.isEmpty()) permissions.add(permission);
}
+7
View File
@@ -154,6 +154,13 @@ class HopperApiFacade:
)
def _configure_hopper_api(document_provider):
"""Inject Hopper's document provider or a standalone test facade."""
global _hopper_api, _rea_session_document
_hopper_api = HopperApiFacade(document_provider)
_rea_session_document = None
def _api():
"""Resolve Hopper globals lazily so importing this module needs no Hopper."""
global _hopper_api
@@ -215,10 +215,7 @@ Resolution follows dyld:
image that loaded it, up to the process root.
- Dependencies load dependents-first in load-command order, and each image
loads once per process. A later @rpath request whose install name matches an
already loaded image reuses it. Loading an image and expanding its dependencies
are separate: an already loaded sibling can first be expanded through another
dependency, inheriting that traversal's rpath stack. Cycles do not expand an
image twice, and lazy edges do not establish a launch-time traversal chain.
already loaded image reuses it.
- Symlinks such as `Versions/Current` are followed inside the analyzed root.
An escape through `..` or an absolute link is `escapes-target`.
- Dyld path components are POSIX names. On Windows hosts, link text uses `\`
@@ -255,12 +252,8 @@ one of:
A `conditional` image is still traced, because it is an in-root candidate
that may load. Every edge below it has `loader_conditional: true`, and a
request that reuses it by install name is `conditional` too. A definitely loaded
image can also first expand through a conditional chain: its dependency edges
then have `loader_conditional: true`, while later reuse of the image retains its
definite first-load result.
An in-root dylib with no platform load command remains unknown unless the root is an executable
request that reuses it by install name is `conditional` too. An in-root dylib
with no platform load command remains unknown unless the root is an executable
with observed macOS platform and the candidate's CPU type is x86_64, i386, or arm64. In that macOS context,
[Apple's dyld](https://github.com/apple-oss-distributions/dyld/blob/fd8d0c4d52320ebf64db34f3cb280310d905c5ae/mach_o/UnsafeHeader.cpp#L1137-L1145) supplies an implicit macOS platform to legacy dylibs on those CPU
types. The candidate records `platform_basis: "macos-dyld-implicit"`; the
+8 -3
View File
@@ -222,7 +222,7 @@ cannot answer, such as an address without one exact file offset, omits this
hint; follow its `remediation.action`. `recovery.kind=inspect_timeout_state` names
the operation and the REA server/provider deadline. Inspect partial Evidence
and reported cleanup state, then follow the returned remediation. Change a
request field or REA\_\* setting only when the returned remediation explicitly
request field or REA_* setting only when the returned remediation explicitly
recommends that change. Restart REA when that text says the value is read at
startup. A mention that a control does not raise the deadline is not a
recommendation to change it. When it names no setting, reduce supported work;
@@ -1911,10 +1911,13 @@ support for omitted historical values.
known and differ. If only one value is known, storage comparison is unknown;
when both are omitted, storage is not compared.
`compare_artifacts` also compares the declarations in the `format_metadata` of <!-- BEGIN:compare-artifacts:compared-format-metadata-kinds -->UnityFS members (`unityfs-entry`), PyInstaller entries
<!-- BEGIN:compare-artifacts:compared-format-metadata-kinds -->
`compare_artifacts` also compares the declarations in the `format_metadata`
of UnityFS members (`unityfs-entry`), PyInstaller entries
(`pyinstaller-entry`), Node SEA members (`node-sea-entry`),
ESP image segments (`esp-image-segment`), DfuSe elements (`dfuse-element`),
ESP flash partitions (`esp-flash-partition`) and FIT images (`fit-image`)<!-- END:compare-artifacts:compared-format-metadata-kinds -->;
ESP flash partitions (`esp-flash-partition`) and FIT images (`fit-image`);
each format's guide names the compared fields. A changed declaration is a
metadata change, including when content is unavailable. A declaration that
only one side records, or that the other side records as another kind, is
@@ -1925,6 +1928,8 @@ container or from caller options that identify the target, or repeat
occurrence fields, except Godot PCK source paths, which
[Godot PCK archives](../godot-pck.md) explains.
<!-- END:compare-artifacts:compared-format-metadata-kinds -->
Every occurrence records `entry_kind`, `executable` and `encrypted`. A
different entry kind or `encrypted` value is a `metadata` difference, and a
different `executable` value is an `executable` difference. Occurrence and node
@@ -10,8 +10,8 @@
4. Verifier: `appTaskPython.ts` renders `READER_RUNNER`, `READERS`,
`_reader_views` and `evaluate_with_readers` only for a task with readers,
so a task without renders as before.
5. Application: `readerViewsOfBytes` in `DesktopTaskReaders.ts`;
`runReaderHere` in `src/desktop/TaskReaderProcess.ts`;
5. Application: `readTaskReaderViews` and `readerViewsOfBytes` in
`DesktopTaskReaders.ts`; `runReaderHere` in `src/desktop/TaskReaderProcess.ts`;
the witness runs readers through `AgentEnvComputer.exec`; the export runs
them on `shippedSeedBytes` and the end's retained content.
6. Docs: the tasks reference, the witness page, the build skill's recipes for
+2 -5
View File
@@ -10,6 +10,7 @@ import { access, readFile, readdir, stat } from "node:fs/promises";
import { dirname, join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { promisify } from "node:util";
import { GHIDRA_ANNOTATIONS_RETAINED } from "../../dist/ghidra/GhidraProviderClient.js";
import Ajv from "ajv/dist/2020.js";
import addFormats from "ajv-formats";
import { OFFICIAL_TOOL_CONTRACTS } from "../../dist/contracts/officialToolContracts.js";
@@ -570,11 +571,7 @@ export async function verifyGhidraBoundaries(
// The producer reason describes the failure; continuing-session guidance
// is projected separately and the following live calls verify it.
assert.match(regexError.details.reason, /stack while matching pattern/u);
assert.ok(
regexError.message.includes(
"The active analysis session and annotations remain available.",
),
);
assert.ok(regexError.message.includes(GHIDRA_ANNOTATIONS_RETAINED));
rejectedCalls++;
const nestedPattern = "(".repeat(12000) + "a" + ")".repeat(12000);
const compileFailure = await client.callTool(
+5 -3
View File
@@ -99,7 +99,8 @@ async function verifyPublishedUpdate({
environment,
);
await commands.execNpm(
await commands.exec(
"npm",
[
"install",
"--global",
@@ -117,7 +118,7 @@ async function verifyPublishedUpdate({
);
const npmRoot = (
await commands.execNpm(["root", "--global"], {
await commands.exec("npm", ["root", "--global"], {
cwd: canaryRoot,
env: environment,
timeout: 30000,
@@ -185,7 +186,8 @@ if (result.status === "failed") process.exitCode = 1;
}
async function findPreviousPublishedVersion(targetVersion, cwd, environment) {
const { stdout } = await commands.execNpm(
const { stdout } = await commands.exec(
"npm",
["view", "rea-agents", "versions", "--json"],
{ cwd, env: environment, timeout: 30000, maxBuffer: 16 * 1024 * 1024 },
);
+16 -12
View File
@@ -58,7 +58,7 @@ export const androidSnapshotCopy = (
: undefined;
/** Adopt a snapshot descriptor whose close rejected, keyed by its exact path. */
type AndroidSnapshotCloseRetention = (
export type AndroidSnapshotCloseRetention = (
path: string,
owner: OwnedFileHandle,
) => void;
@@ -69,8 +69,8 @@ export const snapshotAndroidTarget = async (
sha256: string,
root: string,
operation: AndroidOperation,
signal: AbortSignal | undefined,
retainFailedClose: AndroidSnapshotCloseRetention,
signal?: AbortSignal,
retainFailedClose?: AndroidSnapshotCloseRetention,
): Promise<string> => {
const snapshot = join(root, "target.apk");
try {
@@ -105,8 +105,8 @@ export const snapshotAndroidEngine = async (
sha256: string,
root: string,
operation: AndroidOperation,
signal: AbortSignal | undefined,
retainFailedClose: AndroidSnapshotCloseRetention,
signal?: AbortSignal,
retainFailedClose?: AndroidSnapshotCloseRetention,
): Promise<{ path: string; sha256: string }> => {
const path = join(root, "engine.jar");
try {
@@ -159,14 +159,17 @@ const copyAndroidSnapshot = async (
snapshotPath: string,
expectedSha256: string,
signal: AbortSignal | undefined,
retainFailedClose: AndroidSnapshotCloseRetention,
retainFailedClose: AndroidSnapshotCloseRetention | undefined,
): Promise<AndroidSnapshotCopy> => {
assertRegularFileActive(signal);
let removeSnapshot = false;
const retain = (path: string) => ({
retainFailedClose: (owner: OwnedFileHandle) =>
retainFailedClose(path, owner),
});
const retain = (path: string) =>
retainFailedClose === undefined
? {}
: {
retainFailedClose: (owner: OwnedFileHandle) =>
retainFailedClose(path, owner),
};
try {
// Close each descriptor through its owner. A rejected close keeps the
// copy outcome and stays retained until that owner is retried; the
@@ -284,7 +287,7 @@ const openSnapshotSource = async (
path: string,
signal: AbortSignal | undefined,
retention: {
readonly retainFailedClose: (owner: OwnedFileHandle) => void;
readonly retainFailedClose?: (owner: OwnedFileHandle) => void;
},
): Promise<StableRegularFileDescriptor> => {
try {
@@ -302,9 +305,10 @@ const openSnapshotSource = async (
};
const retainAdmissionOwner = (
retain: (owner: OwnedFileHandle) => void,
retain: ((owner: OwnedFileHandle) => void) | undefined,
owner: OwnedFileHandle,
): RegularFileCleanupFailure["retention"] => {
if (retain === undefined) return { kind: "not_requested" };
try {
retain(owner);
return { kind: "callback_returned" };
+9
View File
@@ -6,6 +6,7 @@ import {
resolveWindowsChangeTimeSettle,
} from "../config/windowsChangeTimeSettle.js";
import { pwntoolsInputBytes } from "../native/pwntools/PwntoolsInputLimit.js";
import { resolveBridgeMaxRequestBytes } from "../config/bridgeMaxRequestBytes.js";
import { resolveCaptureDecoderHeap } from "../config/captureDecoderHeap.js";
import { resolveRegexMatchingDeadline } from "../config/regexMatchingDeadline.js";
import { resolveRegexWorkerHeapRequest } from "../config/regexWorkerHeapRequest.js";
@@ -365,6 +366,7 @@ it("reads an accepted override before an invalid launch seed", () => {
{
revision: 1,
overrides: {
"bridge.max_request_bytes": 8192,
"capture.decoder_old_generation_mib": 64,
"regex.matching_deadline_ms": 250,
"regex.worker_heap_request_mib": 128,
@@ -373,6 +375,9 @@ it("reads an accepted override before an invalid launch seed", () => {
},
},
() => ({
bridge: resolveBridgeMaxRequestBytes({
REA_BRIDGE_MAX_REQUEST_BYTES: "nope",
}),
capture: resolveCaptureDecoderHeap({
REA_CAPTURE_DECODER_OLD_GENERATION_MIB: "0",
}),
@@ -388,6 +393,10 @@ it("reads an accepted override before an invalid launch seed", () => {
}),
}),
);
expect(resolved.bridge).toMatchObject({
maximumBytes: 8192,
source: "runtime",
});
expect(resolved.capture).toMatchObject({
oldGenerationMiB: 64,
source: "runtime",
+9 -5
View File
@@ -86,13 +86,17 @@ describe("app bundle Info.plist magic", () => {
const plist = Buffer.from("bplist00");
plist[0] = (plist[0] ?? 0) | 0x80;
await writeFile(plistPath, plist);
const result = await resolveAppBundleExecutable(bundle);
expect(result).toMatchObject({
ok: false,
error: {
cause: { name: "XmlPlistTextDecodeError", reason: "invalid-bytes" },
let decoded = false;
const result = await resolveAppBundleExecutable(bundle, {
readFile: async () => plist,
realpath: async (path) => path,
decodeBinaryPlist: async () => {
decoded = true;
return "Fixture";
},
});
expect(decoded).toBe(false);
expect(result.ok).toBe(false);
} finally {
await rm(root, { recursive: true, force: true });
}
+20 -2
View File
@@ -91,6 +91,7 @@ export interface ResolvedAppBundleResourceRoot {
export interface AppBundleFileSystem {
readonly readFile: (path: string) => Promise<Buffer>;
readonly realpath: (path: string) => Promise<string>;
readonly decodeBinaryPlist?: (path: string) => Promise<string>;
}
const defaultFileSystem: AppBundleFileSystem = { readFile, realpath };
@@ -395,6 +396,15 @@ const codeDescription = (cause: unknown): string => {
: "";
};
const isSystemErrorCode = (cause: unknown): boolean => {
const code = errorCode(cause);
return (
typeof code === "string" &&
/^[A-Z][A-Z0-9_]+$/u.test(code) &&
!code.startsWith("ERR_")
);
};
const isPermissionDenied = (cause: unknown): boolean =>
errorCode(cause) === "EACCES" || errorCode(cause) === "EPERM";
@@ -451,12 +461,20 @@ const resolveLayoutExecutable = async (
let name: string;
try {
name = executableName(decodeInfoPlistValue(plist));
name =
hasBinaryPlistSignature(plist) &&
fileSystem.decodeBinaryPlist !== undefined
? await fileSystem.decodeBinaryPlist(plistPath)
: executableName(decodeInfoPlistValue(plist));
} catch (cause: unknown) {
return err(
new BinaryTargetError(
plistPath,
`${noun} Info.plist is malformed, unsupported, or lacks CFBundleExecutable: ${plistPath}${cause instanceof Error ? ` (${cause.message})` : ""}`,
isPermissionDenied(cause)
? `permission denied decoding ${noun} Info.plist ${plistPath}${codeDescription(cause)}`
: isSystemErrorCode(cause)
? `could not decode ${noun} Info.plist ${plistPath}${codeDescription(cause)}`
: `${noun} Info.plist is malformed, unsupported, or lacks CFBundleExecutable: ${plistPath}${cause instanceof Error ? ` (${cause.message})` : ""}`,
{
cause,
// A bundle without code, such as a resource bundle, declares no
+2 -2
View File
@@ -747,8 +747,8 @@ const throwIfTargetResolutionCancelled = (signal?: AbortSignal): void => {
const detectArtifactFormat = async (
path: string,
source: StableRegularFileDescriptor,
signal: AbortSignal | undefined,
sourcePath: string,
signal?: AbortSignal,
sourcePath = path,
): Promise<
| Exclude<
BinaryTarget["format"],
+3 -3
View File
@@ -23,14 +23,14 @@ export interface ClientConfigurationSource extends ClientConfigurationDocument {
const invalidEnvironment = (
path: string,
error: ConfigurationError,
overrides: Readonly<Record<string, string>> | undefined,
platform: NodeJS.Platform,
overrides: Readonly<Record<string, string>> = {},
platform: NodeJS.Platform = process.platform,
): ClientConfigurationFileError => {
const constraints = error.settings
.map(({ setting, constraint }) => `${setting}: ${constraint}`)
.join("; ");
const overrideNames = new Set(
Object.keys(overrides ?? {}).map((name) =>
Object.keys(overrides).map((name) =>
platform === "win32" ? name.toUpperCase() : name,
),
);
@@ -34,7 +34,7 @@ export interface ParsedReferenceSourceEntries {
}
/** Preserve the observed reader failure and add entry-specific recovery guidance. */
const projectReferenceSourceEntryFailure = (
export const projectReferenceSourceEntryFailure = (
entry: Extract<ReferenceSourceEntry, { status: "failed" }>,
): string => `${entry.message} ${entryRecoveryGuidance(entry)}`;
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
projectReferenceSourceImportError,
referenceSourceImportAnalysisError,
type ReferenceSourceImportError,
} from "./ReferenceSourceImportTypes.js";
@@ -19,13 +20,11 @@ describe("reference-source import error projection", () => {
parse: "execution_failure",
} as const;
for (const [code, category] of Object.entries(expectedCategories)) {
const projected = projectAnalysisError(
referenceSourceImportAnalysisError({
tag: "reference-source-import",
code: code as ReferenceSourceImportError["code"],
message: "EIO while reading /selected/source/src/main.ts",
}),
);
const projected = projectReferenceSourceImportError({
tag: "reference-source-import",
code: code as ReferenceSourceImportError["code"],
message: "EIO while reading /selected/source/src/main.ts",
});
expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(
true,
);
@@ -5,6 +5,10 @@ import {
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import { analysisErrorWithCancellation } from "../domain/analysisErrorCancellation.js";
import {
projectAnalysisError,
type AnalysisErrorProjection,
} from "../domain/analysisErrorProjection.js";
import type {
AnalysisError,
AnalysisErrorOptions,
@@ -121,6 +125,12 @@ export const referenceSourceImportAnalysisError = (
);
};
/** Canonical CLI/MCP projection, retaining source facts under diagnostic details. */
export const projectReferenceSourceImportError = (
error: ReferenceSourceImportError,
): AnalysisErrorProjection =>
projectAnalysisError(referenceSourceImportAnalysisError(error));
/** Import a caller-selected local source tree as historical reference. */
export interface ReferenceSourceImportOptions {
readonly root: string;
@@ -11,7 +11,7 @@ import {
inspectAnalysisView,
projectRetainedAnalysis,
} from "./AnalysisViewService.js";
import { analysisViewRequestSchema } from "../../domain/analysisView/analysisViewRequest.js";
import { analysisViewRequestSchema } from "../../domain/analysisView/analysisView.js";
import type { Evidence } from "../../domain/evidence.js";
const trace = async (
@@ -58,7 +58,7 @@ interface PrepareSessionExecutionInput {
}
/** Recovery for an operation absent from the selected binary route. */
const undeclaredOperationGuidance = (
export const undeclaredOperationGuidance = (
operation: string,
entryCommentAvailable = false,
): string => {
@@ -31,7 +31,9 @@ type RoutedOperation = Pick<
>;
/** Reason recorded for an operation's own refusal or provider-wide failure. */
const runtimeFailureReason = (failure: AnalysisError): string | undefined => {
export const runtimeFailureReason = (
failure: AnalysisError,
): string | undefined => {
if (failure._tag === "AnalysisCapabilityUnavailableError")
return failure.userMessage ?? failure.message;
return PROVIDER_WIDE_FAILURES.has(failure._tag)
@@ -40,7 +42,7 @@ const runtimeFailureReason = (failure: AnalysisError): string | undefined => {
};
/** Explain unavailability inferred for an operation no request failed for. */
const inferredUnavailabilityReason = (
export const inferredUnavailabilityReason = (
providerId: string,
failure: Pick<ProviderFailure, "operation" | "reason">,
): string =>
@@ -29,7 +29,16 @@ import type {
} from "../../domain/desktop/desktopCapture.js";
import { renderBuildBundle } from "../../domain/desktop/appBuild.js";
import { readDownloadInstall } from "./DesktopExportInstall.js";
import {
NO_LAUNCH_DOCUMENT,
launchDocumentNote,
seedWording,
} from "../../domain/desktop/appExport.js";
import { exportDesktopApplication } from "./DesktopApplicationExport.js";
import {
seedLimitation,
trashedFilesLimitation,
} from "./DesktopExportLimitations.js";
import {
EDITED_NOTE,
HOME,
@@ -520,9 +529,13 @@ describe("exportDesktopApplication and the desktop Trash", () => {
"Documents/today.txt",
],
});
expect(result.limitations).toContain(
"The desktop Trash held 2 file(s) at the seed checkpoint, which are not seeded: ~/.local/share/Trash/files/today.txt, ~/.local/share/Trash/info/today.txt.trashinfo. Restore one the task needs and capture again.",
);
const trashPaths = [
".local/share/Trash/files/today.txt",
".local/share/Trash/info/today.txt.trashinfo",
];
const notice = trashedFilesLimitation(trashPaths);
expect(result.limitations).toContain(notice);
for (const path of trashPaths) expect(notice).toContain(`~/${path}`);
});
});
@@ -1923,9 +1936,7 @@ const exportSeeded = async (
/** The limitations on what the launch opens, found by the lead their formatter gives them. */
const launchLimitations = (limitations: readonly string[]) =>
limitations.filter((line) =>
line.includes("The app opens no document at launch"),
);
limitations.filter((line) => line.includes(NO_LAUNCH_DOCUMENT));
/**
* The seed-then-end capture of an app whose titles name no file, given the
@@ -2026,7 +2037,9 @@ describe("exportDesktopApplication launch document", () => {
`command=["sh", "-c", "cd \\"$HOME\\" 2>/dev/null; exec 'notes' \\"$HOME\\"/'Documents/today.txt'"]`,
);
expect(readme).toContain(
"At launch the app opens `~/Documents/today.txt`, the seed's one document.",
launchDocumentNote({
opens: { kind: "seed", document: "Documents/today.txt" },
}),
);
expect(readme).toContain("`~/Documents/today.txt`");
expect(launchLimitations(result.limitations)).toEqual([]);
@@ -2138,13 +2151,8 @@ describe("exportDesktopApplication launch document", () => {
const made = await exportSeeded("notes %U", { syntheticSeed: true });
const captured = await exportSeeded("notes %U");
const notice = (syntheticSeed: boolean) =>
syntheticSeed
? "The seed was made for this task, not taken from a user's own data; read `envs/notes/seed/` before publishing the bundle."
: "The seed is the user's real data. Keep this bundle local; do not publish it.";
const limitation = (syntheticSeed: boolean) =>
syntheticSeed
? "The seed was made for the task, not taken from a user's own data; read it before publishing the bundle."
: "The seed is the user's real state at the seed checkpoint; keep the bundle local.";
seedWording({ name: "notes", seedCheckpoint: "seed", syntheticSeed })
.notice;
expect(made.result.parameters).toMatchObject({ synthetic_seed: true });
expect(captured.result.parameters).toMatchObject({ synthetic_seed: false });
@@ -2152,8 +2160,10 @@ describe("exportDesktopApplication launch document", () => {
[made, true],
[captured, false],
] as const) {
expect(exported.result.limitations).toContain(limitation(synthetic));
expect(exported.result.limitations).not.toContain(limitation(!synthetic));
expect(exported.result.limitations).toContain(seedLimitation(synthetic));
expect(exported.result.limitations).not.toContain(
seedLimitation(!synthetic),
);
expect(exported.readme).toContain(notice(synthetic));
expect(exported.readme).not.toContain(notice(!synthetic));
}
@@ -328,7 +328,7 @@ const liveComputer = (
* message.
*/
/** The start failure's notice that the bundle seed still holds lock files. */
const seededLockStartNotice = (locks: readonly string[]): string =>
export const seededLockStartNotice = (locks: readonly string[]): string =>
`The bundle's seed holds lock files, which can keep the app from starting when it finds its profile or document in use: ${counted(locks)}. Delete them from the bundle, or export it again, which leaves them out, and start again.`;
const withSeededLocks = async (
@@ -15,7 +15,7 @@ import type { HostIdentity } from "../../domain/desktop/hostIdentity.js";
import { recoveryListLimitation } from "../../domain/desktop/seedRecoveryList.js";
/** The limitation naming files the desktop Trash held at the seed; none when it held none. */
const trashedFilesLimitation = (
export const trashedFilesLimitation = (
files: readonly string[],
): string | undefined =>
files.length === 0
@@ -23,7 +23,7 @@ const trashedFilesLimitation = (
: `The desktop Trash held ${String(files.length)} file(s) at the seed checkpoint, which are not seeded: ${files.map((home) => `~/${home}`).join(", ")}. Restore one the task needs and capture again.`;
/** The limitation on where the seed came from: made for the task, or the user's real state, kept local. */
const seedLimitation = (syntheticSeed: boolean): string =>
export const seedLimitation = (syntheticSeed: boolean): string =>
syntheticSeed
? "The seed was made for the task, not taken from a user's own data; read it before publishing the bundle."
: "The seed is the user's real state at the seed checkpoint; keep the bundle local.";
+30 -11
View File
@@ -30,6 +30,33 @@ export type ReaderFileBytes =
| { readonly kind: "error"; readonly error: string }
| { readonly kind: "bytes"; readonly bytes: Buffer };
/**
* Each reader's view of its file by reader id, read by REA's reader runner;
* an absent file has none. `pathOf` names bytes to read in place of the
* home's file. A runner that does not answer throws.
*/
export const readTaskReaderViews = async (
readers: readonly DesktopTaskReader[],
run: ReaderCommandRun,
pathOf: (reader: DesktopTaskReader) => string | undefined = () => undefined,
work?: string,
): Promise<ReadonlyMap<string, TaskFileView>> => {
const views = new Map<string, TaskFileView>();
for (const reader of readers) {
const result = await run(
readerCommand(reader, pathOf(reader), work),
readerTimeout(reader) + READER_RUNNER_SLACK_S,
);
if (result.exit_code !== 0 || result.timed_out)
throw new Error(
`REA's reader runner for ${reader.id} ${result.timed_out ? "timed out" : `exited ${String(result.exit_code)}`}: ${result.stderr.slice(-1024) || result.stdout.slice(-1024)}`,
);
const view = readerAnswerView(result.stdout);
if (view !== undefined) views.set(reader.id, view);
}
return views;
};
/** Recover only a complete, valid answer emitted before later finalization failed. */
const completedReaderAnswer = (
cause: unknown,
@@ -85,17 +112,9 @@ export const readerViewsOfBytes = async (
const path = join(folder, reader.file.split("/").at(-1) ?? "file");
await writeFile(path, file.bytes);
// The runner works inside this folder, so removing it cleans up after a runner that was killed.
const result = await run(
readerCommand(reader, path, folder),
readerTimeout(reader) + READER_RUNNER_SLACK_S,
);
if (result.exit_code !== 0 || result.timed_out)
throw new Error(
`REA's reader runner for ${reader.id} ${result.timed_out ? "timed out" : `exited ${String(result.exit_code)}`}: ${result.stderr.slice(-1024) || result.stdout.slice(-1024)}`,
);
const view = readerAnswerView(result.stdout);
if (view === undefined) absent.push(reader.id);
else views.set(reader.id, view);
const read = await readTaskReaderViews([reader], run, () => path, folder);
for (const [id, view] of read) views.set(id, view);
if (!read.has(reader.id)) absent.push(reader.id);
} catch (cause: unknown) {
failure = { cause };
const completed = completedReaderAnswer(cause);
@@ -26,7 +26,7 @@ import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirect
import { ArtifactFileCleanupFailure } from "../../artifacts/ArtifactFileOperation.js";
import { writeJsonOutput } from "../../cli/jsonOutput.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { analysisViewRequestSchema } from "../../domain/analysisView/analysisViewRequest.js";
import { analysisViewRequestSchema } from "../../domain/analysisView/analysisView.js";
import { summarizeEnvironmentCoverage } from "../../domain/analysisView/environmentCoverageView.js";
import type { Evidence } from "../../domain/evidence.js";
import { inspectAnalysisViewValidated } from "../analysisView/AnalysisViewService.js";
@@ -26,14 +26,11 @@ const withTools = (tools: readonly EnvironmentManifestTool[]) => ({
manifest: { ...source.manifest, tools: [...tools] },
});
const environmentToolInputSchema = (tool: EnvironmentManifestTool) => {
const [definition] = environmentToolDefinitions(
withTools([tool]),
source.manifest.name,
);
const [definition] = environmentToolDefinitions(withTools([tool]));
if (definition === undefined) throw new Error("Missing tool definition");
return definition.inputSchema;
};
const definitions = environmentToolDefinitions(source, source.manifest.name);
const definitions = environmentToolDefinitions(source);
const tool = (name: string): EnvironmentManifestTool => {
const found = source.manifest.tools.find((entry) => entry.name === name);
@@ -50,9 +47,9 @@ describe("environmentToolDefinitions", () => {
expect(definition.description).toContain(
"state.source, not replay.exact_match, identifies state vs recording",
);
expect(
environmentToolDefinitions(source, source.manifest.name)[0]?.description,
).not.toContain("state.source");
expect(environmentToolDefinitions(source)[0]?.description).not.toContain(
"state.source",
);
});
it("maps every manifest tool in order with a replay description naming its records and evidence", () => {
@@ -157,13 +154,10 @@ const usersAnswering = (
record_ordinals: [...exchanges.keys()],
statuses: [...statuses],
};
const [definition] = environmentToolDefinitions(
{
...withTools([users]),
exchanges,
},
source.manifest.name,
);
const [definition] = environmentToolDefinitions({
...withTools([users]),
exchanges,
});
return definition?.description;
};
const returnsOf = (bodyText: string): string | undefined =>
@@ -320,31 +314,28 @@ describe("environment tool response summaries", () => {
const users = tool("get_users_by_id");
const ordinal = users.record_ordinals[0] ?? 0;
const catalog = (contentType: string) =>
prepareDefinitions(
{
...withTools(
Array.from({ length: 200 }, (_, index) => ({
...users,
name: `get_users_${String(index)}`,
record_ordinals: [ordinal],
})),
),
exchanges: new Map([
[
prepareDefinitions({
...withTools(
Array.from({ length: 200 }, (_, index) => ({
...users,
name: `get_users_${String(index)}`,
record_ordinals: [ordinal],
})),
),
exchanges: new Map([
[
ordinal,
{
ordinal,
{
ordinal,
path: "/api/users/2",
query: null,
status: 200,
contentType,
bodyText: JSON.stringify(fields),
},
],
]),
},
source.manifest.name,
);
path: "/api/users/2",
query: null,
status: 200,
contentType,
bodyText: JSON.stringify(fields),
},
],
]),
});
expect(catalog("text/html").ok).toBe(true);
const bounded = catalog("application/json");
expect(bounded).toMatchObject({
@@ -355,21 +346,18 @@ describe("environment tool response summaries", () => {
reportedLimits: { max_catalog_bytes: ENVIRONMENT_CATALOG_LIMIT_BYTES },
},
});
const [single] = environmentToolDefinitions(
{
...withTools([users]),
exchanges: new Map([
[
ordinal,
{
...source.exchanges.get(ordinal)!,
bodyText: JSON.stringify(fields),
},
],
]),
},
source.manifest.name,
);
const [single] = environmentToolDefinitions({
...withTools([users]),
exchanges: new Map([
[
ordinal,
{
...source.exchanges.get(ordinal)!,
bodyText: JSON.stringify(fields),
},
],
]),
});
expect(Buffer.byteLength(single?.description ?? "")).toBeLessThan(1700);
expect(single?.description).toContain(
"/300 shown; full list in capture Evidence)",
@@ -386,10 +374,7 @@ describe("environment catalog description budgets", () => {
path_template: `/${"😀".repeat(1000)}`,
query_parameter_names: [query],
};
const [definition] = environmentToolDefinitions(
withTools([expanded]),
source.manifest.name,
);
const [definition] = environmentToolDefinitions(withTools([expanded]));
expect(definition?.tool).toBe(expanded);
expect(Buffer.byteLength(definition?.description ?? "")).toBeLessThan(3500);
expect(definition?.description).toContain(
@@ -421,10 +406,7 @@ describe("environment catalog description budgets", () => {
})),
record_ordinals: ordinals,
};
const [definition] = environmentToolDefinitions(
withTools([expanded]),
source.manifest.name,
);
const [definition] = environmentToolDefinitions(withTools([expanded]));
expect(definition?.tool).toBe(expanded);
expect(expanded.params[0]?.observed_values).toBe(observations);
expect(expanded.record_ordinals).toBe(ordinals);
@@ -449,10 +431,7 @@ describe("environment catalog description budgets", () => {
...users,
path_template: `/api/${"segment/".repeat(200_000)}{id}`,
};
const [definition] = environmentToolDefinitions(
withTools([long]),
source.manifest.name,
);
const [definition] = environmentToolDefinitions(withTools([long]));
expect(Buffer.byteLength(definition?.description ?? "")).toBeLessThan(2048);
expect(definition?.description).toContain(
"path display truncated; full value in manifest Evidence",
@@ -616,7 +595,6 @@ describe("environment tool annotations", () => {
method,
})),
),
source.manifest.name,
).map((definition) => definition.annotations);
expect(hints).toEqual([
{ readOnlyHint: true, idempotentHint: true, openWorldHint: false },
@@ -432,7 +432,7 @@ const catalogBody = (
/** Admit complete discovery before binding; six bytes per UTF-16 unit covers JSON escaping and UTF-8 expansion. */
export const environmentToolDefinitions = (
source: EnvironmentSource,
name: string,
name: string = source.manifest.name,
{
operation = ENVIRONMENT_SERVE_OPERATION,
dataPlane = "none",
@@ -5,7 +5,7 @@ import {
} from "./JavaScriptArtifactAnalysis.js";
import { createJavaScriptSemanticGraphProjection } from "./JavaScriptSemanticGraphBuilder.js";
import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js";
import { analyzeJavaScriptStaticSource } from "../../../tests/fixtures/javascriptStaticAnalysis.js";
import { analyzeJavaScriptStaticSource } from "../../domain/javascript/javascriptStaticAnalysis.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import { err, ok } from "../../domain/result.js";
@@ -1,7 +1,7 @@
import { expect, it } from "vitest";
import { analyzeAndAdoptJavaScriptArtifactFiles } from "./JavaScriptArtifactAnalysis.js";
import { createJavaScriptSemanticGraphProjection } from "./JavaScriptSemanticGraphBuilder.js";
import { analyzeJavaScriptStaticSource } from "../../../tests/fixtures/javascriptStaticAnalysis.js";
import { analyzeJavaScriptStaticSource } from "../../domain/javascript/javascriptStaticAnalysis.js";
import type {
JavaScriptArtifactFile,
JavaScriptUnanalyzedArtifactFile,
@@ -5,7 +5,7 @@ import {
AnalysisCancelledError,
AnalysisResourceConstraintError,
} from "../../domain/analysisErrorCore.js";
import { analyzeJavaScriptStaticSource } from "../../../tests/fixtures/javascriptStaticAnalysis.js";
import { analyzeJavaScriptStaticSource } from "../../domain/javascript/javascriptStaticAnalysis.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import { err, ok } from "../../domain/result.js";
@@ -8,7 +8,7 @@ import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptA
import {
analyzeJavaScriptStaticSource,
analyzeParsedJavaScriptStaticSource,
} from "../../../tests/fixtures/javascriptStaticAnalysis.js";
} from "../../domain/javascript/javascriptStaticAnalysis.js";
import { parseJavaScriptSource } from "../../domain/javascript/javascriptSourceParser.js";
import type {
AnalyzedJavaScriptArtifactFile,
@@ -8,7 +8,7 @@ import { JavaScriptJsonDocuments } from "./JavaScriptJsonDocuments.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import { artifactLocalIdentity } from "./JavaScriptArtifactGraphContext.js";
import { applicationNodeIdentitySchema } from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js";
import { analyzeJavaScriptStaticSource } from "../../../tests/fixtures/javascriptStaticAnalysis.js";
import { analyzeJavaScriptStaticSource } from "../../domain/javascript/javascriptStaticAnalysis.js";
const completeInventoryCoverage = {
scope: "container-entries",
@@ -1,5 +1,5 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "../../../tests/fixtures/javascriptStaticAnalysis.js";
import { analyzeJavaScriptStaticSource } from "../../domain/javascript/javascriptStaticAnalysis.js";
import { analyzeJavaScriptSemantics } from "../../domain/javascript/javascriptSemanticAnalysis.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import { projectJavaScriptSemanticFileSteps } from "./JavaScriptSemanticGraphBuilder.js";
@@ -8,12 +8,6 @@ import { jsonValueSchema } from "../../domain/jsonValue.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { err, ok } from "../../domain/result.js";
/** Expected user-facing unknown reasons, independent of the trace producer. */
export const EXPECTED_CLEANUP_STOPPED_TRACE =
"Trace stopped because the provider did not confirm cleanup of resources it owns";
export const EXPECTED_UNRECORDED_VALUE_FLOW =
"Native def-use was not recorded for this procedure.";
/** Simulated provider seam for value traces; not a real decompiler observation. */
export const valueTraceSubject = {
path: "/fixture",
@@ -1,6 +1,9 @@
import { createAnalysisExecutionEvidence } from "../../domain/analysisObservation.js";
import { describe, expect, it } from "vitest";
import { traceNativeValues as traceNativeValuesCore } from "./NativeValueTrace.js";
import {
traceNativeValues as traceNativeValuesCore,
UNRECORDED_NATIVE_VALUE_FLOW,
} from "./NativeValueTrace.js";
import { executeComposedWorkflow } from "../WorkflowEvidence.js";
import {
createAnalysisExecution,
@@ -19,7 +22,6 @@ import {
import { NoBinaryOpenError } from "../../domain/configurationErrors.js";
import { ok, err } from "../../domain/result.js";
import {
EXPECTED_UNRECORDED_VALUE_FLOW,
nativeFlowDossier,
pcodeOperation as node,
pcodeScalar as scalar,
@@ -194,7 +196,7 @@ describe("native value trace seed admission", () => {
{
procedure: base.procedure.address,
address: null,
reason: EXPECTED_UNRECORDED_VALUE_FLOW,
reason: UNRECORDED_NATIVE_VALUE_FLOW,
},
],
});
+2 -2
View File
@@ -29,11 +29,11 @@ import {
import { createValueTraceBudget } from "./NativeValueTraceBudget.js";
/** The located unknown where unconfirmed provider cleanup stopped a trace. */
const CLEANUP_STOPPED_VALUE_TRACE =
export const CLEANUP_STOPPED_VALUE_TRACE =
"Trace stopped because the provider did not confirm cleanup of resources it owns";
/** Fallback when a dossier has no native value-flow facet for this procedure. */
const UNRECORDED_NATIVE_VALUE_FLOW =
export const UNRECORDED_NATIVE_VALUE_FLOW =
"Native def-use was not recorded for this procedure.";
/** Compose bounded high-p-code def-use graphs across exactly referenced call destinations. */
@@ -6,9 +6,11 @@ import {
AnalysisCapabilityUnavailableError,
} from "../../domain/analysisErrorCore.js";
import { nativeValueTraceSchema } from "../../domain/native/nativeValueTrace.js";
import { traceNativeValues } from "./NativeValueTrace.js";
import {
EXPECTED_CLEANUP_STOPPED_TRACE,
CLEANUP_STOPPED_VALUE_TRACE,
traceNativeValues,
} from "./NativeValueTrace.js";
import {
failingValueTraceReads,
valueTraceDiamond,
valueTraceProvider as provider,
@@ -147,7 +149,7 @@ describe("native value trace child stops", () => {
{
procedure: "0x1000",
address: "0x1008",
reason: EXPECTED_CLEANUP_STOPPED_TRACE,
reason: CLEANUP_STOPPED_VALUE_TRACE,
},
],
);
@@ -34,6 +34,7 @@ export const compareProcessEvidence = (
lookup?: EvidenceLookup,
options: {
readonly signal?: AbortSignal;
readonly now?: () => number;
} = {},
): Result<ProcessComparisonExecution, AnalysisError> => {
const selected = resolveEvidenceRequest(
@@ -56,6 +57,7 @@ export const compareProcessEvidence = (
...(request.trace_spec === undefined
? {}
: { traceSpecification: request.trace_spec }),
...(options.now === undefined ? {} : { now: options.now }),
});
};
+3 -3
View File
@@ -22,9 +22,9 @@ import type { ArtifactEntry } from "./ArtifactReader.js";
import { artifactFormatDescriptor } from "./artifactFormats.js";
import { ArtifactResourceScope } from "./ArtifactResourceScope.js";
import { extractArtifact } from "./extraction/ArtifactExtraction.js";
import { NODE_SEA_MAGIC } from "./nodeSea/NodeSeaBlob.js";
import {
NODE_SEA_FUSE_HASH,
NODE_SEA_FIXTURE_MAGIC,
buildNodeSeaBlob,
nodeSeaElf,
nodeSeaMachO,
@@ -206,7 +206,7 @@ it.each([
expect(root).toMatchObject({ artifact_kind: "container" });
expect(root?.format_metadata).toMatchObject({
kind: "node-sea",
magic: NODE_SEA_FIXTURE_MAGIC,
magic: NODE_SEA_MAGIC,
carrier,
code_path: "app/main.js",
disable_experimental_sea_warning: true,
@@ -503,7 +503,7 @@ it("fails closed when the located blob is truncated, has a bad magic, or unknown
message: expect.stringMatching(/magic.*not.*0x143da20/iu),
});
const unknown = Buffer.alloc(9);
unknown.writeUInt32LE(NODE_SEA_FIXTURE_MAGIC, 0);
unknown.writeUInt32LE(NODE_SEA_MAGIC, 0);
unknown.writeUInt32LE(1 << 5, 4);
unknown[8] = 1;
await expect(
+1 -1
View File
@@ -2551,7 +2551,7 @@ const embeddedPackageFormat = async (
nativeFormat: NativeFormat,
state: ScanState,
context: ScanContext,
location: string,
location = filePath,
expectedSha256?: string,
): Promise<EmbeddedPackageProbe> => {
try {
@@ -1014,8 +1014,8 @@ const isInterfaceBuilderArchive = (path: string): boolean => {
const readEntry = async (
reader: DirectoryArtifactReader,
entry: ArtifactEntry,
signal: AbortSignal | undefined,
maxInputBytes: number,
signal?: AbortSignal,
maxInputBytes = MAX_AGGREGATE_INPUT_BYTES,
): Promise<Buffer> => {
if (entry.declaredSize !== null && entry.declaredSize > maxInputBytes)
throw new InterfaceBuilderDecodeBudgetExceeded(
+14 -49
View File
@@ -1,34 +1,6 @@
import { describe, expect, it } from "vitest";
import { projectNibViewHierarchy } from "./NibViewHierarchy.js";
import { InterfaceBuilderDecodeBudget } from "./InterfaceBuilderDecodeBudget.js";
// Use the same budget and diagnostic accounting as Interface Builder's NIB owner.
const projectFixture = (
objects: Parameters<typeof projectNibViewHierarchy>[0],
retainedIds: ReadonlySet<number>,
expectedDiagnostics: readonly string[] = [],
maximumBytes = 4_000_000,
) => {
const budget = new InterfaceBuilderDecodeBudget(maximumBytes);
const diagnostics: string[] = [];
const projection = projectNibViewHierarchy(
objects,
retainedIds,
() => budget.tryReserve(128),
20_000,
(description) => {
budget.reserve(
256,
"NIB registry hierarchy diagnostics exceed the aggregate Interface Builder decode budget",
);
budget.reserveJsonStringOccurrence(description);
diagnostics.push(description);
},
);
expect(diagnostics).toEqual(expectedDiagnostics);
return projection;
};
const object = (id: number, parent?: number) => ({
id,
@@ -39,7 +11,7 @@ const object = (id: number, parent?: number) => ({
describe("serialized NIB view hierarchy bounds", () => {
it("does not invent hierarchy for objects without serialized relationships", () => {
expect(projectFixture([object(0)], new Set([0]))).toEqual({
expect(projectNibViewHierarchy([object(0)], new Set([0]))).toEqual({
hierarchy: null,
omitted: 0,
});
@@ -47,7 +19,7 @@ describe("serialized NIB view hierarchy bounds", () => {
it("uses the serialized parent direction and retains all sibling links", () => {
expect(
projectFixture(
projectNibViewHierarchy(
[object(0), object(1, 0), object(2, 0)],
new Set([0, 1, 2]),
),
@@ -66,7 +38,7 @@ describe("serialized NIB view hierarchy bounds", () => {
});
it("reports missing parents without inventing parentage", () => {
expect(projectFixture([object(0, 99)], new Set([0]))).toEqual({
expect(projectNibViewHierarchy([object(0, 99)], new Set([0]))).toEqual({
hierarchy: [],
omitted: 1,
});
@@ -74,7 +46,7 @@ describe("serialized NIB view hierarchy bounds", () => {
it("reports a missing encoded window content view as incomplete", () => {
expect(
projectFixture(
projectNibViewHierarchy(
[
{
id: 0,
@@ -89,7 +61,7 @@ describe("serialized NIB view hierarchy bounds", () => {
it("marks unsupported serialized parent values as incomplete", () => {
expect(
projectFixture(
projectNibViewHierarchy(
[{ id: 0, class_name: "NSView", values: { NSSuperview: "unknown" } }],
new Set([0]),
),
@@ -98,7 +70,7 @@ describe("serialized NIB view hierarchy bounds", () => {
it("reports cyclic and self-referential parentage as incomplete", () => {
expect(
projectFixture(
projectNibViewHierarchy(
[object(0, 1), object(1, 0), object(2, 2)],
new Set([0, 1, 2]),
),
@@ -109,7 +81,9 @@ describe("serialized NIB view hierarchy bounds", () => {
});
it("does not project children whose parent was not retained", () => {
expect(projectFixture([object(0), object(1, 0)], new Set([1]))).toEqual({
expect(
projectNibViewHierarchy([object(0), object(1, 0)], new Set([1])),
).toEqual({
hierarchy: [],
omitted: 1,
});
@@ -120,7 +94,8 @@ describe("serialized NIB view hierarchy bounds", () => {
object(id, id === 0 ? undefined : id - 1),
);
expect(
projectFixture(objects, new Set(objects.map(({ id }) => id))).omitted,
projectNibViewHierarchy(objects, new Set(objects.map(({ id }) => id)))
.omitted,
).toBe(3);
});
@@ -129,21 +104,14 @@ describe("serialized NIB view hierarchy bounds", () => {
object(id, id === 0 ? undefined : 0),
);
expect(
projectFixture(objects, new Set(objects.map(({ id }) => id))).omitted,
projectNibViewHierarchy(objects, new Set(objects.map(({ id }) => id)))
.omitted,
).toBe(2);
});
it("reports nodes the shared representation budget cannot reserve", () => {
const objects = [object(0), object(1, 0)];
expect(projectFixture(objects, new Set([0, 1]), [], 128)).toEqual({
hierarchy: [{ objectID: "0", children: [] }],
omitted: 1,
});
});
});
it("does not treat a dangling registry owner as top-level authority", () => {
const result = projectFixture(
const result = projectNibViewHierarchy(
[
{
id: 0,
@@ -167,9 +135,6 @@ it("does not treat a dangling registry owner as top-level authority", () => {
},
],
new Set([1]),
[
"NSIBObjectData object 0: NSObjectsKeys/NSObjectsValues pair child 1 with registry value 99; NSRoot is 99 (unresolved). This decoded registry association does not establish visual parentage.",
],
);
expect(result.hierarchy).toEqual([]);
expect(result.omitted).toBeGreaterThan(0);
+3 -3
View File
@@ -56,9 +56,9 @@ const isNonViewCell = (
export const projectNibViewHierarchy = (
objects: NibArchiveDocument["objects"],
retainedIds: ReadonlySet<number>,
reserveNode: () => boolean,
maxNodes: number,
reportUnknownRegistry: (description: string) => void,
reserveNode: () => boolean = () => true,
maxNodes = 20_000,
reportUnknownRegistry: (description: string) => void = () => {},
) => {
const byId = new Map(objects.map((object) => [object.id, object]));
const parents = new Map<number, number | null>();
@@ -74,10 +74,13 @@ const XML_REFERENCE_BYTES = 64;
const estimateXmlPlistExpansion = (
bytes: Buffer,
maxBytes: number,
xmlText: string | undefined,
numberProjection: boolean,
budget: string,
fail: (kind: BinaryPlistExpansionFailure, message: string) => Error,
xmlText?: string,
numberProjection = false,
budget = "the property list decode budget",
fail: (
kind: BinaryPlistExpansionFailure,
message: string,
) => Error = propertyListDecodeBudgetFailure,
): number => {
const xml = xmlText ?? decodeXmlPlistText(bytes);
// The decoded text and the DOM's copies of its character data.
+5 -10
View File
@@ -5,7 +5,10 @@ import {
ZIP_PACKAGE_FORMATS,
type ZipPackageFormat,
} from "../domain/zipPackageFormat.js";
import { ARTIFACT_FORMATS } from "./artifactFormats.js";
import {
ARTIFACT_FORMATS,
ZIP_FAMILY_CAPABILITIES,
} from "./artifactFormats.js";
import {
createArtifactReader,
type ArtifactReaderFormat,
@@ -73,15 +76,7 @@ describe("artifact format descriptors", () => {
it("shares one capability row across zip package families", () => {
for (const id of ZIP_PACKAGE_FORMATS)
expect(ARTIFACT_FORMATS[id]).toMatchObject({
zipFamily: true,
occurrenceContainer: true,
roleContainer: false,
retainClassifiedSource: true,
extractable: true,
requiresDarwin: false,
absentInventory: "unavailable",
});
expect(ARTIFACT_FORMATS[id]).toMatchObject(ZIP_FAMILY_CAPABILITIES);
const zipIds = new Set<ZipPackageFormat>(ZIP_PACKAGE_FORMATS);
for (const id of ARTIFACT_FORMAT_IDS)
if (!zipIds.has(id as ZipPackageFormat))
+1 -1
View File
@@ -134,7 +134,7 @@ function admittedAs(
}
/** One capability row for every ZIP-backed package family. */
const ZIP_FAMILY_CAPABILITIES = {
export const ZIP_FAMILY_CAPABILITIES = {
zipFamily: true,
occurrenceContainer: true,
roleContainer: false,
+5 -7
View File
@@ -6,11 +6,13 @@ import { open, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { probeBunStandaloneGraph } from "./BunModuleGraphLayout.js";
import {
probeBunStandaloneGraph,
BUN_GRAPH_SUFFIX_BYTES,
} from "./BunModuleGraphLayout.js";
import { decodeBunModuleGraph } from "./BunModuleGraphMembers.js";
import {
locateBunGraphFixture,
BUN_FIXTURE_GRAPH_SUFFIX_BYTES,
bunModuleGraphPayload,
bunPrefixedPayload,
machoBunSection,
@@ -165,11 +167,7 @@ it.each(["trailer", "byte_count"])(
modules: [{ name: "/$bunfs/root/main.js" }],
});
if (field === "trailer") payload[payload.length - 1] = 0;
else
payload.writeBigUInt64LE(
0n,
payload.length - BUN_FIXTURE_GRAPH_SUFFIX_BYTES,
);
else payload.writeBigUInt64LE(0n, payload.length - BUN_GRAPH_SUFFIX_BYTES);
const prefix = Buffer.alloc(4);
prefix.writeUInt32LE(payload.length);
expect(
+4 -4
View File
@@ -17,10 +17,10 @@ import { abortIfNeeded } from "../ArtifactHash.js";
import { ArtifactReaderFailure } from "../ArtifactReader.js";
/** Exact trailer written by Bun's standalone module graph serializer. */
const BUN_TRAILER = Buffer.from("\n---- Bun! ----\n");
const BUN_TRAILER_BYTES = BUN_TRAILER.length;
const BUN_OFFSETS_BYTES = 32;
const BUN_GRAPH_SUFFIX_BYTES = BUN_OFFSETS_BYTES + BUN_TRAILER_BYTES;
export const BUN_TRAILER = Buffer.from("\n---- Bun! ----\n");
export const BUN_TRAILER_BYTES = BUN_TRAILER.length;
export const BUN_OFFSETS_BYTES = 32;
export const BUN_GRAPH_SUFFIX_BYTES = BUN_OFFSETS_BYTES + BUN_TRAILER_BYTES;
/** Current compilers prefix a section with this little-endian payload length. */
export const BUN_LENGTH_PREFIX_BYTES = 8;
/** Bun 1.3.3 through 1.3.8 module records. */
+9 -14
View File
@@ -1,5 +1,9 @@
import type { FileHandle } from "node:fs/promises";
import {
BUN_OFFSETS_BYTES,
BUN_RECORD_BYTES_1_3_3,
BUN_RECORD_BYTES_1_3_9,
BUN_TRAILER,
loadBunStandaloneGraph,
probeBunStandaloneGraph,
type BunLayoutRevision,
@@ -7,15 +11,6 @@ import {
type BunStringPointer,
} from "./BunModuleGraphLayout.js";
// https://github.com/oven-sh/bun/blob/bun-v1.3.9/src/StandaloneModuleGraph.zig
// and the 1.3.3 serializer define the two supported layouts.
const OFFSETS_BYTES = 32;
const RECORD_BYTES_1_3_3 = 36;
const RECORD_BYTES_1_3_9 = 52;
export const BUN_FIXTURE_TRAILER = Buffer.from("\n---- Bun! ----\n");
export const BUN_FIXTURE_GRAPH_SUFFIX_BYTES =
OFFSETS_BYTES + BUN_FIXTURE_TRAILER.length;
/** Exercise the same two-stage probe and payload read as the artifact reader. */
export const locateBunGraphFixture = async (
handle: FileHandle,
@@ -51,7 +46,7 @@ export const bunModuleGraphPayload = (input: {
}): Buffer => {
const revision = input.revision ?? "bun-1.3.9";
const recordBytes =
revision === "bun-1.3.3" ? RECORD_BYTES_1_3_3 : RECORD_BYTES_1_3_9;
revision === "bun-1.3.3" ? BUN_RECORD_BYTES_1_3_3 : BUN_RECORD_BYTES_1_3_9;
const modules = input.modules ?? [];
const chunks: Buffer[] = [];
let cursor = 0;
@@ -92,8 +87,8 @@ export const bunModuleGraphPayload = (input: {
writePointer(record, 8, contents);
writePointer(record, 16, row.sourcemap);
writePointer(record, 24, row.bytecode);
const metadataAt = recordBytes === RECORD_BYTES_1_3_9 ? 48 : 32;
if (recordBytes === RECORD_BYTES_1_3_9) {
const metadataAt = recordBytes === BUN_RECORD_BYTES_1_3_9 ? 48 : 32;
if (recordBytes === BUN_RECORD_BYTES_1_3_9) {
writePointer(record, 32, row.moduleInfo);
writePointer(record, 40, row.origin);
}
@@ -105,13 +100,13 @@ export const bunModuleGraphPayload = (input: {
chunks.push(records.subarray(0, tableLength));
cursor += tableLength;
const data = Buffer.concat(chunks);
const offsets = Buffer.alloc(OFFSETS_BYTES);
const offsets = Buffer.alloc(BUN_OFFSETS_BYTES);
offsets.writeBigUInt64LE(BigInt(data.length), 0);
writePointer(offsets, 8, { offset: tableOffset, length: tableLength });
offsets.writeUInt32LE(input.entryPointId ?? 0, 16);
writePointer(offsets, 20, argv);
offsets.writeUInt32LE(input.flags ?? 0, 28);
return Buffer.concat([data, offsets, BUN_FIXTURE_TRAILER]);
return Buffer.concat([data, offsets, BUN_TRAILER]);
};
/** Length prefix used by current Mach-O, PE, and ELF section writers. */
+5 -13
View File
@@ -1,26 +1,18 @@
import { expect, it } from "vitest";
import {
DENO_SECTION_MAGIC,
MAX_DENO_METADATA_JSON_BYTES,
MAX_DENO_TABLE_COUNT,
parseDenoSection,
parseDenoSectionBytes,
} from "./DenoSectionLayout.js";
import {
buildDenoSection,
defaultDenoMetadata,
DENO_FIXTURE_SECTION_MAGIC,
} from "./DenoStandalone.fixture.js";
const secret = "super-secret-value";
const parseDenoSectionBytes = (bytes: Buffer, signal?: AbortSignal) =>
parseDenoSection(
bytes.length,
(offset, length) =>
Promise.resolve(bytes.subarray(offset, offset + length)),
signal,
);
it("parses metadata, tables, remote regions, and embedded configuration", async () => {
const original = Buffer.from("export const value = 1;\n");
const transpiled = Buffer.from("transpiled\n");
@@ -131,9 +123,9 @@ it("treats a missing opening marker as an unsupported layout", async () => {
it("refuses metadata JSON above the named allowance before reading it as JSON", async () => {
const claimed = MAX_DENO_METADATA_JSON_BYTES + 1;
const bytes = Buffer.alloc(DENO_FIXTURE_SECTION_MAGIC.length + 8 + claimed);
DENO_FIXTURE_SECTION_MAGIC.copy(bytes, 0);
bytes.writeBigUInt64LE(BigInt(claimed), DENO_FIXTURE_SECTION_MAGIC.length);
const bytes = Buffer.alloc(DENO_SECTION_MAGIC.length + 8 + claimed);
DENO_SECTION_MAGIC.copy(bytes, 0);
bytes.writeBigUInt64LE(BigInt(claimed), DENO_SECTION_MAGIC.length);
await expect(parseDenoSectionBytes(bytes)).rejects.toMatchObject({
reason: "limit",
message: expect.stringContaining("metadata JSON"),
+12
View File
@@ -366,6 +366,18 @@ export const parseDenoSection = async (
};
};
/** Parse a section that is already in memory. Tests and small fixtures use this. */
export const parseDenoSectionBytes = (
bytes: Buffer,
signal?: AbortSignal,
): Promise<DenoParsedSection> =>
parseDenoSection(
bytes.length,
(offset, length) =>
Promise.resolve(bytes.subarray(offset, offset + length)),
signal,
);
const addRemoteBody = (total: bigint, length: number): bigint => {
const next = total + BigInt(length);
if (next > BigInt(MAX_DENO_REMOTE_BODY_BYTES))
+3 -5
View File
@@ -1,9 +1,7 @@
import { elfNote, elfNoteFixture } from "../../native/elf/ElfNotes.fixture.js";
import { peResourceFixture } from "../../native/pe/PeResources.fixture.js";
// https://github.com/denoland/deno/blob/v2.9.8/cli/lib/standalone/binary.rs
// declares this section marker.
export const DENO_FIXTURE_SECTION_MAGIC = Buffer.from("d3n0l4nd", "latin1");
import { DENO_SECTION_MAGIC } from "./DenoSectionLayout.js";
export interface DenoSpecifierFixture {
readonly text?: string;
@@ -95,7 +93,7 @@ export const buildDenoSection = (options: DenoSectionFixture = {}): Buffer => {
const vfsBytes = Buffer.from(JSON.stringify(vfs), "utf8");
const parts: Buffer[] = [];
if (options.openingMagic !== null)
parts.push(options.openingMagic ?? DENO_FIXTURE_SECTION_MAGIC);
parts.push(options.openingMagic ?? DENO_SECTION_MAGIC);
parts.push(
u64(options.metadataLength ?? metadataBytes.length),
metadataBytes,
@@ -140,7 +138,7 @@ export const buildDenoSection = (options: DenoSectionFixture = {}): Buffer => {
}
parts.push(u64(vfsBytes.length), vfsBytes, u64(contents.length), contents);
if (options.closingMagic !== null)
parts.push(options.closingMagic ?? DENO_FIXTURE_SECTION_MAGIC);
parts.push(options.closingMagic ?? DENO_SECTION_MAGIC);
if (options.trailing !== undefined) parts.push(options.trailing);
return Buffer.concat(parts);
};
@@ -19,13 +19,13 @@ import { artifactFormatDescriptor } from "../artifactFormats.js";
import { ArtifactPathRegistry } from "../ArtifactPaths.js";
import { DenoStandaloneArtifactReader } from "../DenoStandaloneArtifactReader.js";
import { cArchiveExecutable } from "../pyinstaller/CArchive.fixture.js";
import { DENO_SECTION_MAGIC } from "./DenoSectionLayout.js";
import {
locateDenoStandalone,
MAX_DENO_INTEL_TRAILER_SENTINELS,
} from "./DenoStandaloneLocation.js";
import {
buildDenoSection,
DENO_FIXTURE_SECTION_MAGIC,
defaultDenoMetadata,
denoElf,
denoIntelTrailer,
@@ -249,7 +249,7 @@ it("fails closed when a named section is truncated or lacks the opening marker",
await expect(
inventoryOf(
"truncated",
denoMachO(Buffer.concat([DENO_FIXTURE_SECTION_MAGIC, Buffer.alloc(4)])),
denoMachO(Buffer.concat([DENO_SECTION_MAGIC, Buffer.alloc(4)])),
),
).rejects.toMatchObject({ reason: "format" });
await expect(
@@ -560,7 +560,7 @@ export const classifyArtifactPath = (
export const classifyArtifactContent = (
path: string,
evidence: ArtifactClassificationEvidence,
fileSize: number,
fileSize = evidence.prefix.length,
): {
readonly kind: ArtifactOccurrence["artifact_kind"];
readonly format: ArtifactOccurrence["artifact_format"];
@@ -620,7 +620,7 @@ const artifactRoleForFormat = (
/** Classify storage bytes independently of path names, roles, and permissions. */
export const classifyArtifactBytes = (
evidence: ArtifactClassificationEvidence,
fileSize: number,
fileSize = evidence.prefix.length,
): ArtifactNode["format"] => {
const { prefix, suffix, mzWindowsHeaderSignature } = evidence;
// A header-only region has the signature but not the declared cartridge
+2 -3
View File
@@ -1,8 +1,7 @@
import { elfNote, elfNoteFixture } from "../../native/elf/ElfNotes.fixture.js";
import { peResourceFixture } from "../../native/pe/PeResources.fixture.js";
/** Node 24 SEA blob magic: https://github.com/nodejs/node/blob/v24.12.0/src/node_sea.h */
export const NODE_SEA_FIXTURE_MAGIC = 0x143da20;
import { NODE_SEA_MAGIC } from "./NodeSeaBlob.js";
/** Official Node `NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2` hash. */
export const NODE_SEA_FUSE_HASH = "fce680ab2cc467b6e072b8b5df1996b2";
@@ -63,7 +62,7 @@ export const buildNodeSeaBlob = (
if (assets.length > 0) flags |= 1 << 3;
if (execArgv.length > 0) flags |= 1 << 4;
const header = Buffer.alloc(9);
header.writeUInt32LE(NODE_SEA_FIXTURE_MAGIC, 0);
header.writeUInt32LE(NODE_SEA_MAGIC, 0);
header.writeUInt32LE(flags, 4);
header[8] = options.execArgvExtension ?? 1;
const parts: Buffer[] = [header, sized(codePath), sized(body)];
+7 -3
View File
@@ -3,12 +3,16 @@ import { setImmediate } from "node:timers/promises";
import { expect, it } from "vitest";
import { DEFAULT_NODE_SEA_READER_LIMITS } from "../../domain/artifactReaderLimits.js";
import { decodeNodeSeaBlob, observeNodeSeaFuseMatches } from "./NodeSeaBlob.js";
import { buildNodeSeaBlob, NODE_SEA_FIXTURE_MAGIC } from "./NodeSea.fixture.js";
import {
NODE_SEA_MAGIC,
decodeNodeSeaBlob,
observeNodeSeaFuseMatches,
} from "./NodeSeaBlob.js";
import { buildNodeSeaBlob } from "./NodeSea.fixture.js";
const header = (flags: number, extension = 1): Buffer => {
const bytes = Buffer.alloc(9);
bytes.writeUInt32LE(NODE_SEA_FIXTURE_MAGIC, 0);
bytes.writeUInt32LE(NODE_SEA_MAGIC, 0);
bytes.writeUInt32LE(flags, 4);
bytes[8] = extension;
return bytes;
+1 -1
View File
@@ -9,7 +9,7 @@ import {
import { artifactReaderLimitFailure } from "../ArtifactReaderLimits.js";
/** Node `SeaBlob::kMagic` from `src/node_sea.h` (v24). */
const NODE_SEA_MAGIC = 0x143da20;
export const NODE_SEA_MAGIC = 0x143da20;
const FLAG_DISABLE_EXPERIMENTAL_SEA_WARNING = 1 << 0;
const FLAG_USE_SNAPSHOT = 1 << 1;
@@ -6,7 +6,7 @@ import {
} from "../../python/PythonPyc.fixture.js";
// PyInstaller CArchiveWriter's on-disk cookie signature, independent of REA's reader.
export const CARCHIVE_COOKIE_MAGIC = Buffer.from("4d45490c0b0a0b0e", "hex");
const CARCHIVE_COOKIE_MAGIC = Buffer.from("4d45490c0b0a0b0e", "hex");
/** Final CPython 3.9 bytecode magic, little-endian with the CR LF trailer. */
export const PYTHON_39_MAGIC = Buffer.from("610d0d0a", "hex");
+1 -1
View File
@@ -8,7 +8,7 @@ import {
} from "./PyInstallerFailure.js";
/** Magic at the start of a CArchive cookie. */
const PYINSTALLER_COOKIE_MAGIC = Buffer.from("MEI\x0c\x0b\x0a\x0b\x0e");
export const PYINSTALLER_COOKIE_MAGIC = Buffer.from("MEI\x0c\x0b\x0a\x0b\x0e");
/** PyInstaller 2.1+ cookie size, including the 64-byte library name. */
export const PYINSTALLER_COOKIE_V21_BYTES = 88;
@@ -9,10 +9,7 @@ import { ArtifactResourceScope } from "../ArtifactResourceScope.js";
import { inventoryArtifact } from "../inventory/ArtifactInventory.js";
import { classifyAndHashRootForInventory } from "../inventory/classify.js";
import { PyInstallerArtifactReader } from "../PyInstallerArtifactReader.js";
import {
CARCHIVE_COOKIE_MAGIC,
cArchiveExecutable,
} from "./CArchive.fixture.js";
import { cArchiveExecutable } from "./CArchive.fixture.js";
import * as layout from "./CArchiveLayout.js";
const fixture = () =>
@@ -251,7 +248,7 @@ it("retains rejected magic candidates without classifying their native carrier a
path,
Buffer.concat([
fixture().subarray(0, 64),
CARCHIVE_COOKIE_MAGIC,
layout.PYINSTALLER_COOKIE_MAGIC,
Buffer.alloc(88),
]),
);
@@ -291,7 +288,11 @@ it("continues past an invalid trailing magic copy to inventory the valid cookie"
const path = join(root, "app");
await writeFile(
path,
Buffer.concat([fixture(), CARCHIVE_COOKIE_MAGIC, Buffer.alloc(88)]),
Buffer.concat([
fixture(),
layout.PYINSTALLER_COOKIE_MAGIC,
Buffer.alloc(88),
]),
);
const resourceScope = new ArtifactResourceScope();
try {
+3 -3
View File
@@ -614,8 +614,8 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
const waitForOpen = async (
socket: WebSocket,
operation: BrowserObservationOperation,
signal: AbortSignal | undefined,
handshakeTimeoutMs: number,
signal?: AbortSignal,
commandTimeoutMs: number = DEFAULT_CDP_COMMAND_TIMEOUT_MS,
): Promise<void> =>
await new Promise((resolve, reject) => {
const onOpen = (): void => {
@@ -651,7 +651,7 @@ const waitForOpen = async (
};
const boundedTimeoutMs = Math.min(
MAX_BROWSER_DEADLINE_MS,
handshakeTimeoutMs,
commandTimeoutMs,
);
const timer = setTimeout(() => {
terminateSocket(socket);
+1 -1
View File
@@ -43,7 +43,7 @@ export interface CdpResponseFacts {
readonly encodedDataLength: number | undefined;
readonly headers: unknown;
}
const readCdpResponseFacts = (
export const readCdpResponseFacts = (
reported: unknown,
): CdpResponseFacts | undefined => {
const source = recordValue(reported);
+2 -2
View File
@@ -2,7 +2,7 @@ import { MIMEType } from "node:util";
import { describe, expect, it } from "vitest";
import { safeResponseMetadata as projectResponseMetadata } from "./CdpSafeMetadata.js";
import { readCdpResponseReceived } from "./CdpNetworkEventFacts.js";
import { readCdpResponseFacts } from "./CdpNetworkEventFacts.js";
import type { UnknownRecord } from "./CdpCaptureValues.js";
/** Admit each raw response fixture through the production field owner. */
@@ -12,7 +12,7 @@ const safeResponseMetadata = (
response: UnknownRecord,
allowedOrigins: ReadonlySet<string>,
) => {
const facts = readCdpResponseReceived({ requestId, response }).response;
const facts = readCdpResponseFacts(response);
if (facts === undefined)
throw new Error("Expected a response fixture record");
return projectResponseMetadata(requestId, url, facts, allowedOrigins);
+3 -4
View File
@@ -15,6 +15,7 @@ export interface InspectArgumentsPreflightOptions {
readonly signal?: AbortSignal;
readonly resources?: ArtifactResourceScope;
readonly readFuseImage?: typeof readElectronFuseImage;
readonly resolveFuseTarget?: typeof resolveElectronFuseTarget;
}
const appBundleDirectoryOf = (executablePath: string): string | null => {
@@ -46,15 +47,13 @@ export const assertNodeCliInspectArgumentsSupported = async (
): Promise<void> => {
assertArtifactReadActive(options?.signal);
const reader = options?.readFuseImage ?? readElectronFuseImage;
const resolver = options?.resolveFuseTarget ?? resolveElectronFuseTarget;
try {
let targetPath = executablePath;
const bundleDir = appBundleDirectoryOf(executablePath);
if (bundleDir !== null) {
try {
const resolved = await resolveElectronFuseTarget(
bundleDir,
options?.signal,
);
const resolved = await resolver(bundleDir, options?.signal);
targetPath = resolved.file.path;
} catch (cause: unknown) {
if (options?.signal?.aborted) throw cause;
+14 -36
View File
@@ -191,13 +191,9 @@ it("retains a fetch host that ignores an item deadline until it settles", async
});
const pending = fetchWebSourceMaps(
[request],
{
...input,
allowed_origins: [new URL(request.fetchUrl).origin],
source_map_fetch_timeout_ms: 50,
},
{ ...input, allowed_origins: [new URL(request.fetchUrl).origin] },
undefined,
{ fetch: async () => fetching },
{ fetch: async () => fetching, timeoutMs: 50 },
);
const result = await pending;
if (result.ok) throw new Error("Expected pending-fetch cleanup");
@@ -369,10 +365,9 @@ it.each([
{
...input,
allowed_origins: [new URL(request.fetchUrl).origin],
source_map_limits: { fetch_bytes: 1 },
},
controller.signal,
{ fetch: async () => new Response(body) },
{ fetch: async () => new Response(body), maxResponseBytes: 1 },
);
try {
await reading;
@@ -539,11 +534,7 @@ it.each([null, false])(
try {
const pending = fetchWebSourceMaps(
[{ ...request, declaredUrl: url, fetchUrl: url }],
{
...input,
allowed_origins: [new URL(url).origin],
source_map_fetch_timeout_ms: 10_000,
},
{ ...input, allowed_origins: [new URL(url).origin] },
controller.signal,
{
fetch: async (...args) => {
@@ -551,6 +542,7 @@ it.each([null, false])(
delivered();
return response;
},
timeoutMs: 10_000,
},
);
await responseReceived;
@@ -612,13 +604,9 @@ it("keeps an actual stalled HTTP header deadline local to its map", async () =>
try {
const result = await fetchWebSourceMaps(
[{ ...request, declaredUrl: url, fetchUrl: url }],
{
...input,
allowed_origins: [new URL(url).origin],
source_map_fetch_timeout_ms: 1000,
},
{ ...input, allowed_origins: [new URL(url).origin] },
undefined,
{ fetch },
{ fetch, timeoutMs: 1000 },
);
expect(result).toMatchObject({
ok: true,
@@ -653,13 +641,9 @@ it("keeps an actual stalled HTTP response deadline local to its map", async () =
try {
const result = await fetchWebSourceMaps(
[{ ...request, declaredUrl: url, fetchUrl: url }],
{
...input,
allowed_origins: [new URL(url).origin],
source_map_fetch_timeout_ms: 1000,
},
{ ...input, allowed_origins: [new URL(url).origin] },
undefined,
{ fetch },
{ fetch, timeoutMs: 1000 },
);
expect(result).toMatchObject({
ok: true,
@@ -690,11 +674,7 @@ it.each([302, 503])(
}).tee();
const pending = fetchWebSourceMaps(
[request],
{
...input,
allowed_origins: [new URL(request.fetchUrl).origin],
source_map_fetch_timeout_ms: 100,
},
{ ...input, allowed_origins: [new URL(request.fetchUrl).origin] },
undefined,
{
fetch: async () =>
@@ -702,6 +682,7 @@ it.each([302, 503])(
status,
...(status === 302 ? { headers: { location: "/next" } } : {}),
}),
timeoutMs: 100,
},
);
let timer: ReturnType<typeof setTimeout> | undefined;
@@ -738,11 +719,7 @@ it("retains an admitted response when the deadline expires before body reading",
}).tee();
const pending = fetchWebSourceMaps(
[request],
{
...input,
allowed_origins: [new URL(request.fetchUrl).origin],
source_map_fetch_timeout_ms: 50,
},
{ ...input, allowed_origins: [new URL(request.fetchUrl).origin] },
undefined,
{
fetch: async () => {
@@ -750,6 +727,7 @@ it("retains an admitted response when the deadline expires before body reading",
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 150);
return new Response(body);
},
timeoutMs: 50,
},
);
try {
@@ -779,7 +757,6 @@ it("retains an oversized redirect identity's pending response cleanup", async ()
...input,
allowed_origins: [new URL(request.fetchUrl).origin],
source_map_limits: { output_bytes: 4000 },
source_map_fetch_timeout_ms: 100,
},
undefined,
{
@@ -787,6 +764,7 @@ it("retains an oversized redirect identity's pending response cleanup", async ()
String(url) === request.fetchUrl
? new Response(null, { status: 302, headers: { location: finalUrl } })
: new Response(body),
timeoutMs: 100,
},
);
try {
+11 -5
View File
@@ -60,6 +60,10 @@ export interface WebSourceMapRequest {
interface SourceMapFetchHost {
readonly fetch: typeof fetch;
/** Fetch-operation deadline; injectable so boundary regressions stay fast. */
readonly timeoutMs?: number;
/** Maximum response bytes retained across this operation. */
readonly maxResponseBytes?: number;
}
// Source maps are normally fetched as a small part of a larger inspection.
@@ -121,10 +125,12 @@ export const fetchWebSourceMaps = async (
const limits = selectedSourceMapLimits(input.source_map_limits);
const items: WebSourceMapItem[] = [];
const operationController = new AbortController();
const fetchTimeoutMs = selectedBrowserDeadlineMs(
input.source_map_fetch_timeout_ms,
SOURCE_MAP_FETCH_TIMEOUT_MS,
);
const fetchTimeoutMs =
host.timeoutMs ??
selectedBrowserDeadlineMs(
input.source_map_fetch_timeout_ms,
SOURCE_MAP_FETCH_TIMEOUT_MS,
);
const fetchDeadlineLimitation =
sourceMapFetchDeadlineLimitation(fetchTimeoutMs);
const timeout = setTimeout(
@@ -689,7 +695,7 @@ const fetchOne = async (
const body = await readBoundedText(
response,
budget,
budget.decodedRecords.limits.fetchBytes,
host.maxResponseBytes ?? budget.decodedRecords.limits.fetchBytes,
signal,
fetchedUrl,
);
@@ -125,6 +125,7 @@ const harness = (
selection: selected,
limits: scenario.capture.limits,
secrets,
readTimeoutMs: 50,
...(signal === undefined ? {} : { signal }),
emit: (event) => {
const sequence = events.length + 1;
@@ -732,7 +733,7 @@ it("starts each queued body's timeout when its read begins", async () => {
const response = new ResponseFixture(request, "unused");
response.read = () =>
new Promise((resolve) => {
setTimeout(() => resolve(Buffer.from("captured")), 4_000);
setTimeout(() => resolve(Buffer.from("captured")), 40);
});
collector.request(request);
collector.response(response);
@@ -740,8 +741,8 @@ it("starts each queued body's timeout when its read begins", async () => {
}
const finishing = collector.finish();
await vi.advanceTimersByTimeAsync(4_000);
await vi.advanceTimersByTimeAsync(4_000);
await vi.advanceTimersByTimeAsync(40);
await vi.advanceTimersByTimeAsync(40);
await finishing;
const content = events.filter(
@@ -816,7 +817,7 @@ it("bounds hung completed-body reads and rejects late mutation after finalizatio
collector.response(response);
collector.finished(request);
const finishing = collector.finish();
await vi.advanceTimersByTimeAsync(5_001);
await vi.advanceTimersByTimeAsync(51);
await finishing;
const frozen = JSON.stringify(events);
expect(events).toContainEqual(
@@ -125,6 +125,8 @@ interface NetworkCaptureOptions {
reason: BrowserScenarioEventBudgetReason,
) => void;
readonly signal?: AbortSignal;
/** Production uses 5s; a fixture can supply its own bounded-read clock. */
readonly readTimeoutMs?: number;
}
/** Captures correlated network phases and drains selected content before browser cleanup. */
@@ -154,7 +156,7 @@ export class PlaywrightScenarioNetwork {
constructor(private readonly options: NetworkCaptureOptions) {
this.limits = options.limits;
this.readOptions = {
timeoutMs: PLAYWRIGHT_NETWORK_READ_TIMEOUT_MS,
timeoutMs: options.readTimeoutMs ?? PLAYWRIGHT_NETWORK_READ_TIMEOUT_MS,
maxBodyBytes: this.limits.max_network_body_bytes,
maxBodyAllocationBytes: this.limits.max_network_body_allocation_bytes,
maxHeaderAllocationBytes: this.limits.max_network_header_allocation_bytes,
@@ -1,15 +0,0 @@
import { expect, it } from "vitest";
import { ANALYSIS_SETTINGS } from "../domain/analysisSettingDefinitions.js";
import { parseConfig } from "./parseConfig.js";
import { parseEnvironment } from "./environment.js";
it("admits invalid runtime-owned launch seeds at startup", () => {
for (const definition of Object.values(ANALYSIS_SETTINGS)) {
if (definition.updateMode !== "runtime" || !("environment" in definition))
continue;
const environment = { [definition.environment]: "not-a-number" };
expect(parseEnvironment(environment).ok, definition.environment).toBe(true);
expect(parseConfig(environment).ok, definition.environment).toBe(true);
}
});
+19
View File
@@ -1,6 +1,7 @@
import { positiveDecimalIntegerSchema } from "./positiveDecimalInteger.js";
import { analysisSettingLimit } from "../domain/analysisSettingDefinitions.js";
import { currentAnalysisSettingOverride } from "../domain/analysisOperationSnapshot.js";
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { err, ok, type Result } from "../domain/result.js";
@@ -94,6 +95,24 @@ export const parseBridgeMaxRequestBytes = (
);
};
/**
* Resolve the ceiling from a composition root's selected environment. An
* invalid setting is refused, not ignored. Callers that already resolved it
* at startup pass that value through instead of reading the environment again.
*/
export const resolveBridgeMaxRequestBytes = (
environment: Readonly<Record<string, string | undefined>>,
): BridgeMaxRequestBytes => {
const override = currentAnalysisSettingOverride("bridge.max_request_bytes");
if (override !== undefined)
return { maximumBytes: override, source: "runtime" };
const parsed = parseBridgeMaxRequestBytes(
environment[BRIDGE_MAX_REQUEST_BYTES_SETTING],
);
if (!parsed.ok) throw parsed.error;
return parsed.value;
};
/** How to read and change the effective bridge request ceiling. */
export const bridgeRequestLimit = (limit: BridgeMaxRequestBytes) =>
analysisSettingLimit(
+6 -8
View File
@@ -23,6 +23,7 @@ import {
DEBUGGER_MAX_PENDING_COMMANDS_SETTING,
DEBUGGER_MAX_SESSIONS_SETTING,
parseDebuggerSessionLimits,
resolveDebuggerSessionLimits,
} from "./debuggerSessionLimits.js";
it("uses the shared defaults when the settings are unset", () => {
@@ -301,12 +302,9 @@ it("defers an invalid GDB deadline until a command reads it", () => {
code: "configuration_invalid",
details: { settings: parsed.error.settings },
});
const valid = parseDebuggerSessionLimits({
[DEBUGGER_COMMAND_DEADLINE_SETTING]: "1500",
});
if (!valid.ok) throw valid.error;
expect(valid.value.commandDeadline).toMatchObject({
value: 1500,
source: "setting",
});
expect(
resolveDebuggerSessionLimits({
[DEBUGGER_COMMAND_DEADLINE_SETTING]: "1500",
}).commandDeadline,
).toMatchObject({ value: 1500, source: "setting" });
});
+9
View File
@@ -270,6 +270,15 @@ export const debuggerConstructionLimits = (
return again.value;
};
/** Refuse an invalid launch cap instead of falling back to the default. */
export const resolveDebuggerSessionLimits = (
environment: Readonly<Record<string, string | undefined>>,
): DebuggerSessionLimits => {
const parsed = parseDebuggerSessionLimits(environment);
if (!parsed.ok) throw parsed.error;
return parsed.value;
};
/**
* Fixed budget for Rizin cleanup readback and GDB startup or detach MI.
* This is not `REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES`. A caller cap below
+10 -13
View File
@@ -16,6 +16,7 @@ import { ConfigurationError } from "../domain/configurationErrors.js";
import { runWithAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import {
HOPPER_CALLER_SCAN_BUDGET_SETTING,
hopperCallerScanLimit,
hopperCallerScanRecovery,
} from "./hopperCallerScanBudget.js";
@@ -78,19 +79,15 @@ it("explains the time budget and that fixed byte allowances have no override", (
const parsed = parseConfig({ [HOPPER_CALLER_SCAN_BUDGET_SETTING]: "250" });
if (!parsed.ok) throw parsed.error;
const budget = parsed.value.hopperCallerScanBudget;
const deadline = hopperCallerScanRecovery(
facts({ stop: { code: "work_budget", budget_ms: 250 } }),
budget,
);
expect(deadline).toContain(
"hopper.caller_scan_budget_ms is 250 ms (source setting",
);
expect(deadline).toContain(
`floor ${String(MINIMUM_HOPPER_CALLER_SCAN_BUDGET_MS)} ms`,
);
expect(deadline).toContain(`maximum ${String(MAX_NODE_TIMER_DELAY_MS)} ms`);
expect(deadline).toContain("update_analysis_settings");
expect(deadline).toContain(HOPPER_CALLER_SCAN_BUDGET_SETTING);
expect(hopperCallerScanLimit(budget)).toMatchObject({
setting: "hopper.caller_scan_budget_ms",
effective_value: 250,
source: "setting",
minimum: MINIMUM_HOPPER_CALLER_SCAN_BUDGET_MS,
maximum: MAX_NODE_TIMER_DELAY_MS,
update_mode: "runtime",
pending_restart: false,
});
expect(DEFAULT_HOPPER_CALLER_SCAN_BUDGET_MS).toBe(2000);
const page = hopperCallerScanRecovery(
facts({
+1 -1
View File
@@ -56,7 +56,7 @@ export const hopperCallerScanBudgetForMilliseconds = (
const fixedAllowanceRecovery = `This release has no runtime override for the ${String(HOPPER_CALLER_PAGE_ALLOWANCE_BYTES)} byte instruction, native-caller, and page allowances or the ${String(HOPPER_CALLER_SCAN_RETENTION_BYTES)} byte retention budget. hopper.caller_scan_budget_ms does not raise them.`;
/** Structured facts for the scan time budget. The sentence is display text. */
const hopperCallerScanLimit = (budget: HopperCallerScanBudget) =>
export const hopperCallerScanLimit = (budget: HopperCallerScanBudget) =>
analysisSettingLimit(
"hopper.caller_scan_budget_ms",
budget.milliseconds,
+1 -1
View File
@@ -6,8 +6,8 @@ import {
binaryLayoutSummaryViewResultSchema,
javascriptApplicationSummaryViewResultSchema,
projectAnalysisView,
analysisViewRequestSchema,
} from "../domain/analysisView/analysisView.js";
import { analysisViewRequestSchema } from "../domain/analysisView/analysisViewRequest.js";
import {
analysisViewCaptureEvidence,
analysisViewJavaScriptEvidence,
-13
View File
@@ -754,19 +754,6 @@ it("includes restored root identity in the compact byte budget", () => {
expect(result.schema.properties).toEqual({ value: { type: "number" } });
});
it("bounds an oversized root description while retaining an object input", () => {
const budget = 13 * 1024;
const result = compactAdvertisedInputSchema(
{ type: "object", description: "x".repeat(budget + 1), properties: {} },
budget,
);
expect(result.presentation).toBe("reduced");
expect(serializedBytes(result.schema)).toBeLessThanOrEqual(budget);
const validate = new Ajv2020({ strict: false }).compile(result.schema);
expect(validate({ value: "allowed" })).toBe(true);
expect(validate("not an object")).toBe(false);
});
// An untyped primitive enum is gateway-incompatible, so these schemas take
// the reduced projection independently of the budget.
const reducedPresentation = (
+7 -1
View File
@@ -87,7 +87,13 @@ export const compactAdvertisedInputSchema = (
// an honest, budget-fitting shape instead of an oversized one.
return {
schema: reduceToShallowProperties(
{ type: "object" },
{
type: "object",
description:
(typeof schema.description === "string"
? `${schema.description} `
: "") + INPUT_REDUCED_ANNOTATION,
},
INPUT_REDUCED_ANNOTATION,
),
presentation: "reduced",
+12 -95
View File
@@ -180,79 +180,6 @@ const isBooleanSchema = (schema: unknown): boolean =>
isObject(schema) &&
(schema.type === "boolean" || isBooleanLiteralSchema(schema));
const isObjectKeyword = (key: string) => schemaMaps.has(key);
const isArrayKeyword = (key: string) => schemaArrays.has(key);
const isChildKeyword = (key: string) => schemaChildren.has(key);
/**
* Remove every instance default below a schema, not only the one this keyword
* carries, keeping the entries at `kept`. A nested default belongs to the group
* that declares it: outside that group a client that fills it would send an
* object a strict sibling refuses. A default a reference still targets stays
* caller data; dropping it would leave that reference dangling.
*/
const omitNestedDefaults = (
schema: unknown,
kept: ReadonlySet<string> = new Set(),
path: readonly string[] = [],
): unknown => {
if (Array.isArray(schema))
return schema.map((child, index) =>
omitNestedDefaults(child, kept, [...path, String(index)]),
);
if (!isObject(schema)) return schema;
return Object.fromEntries(
Object.entries(schema).flatMap(([key, value]) => {
const childPath = [...path, key];
if (kept.has(JSON.stringify(childPath))) return [[key, value]];
if (key === "default") return [];
return [
[
key,
isObjectKeyword(key) && isObject(value)
? Object.fromEntries(
Object.entries(value).map(([name, child]) => [
name,
omitNestedDefaults(child, kept, [...childPath, name]),
]),
)
: isArrayKeyword(key) && Array.isArray(value)
? value.map((child, index) =>
omitNestedDefaults(child, kept, [
...childPath,
String(index),
]),
)
: isChildKeyword(key)
? Array.isArray(value)
? value.map((child, index) =>
omitNestedDefaults(child, kept, [
...childPath,
String(index),
]),
)
: omitNestedDefaults(value, kept, childPath)
: value,
],
];
}),
);
};
/** Every path into a `default` that a reference inside `schema` targets. */
const referencedDefaultPaths = (
schema: Readonly<Record<string, unknown>>,
): ReadonlySet<string> => {
const kept = new Set<string>();
for (const { target } of new JsonSchemaReferences(schema).references()) {
if (target.kind !== "target") continue;
const defaultAt = target.path.indexOf("default");
if (defaultAt < 0) continue;
kept.add(JSON.stringify(target.path.slice(0, defaultAt + 1)));
}
return kept;
};
const preserveSharedDefault = (
schema: Record<string, unknown>,
branches: readonly unknown[],
@@ -672,19 +599,14 @@ const flattenRootObjectUnion = (
const required = sharedRequiredProperties(branches);
// A default lifted out of an input group can make a client send a field
// rejected by another strict group. Keep it only when every group owns the
// property and supplies the same default. A property some group omits takes
// its nested defaults too: filling only the inner property would otherwise
// still send an object that sibling group refuses. A default the nested
// property carries inside a group that does accept the parent stays, so
// callers keep that guidance.
// property and supplies the same default.
for (const [name, schemas] of variants) {
const properties = branches.map((branch) =>
isObject(branch.properties) ? branch.properties[name] : undefined,
);
const ownedByEveryGroup = properties.every(isObject);
const defaults = properties.map((property) =>
isObject(property) ? property.default : undefined,
);
const defaults = branches.map((branch) => {
const property = isObject(branch.properties)
? branch.properties[name]
: undefined;
return isObject(property) ? property.default : undefined;
});
if (
defaults.every(
(value) => value !== undefined && isDeepStrictEqual(value, defaults[0]),
@@ -693,16 +615,11 @@ const flattenRootObjectUnion = (
continue;
const withoutDefaults: unknown[] = [];
for (const schema of schemas) {
const withoutDefault = ownedByEveryGroup
? isObject(schema) && Object.hasOwn(schema, "default")
? Object.fromEntries(
Object.entries(schema).filter(([key]) => key !== "default"),
)
: schema
: omitNestedDefaults(
schema,
isObject(schema) ? referencedDefaultPaths(schema) : new Set(),
);
let withoutDefault = schema;
if (isObject(schema) && Object.hasOwn(schema, "default")) {
const { default: _default, ...rest } = schema;
withoutDefault = rest;
}
if (
!withoutDefaults.some((known) =>
isDeepStrictEqual(known, withoutDefault),
-113
View File
@@ -831,116 +831,3 @@ it.each(["$ref", "$dynamicRef"])(
expect(advertised(value)).toBe(canonical(value));
},
);
describe("root union default presentation", () => {
it.each(["draft-2020-12", "draft-07"] as const)(
"omits a nested default the other root group would refuse in %s",
(target) => {
const normalization = z
.strictObject({ rules: z.array(z.string()).default([]) })
.default({ rules: [] });
const schema = z.union([
z.strictObject({ before: z.string(), after: z.string() }),
z.strictObject({
before_scenario: z.string(),
after_scenario: z.string(),
normalization,
}),
]);
const canonical = z.toJSONSchema(schema, { io: "input", target });
const advertised = presentInputJsonSchema(
canonical,
(property) => property,
);
const defaults: string[] = [];
const collect = (value: unknown, path: readonly string[]): void => {
if (Array.isArray(value)) {
value.forEach((child, index) =>
collect(child, [...path, String(index)]),
);
return;
}
if (typeof value !== "object" || value === null) return;
if (Object.hasOwn(value, "default")) defaults.push(path.join("."));
for (const [key, child] of Object.entries(value))
collect(child, [...path, key]);
};
collect(advertised, []);
// `normalization` belongs to the scenario group only. Leaving its
// `rules` default behind made a client that fills nested defaults send
// an object the strict passive group rejects.
expect(defaults).toEqual([]);
const ajv =
target === "draft-2020-12"
? new Ajv2020({ strict: false })
: new Ajv({ strict: false });
expect(
ajv.compile(advertised)({ before: "first", after: "second" }),
).toBe(true);
expect(
schema.safeParse({
before: "first",
after: "second",
normalization: { rules: [] },
}).success,
).toBe(false);
},
);
it.each(["draft-2020-12", "draft-07"] as const)(
"keeps a nested default a reference inside its resource targets in %s",
(target) => {
const schema = z.union([
z.strictObject({ before: z.string(), after: z.string() }),
z.strictObject({
before_scenario: z.string(),
after_scenario: z.string(),
normalization: z
.object({ rules: z.array(z.string()).optional() })
.default({ rules: [] }),
}),
]);
const canonical = z.toJSONSchema(schema, { io: "input", target }) as {
anyOf: {
properties: Record<string, Record<string, unknown>>;
required: string[];
additionalProperties: boolean;
}[];
};
// A nested resource keeps the group, so the union still flattens. Its
// sibling reference points into the default the flattening would drop.
const scenario = canonical.anyOf[1] as NonNullable<
(typeof canonical.anyOf)[1]
>;
const normalization = scenario.properties.normalization as Record<
string,
unknown
>;
canonical.anyOf[1] = {
...scenario,
properties: {
...scenario.properties,
normalization: {
$id: "https://example.test/normalization",
...normalization,
properties: {
...(normalization.properties as Record<string, unknown>),
alias: { $ref: "#/default/rules" },
},
},
},
};
const advertised = presentInputJsonSchema(
canonical,
(property) => property,
);
expect(advertised).not.toHaveProperty("anyOf");
const ajv =
target === "draft-2020-12"
? new Ajv2020({ strict: false })
: new Ajv({ strict: false });
const validate = ajv.compile(advertised);
expect(validate({ before: "first", after: "second" })).toBe(true);
},
);
});
+3 -1
View File
@@ -256,7 +256,9 @@ const hasRelativeSchemaReference = (
const withAdvertisedJsonSchema = <Schema extends z.ZodType>(
canonical: Schema,
io: "input" | "output",
advertise: (project: JsonSchemaProjection) => JsonSchemaProjection,
advertise: (project: JsonSchemaProjection) => JsonSchemaProjection = (
project,
) => project,
): Schema => {
// Zod's input projection drops root metadata when a descendant transforms.
// Preserve the parser and let the SDK own conversion of everything else.
+7 -20
View File
@@ -66,16 +66,10 @@ it.each(["http", "invalid_json", "readiness"] as const)(
}
},
);
it.each([
"canonical",
"missing",
"rpc_error",
"trailing_base64",
"scalar_metadata",
] as const)(
it.each(["canonical", "missing", "rpc_error"] as const)(
"validates recording replies and redacts their errors (%s)",
async (answer) => {
const present = answer !== "missing" && answer !== "rpc_error";
const present = answer === "canonical";
const token = "fake-recording-control-token-123456789";
const path = "rec-0123/0000.mp4";
const homePath = `.agentenv-recordings/${path}`;
@@ -101,16 +95,11 @@ it.each([
parts: [
{
kind: "file",
file: {
bytes:
bytes.toString("base64") +
(answer === "trailing_base64" ? "!ignored" : ""),
},
file: { bytes: bytes.toString("base64") },
metadata: {
"urn:unrelated":
answer === "scalar_metadata"
? "recording label"
: { path: present ? "wrong-path" : homePath },
"urn:unrelated": {
path: present ? "wrong-path" : homePath,
},
...(present
? {
"urn:agentenv:data-objects/v1": {
@@ -149,9 +138,7 @@ it.each([
},
],
});
if (answer === "trailing_base64")
await expect(read).rejects.toThrow("Expected canonical padded base64");
else if (present) await expect(read).resolves.toEqual([{ path, bytes }]);
if (present) await expect(read).resolves.toEqual([{ path, bytes }]);
else if (answer === "rpc_error") {
const failure = await read.catch((cause: unknown) => cause);
expect(failure).toBeInstanceOf(Error);
@@ -94,30 +94,6 @@ it.each(["list", "sources"] as const)(
describe.skipIf(
process.platform !== "linux" || !existsSync("/usr/bin/apt-config"),
)("resolveAptLocations with apt-config", () => {
it("preserves newline paths printed by the real apt-config command", async () => {
const root = await createTestTempDirectory("apt-config-newline-");
const selected = {
lists: `${root}/user's\nlists/`,
trustedParts: `${root}/trusted\nparts/`,
trusted: `${root}/trusted\nkeys.gpg`,
};
const configured: QueryRunner = (command, argv, signal) =>
runHostQuery(
command,
[
"-o",
`Dir::State::Lists=${selected.lists}`,
"-o",
`Dir::Etc::TrustedParts=${selected.trustedParts}`,
"-o",
`Dir::Etc::Trusted=${selected.trusted}`,
...argv,
],
signal,
);
expect(await resolveAptLocations(configured, undefined)).toEqual(selected);
});
it("reads the lists folder an APT_CONFIG file names, resolved under Dir::State", async () => {
const root = await createTestTempDirectory("apt-config-");
const config = join(root, "apt.conf");
+2 -10
View File
@@ -1,7 +1,5 @@
import { z } from "zod";
import { canonicalBase64Schema } from "../domain/stringPatterns.js";
/** Where a data-plane file part names its path in the home. */
export const DATA_OBJECTS = "urn:agentenv:data-objects/v1";
/** A `data/get` answer: file parts, each naming its home path, or an error. */
@@ -10,15 +8,9 @@ export const dataGetSchema = z.union([
result: z.object({
parts: z.array(
z.looseObject({
file: z
.looseObject({ bytes: canonicalBase64Schema.optional() })
.optional(),
file: z.looseObject({ bytes: z.string().optional() }).optional(),
metadata: z
.looseObject({
[DATA_OBJECTS]: z
.looseObject({ path: z.string().optional() })
.optional(),
})
.record(z.string(), z.looseObject({ path: z.string().optional() }))
.optional(),
}),
),
+26 -29
View File
@@ -876,37 +876,34 @@ describe.skipIf(process.platform === "win32")("desktop entry package", () => {
expect(installed).not.toHaveProperty("executable_package_reason");
});
it.each(["user's lists", "user's\nlists"])(
"finds apt's package lists in %j, or the default when it cannot say",
async (name) => {
const { root, runQuery, inspect } = await writerFixture();
// A user-space apt-get update under APT_CONFIG keeps its lists elsewhere.
const userLists = join(root, name);
await rename(join(root, "lists"), userLists);
await mkdir(join(root, "lists"));
const answering =
(outcome: QueryOutcome): QueryRunner =>
async (command, argv, signal) =>
command === "apt-config" ? outcome : runQuery(command, argv, signal);
const quoted = `LISTS='${userLists.replaceAll("'", "'\\''")}/'\n`;
it("finds apt's package lists in the folder apt-config names, or the default when it cannot say", async () => {
const { root, runQuery, inspect } = await writerFixture();
// A user-space apt-get update under APT_CONFIG keeps its lists elsewhere.
const userLists = join(root, "user's lists");
await rename(join(root, "lists"), userLists);
await mkdir(join(root, "lists"));
const answering =
(outcome: QueryOutcome): QueryRunner =>
async (command, argv, signal) =>
command === "apt-config" ? outcome : runQuery(command, argv, signal);
const quoted = `LISTS='${userLists.replaceAll("'", "'\\''")}/'\n`;
const present = await Promise.all(
[
{ stdout: quoted, exitCode: 0 },
{ stdout: "", exitCode: 100 },
"missing" as const,
].map(async (outcome) => {
const { installed } = await inspect(
{ desktopEntry: "libreoffice-writer" },
{ run: answering(outcome) },
);
return installed.package_lists_present;
}),
);
const present = await Promise.all(
[
{ stdout: quoted, exitCode: 0 },
{ stdout: "", exitCode: 100 },
"missing" as const,
].map(async (outcome) => {
const { installed } = await inspect(
{ desktopEntry: "libreoffice-writer" },
{ run: answering(outcome) },
);
return installed.package_lists_present;
}),
);
expect(present).toEqual([true, false, false]);
},
);
expect(present).toEqual([true, false, false]);
});
it("records why the computer cannot install the program's package when no source it can use offers it", async () => {
const { runQuery, inspect } = await writerFixture();
+4 -73
View File
@@ -12,79 +12,10 @@ import {
retainGuestFiles,
} from "./AgentEnvComputer.js";
describe("guest file encoding", () => {
const read = (encoded: string) =>
readHomeFiles(
async () => ({
result: {
parts: [
{
file: { bytes: encoded },
metadata: {
"urn:agentenv:data-objects/v1": { path: "selected.bin" },
},
},
],
},
}),
(text) => text,
["selected.bin"],
);
it.each(["!!!", "aGVsbG8=garbage", "aGVsbG8", "Zh==", "AA-_"])(
"rejects malformed base64 %j before retaining file bytes",
async (encoded) => {
await expect(read(encoded)).rejects.toThrow(
"Expected canonical padded base64",
);
},
);
it.each([
Buffer.alloc(0),
Buffer.from("hello"),
Buffer.from([0, 255, 128, 0]),
])("preserves valid empty, text and binary files: %j", async (bytes) => {
await expect(read(bytes.toString("base64"))).resolves.toEqual([
{ path: "selected.bin", bytes },
]);
});
});
describe("guest data-object metadata", () => {
it.each([null, 42, { path: 42 }])(
"refuses malformed known-namespace facts: %j",
async (metadata) => {
await expect(
readHomeFiles(
async () => ({
result: {
parts: [
{
file: { bytes: "aGVsbG8=" },
metadata: { "urn:agentenv:data-objects/v1": metadata },
},
],
},
}),
(text) => text,
["selected.bin"],
),
).rejects.toThrow();
},
);
});
describe("guest file answers", () => {
it.each(
[true, false].flatMap((present) =>
[{ path: "other.txt" }, "download", 42, false, null, ["trace"]].map(
(unrelated) => ({ present, unrelated }),
),
),
)(
"uses only named data-object metadata (present: $present, unrelated: $unrelated)",
async ({ present, unrelated }) => {
it.each([true, false])(
"uses only the named data-object metadata for file identity (present: %s)",
async (present) => {
const files = await readHomeFiles(
async () => ({
result: {
@@ -93,7 +24,7 @@ describe("guest file answers", () => {
kind: "file",
file: { bytes: Buffer.from("source bytes").toString("base64") },
metadata: {
"urn:unrelated": unrelated,
"urn:unrelated": { path: "other.txt" },
...(present
? {
"urn:agentenv:data-objects/v1": {
@@ -10,50 +10,6 @@ import { inspectLibraryPackages } from "./LibraryPackageInventory.js";
import { elfImage } from "../domain/desktop/elfImage.fixture.js";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
describe.skipIf(process.platform === "win32")(
"literal ELF origin paths",
() => {
it.each(["rpath", "runpath"] as const)(
"resolves %s libraries when the executable's directory contains dollars",
async (tag) => {
const root = await createTestTempDirectory("rea-library-origin-");
const bin = join(root, "$build-$&", "bin");
const lib = join(root, "$build-$&", "lib");
await Promise.all([
mkdir(bin, { recursive: true }),
mkdir(lib, { recursive: true }),
]);
const executable = join(bin, "app");
const library = join(lib, "liborigin.so");
await Promise.all([
writeFile(
executable,
elfImage({ needed: ["liborigin.so"], [tag]: "${ORIGIN}/../lib" }),
),
writeFile(library, elfImage({})),
]);
const queried: string[] = [];
const result = await inspectLibraryPackages(
executable,
"app",
async (command, args) => {
if (command === "ldconfig") return { stdout: "", exitCode: 0 };
queried.push(...args.slice(1));
return { stdout: `origin-owner: ${library}`, exitCode: 0 };
},
undefined,
);
expect(result).toEqual({
library_packages: ["origin-owner"],
missing_libraries: [],
unpackaged_libraries: [],
});
expect(queried).toContain(library);
},
);
},
);
it.skipIf(process.platform === "win32")(
"retains a compatible candidate's package when its declared header size is invalid",
async () => {
@@ -99,41 +99,6 @@ describe.skipIf(process.platform === "win32")(
},
);
describe.skipIf(process.platform === "win32")(
"desktop entry Unicode values",
() => {
it.each(["\u2028", "\u2029"])(
"retains Unicode separator %j in desktop launch arguments",
async (separator) => {
const { home, bin, environment } = await fixture();
const desktopEntry = join(home, "unicode.desktop");
await writeFile(
desktopEntry,
`[Desktop Entry]\nType=Application\nName=Notes${separator}Editor\nExec=notes-app --title "A${separator}B" %F\n`,
);
const inspected = await inspectLinuxApplication({
desktopEntry,
executable: undefined,
arguments: ["today.md"],
environment,
home,
callerDirectory: home,
});
expect(inspected).toMatchObject({
ok: true,
value: {
inventory: {
argv: ["notes-app", "--title", `A${separator}B`, "today.md"],
desktop_entry: { name: `Notes${separator}Editor` },
executable: { path: join(bin, "notes-app") },
},
},
});
},
);
},
);
describe.skipIf(process.platform === "win32")("inspectLinuxApplication", () => {
it("resolves a desktop entry id into a launch command and state directories", async () => {
const { home, bin, environment } = await fixture();
+20 -1
View File
@@ -1,5 +1,24 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { expect, it } from "vitest";
import { analysisSettingDefinition } from "./analysisSettingDefinitions.js";
import {
analysisSettingDefinition,
runtimeAnalysisEnvironmentNames,
} from "./analysisSettingDefinitions.js";
const source = (path: string) =>
readFileSync(join(import.meta.dirname, path), "utf8");
it("does not validate runtime-owned launch seeds at startup", () => {
const startup = [
source("../config/environment.ts"),
source("../config/debuggerSessionLimits.ts"),
].join("\n");
const mentioned = [...runtimeAnalysisEnvironmentNames()].filter(
(name) => startup.includes(`${name}:`) || startup.includes(`[${name}]`),
);
expect(mentioned).toEqual([]);
});
it("advertises the operations that consume each migrated setting", () => {
const operations = (id: Parameters<typeof analysisSettingDefinition>[0]) =>
+10
View File
@@ -559,6 +559,16 @@ export const ANALYSIS_SETTINGS = {
operations: windowsChangeTimeOperations,
},
} as const satisfies Record<AnalysisSettingId, SettingDefinition>;
/** Launch seeds `update_analysis_settings` can replace. Startup must not reject them. */
export const runtimeAnalysisEnvironmentNames = (): ReadonlySet<string> =>
new Set(
Object.values(ANALYSIS_SETTINGS).flatMap((definition) =>
definition.updateMode === "runtime" && "environment" in definition
? [definition.environment]
: [],
),
);
export const analysisSettingIds = Object.keys(
ANALYSIS_SETTINGS,
) as AnalysisSettingId[];
@@ -27,6 +27,7 @@ import {
} from "../javascript/javascriptSemanticGraph.js";
import { sourceMapReference } from "../javascript/javascriptApplicationGraph.fixture.js";
import { createArtifactInspection } from "../artifactInspection.js";
import { javascriptModulePaths } from "./javascriptApplicationView.js";
import {
analysisViewResultSchema,
inspectAnalysisViewInputSchema,
@@ -792,6 +793,9 @@ it("projects JavaScript summary and preserves selected module observations", ()
selector: { path: "renderer.js" },
});
if (!item.ok) throw item.error;
const moduleNode = analysis.graph.nodes[0];
if (moduleNode === undefined) throw new Error("missing module");
expect(javascriptModulePaths(moduleNode)).toEqual(["renderer.js"]);
if (item.value.kind !== "item") throw new Error("expected item view");
expect(item.value.item).toMatchObject({
path: "renderer.js",
+2
View File
@@ -101,6 +101,7 @@ import { projectIl2cppMetadataView } from "./il2cppMetadataView.js";
import type { AnalysisViewParent } from "./analysisViewParent.js";
import type { GroupingViewKind } from "./analysisViewShape.js";
import {
analysisViewRequestSchema,
inspectAnalysisViewInputSchema,
type AnalysisViewRequest,
type InspectAnalysisViewInput,
@@ -179,6 +180,7 @@ import {
type UnsignedAnalysisView,
} from "./analysisViewResult.js";
export {
analysisViewRequestSchema,
inspectAnalysisViewInputSchema,
type AnalysisViewRequest,
type InspectAnalysisViewInput,
@@ -3,12 +3,12 @@ import { createEvidence } from "../evidence.js";
import { projectAnalysisError } from "../analysisErrorProjection.js";
import type { JsonValue } from "../jsonValue.js";
import {
analysisViewRequestSchema,
androidComponentsViewResultSchema,
androidComponentSummaryViewResultSchema,
projectAnalysisView,
type AnalysisViewParent,
} from "./analysisView.js";
import { analysisViewRequestSchema } from "./analysisViewRequest.js";
const component = (name: string, extra: Record<string, JsonValue> = {}) => ({
kind: "activity",
@@ -243,7 +243,9 @@ const summarizeBatch = (items: readonly BatchDecompileItem[]) => {
};
};
const batchUnknowns = (items: readonly BatchDecompileItem[]): string[] => {
export const batchUnknowns = (
items: readonly BatchDecompileItem[],
): string[] => {
const unknownIdentity = items.filter(
({ procedure }) => procedure.status === "unknown",
).length;
@@ -11,11 +11,11 @@ import {
} from "../../../tests/fixtures/browserScenarioCapture.js";
import type { Evidence } from "../evidence.js";
import {
analysisViewRequestSchema,
projectAnalysisView,
type AnalysisViewParent,
type AnalysisViewResult,
} from "./analysisView.js";
import { analysisViewRequestSchema } from "./analysisViewRequest.js";
import {
WITHHELD_WRITE_HINTS,
summarizeEnvironmentCoverage,
@@ -5,10 +5,10 @@ import {
environmentManifestCaptureFixture,
} from "../../../tests/fixtures/analysisView.js";
import {
analysisViewRequestSchema,
projectAnalysisView,
type AnalysisViewRequest,
} from "./analysisView.js";
import { analysisViewRequestSchema } from "./analysisViewRequest.js";
type ManifestView = Extract<
AnalysisViewRequest,
@@ -54,7 +54,9 @@ const jsIncompatible = (detail: string): AnalysisError =>
]);
/** Every exact module path or original source-map reference retained by a node. */
const javascriptModulePaths = (node: ApplicationNode): readonly string[] => {
export const javascriptModulePaths = (
node: ApplicationNode,
): readonly string[] => {
if (node.identity.strategy === "canonical-path") return [node.identity.path];
if (node.identity.strategy === "source-map-original")
return [node.identity.original_source];
@@ -1,14 +1,14 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "../../../tests/fixtures/javascriptStaticAnalysis.js";
import { analyzeJavaScriptStaticSource } from "../javascript/javascriptStaticAnalysis.js";
import { completeSemanticSteps } from "../javascript/javascriptSemanticTraversal.js";
import { buildJavaScriptRouteInventorySteps } from "../javascript/javascriptRouteInventory.js";
import { freezeJsonSnapshot } from "../immutableJson.js";
import { projectAnalysisError } from "../analysisErrorProjection.js";
import {
projectAnalysisView,
analysisViewRequestSchema,
type AnalysisViewParent,
} from "./analysisView.js";
import { analysisViewRequestSchema } from "./analysisViewRequest.js";
const inventory = (
text = `createBrowserRouter([{path:'/reports',children:[{path:':id'},{path:'settings'}]}]);router.push('/login');`,
@@ -2,10 +2,10 @@ import { afterEach, expect, it, vi } from "vitest";
import { freezeJsonSnapshot } from "../immutableJson.js";
import { nativeInventoryRowsSchemas } from "../native/nativeInventory.js";
import {
analysisViewRequestSchema,
projectAnalysisView,
type AnalysisViewParent,
} from "./analysisView.js";
import { analysisViewRequestSchema } from "./analysisViewRequest.js";
afterEach(() => vi.restoreAllMocks());
@@ -3,8 +3,10 @@ import { createEvidence } from "../evidence.js";
import { MAX_JSON_DEPTH, type JsonValue } from "../jsonValue.js";
import { projectAnalysisError } from "../analysisErrorProjection.js";
import type { AnalysisViewParent } from "./analysisViewParent.js";
import { projectAnalysisView } from "./analysisView.js";
import { analysisViewRequestSchema } from "./analysisViewRequest.js";
import {
analysisViewRequestSchema,
projectAnalysisView,
} from "./analysisView.js";
const nested = (levels: number): JsonValue => {
let value: JsonValue = "leaf";
@@ -3,10 +3,10 @@ import type { WebBundleAnalysis } from "../webBundleAnalysis.js";
import { AnalysisInputError } from "../analysisErrorCore.js";
import { projectAnalysisError } from "../analysisErrorProjection.js";
import {
analysisViewRequestSchema,
projectAnalysisView,
type AnalysisViewParent,
} from "./analysisView.js";
import { analysisViewRequestSchema } from "./analysisViewRequest.js";
const sampleAnalysis = (): WebBundleAnalysis => ({
capture: {
@@ -3,8 +3,10 @@ import {
analysisViewCaptureFixture,
analysisViewCaptureEvidence,
} from "../../../tests/fixtures/analysisView.js";
import { projectAnalysisView } from "./analysisView.js";
import { analysisViewRequestSchema } from "./analysisViewRequest.js";
import {
analysisViewRequestSchema,
projectAnalysisView,
} from "./analysisView.js";
import { projectAnalysisError } from "../analysisErrorProjection.js";
const project = (
Loaded 100 of 325 files, more files were not shown because too many files have changed in this diff. Show more