Compare commits

..
96 changed files with 825 additions and 3531 deletions
-6
View File
@@ -136,12 +136,6 @@ existing analysis families directly, for example
- Pull and capture outputs are additive: the destination must not already
exist. Re-running a completed pull refuses the existing directory instead
of overwriting evidence.
- Cancellation during a package pull preserves caller-selected output. The
error includes partial observations identifying completed artifacts and the
interrupted local path; remaining APKs were not attempted.
- Failed package acquisition rollback retains its directory identity and retries
cleanup before later work and during shutdown. Cleanup refuses a replacement
directory. Terminal shutdown stops admission and drains active operations.
- The provider holds no long-lived processes; every invocation is one
bounded adb run. The adb server belongs to the caller.
+46 -348
View File
@@ -1,6 +1,6 @@
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { constants as fsConstants } from "node:fs";
import { createReadStream, constants as fsConstants } from "node:fs";
import { access, lstat, mkdir, rm, stat, writeFile } from "node:fs/promises";
import { basename, join, resolve } from "node:path";
@@ -24,11 +24,8 @@ import {
AnalysisTimeoutError,
AnalysisUnsupportedTargetError,
} from "../domain/analysisErrorCore.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { err, ok, type Result } from "../domain/result.js";
import { hashStableFile } from "../filesystem/StableFileHash.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import {
execFileOutput,
execFileOutputFailure,
@@ -100,18 +97,11 @@ const STDERR_EXCERPT_BYTES = 512;
const UNPARSED_LINES_REPORTED = 10;
const STOP_GRACE_MS = 5_000;
const hashAdbFile = async (
path: string,
operation: string,
signal: AbortSignal | undefined,
) => {
try {
return await hashStableFile(path, signal);
} catch (cause: unknown) {
if (signal?.aborted === true)
throw new AnalysisCancelledError(operation, { cause });
throw cause;
}
/** Stream a local file digest without retaining large files in memory. */
const sha256File = async (path: string): Promise<string> => {
const hash = createHash("sha256");
for await (const chunk of createReadStream(path)) hash.update(chunk);
return hash.digest("hex");
};
/** adb joins shell operands into a remote POSIX command; quote at that boundary. */
@@ -345,48 +335,6 @@ export interface AdbProviderOptions {
readonly environment?: Readonly<Record<string, string | undefined>>;
}
interface PendingPackageDirectoryCleanup {
readonly path: string;
readonly identity: PackageDirectoryIdentity | undefined;
readonly primary: AnalysisError;
readonly remove: () => Promise<void>;
}
interface PackageDirectoryIdentity {
readonly dev: number;
readonly ino: number;
readonly mode: number;
}
const removeOwnedPackageDirectory = async (
path: string,
identity: PackageDirectoryIdentity | undefined,
): Promise<void> => {
if (identity === undefined)
throw new Error(
`Cannot verify the created package directory identity; refusing to remove ${path}`,
);
let current: Awaited<ReturnType<typeof lstat>>;
try {
current = await lstat(path);
} catch (cause: unknown) {
if (cause instanceof Error && Reflect.get(cause, "code") === "ENOENT")
return;
throw cause;
}
if (
!current.isDirectory() ||
current.isSymbolicLink() ||
current.dev !== identity.dev ||
current.ino !== identity.ino ||
current.mode !== identity.mode
)
throw new Error(
`The created package directory was replaced or changed; refusing to remove ${path}`,
);
await rm(path, { recursive: true, force: true });
};
/**
* ADB device inspection and acquisition over a caller-selected adb binary.
*
@@ -398,12 +346,6 @@ const removeOwnedPackageDirectory = async (
*/
export class AdbProvider {
private readonly environment: Readonly<Record<string, string | undefined>>;
readonly #pendingPackageDirectoryCleanup =
new Set<PendingPackageDirectoryCleanup>();
readonly #activeOperations = new Set<Promise<void>>();
#closing = false;
#closePromise: Promise<void> | undefined;
#packageCleanupPromise: Promise<void> | undefined;
constructor(options: AdbProviderOptions = {}) {
this.environment = options.environment ?? process.env;
@@ -436,121 +378,12 @@ export class AdbProvider {
}
}
/** Retry removal of package directories retained after failed rollback. */
close(): Promise<void> {
this.#closing = true;
if (this.#closePromise !== undefined) return this.#closePromise;
const closing = (async () => {
await Promise.all(this.#activeOperations);
await this.#retryPackageDirectoryCleanup();
})().finally(() => {
if (this.#closePromise === closing) this.#closePromise = undefined;
});
this.#closePromise = closing;
return this.#closePromise;
}
#retryPackageDirectoryCleanup(): Promise<void> {
if (this.#packageCleanupPromise !== undefined)
return this.#packageCleanupPromise;
const cleanup = this.#performPackageDirectoryCleanup().finally(() => {
if (this.#packageCleanupPromise === cleanup)
this.#packageCleanupPromise = undefined;
});
this.#packageCleanupPromise = cleanup;
return cleanup;
}
async #performPackageDirectoryCleanup(): Promise<void> {
const failures: AnalysisError[] = [];
for (const owner of this.#pendingPackageDirectoryCleanup) {
try {
await owner.remove();
this.#pendingPackageDirectoryCleanup.delete(owner);
} catch (cause: unknown) {
const cleanup = new ProviderCleanupError(
ADB_PROVIDER_IDENTITY.id,
[owner.path],
{
reason: cause instanceof Error ? cause.message : String(cause),
},
{ operation: "pull_adb_package", cause },
);
failures.push(
analysisErrorWithCleanupFailure(
owner.primary,
cleanup,
"pull_adb_package",
),
);
}
}
if (failures.length === 1) throw failures[0];
if (failures.length > 1)
throw new ProviderCleanupError(
ADB_PROVIDER_IDENTITY.id,
[...this.#pendingPackageDirectoryCleanup].map(({ path }) => path),
{
reason:
"Failed package output directories remain after cleanup retry",
failures: failures.map(({ message, cleanupResources }) => ({
message,
resources: [...cleanupResources],
})),
},
{
operation: "pull_adb_package",
cause: new AggregateError(
failures,
"Multiple ADB package rollback cleanups failed",
),
},
);
}
/** The provider holds no long-lived resources; the adb server is caller-owned. */
async close(): Promise<void> {}
async execute(
request: AdbRequest,
options?: ExecutionOptions,
): Promise<Result<AnalysisExecution, AnalysisError>> {
if (this.#closing)
return err(new AnalysisCancelledError(request.operation));
let release!: () => void;
const active = new Promise<void>((resolve) => {
release = resolve;
});
this.#activeOperations.add(active);
try {
if (this.#pendingPackageDirectoryCleanup.size > 0) {
try {
await this.#retryPackageDirectoryCleanup();
} catch (cause: unknown) {
return err(
cause instanceof AnalysisError
? cause
: new ProviderCleanupError(
ADB_PROVIDER_IDENTITY.id,
[...this.#pendingPackageDirectoryCleanup].map(
({ path }) => path,
),
{
reason:
cause instanceof Error ? cause.message : String(cause),
},
{ operation: request.operation, cause },
),
);
}
}
return await this.#executeOperation(request, options);
} finally {
this.#activeOperations.delete(active);
release();
}
}
async #executeOperation(
request: AdbRequest,
options?: ExecutionOptions,
): Promise<Result<AnalysisExecution, AnalysisError>> {
const signal = options?.signal;
if (signal?.aborted === true)
@@ -1177,11 +1010,9 @@ export class AdbProvider {
);
if (!directory.ok) return directory;
const packageDirectory = join(directory.value, packageName);
let packageDirectoryCreated = false;
let packageDirectoryIdentity: PackageDirectoryIdentity | undefined;
try {
if (
await lstat(packageDirectory).then(
await stat(packageDirectory).then(
() => true,
() => false,
)
@@ -1190,44 +1021,14 @@ export class AdbProvider {
`The package output directory already exists: ${packageDirectory}`,
);
await mkdir(packageDirectory);
packageDirectoryCreated = true;
const created = await lstat(packageDirectory);
if (!created.isDirectory() || created.isSymbolicLink())
throw new Error(
`The created package output is not a regular directory: ${packageDirectory}`,
);
packageDirectoryIdentity = {
dev: created.dev,
ino: created.ino,
mode: created.mode,
};
} catch (cause) {
const primary = new AnalysisOutputError(
operation,
cause instanceof Error ? cause.message : String(cause),
{ cause },
return err(
new AnalysisOutputError(
operation,
cause instanceof Error ? cause.message : String(cause),
{ cause },
),
);
if (!packageDirectoryCreated) return err(primary);
const owner: PendingPackageDirectoryCleanup = {
path: packageDirectory,
identity: packageDirectoryIdentity,
primary,
remove: () =>
removeOwnedPackageDirectory(
packageDirectory,
packageDirectoryIdentity,
),
};
this.#pendingPackageDirectoryCleanup.add(owner);
const cleanup = new ProviderCleanupError(
ADB_PROVIDER_IDENTITY.id,
[packageDirectory],
{
reason: "Could not establish an owned package directory identity",
},
{ operation, cause },
);
return err(analysisErrorWithCleanupFailure(primary, cleanup, operation));
}
const artifacts: {
device_path: string;
@@ -1243,37 +1044,6 @@ export class AdbProvider {
stage: "resolve_paths" | "pull" | "digest";
message: string;
}[] = [];
const cancelledPackagePull = (
error: AnalysisCancelledError,
localPath: string,
devicePath: string,
stage: "pull" | "digest",
) => {
error.retainPartialObservation({
provider_id: "adb",
operation,
result: {
client,
serial,
package_name: packageName,
output_directory: directory.value,
artifacts,
failures: [
...failures,
{
device_path: devicePath,
stage,
message:
stage === "pull"
? `Pull cancelled; any incomplete file is retained at ${localPath}. Remaining APKs were not attempted.`
: `Digesting the pulled file was cancelled; the file is retained at ${localPath}. Remaining APKs were not attempted.`,
},
],
coverage: "partial",
},
});
return err(error);
};
const seenFileNames = new Set<string>();
for (const entry of parsed.paths) {
if (seenFileNames.has(entry.file_name)) {
@@ -1295,13 +1065,7 @@ export class AdbProvider {
signal,
);
if (!pulled.ok) {
if (pulled.error instanceof AnalysisCancelledError)
return cancelledPackagePull(
pulled.error,
localPath,
entry.device_path,
"pull",
);
if (pulled.error instanceof AnalysisCancelledError) return pulled;
failures.push({
device_path: entry.device_path,
stage: "pull",
@@ -1310,24 +1074,18 @@ export class AdbProvider {
continue;
}
try {
const digest = await hashAdbFile(localPath, operation, signal);
const info = await stat(localPath);
const sha256 = await sha256File(localPath);
artifacts.push({
device_path: entry.device_path,
file_name: entry.file_name,
local_path: localPath,
bytes: digest.bytes,
sha256: digest.sha256,
bytes: info.size,
sha256,
role: entry.role,
role_basis: "file_name",
});
} catch (cause: unknown) {
if (cause instanceof AnalysisCancelledError)
return cancelledPackagePull(
cause,
localPath,
entry.device_path,
"digest",
);
} catch {
failures.push({
device_path: entry.device_path,
stage: "digest",
@@ -1336,36 +1094,14 @@ export class AdbProvider {
}
}
if (artifacts.length === 0 && failures.length > 0) {
const primary = new AnalysisProtocolError(
`Every APK pull failed for ${packageName} on ${serial}: ${failures[0]!.message}`,
await rm(packageDirectory, { recursive: true, force: true }).catch(
() => undefined,
);
return err(
new AnalysisProtocolError(
`Every APK pull failed for ${packageName} on ${serial}: ${failures[0]!.message}`,
),
);
const owner: PendingPackageDirectoryCleanup = {
path: packageDirectory,
identity: packageDirectoryIdentity,
primary,
remove: () =>
removeOwnedPackageDirectory(
packageDirectory,
packageDirectoryIdentity,
),
};
try {
await owner.remove();
} catch (cause: unknown) {
this.#pendingPackageDirectoryCleanup.add(owner);
const cleanup = new ProviderCleanupError(
ADB_PROVIDER_IDENTITY.id,
[packageDirectory],
{
reason: cause instanceof Error ? cause.message : String(cause),
},
{ operation: operation, cause },
);
return err(
analysisErrorWithCleanupFailure(primary, cleanup, operation),
);
}
return err(primary);
}
return ok(
createAnalysisExecution(
@@ -1420,7 +1156,8 @@ export class AdbProvider {
);
if (!pulled.ok) return pulled;
try {
const digest = await hashAdbFile(localPath, operation, signal);
const info = await stat(localPath);
const sha256 = await sha256File(localPath);
return ok(
createAnalysisExecution(
{
@@ -1428,8 +1165,8 @@ export class AdbProvider {
serial: input.serial,
device_path: input.device_path,
local_path: localPath,
bytes: digest.bytes,
sha256: digest.sha256,
bytes: info.size,
sha256,
},
provider,
{
@@ -1440,20 +1177,6 @@ export class AdbProvider {
),
);
} catch (cause) {
if (cause instanceof AnalysisCancelledError) {
cause.retainPartialObservation({
provider_id: "adb",
operation,
result: {
client,
serial: input.serial,
local_path: localPath,
device_path: input.device_path,
digest_status: "unknown",
},
});
return err(cause);
}
return err(
new AnalysisOutputError(
operation,
@@ -1488,13 +1211,7 @@ export class AdbProvider {
cause: new Error(`The local file does not exist: ${localPath}`),
}),
);
let digest: Awaited<ReturnType<typeof hashAdbFile>>;
try {
digest = await hashAdbFile(localPath, operation, signal);
} catch (cause: unknown) {
if (cause instanceof AnalysisCancelledError) return err(cause);
throw cause;
}
const sha256 = await sha256File(localPath);
const existsProbe = await runAdb(
operation,
input.device_path,
@@ -1540,7 +1257,7 @@ export class AdbProvider {
);
if (deviceDigest.ok) {
const digestMatch = /^([a-f0-9]{64})\s/u.exec(deviceDigest.value.stdout);
if (digestMatch !== null && digestMatch[1] !== digest.sha256)
if (digestMatch !== null && digestMatch[1] !== sha256)
return err(
new AnalysisArtifactChangedError(
operation,
@@ -1555,8 +1272,8 @@ export class AdbProvider {
serial: input.serial,
local_path: localPath,
device_path: input.device_path,
bytes: digest.bytes,
sha256: digest.sha256,
bytes: info.size,
sha256,
device_sha256: digestMatch?.[1] ?? null,
overwritten: deviceExists,
device_digest_source: "device_sha256sum",
@@ -1577,8 +1294,8 @@ export class AdbProvider {
serial: input.serial,
local_path: localPath,
device_path: input.device_path,
bytes: digest.bytes,
sha256: digest.sha256,
bytes: info.size,
sha256,
device_sha256: null,
overwritten: deviceExists,
device_digest_source: "unavailable",
@@ -1617,13 +1334,7 @@ export class AdbProvider {
cause: new Error(`The local APK does not exist: ${apkPath}`),
}),
);
let digest: Awaited<ReturnType<typeof hashAdbFile>>;
try {
digest = await hashAdbFile(apkPath, operation, signal);
} catch (cause: unknown) {
if (cause instanceof AnalysisCancelledError) return err(cause);
throw cause;
}
const sha256 = await sha256File(apkPath);
const installed = await runAdb(
operation,
input.serial,
@@ -1652,8 +1363,8 @@ export class AdbProvider {
client,
serial: input.serial,
apk_path: apkPath,
bytes: digest.bytes,
sha256: digest.sha256,
bytes: info.size,
sha256,
replaced: input.replace,
},
provider,
@@ -2023,15 +1734,16 @@ export class AdbProvider {
),
);
try {
const digest = await hashAdbFile(reportedPath, operation, signal);
const info = await stat(reportedPath);
const sha256 = await sha256File(reportedPath);
return ok(
createAnalysisExecution(
{
client,
serial: input.serial,
local_path: reportedPath,
bytes: digest.bytes,
sha256: digest.sha256,
bytes: info.size,
sha256,
adb_reported_path: reportedPath,
},
provider,
@@ -2043,20 +1755,6 @@ export class AdbProvider {
),
);
} catch (cause) {
if (cause instanceof AnalysisCancelledError) {
cause.retainPartialObservation({
provider_id: "adb",
operation,
result: {
client,
serial: input.serial,
local_path: reportedPath,
adb_reported_path: reportedPath,
digest_status: "unknown",
},
});
return err(cause);
}
return err(
new AnalysisOutputError(
operation,
+21 -129
View File
@@ -1,43 +1,40 @@
import { constants, createReadStream } from "node:fs";
import { chmod, copyFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import type { ReadStream, Stats, WriteStream } from "node:fs";
import { chmod, open, rm, stat, type FileHandle } from "node:fs/promises";
import { join } from "node:path";
import { Transform, type TransformCallback } from "node:stream";
import { pipeline } from "node:stream/promises";
import {
AnalysisInputError,
AnalysisCapabilityUnavailableError,
} from "../domain/analysisErrorCore.js";
import {
openRegularFile,
sameRegularFileState,
} from "../filesystem/RegularFile.js";
import type { AndroidOperation } from "../domain/android/androidAnalysis.js";
/** Stream a local file digest without retaining the APK or JAR in memory. */
export const hashAndroidFile = async (path: string): Promise<string> => {
const hash = createHash("sha256");
for await (const chunk of createReadStream(path)) hash.update(chunk);
return hash.digest("hex");
};
/** Bind decompiler input to the admitted artifact, even if the original changes. */
export const snapshotAndroidTarget = async (
path: string,
sha256: string,
root: string,
operation: AndroidOperation,
signal?: AbortSignal,
): Promise<string> => {
const snapshot = join(root, "target.apk");
try {
await copyAndroidSnapshot(path, snapshot, sha256, signal);
signal?.throwIfAborted();
await chmod(snapshot, 0o400);
return snapshot;
} catch (cause: unknown) {
if (!(cause instanceof AndroidSnapshotChangedError)) throw cause;
throw new AnalysisInputError(operation, { cause }, [
await copyFile(path, snapshot, constants.COPYFILE_EXCL);
await chmod(snapshot, 0o400);
if ((await hashAndroidFile(snapshot)) !== sha256)
throw new AnalysisInputError(operation, undefined, [
{
path: ["path"],
reason: "invalid_value",
message: `APK bytes changed after admission at ${path}; retry against a stable file.`,
message:
"APK bytes changed after admission; retry against a stable file.",
},
]);
}
return snapshot;
};
/** Fingerprint the actual immutable engine bytes executed for this observation. */
@@ -46,120 +43,15 @@ export const snapshotAndroidEngine = async (
sha256: string,
root: string,
operation: AndroidOperation,
signal?: AbortSignal,
): Promise<{ path: string; sha256: string }> => {
const path = join(root, "engine.jar");
try {
const observed = await copyAndroidSnapshot(source, path, sha256, signal);
signal?.throwIfAborted();
await chmod(path, 0o400);
return { path, sha256: observed };
} catch (cause: unknown) {
if (!(cause instanceof AndroidSnapshotChangedError)) throw cause;
await copyFile(source, path, constants.COPYFILE_EXCL);
await chmod(path, 0o400);
if ((await hashAndroidFile(path)) !== sha256)
throw new AnalysisCapabilityUnavailableError(
"jadx",
operation,
`REA_JADX_MCP_JAR bytes changed during admission at ${source}; retry with a stable engine file.`,
{ cause },
"REA_JADX_MCP_JAR bytes changed during admission; retry with a stable engine file.",
);
}
return { path, sha256 };
};
const copyAndroidSnapshot = async (
sourcePath: string,
snapshotPath: string,
expectedSha256: string,
signal?: AbortSignal,
): Promise<string> => {
signal?.throwIfAborted();
const source = await openRegularFile(sourcePath, {
symlinks: "follow",
signal,
});
let destination: FileHandle | undefined;
let inputStream: ReadStream | undefined;
let outputStream: WriteStream | undefined;
let removeSnapshot = false;
let copiedBytes = 0;
const hash = createHash("sha256");
try {
const initial = await source.stat();
signal?.throwIfAborted();
const digest = new Transform({
transform(
chunk: Buffer,
_encoding: BufferEncoding,
callback: TransformCallback,
) {
copiedBytes += chunk.length;
if (copiedBytes > initial.size) {
callback(
new AndroidSnapshotChangedError(
`Android input grew while being copied: ${sourcePath}`,
),
);
return;
}
hash.update(chunk);
callback(null, chunk);
},
});
try {
destination = await open(snapshotPath, "wx", 0o600);
signal?.throwIfAborted();
inputStream = source.createReadStream({
start: 0,
end: initial.size,
autoClose: false,
});
outputStream = destination.createWriteStream({ autoClose: false });
await pipeline(inputStream, digest, outputStream, { signal });
signal?.throwIfAborted();
await verifySourceState(source, sourcePath, initial, copiedBytes);
const observedSha256 = hash.digest("hex");
if (observedSha256 !== expectedSha256)
throw new AndroidSnapshotChangedError(
`Android snapshot digest mismatch for ${sourcePath}`,
);
return observedSha256;
} catch (cause: unknown) {
removeSnapshot = destination !== undefined;
throw cause;
}
} finally {
// Non-auto-closing streams retain a reference to their FileHandle even
// after pipeline completion. Release those references before awaiting
// descriptor closure, after the admitted source state has been verified.
inputStream?.destroy();
outputStream?.destroy();
try {
await Promise.all([source.close(), destination?.close()]);
} finally {
if (removeSnapshot)
await rm(snapshotPath, { force: true }).catch(() => undefined);
}
}
};
const verifySourceState = async (
handle: Awaited<ReturnType<typeof openRegularFile>>,
path: string,
initial: Stats,
observedBytes: number,
): Promise<void> => {
const [opened, currentPath] = await Promise.all([handle.stat(), stat(path)]);
if (
observedBytes !== initial.size ||
!sameRegularFileState(initial, opened) ||
!sameRegularFileState(initial, currentPath)
)
throw new AndroidSnapshotChangedError(
`Android input changed while being read: ${path}`,
);
};
class AndroidSnapshotChangedError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
}
}
+29 -64
View File
@@ -52,11 +52,7 @@ export const inspectJadxAvailability = async (
signal?: AbortSignal,
): Promise<ProviderAvailability> => {
try {
const configuration = await readJadxConfigurationInputs(
environment,
signal,
);
await validateJadxConfigurationContents(configuration, environment, signal);
const configuration = await readJadxConfiguration(environment, signal);
return {
status: "available",
code: null,
@@ -81,83 +77,52 @@ export const inspectJadxAvailability = async (
}
};
/** Resolve caller-selected paths and JVM options without probing their contents. */
export const resolveJadxConfigurationInputs = async (
/** Resolve caller-selected tools and preserve the same prerequisite diagnostics. */
export const resolveJadxConfiguration = async (
environment: Readonly<Record<string, string | undefined>>,
operation: AndroidOperation,
signal?: AbortSignal,
): Promise<JadxConfiguration> => {
try {
return await readJadxConfigurationInputs(environment, signal);
return await readJadxConfiguration(environment, signal);
} catch (cause) {
if (signal?.aborted === true) throw cause;
if (!(cause instanceof JadxConfigurationFailure)) throw cause;
throw projectConfigurationFailure(operation, cause);
const output = cause.diagnostics;
throw new AnalysisCapabilityUnavailableError(
"jadx",
operation,
cause.message,
{
cause,
userMessage: cause.message,
...(typeof output.stdout === "string" &&
typeof output.stderr === "string"
? {
capturedOutput: {
stdout: output.stdout,
stderr: output.stderr,
truncated: output.output_truncated === true,
},
}
: {}),
},
);
}
};
/** Probe Java and archive prerequisites before starting a new engine session. */
export const validateJadxConfiguration = async (
configuration: JadxConfiguration,
environment: Readonly<Record<string, string | undefined>>,
operation: AndroidOperation,
signal?: AbortSignal,
): Promise<void> => {
try {
await validateJadxConfigurationContents(configuration, environment, signal);
} catch (cause) {
if (signal?.aborted === true) throw cause;
if (!(cause instanceof JadxConfigurationFailure)) throw cause;
throw projectConfigurationFailure(operation, cause);
}
};
const readJadxConfigurationInputs = async (
const readJadxConfiguration = async (
environment: Readonly<Record<string, string | undefined>>,
signal?: AbortSignal,
): Promise<JadxConfiguration> => {
signal?.throwIfAborted();
requireSupportedHost();
const jar = await resolveJar(environment);
const java = await resolveJava(environment, signal);
await requireJavaHomeExecutable(environment.JAVA_HOME, java);
signal?.throwIfAborted();
return { jar, java, jvmArguments: resolveJvmArguments(environment) };
};
const validateJadxConfigurationContents = async (
configuration: JadxConfiguration,
environment: Readonly<Record<string, string | undefined>>,
signal?: AbortSignal,
): Promise<void> => {
signal?.throwIfAborted();
await inspectJavaRuntime(configuration.java, environment, signal);
await requireEngineArchive(configuration.jar, signal);
};
const projectConfigurationFailure = (
operation: AndroidOperation,
cause: JadxConfigurationFailure,
): AnalysisCapabilityUnavailableError => {
const output = cause.diagnostics;
return new AnalysisCapabilityUnavailableError(
"jadx",
operation,
cause.message,
{
cause,
userMessage: cause.message,
...(typeof output.stdout === "string" && typeof output.stderr === "string"
? {
capturedOutput: {
stdout: output.stdout,
stderr: output.stderr,
truncated: output.output_truncated === true,
},
}
: {}),
},
);
const jvmArguments = resolveJvmArguments(environment);
await inspectJavaRuntime(java, environment, signal);
await requireEngineArchive(jar, signal);
return { jar, java, jvmArguments };
};
const requireEngineArchive = async (
+28 -98
View File
@@ -21,14 +21,12 @@ import { err, ok, type Result } from "../domain/result.js";
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
import {
snapshotAndroidEngine,
hashAndroidFile,
snapshotAndroidTarget,
} from "./AndroidTargetSnapshot.js";
import { hashStableFile } from "../filesystem/StableFileHash.js";
import { RegularFileChangedError } from "../filesystem/RegularFile.js";
import {
inspectJadxAvailability,
resolveJadxConfigurationInputs,
validateJadxConfiguration,
resolveJadxConfiguration,
} from "./JadxConfiguration.js";
import type { JadxLauncher } from "./JadxMcpTransport.js";
@@ -106,50 +104,6 @@ const executionError = (context: {
});
};
const androidTargetChanged = (
operation: string,
path: string,
cause?: unknown,
): AnalysisInputError =>
new AnalysisInputError(
operation,
cause === undefined ? undefined : { cause },
[
{
path: ["path"],
reason: "invalid_value",
message: `APK bytes changed after admission at ${path}; retry against a stable file.`,
},
],
);
const androidSourceChanged = (
operation: string,
source: string,
cause?: unknown,
): AnalysisCapabilityUnavailableError =>
new AnalysisCapabilityUnavailableError(
"jadx",
operation,
`Selected JADX input bytes changed during admission at ${source}; retry with a stable installation.`,
cause === undefined ? undefined : { cause },
);
const hashAndroidSource = async (
path: string,
operation: string,
signal: AbortSignal,
): Promise<string> => {
try {
return (await hashStableFile(path, signal)).sha256;
} catch (cause: unknown) {
if (signal.aborted) throw cause;
if (cause instanceof RegularFileChangedError)
throw androidSourceChanged(operation, path, cause);
throw cause;
}
};
interface PendingCleanup {
session: JadxSession | undefined;
root: PrivateRuntimeRoot | undefined;
@@ -193,18 +147,17 @@ const cleanup = async (
await root?.close();
resources.root = undefined;
} catch (cause) {
const error = new ProviderCleanupError(
"jadx",
[root?.path ?? "unknown"],
{
...diagnostics,
reason: cause instanceof Error ? cause.message : String(cause),
},
{ cause: previousError ?? cause },
return err(
new ProviderCleanupError(
"jadx",
[root?.path ?? "unknown"],
{
...diagnostics,
reason: cause instanceof Error ? cause.message : String(cause),
},
{ cause: previousError ?? cause },
),
);
if (previousError?.partialObservation !== undefined)
error.retainPartialObservation(previousError.partialObservation);
return err(error);
}
return ok(undefined);
};
@@ -334,33 +287,23 @@ export class JadxProvider implements AndroidAnalysisPort {
request.operation,
`Target ${target.path} is ${target.format}; provide one standalone APK.`,
);
const configuration = await resolveJadxConfigurationInputs(
const configuration = await resolveJadxConfiguration(
this.environment,
request.operation,
signal,
);
signal.throwIfAborted();
const jarHash = await hashAndroidSource(
configuration.jar,
request.operation,
signal,
);
const bridgeHash = await hashAndroidSource(
BRIDGE_SOURCE,
request.operation,
signal,
);
let targetHash: string;
try {
targetHash = (await hashStableFile(target.path, signal)).sha256;
} catch (cause: unknown) {
if (signal.aborted) throw cause;
if (cause instanceof RegularFileChangedError)
throw androidTargetChanged(request.operation, target.path, cause);
throw cause;
}
if (targetHash !== target.sha256)
throw androidTargetChanged(request.operation, target.path);
const jarHash = await hashAndroidFile(configuration.jar);
const bridgeHash = await hashAndroidFile(BRIDGE_SOURCE);
if ((await hashAndroidFile(target.path)) !== target.sha256)
throw new AnalysisInputError(request.operation, undefined, [
{
path: ["path"],
reason: "invalid_value",
message:
"APK bytes changed after admission; retry against a stable file.",
},
]);
const key = JSON.stringify({
path: target.path,
sha256: target.sha256,
@@ -383,40 +326,27 @@ export class JadxProvider implements AndroidAnalysisPort {
}
let retained = this.#retained;
if (retained === undefined) {
await validateJadxConfiguration(
configuration,
this.environment,
request.operation,
signal,
);
root = await PrivateRuntimeRoot.create({ prefix: "rea-android-" });
const engine = await snapshotAndroidEngine(
configuration.jar,
jarHash,
root.path,
request.operation,
signal,
);
const snapshot = await snapshotAndroidTarget(
target.path,
target.sha256,
root.path,
request.operation,
signal,
);
const bridge = join(root.path, "ReaJadxBridge.java");
await copyFile(BRIDGE_SOURCE, bridge);
await chmod(bridge, 0o400);
const copiedBridgeHash = await hashAndroidSource(
bridge,
request.operation,
signal,
);
if (copiedBridgeHash !== bridgeHash)
throw androidSourceChanged(
if ((await hashAndroidFile(bridge)) !== bridgeHash)
throw new AnalysisCapabilityUnavailableError(
"jadx",
request.operation,
BRIDGE_SOURCE,
undefined,
"REA Android bridge bytes changed during admission; retry with a stable installation.",
);
signal.throwIfAborted();
session = new JadxSession(
+2 -2
View File
@@ -1,5 +1,5 @@
import { err, ok, type Result } from "../domain/result.js";
import { readRegularFileText } from "./RegularFileRead.js";
import { readFile } from "node:fs/promises";
import {
effectiveClientServer,
parseClientConfiguration,
@@ -19,7 +19,7 @@ export const readConfigurationText = async (
path: string,
): Promise<Result<string | undefined, ClientConfigurationFileError>> => {
try {
return ok(await readRegularFileText(path));
return ok(await readFile(path, "utf8"));
} catch (cause: unknown) {
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
return ok(undefined);
+5 -12
View File
@@ -16,7 +16,8 @@ import {
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import { err, ok, type Result } from "../domain/result.js";
import { readJsonInputFile } from "./JsonInputFile.js";
import { parseUtf8Json } from "./Utf8JsonInput.js";
import { readRegularFile } from "./RegularFileRead.js";
import { NonRegularFileReadError } from "../filesystem/RegularFile.js";
/** Request control and its owning operation for an interruptible atomic write. */
@@ -36,20 +37,12 @@ export const readJsonFile = async (
> => {
const requestedPath = resolve(path);
try {
const decoded = await readJsonInputFile(
requestedPath,
"read_evidence_file",
);
const encoded = await readRegularFile(requestedPath);
const decoded = parseUtf8Json(encoded, "read_evidence_file", requestedPath);
if (!decoded.ok) {
if (decoded.error instanceof AnalysisResourceConstraintError)
return err(decoded.error);
return err(
new EvidenceFileError("read", "invalid-json", {
cause:
decoded.error.cause ??
new SyntaxError(
decoded.error.issues[0]?.message ?? decoded.error.message,
),
cause: decoded.cause ?? new SyntaxError(decoded.error),
path: requestedPath,
}),
);
@@ -1,11 +1,3 @@
import { javascriptApplicationAnalysisResultSchema } from "../domain/javascript/javascriptApplicationAnalysis.js";
import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../contracts/javascript/javascriptRuntimeReconciliationExample.js";
import {
createJavaScriptSemanticGraph,
createJavaScriptSemanticGraphNode,
JavaScriptSemanticEvidenceContextRegistry,
} from "../domain/javascript/javascriptSemanticGraph.js";
import { proofEvidence } from "./ReconstructionObligationLedger.fixture.js";
import { describe, expect, it } from "vitest";
import { createEvidence } from "../domain/evidence.js";
@@ -179,115 +171,3 @@ describe("reconstruction obligation candidates", () => {
);
});
});
it("keeps each semantic candidate bound to its own file and authority context", () => {
const contexts = new JavaScriptSemanticEvidenceContextRegistry();
const base = javascriptApplicationAnalysisResultSchema.parse(
JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE.normalized_result,
);
const sourceRecords = [
proofEvidence("first-file"),
proofEvidence("second-file"),
];
const module = createJavaScriptSemanticGraphNode(
{
kind: "module",
identity: {
artifact_sha256: base.root_artifact_sha256,
module_path: "app.js",
source_range: null,
role_key: "module",
},
function_node_id: null,
application_node_ids: [],
label: "app.js",
properties: {},
evidence: artifactEvidence(base.root_artifact_sha256, "app.js"),
},
contexts,
);
const requests = sourceRecords.flatMap((source, fileIndex) =>
[1, 2].map((line) => {
const sha256 = String(fileIndex + 4).repeat(64);
const path = `file-${String(fileIndex)}.js`;
return createJavaScriptSemanticGraphNode(
{
kind: "request",
identity: {
artifact_sha256: sha256,
module_path: path,
source_range: {
start: { line, column: 0 },
end: { line, column: 8 },
},
role_key: `request-${String(line)}`,
},
function_node_id: null,
application_node_ids: [],
label: `${path}:${String(line)}`,
properties: {},
evidence: {
...artifactEvidence(sha256, path, "ast-static-analysis"),
evidence_ids: [source.evidence_id],
},
},
contexts,
);
}),
);
const { graph_id: _graphId, ...semantic } = base.semantic_graph;
const semanticGraph = createJavaScriptSemanticGraph({
...semantic,
root_node_ids: [module.node_id],
nodes: [module, ...requests],
evidence_contexts: contexts.contexts,
});
const example = JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE;
const application = createEvidence(
{
path: base.input_path,
sha256: base.root_artifact_sha256,
format: base.format,
},
example.provider,
{
predicateType: example.predicate_type,
operation: example.operation,
parameters: example.parameters,
result: jsonValueSchema.parse({ ...base, semantic_graph: semanticGraph }),
confidence: "derived",
authority: "shipped-artifact",
evidenceLinks: sourceRecords.map(({ evidence_id }) => evidence_id),
},
);
const generated = deriveReconstructionObligationCandidates(
createEvidenceBundle([...sourceRecords, application]),
[],
);
expect(generated.limitations).toEqual([]);
expect(generated.candidates).toHaveLength(requests.length);
for (const [index, node] of requests.entries()) {
const source = sourceRecords[Math.floor(index / 2)];
expect(
generated.candidates.find(
({ target }) => target.semantic_node_id === node.node_id,
),
).toMatchObject({
title: node.label,
family: "request",
source_state: "candidate",
target: {
artifact_sha256: node.identity.artifact_sha256,
semantic_node_id: node.node_id,
},
authority_references: [
{
evidence_id: source?.evidence_id,
authority: "controlled-replay",
state: "candidate",
location: `${semanticGraph.graph_id}/node/${node.node_id}`,
},
],
});
}
});
@@ -12,6 +12,7 @@ import type {
ReconstructionObligation,
ReviewedReconstructionObligation,
} from "../domain/reconstructionObligationLedgerSchemas.js";
import { resolveJavaScriptSemanticEvidence } from "../domain/javascript/javascriptSemanticGraph.js";
import { parseApplicationGraphEvidence } from "./javascript/JavaScriptApplicationEvidenceGraph.js";
import {
applicationObligationPolicy,
@@ -114,21 +115,14 @@ const deriveApplicationCandidates = (
}),
);
}
if (source.semanticGraph !== null) {
const evidenceContexts = new Map(
source.semanticGraph.evidence_contexts.map((context) => [
context.context_id,
context,
]),
);
if (source.semanticGraph !== null)
for (const node of source.semanticGraph.nodes) {
const candidatePolicy = semanticObligationPolicy(node.kind);
if (candidatePolicy === undefined) continue;
const evidenceContext = evidenceContexts.get(node.evidence.context_id);
if (evidenceContext === undefined)
throw new TypeError(
"Semantic evidence reference names an absent context",
);
const evidenceContext = resolveJavaScriptSemanticEvidence(
source.semanticGraph,
node.evidence,
);
addCandidate(
candidates,
generatedCandidate({
@@ -150,7 +144,6 @@ const deriveApplicationCandidates = (
}),
);
}
}
if (source.graph.coverage.status !== "complete")
limitations.add(
`Application graph ${source.graph.graph_id} coverage is ${source.graph.coverage.status}; omitted candidates remain unknown.`,
+9 -36
View File
@@ -1,8 +1,7 @@
import { lstat, realpath, stat } from "node:fs/promises";
import { readFile, realpath, stat } from "node:fs/promises";
import { join, resolve } from "node:path";
import ignore from "ignore";
import { readRegularFileText } from "./RegularFileRead.js";
import { err, ok, type Result } from "../domain/result.js";
import {
@@ -48,27 +47,14 @@ const resolveRoot = async (
const buildIgnored = async (
root: string,
excludePaths: readonly string[],
signal?: AbortSignal,
): Promise<ReturnType<typeof ignore>> => {
const ignored = ignore();
const policyPath = join(root, ".gitignore");
let present = true;
try {
await lstat(policyPath);
ignored.add(await readFile(join(root, ".gitignore"), "utf8"));
} catch (cause: unknown) {
if (
!(
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
cause.code === "ENOENT"
)
)
throw cause;
present = false;
// Missing or unreadable ignore file does not authorize broader access.
void cause;
}
signal?.throwIfAborted();
if (present) ignored.add(await readRegularFileText(policyPath, { signal }));
ignored.add([...DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS]);
for (const path of excludePaths) {
ignored.add(path);
@@ -85,22 +71,9 @@ export const prepareReferenceSourceImport = async (
> => {
const root = await resolveRoot(options.root);
if (!root.ok) return root;
try {
return ok({
root: root.value,
ignored: await buildIgnored(
root.value,
options.excludePaths ?? [],
options.signal,
),
secrets: ignore().add([...options.policy.secretPatterns]),
});
} catch (cause: unknown) {
return err(
failure(
options.signal?.aborted === true ? "cancelled" : "io",
`Reference source ignore policy could not be read at ${join(root.value, ".gitignore")}: ${cause instanceof Error ? cause.message : String(cause)}`,
),
);
}
return ok({
root: root.value,
ignored: await buildIgnored(root.value, options.excludePaths ?? []),
secrets: ignore().add([...options.policy.secretPatterns]),
});
};
+17 -62
View File
@@ -1,7 +1,6 @@
import fs from "node:fs";
import { lstat } from "node:fs/promises";
import { lstat, readFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { readRegularFile, readRegularFileText } from "./RegularFileRead.js";
export type ReferenceSourceVcsInfo =
| {
@@ -30,7 +29,7 @@ export const readReferenceSourceVcs = async (
: { kind: "unknown", head: null, dirty: null };
}
try {
const head = await resolveSourceHead(root, signal);
const head = await resolveSourceHead(root);
if (isAborted(signal)) return { kind: "unknown", head: null, dirty: null };
return { kind: "git", head, dirty: null };
} catch (cause: unknown) {
@@ -40,59 +39,13 @@ export const readReferenceSourceVcs = async (
}
};
const resolveSourceHead = async (
root: string,
signal?: AbortSignal,
): Promise<string> => {
const resolveSourceHead = async (root: string): Promise<string> => {
// isomorphic-git is loaded on first use so CLI and MCP startup skip it.
const { resolveRef } = await import("isomorphic-git");
let failedRead: { readonly cause: unknown } | undefined;
const referenceFs = {
...fs,
promises: {
...fs.promises,
// isomorphic-git treats every read failure as absence. Preserve real
// failures so a readable packed ref cannot hide an unreadable loose ref.
readFile: async (
path: string | undefined,
options:
| BufferEncoding
| { readonly encoding?: BufferEncoding | null }
| null = null,
) => {
// The library probes readFile() without a path to detect promise APIs.
// Reject that invocation without attributing it to a source read.
if (path === undefined)
throw new TypeError("Git filesystem read requires a path");
try {
const bytes = await readRegularFile(path, { signal });
const encoding =
typeof options === "string" ? options : options?.encoding;
return encoding === undefined || encoding === null
? bytes
: bytes.toString(encoding);
} catch (cause: unknown) {
if (errorCode(cause) !== "ENOENT") failedRead ??= { cause };
throw cause;
}
},
},
};
const readRef = async (options: {
readonly gitdir?: string;
readonly ref: string;
}) => {
const head = await resolveRef({ fs: referenceFs, dir: root, ...options });
signal?.throwIfAborted();
if (failedRead !== undefined) throw failedRead.cause;
return head;
};
const dotgit = join(root, ".git");
if (!(await lstat(dotgit)).isFile()) return readRef({ ref: "HEAD" });
const pointer = (await readRegularFileText(dotgit, { signal })).replace(
/\r?\n$/u,
"",
);
if (!(await lstat(dotgit)).isFile())
return resolveRef({ fs, dir: root, ref: "HEAD" });
const pointer = (await readFile(dotgit, "utf8")).replace(/\r?\n$/u, "");
if (!pointer.startsWith("gitdir: ") || pointer.length === 8)
throw new Error("Invalid Git directory pointer");
const gitdir = resolve(root, pointer.slice(8));
@@ -100,23 +53,25 @@ const resolveSourceHead = async (
try {
commonDirectory = resolve(
gitdir,
(
await readRegularFileText(join(gitdir, "commondir"), { signal })
).replace(/\r?\n$/u, ""),
(await readFile(join(gitdir, "commondir"), "utf8")).replace(
/\r?\n$/u,
"",
),
);
} catch (cause: unknown) {
if (errorCode(cause) !== "ENOENT") throw cause;
return readRef({ gitdir, ref: "HEAD" });
return resolveRef({ fs, dir: root, gitdir, ref: "HEAD" });
}
const head = (
await readRegularFileText(join(gitdir, "HEAD"), { signal })
).trim();
if (!head.startsWith("ref: ")) return readRef({ gitdir, ref: "HEAD" });
const head = (await readFile(join(gitdir, "HEAD"), "utf8")).trim();
if (!head.startsWith("ref: "))
return resolveRef({ fs, dir: root, gitdir, ref: "HEAD" });
const ref = head.slice(5);
const privateRef = ["refs/bisect/", "refs/rewritten/", "refs/worktree/"].some(
(prefix) => ref.startsWith(prefix),
);
return readRef({
return resolveRef({
fs,
dir: root,
gitdir: privateRef ? gitdir : commonDirectory,
ref,
});
+6 -49
View File
@@ -1,44 +1,20 @@
import type { Stats } from "node:fs";
import { lstat, stat, type FileHandle } from "node:fs/promises";
import type { FileHandle } from "node:fs/promises";
import {
openRegularFile,
sameRegularFileState,
RegularFileChangedError,
} from "../filesystem/RegularFile.js";
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
/** Filesystem admission policy for one selected regular input. */
export interface RegularFileReadOptions {
readonly signal?: AbortSignal | undefined;
readonly symlinks?: "follow" | "reject";
}
import { openRegularFile } from "../filesystem/RegularFile.js";
/** Admit one regular-file handle, retain it through the consumer, and always close it. */
export const withRegularFile = async <Value>(
path: string,
read: (handle: FileHandle, stats: Stats) => Promise<Value>,
options: RegularFileReadOptions = {},
signal?: AbortSignal,
): Promise<Value> => {
const { signal } = options;
const handle = await openRegularFile(path, {
symlinks: options.symlinks ?? "follow",
signal,
});
const handle = await openRegularFile(path, { symlinks: "follow", signal });
try {
const stats = await handle.stat();
signal?.throwIfAborted();
const value = await read(handle, stats);
signal?.throwIfAborted();
const [opened, currentPath] = await Promise.all([
handle.stat(),
options.symlinks === "reject" ? lstat(path) : stat(path),
]);
if (
!sameRegularFileState(stats, opened) ||
!sameRegularFileState(stats, currentPath)
)
throw new RegularFileChangedError(path);
return value;
} finally {
await handle.close();
@@ -48,25 +24,6 @@ export const withRegularFile = async <Value>(
/** Admit a regular file without waiting for a pipe, then read its verified handle. */
export const readRegularFile = (
path: string,
options: RegularFileReadOptions = {},
signal?: AbortSignal,
): Promise<Buffer> =>
withRegularFile(
path,
async (handle, stats) => {
const bytes = await readBoundedFileBytes(
handle,
stats.size,
options.signal,
);
if (bytes === undefined || bytes.length !== stats.size)
throw new RegularFileChangedError(path);
return bytes;
},
options,
);
/** Read one admitted regular file as UTF-8 without waiting on a pipe. */
export const readRegularFileText = async (
path: string,
options: RegularFileReadOptions = {},
): Promise<string> => (await readRegularFile(path, options)).toString("utf8");
withRegularFile(path, (handle) => handle.readFile({ signal }), signal);
+5 -6
View File
@@ -14,7 +14,7 @@ import {
type ClientRegistrationDialect,
} from "./ClientConfigurationDocument.js";
import { constants as fsConstants } from "node:fs";
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import writeFileAtomic from "write-file-atomic";
@@ -22,7 +22,6 @@ import { PRODUCT_IDENTITY } from "../identity.js";
import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js";
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
import { readRegularFileText } from "./RegularFileRead.js";
import type {
ClientConfigurationInspection,
ClientConfigurationResult,
@@ -65,7 +64,7 @@ const configureClientDocument = async (
return { status: "failed", reason: "path" };
let original: string | undefined;
try {
original = await readRegularFileText(transactionPath);
original = await readFile(transactionPath, "utf8");
} catch (cause: unknown) {
if (!isMissing(cause)) return { status: "failed", reason: "readback" };
}
@@ -150,7 +149,7 @@ const configureClientDocument = async (
}
try {
const readback = parseClientConfiguration(
await readRegularFileText(transactionPath),
await readFile(transactionPath, "utf8"),
format,
);
if (!registrationCurrent(readback, desired)) {
@@ -179,7 +178,7 @@ export const clientConfigurationAligned = async (
try {
const files = await readClientConfigurationFiles(client);
if (!files.ok) return false;
const original = await readRegularFileText(client.configPath);
const original = await readFile(client.configPath, "utf8");
const parsed = parseClientConfiguration(original, client.format);
const policy = await readClientPolicyBlock(
client,
@@ -247,7 +246,7 @@ export const inspectClientConfiguration = async (
};
let original: string;
try {
original = await readRegularFileText(transactionPath);
original = await readFile(transactionPath, "utf8");
} catch (cause: unknown) {
if (isMissing(cause)) return { status: "create" };
return {
+14 -6
View File
@@ -4,8 +4,10 @@ import { dirname, join } from "node:path";
import writeFileAtomic from "write-file-atomic";
import { PRODUCT_IDENTITY } from "../identity.js";
import { NonRegularFileReadError } from "../filesystem/RegularFile.js";
import { readRegularFileText } from "./RegularFileRead.js";
import {
NonRegularFileReadError,
openRegularFile,
} from "../filesystem/RegularFile.js";
import { clientSkillDirectories } from "./SupportedClients.js";
export const MANAGED_SKILL_FILES = [
@@ -21,8 +23,14 @@ export const isManagedSkillManifest = (content: string): boolean =>
content.startsWith(`---\nname: ${PRODUCT_IDENTITY.skillName}\n`) ||
content.startsWith(`---\r\nname: ${PRODUCT_IDENTITY.skillName}\r\n`);
export const readSkillFile = (path: string): Promise<string> =>
readRegularFileText(path, { symlinks: "reject" });
export const readSkillFile = async (path: string): Promise<string> => {
const handle = await openRegularFile(path, { symlinks: "reject" });
try {
return await handle.readFile("utf8");
} finally {
await handle.close();
}
};
interface CanonicalSkillFile {
readonly content: string;
@@ -191,7 +199,7 @@ export const installCanonicalSkill = async (
for (const { destination, content } of changed)
await writeText(destination, content);
for (const { destination, content } of changed)
if ((await readSkillFile(destination)) !== content)
if ((await readFile(destination, "utf8")) !== content)
throw new Error(`skill readback mismatch: ${destination}`);
return "installed";
} catch (cause: unknown) {
@@ -223,7 +231,7 @@ export const readInstalledSkillIdentity = async (
let content: string | undefined;
for (const destination of destinations) {
try {
content = await readSkillFile(join(destination.path, "SKILL.md"));
content = await readFile(join(destination.path, "SKILL.md"), "utf8");
break;
} catch {
// Another selected copy may provide metadata; canonical validation below
+2 -3
View File
@@ -6,7 +6,7 @@ import {
withClientServers,
type ClientConfigurationDocument,
} from "./ClientConfigurationDocument.js";
import { copyFile, lstat, realpath, rm } from "node:fs/promises";
import { copyFile, lstat, readFile, realpath, rm } from "node:fs/promises";
import { constants as fsConstants } from "node:fs";
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
import { join } from "node:path";
@@ -23,7 +23,6 @@ import {
} from "./SetupSkill.js";
import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
import { readRegularFileText } from "./RegularFileRead.js";
import {
manualRegistrationRemediation,
supportedClients,
@@ -50,7 +49,7 @@ export interface UninstallFileSystem {
}
const systemFileSystem: UninstallFileSystem = {
readText: (path) => readRegularFileText(path),
readText: (path) => readFile(path, "utf8"),
readRegularText: readSkillFile,
copy: (source, destination) =>
copyFile(source, destination, fsConstants.COPYFILE_EXCL),
+2 -2
View File
@@ -1,3 +1,4 @@
import { readFile } from "node:fs/promises";
import { z } from "zod";
import { PRODUCT_IDENTITY } from "../identity.js";
@@ -13,7 +14,6 @@ import { supportedClients } from "./SupportedClients.js";
import { existingSkillDestinations } from "./SetupSkill.js";
import type { SetupAction } from "./SetupTypes.js";
import type { Result } from "../domain/result.js";
import { readRegularFileText } from "./RegularFileRead.js";
/** Existing REA integrations selected for maintenance, without discovering new targets. */
export interface MaintenanceScope {
@@ -73,7 +73,7 @@ export const existingMaintenanceScope = async (
const client = supported.find(({ name }) => name === registration.client);
if (client === undefined) continue;
const parsed = parseClientConfiguration(
await readRegularFileText(client.configPath),
await readFile(client.configPath, "utf8"),
client.format,
);
const enabled = z
+2 -6
View File
@@ -27,10 +27,7 @@ import { WebScriptExportError } from "../domain/webScriptExportError.js";
import type { ExecutionOptions } from "./AnalysisProvider.js";
import { WEB_SCRIPT_EXPORT_PROVIDER as PROVIDER } from "./InvestigationProviders.js";
import { readRegularFile } from "./RegularFileRead.js";
import {
NonRegularFileReadError,
RegularFileChangedError,
} from "../filesystem/RegularFile.js";
import { NonRegularFileReadError } from "../filesystem/RegularFile.js";
const OPERATION = "export_web_scripts";
@@ -156,7 +153,7 @@ const readCapture = async (
signal: AbortSignal | undefined,
): Promise<Result<Buffer, AnalysisError>> => {
try {
return ok(await readRegularFile(path, { signal }));
return ok(await readRegularFile(path, signal));
} catch (cause: unknown) {
const code =
cause instanceof Error && "code" in cause ? String(cause.code) : "";
@@ -166,7 +163,6 @@ const readCapture = async (
);
if (
cause instanceof NonRegularFileReadError ||
cause instanceof RegularFileChangedError ||
code === "ENOENT" ||
code === "ENOTDIR" ||
code === "EISDIR" ||
+1 -1
View File
@@ -14,7 +14,7 @@ import type { Result } from "../../domain/result.js";
export interface AdbDeviceAnalysisPort {
/** Probe the selected adb binary without contacting devices. */
inspectAvailability(signal?: AbortSignal): Promise<ProviderAvailability>;
/** Terminally stop admission, drain work, and retry retained package rollback. */
/** The provider holds no long-lived resources; safe to call always. */
close(): Promise<void>;
execute(
request: AdbRequest,
@@ -2,10 +2,9 @@ import type { ApplicationNode } from "../../domain/javascript/javascriptApplicat
import { sha256Text } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js";
import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import {
sourceNodeFor,
type JavaScriptArtifactGraphContext,
type JavaScriptArtifactGraphCoverage,
import type {
JavaScriptArtifactGraphContext,
JavaScriptArtifactGraphCoverage,
} from "./JavaScriptArtifactGraphContext.js";
import {
astObservationEvidence,
@@ -39,11 +38,10 @@ export const electronFindingSourceNode = (
context: JavaScriptArtifactGraphContext,
file: JavaScriptArtifactFile,
moduleKey: string | null,
runtime?: string,
): ApplicationNode | undefined =>
moduleKey === null
? (context.assetNodes.get(file.path) ?? context.fileNodes.get(file.path))
: sourceNodeFor(context, file.path, moduleKey, runtime);
: context.moduleNodes.get(`${file.path}\0${moduleKey}`);
/** Observation-scoped identity shared safely across different artifact files. */
export const electronObservationIdentity = (
@@ -73,7 +73,6 @@ const addIpcRecord = (
context,
record.file,
record.finding.module_key,
record.finding.module_runtime,
);
if (source === undefined) return;
state.sources.set(record.key, source);
@@ -256,7 +255,6 @@ const addValidationObservations = (state: IpcGraphState): void => {
state.context,
file,
validation.module_key,
validation.module_runtime,
)
: state.handlers.get(handlerRecord.key);
if (target !== undefined)
@@ -38,12 +38,7 @@ export const addElectronNativeBoundaries = (
const addNativeBinding = (input: NativeBindingInput): void => {
const { context, file, value, coverage } = input;
const source = electronFindingSourceNode(
context,
file,
value.module_key,
value.module_runtime,
);
const source = electronFindingSourceNode(context, file, value.module_key);
if (source === undefined) return;
const resolved = resolveArtifactPath(
value.specifier,
@@ -39,32 +39,17 @@ export const addElectronWindowBoundaries = (
if (javascript === null) continue;
const coverage = javascriptAnalysisCoverage(javascript);
for (const value of javascript.electron.browser_windows) {
const source = electronFindingSourceNode(
context,
file,
value.module_key,
value.module_runtime,
);
const source = electronFindingSourceNode(context, file, value.module_key);
if (source !== undefined)
addBrowserWindow({ context, file, source, value, coverage });
}
for (const value of javascript.electron.context_bridge_apis) {
const source = electronFindingSourceNode(
context,
file,
value.module_key,
value.module_runtime,
);
const source = electronFindingSourceNode(context, file, value.module_key);
if (source !== undefined)
addContextBridge({ context, file, source, value, coverage });
}
for (const value of javascript.electron.utility_processes) {
const source = electronFindingSourceNode(
context,
file,
value.module_key,
value.module_runtime,
);
const source = electronFindingSourceNode(context, file, value.module_key);
if (source !== undefined)
addUtilityProcess({ context, file, source, value, coverage });
}
@@ -31,16 +31,7 @@ const graphIdentities = async (source: string) => {
...(identity.strategy === "artifact-local-key"
? {
namespace: identity.namespace,
// Module-export identity is a JSON tuple whose first entry
// binds the whole container; only that digest changes here.
key:
identity.namespace === "module-export"
? JSON.stringify(
(JSON.parse(identity.key) as unknown[]).slice(1),
)
: identity.namespace === "source-module"
? identity.key.replace(/^[0-9a-f]{64}:/u, "")
: identity.key,
key: identity.key.replace(/^[0-9a-f]{64}:/u, ""),
}
: {}),
}))
@@ -161,7 +161,7 @@ const addBundlerModuleNodes = (
],
});
context.moduleNodes.set(
moduleLookupKey(file.path, registration.runtime, moduleValue.module_key),
moduleLookupKey(file.path, moduleValue.module_key),
module,
);
addAstContainsEdge(context, {
@@ -195,7 +195,7 @@ const addBundlerRuntimeEdges = (
const { file, registration, chunk, coverage } = record;
for (const moduleKey of registration.entry_module_keys) {
const resolved = context.moduleNodes.get(
moduleLookupKey(file.path, registration.runtime, moduleKey),
moduleLookupKey(file.path, moduleKey),
);
const target =
resolved ?? unresolvedBundlerModuleNode(context, record, moduleKey);
@@ -323,11 +323,8 @@ export const selfReferenceOmissions = (
];
/** Deterministic key for one recovered module inside a bundle asset. */
export const moduleLookupKey = (
path: string,
runtime: string,
moduleKey: string,
): string => compositeKey([path, runtime, moduleKey]);
export const moduleLookupKey = (path: string, moduleKey: string): string =>
`${path}\0${moduleKey}`;
/** Deterministic key for one recovered bundler chunk inside a bundle asset. */
export const chunkLookupKey = (
@@ -356,10 +353,7 @@ export const sourceNodeFor = (
context: JavaScriptArtifactGraphContext,
path: string,
moduleKey: string | null,
runtime?: string,
): ApplicationNode | undefined =>
moduleKey === null
? context.sourceModuleNodes.get(path)
: runtime === undefined
? undefined
: context.moduleNodes.get(moduleLookupKey(path, runtime, moduleKey));
: context.moduleNodes.get(moduleLookupKey(path, moduleKey));
@@ -78,12 +78,8 @@ const addReference = (
omissions: { selfReferences: number },
): void => {
const source =
sourceNodeFor(
context,
input.file.path,
input.value.module_key,
input.value.module_runtime,
) ?? input.asset;
sourceNodeFor(context, input.file.path, input.value.module_key) ??
input.asset;
const resolved =
input.value.specifier === null
? null
@@ -166,12 +162,8 @@ const addEndpoint = (
input: FindingInput<StaticEndpoint>,
): void => {
const source =
sourceNodeFor(
context,
input.file.path,
input.value.module_key,
input.value.module_runtime,
) ?? input.asset;
sourceNodeFor(context, input.file.path, input.value.module_key) ??
input.asset;
const key = compactStringIdentityKey(input.value.value);
const label = `${input.value.kind} endpoint`;
const node = context.accumulator.addNode({
@@ -212,12 +204,8 @@ const addStorage = (
input: FindingInput<StaticStorage>,
): void => {
const source =
sourceNodeFor(
context,
input.file.path,
input.value.module_key,
input.value.module_runtime,
) ?? input.asset;
sourceNodeFor(context, input.file.path, input.value.module_key) ??
input.asset;
const node = context.accumulator.addNode({
kind: "storage",
identity: artifactLocalIdentity(
@@ -274,12 +262,8 @@ const addDiscoveredRole = (
coverage: input.coverage,
});
const source =
sourceNodeFor(
context,
input.file.path,
input.value.module_key,
input.value.module_runtime,
) ?? input.asset;
sourceNodeFor(context, input.file.path, input.value.module_key) ??
input.asset;
addStaticInferenceEdge(context, {
source,
target: role,
@@ -375,12 +359,9 @@ const resolveReference = (
if (specifier === null) return null;
const chunk = resolveBundlerChunkReference(context, sourcePath, reference);
if (chunk !== null) return chunk;
const module =
reference.module_runtime === undefined
? undefined
: context.moduleNodes.get(
moduleLookupKey(sourcePath, reference.module_runtime, specifier),
);
const module = context.moduleNodes.get(
moduleLookupKey(sourcePath, specifier),
);
if (module !== undefined) {
const file = context.filesByPath.get(sourcePath);
return file === undefined
@@ -426,11 +407,10 @@ const resolveBundlerChunkReference = (
if (file === undefined) return null;
const registrations = context.analysis.files
.find(({ file: analyzedFile }) => analyzedFile.path === sourcePath)
?.javascript?.bundler_registrations.filter(({ runtime, modules }) =>
reference.module_runtime === undefined
? reference.module_key === null
: runtime === reference.module_runtime &&
modules.some(
?.javascript?.bundler_registrations.filter(({ modules }) =>
reference.module_key === null
? true
: modules.some(
({ module_key: moduleKey }) => moduleKey === reference.module_key,
),
);
@@ -325,7 +325,8 @@ const resolveUiAction = (
(node) =>
["control", "view", "view_controller", "scene"].includes(node.kind) &&
(node.id === query ||
node.attributes.interface_builder_object_id === query),
node.attributes.interface_builder_object_id === query ||
node.id.endsWith(`:object:${query}`)),
);
const actionIds = new Set(
graph.edges.flatMap((edge) =>
-33
View File
@@ -60,30 +60,6 @@ describe("English Unicode case collisions", () => {
]);
});
it("retains every spelling in a high-cardinality folded sibling group", () => {
const count = 8_192;
const variants = caseVariants("abcdefghijklm", count);
const { limitation, byPath } = annotate(variants);
const ordered = [...variants].sort();
const first = ordered[0];
const second = ordered[1];
const original = variants[0];
if (first === undefined || second === undefined || original === undefined) {
throw new Error("Expected at least two case variants");
}
expect(limitation).toBe(ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION);
expect(byPath.size).toBe(count);
expect(byPath.get(first)).toEqual([
`Logical path ${first} collides under English (en-US) Unicode case folding with ${second} and ${String(count - 2)} other spellings; ${NOTE}`,
]);
expect(byPath.get(original)?.[0]).toContain(
`with ${first} and ${String(count - 2)} other spellings;`,
);
expect(
[...byPath.values()].every((limitations) => limitations.length === 1),
).toBe(true);
});
it("stays linear for large case-variant subtrees", () => {
const count = 5_000;
const paths = Array.from({ length: count }, (_, index) => [
@@ -105,12 +81,3 @@ describe("English Unicode case collisions", () => {
expect(byPath.get(deep)).toEqual([]);
});
});
const caseVariants = (lowercase: string, count: number): string[] =>
Array.from({ length: count }, (_, mask) =>
[...lowercase]
.map((character, index) =>
(mask & (1 << index)) === 0 ? character : character.toUpperCase(),
)
.join(""),
);
+4 -4
View File
@@ -177,12 +177,12 @@ const englishUnicodeCaseCollisions = (
if (path.length === 0 || path === ".") continue;
let node = root;
for (const part of path.split("/")) {
const folded = englishCaseFold(part);
const spellings = node.spellingsByFold.get(folded);
if (spellings === undefined) node.spellingsByFold.set(folded, [part]);
else if (!spellings.includes(part)) spellings.push(part);
let child = node.bySpelling.get(part);
if (child === undefined) {
const folded = englishCaseFold(part);
const spellings = node.spellingsByFold.get(folded);
if (spellings === undefined) node.spellingsByFold.set(folded, [part]);
else spellings.push(part);
child = emptyCaseNode();
node.bySpelling.set(part, child);
}
+24 -101
View File
@@ -1,5 +1,5 @@
import { lstat } from "node:fs/promises";
import type { FileHandle } from "node:fs/promises";
import { createReadStream } from "node:fs";
import { open as openFile, stat } from "node:fs/promises";
import { Readable } from "node:stream";
import type { ArtifactCommand } from "../domain/artifactGraph.js";
@@ -19,21 +19,11 @@ import {
type ArtifactReader,
} from "./ArtifactReader.js";
import { streamChunkToBuffer } from "./StreamBytes.js";
import {
openRegularFile,
sameRegularFileState,
type StableRegularFileDescriptor,
} from "../filesystem/RegularFile.js";
/** Read-only universal Mach-O slice reader backed by native lipo metadata. */
export class MachOSliceArtifactReader implements ArtifactReader {
readonly format = "file" as const;
#command: ArtifactCommand | undefined;
#source: StableRegularFileDescriptor | undefined;
#pendingSource: Promise<StableRegularFileDescriptor> | undefined;
readonly #ownsSource: boolean;
#closed = false;
#closePromise: Promise<void> | undefined;
constructor(
private readonly path: string,
@@ -41,15 +31,9 @@ export class MachOSliceArtifactReader implements ArtifactReader {
private readonly runner: NativeCommandRunner = new XcrunCommandRunner(
environment,
),
source?: StableRegularFileDescriptor,
) {
this.#source = source;
this.#ownsSource = source === undefined;
}
) {}
async *entries(signal?: AbortSignal): AsyncIterable<ArtifactEntry> {
const source = await this.#ensureSource();
await this.#verifySource(source);
const captured = await this.runner.run(
"lipo",
["-detailed_info", this.path],
@@ -69,11 +53,9 @@ export class MachOSliceArtifactReader implements ArtifactReader {
exit_code: captured.value.exitCode,
effects: ["read"],
};
await this.#verifySource(source);
const fileSize = source.initial.size;
const fileSize = (await stat(this.path)).size;
const architectures = parseLipoArchitectures(captured.value.stdout);
const structural = await readStructuralSlices(source.handle, fileSize);
await this.#verifySource(source);
const structural = await readStructuralSlices(this.path, fileSize);
if (structural.status === "malformed")
throw new ArtifactReaderFailure(
"integrity",
@@ -186,28 +168,22 @@ export class MachOSliceArtifactReader implements ArtifactReader {
"integrity",
"Invalid Mach-O slice byte range",
);
const source = await this.#ensureSource();
await this.#verifySource(source);
if (
!Number.isSafeInteger(offset + size) ||
offset + size > source.initial.size
)
const fileSize = (await stat(this.path)).size;
if (!Number.isSafeInteger(offset + size) || offset + size > fileSize)
throw new ArtifactReaderFailure(
"integrity",
`Mach-O slice range is outside the artifact: ${entry.path}`,
);
const sourceStream = source.handle.createReadStream({
const source = createReadStream(this.path, {
start: offset,
end: offset + size - 1,
autoClose: false,
...(signal === undefined ? {} : { signal }),
});
const verifySource = () => this.#verifySource(source);
return Readable.from(
(async function* () {
let observedBytes = 0;
try {
for await (const raw of sourceStream) {
for await (const raw of source) {
const chunk = streamChunkToBuffer(raw);
observedBytes += chunk.byteLength;
if (observedBytes > size)
@@ -236,7 +212,6 @@ export class MachOSliceArtifactReader implements ArtifactReader {
"integrity",
`Mach-O slice size disagrees with lipo metadata: ${entry.path}`,
);
await verifySource();
})(),
);
}
@@ -245,80 +220,28 @@ export class MachOSliceArtifactReader implements ArtifactReader {
return this.#command === undefined ? [] : [structuredClone(this.#command)];
}
async close(): Promise<void> {
this.#closed = true;
this.#closePromise ??= this.#closeSource().catch((cause: unknown) => {
this.#closePromise = undefined;
throw cause;
});
return this.#closePromise;
}
async #ensureSource(): Promise<StableRegularFileDescriptor> {
if (this.#closed)
throw new ArtifactReaderFailure(
"unavailable",
"Universal Mach-O reader is closed",
);
if (this.#source !== undefined) return this.#source;
this.#pendingSource ??= this.#acquireSource();
const source = await this.#pendingSource;
if (this.#closed)
throw new ArtifactReaderFailure(
"unavailable",
"Universal Mach-O reader closed during source acquisition",
);
return source;
}
async #acquireSource(): Promise<StableRegularFileDescriptor> {
const handle = await openRegularFile(this.path, { symlinks: "reject" });
try {
const initial = await handle.stat();
const source = { handle, initial };
this.#source = source;
return source;
} catch (cause: unknown) {
await handle.close().catch(() => undefined);
throw cause;
}
}
async #closeSource(): Promise<void> {
await this.#pendingSource?.catch(() => undefined);
if (!this.#ownsSource || this.#source === undefined) return;
const source = this.#source;
await source.handle.close();
this.#source = undefined;
}
async #verifySource(source: StableRegularFileDescriptor): Promise<void> {
const [opened, currentPath] = await Promise.all([
source.handle.stat(),
lstat(this.path),
]);
if (
!sameRegularFileState(source.initial, opened) ||
!sameRegularFileState(source.initial, currentPath)
)
throw new ArtifactReaderFailure(
"integrity",
`Universal Mach-O source changed during inventory: ${this.path}`,
);
close(): Promise<void> {
return Promise.resolve();
}
}
/** Compare lipo's independent ranges with structurally parsed slice identity. */
const readStructuralSlices = async (
file: FileHandle,
path: string,
size: number,
): Promise<Awaited<ReturnType<typeof readMachoImage>>> => {
const readAt: ReadAt = async (offset, length) => {
const bytes = Buffer.alloc(length);
const { bytesRead } = await file.read(bytes, 0, length, offset);
return bytes.subarray(0, bytesRead);
};
return readMachoImage(readAt, size);
const file = await openFile(path, "r");
try {
const readAt: ReadAt = async (offset, length) => {
const bytes = Buffer.alloc(length);
const { bytesRead } = await file.read(bytes, 0, length, offset);
return bytes.subarray(0, bytesRead);
};
const facts = await readMachoImage(readAt, size);
return facts;
} finally {
await file.close();
}
};
const lipoMatchesSlice = (
@@ -295,53 +295,23 @@ export const createArtifactEdges = (
return edges;
};
interface ArtifactParentIndex {
readonly children: Map<string, ArtifactParentIndex>;
occurrence?: MutableOccurrence;
}
/** Resolve strict observed ancestors without rebuilding every path prefix. */
export const resolveOccurrenceParents = (
occurrences: readonly MutableOccurrence[],
byPath: ReadonlyMap<string, MutableOccurrence>,
export const nearestParent = (
path: string,
occurrences: ReadonlyMap<string, MutableOccurrence>,
expandedContainerIds: ReadonlySet<string>,
): void => {
const root: ArtifactParentIndex = { children: new Map() };
for (const occurrence of byPath.values()) {
): MutableOccurrence | undefined => {
const parts = path.split("/");
while (parts.length > 1) {
parts.pop();
const candidate = occurrences.get(parts.join("/"));
if (
occurrence.entry_kind !== "directory" &&
!expandedContainerIds.has(occurrence.occurrence_id)
candidate !== undefined &&
(candidate.entry_kind === "directory" ||
expandedContainerIds.has(candidate.occurrence_id))
)
continue;
let branch = root;
for (const segment of occurrence.logical_path.split("/")) {
let child = branch.children.get(segment);
if (child === undefined) {
child = { children: new Map() };
branch.children.set(segment, child);
}
branch = child;
}
branch.occurrence = occurrence;
}
for (const occurrence of occurrences) {
const path = occurrence.logical_path;
let branch = root;
let parent: MutableOccurrence | undefined;
// Walk parent segments only. Stop scanning the path once no observed
// directory or expanded container can be an ancestor.
for (let start = 0; ;) {
const slash = path.indexOf("/", start);
if (slash < 0) break;
const segment = path.slice(start, slash);
const child = branch.children.get(segment);
if (child === undefined) break;
branch = child;
parent = branch.occurrence ?? parent;
start = slash + 1;
}
occurrence.parent_occurrence_id = parent?.occurrence_id ?? null;
return candidate;
}
return undefined;
};
/**
+6 -6
View File
@@ -24,7 +24,7 @@ interface RootClassification {
}
type RootInventoryClassification = RootClassification & {
readonly rootSource: StableRegularFileDescriptor | undefined;
readonly zipSource: StableRegularFileDescriptor | undefined;
};
/** Classify and hash one file root through the same stable open descriptor. */
@@ -43,7 +43,7 @@ export const classifyAndHashRoot = async (
try {
return { format: result.format, digest: result.digest };
} finally {
await result.rootSource?.handle.close();
await result.zipSource?.handle.close();
}
};
@@ -55,7 +55,7 @@ export const classifyAndHashRootForInventory = async (
signal?: AbortSignal,
): Promise<RootInventoryClassification> => {
if (directory)
return { format: "directory", digest: null, rootSource: undefined };
return { format: "directory", digest: null, zipSource: undefined };
const handle = await openRootFile(path, signal);
let transferHandle = false;
try {
@@ -72,12 +72,12 @@ export const classifyAndHashRootForInventory = async (
initial,
signal,
);
const retainSource = isZipFormat(format) || format === "mach-o-universal";
if (retainSource) transferHandle = true;
const zipSource = isZipFormat(format);
if (zipSource) transferHandle = true;
return {
format,
digest,
rootSource: retainSource ? { handle, initial } : undefined,
zipSource: zipSource ? { handle, initial } : undefined,
};
} finally {
if (!transferHandle) await handle.close();
+3 -3
View File
@@ -13,7 +13,7 @@ export const createReader = async (
format: ArtifactOccurrence["artifact_format"],
environment: Readonly<NodeJS.ProcessEnv>,
signal?: AbortSignal,
rootSource?: StableRegularFileDescriptor,
zipSource?: StableRegularFileDescriptor,
): Promise<ArtifactReader | undefined> => {
switch (format) {
case "directory":
@@ -23,12 +23,12 @@ export const createReader = async (
case "apk":
case "msix":
case "appx":
return new ZipArtifactReader(path, format, undefined, rootSource);
return new ZipArtifactReader(path, format, undefined, zipSource);
case "asar":
return new AsarArtifactReader(path);
case "mach-o-universal":
return process.platform === "darwin"
? new MachOSliceArtifactReader(path, environment, undefined, rootSource)
? new MachOSliceArtifactReader(path, environment)
: undefined;
case "dmg":
if (process.platform !== "darwin") return undefined;
+13 -13
View File
@@ -58,7 +58,7 @@ export const scanCanonicalArtifactInventory = async (
const {
format: rootFormat,
digest: rootDigest,
rootSource,
zipSource,
} = await classifyAndHashRootForInventory(
path,
metadata.isDirectory(),
@@ -73,7 +73,7 @@ export const scanCanonicalArtifactInventory = async (
rootFormat,
options.environment ?? {},
options.signal,
rootSource,
zipSource,
);
} catch (cause: unknown) {
readerCreationFailure = { cause };
@@ -102,7 +102,7 @@ export const scanCanonicalArtifactInventory = async (
metadata,
rootFormat,
rootDigest,
rootSource,
zipSource,
reader,
signal: options.signal,
nodes,
@@ -133,13 +133,13 @@ export const scanCanonicalArtifactInventory = async (
);
}
}
if (rootSource !== undefined) {
if (zipSource !== undefined) {
try {
await rootSource.handle.close();
await zipSource.handle.close();
} catch (cleanupCause: unknown) {
const cleanup = ArtifactReaderFailure.cleanupObservation(
cleanupCause,
`root artifact descriptor for ${path}`,
`root ZIP descriptor for ${path}`,
);
cleanupFailure = ArtifactReaderFailure.withCleanup(
cleanupFailure ??
@@ -161,7 +161,7 @@ interface SnapshotBuildInput {
readonly metadata: Stats;
readonly rootFormat: ArtifactOccurrence["artifact_format"];
readonly rootDigest: HashResult | null;
readonly rootSource: StableRegularFileDescriptor | undefined;
readonly zipSource: StableRegularFileDescriptor | undefined;
readonly reader: ArtifactReader | undefined;
readonly signal: AbortSignal | undefined;
readonly nodes: Map<string, ArtifactNode>;
@@ -173,7 +173,7 @@ interface SnapshotBuildInput {
const buildInventorySnapshot = async (
input: SnapshotBuildInput,
): Promise<ArtifactInventorySnapshot> => {
const { path, metadata, rootFormat, rootDigest, rootSource, reader, signal } =
const { path, metadata, rootFormat, rootDigest, zipSource, reader, signal } =
input;
materializeDirectoryNodes(input.occurrences, input.nodes);
const rootNode = createRootNode({
@@ -218,7 +218,7 @@ const buildInventorySnapshot = async (
ordinal,
}));
await verifyRootDigest(path, rootDigest, rootSource, signal);
await verifyRootDigest(path, rootDigest, zipSource, signal);
const graphSha256 = artifactGraphDigest({
nodes: orderedNodes,
@@ -293,17 +293,17 @@ const buildIntegrityContradictions = (
const verifyRootDigest = async (
path: string,
rootDigest: HashResult | null,
rootSource: StableRegularFileDescriptor | undefined,
zipSource: StableRegularFileDescriptor | undefined,
signal: AbortSignal | undefined,
): Promise<void> => {
if (rootDigest === null) return;
const verified =
rootSource === undefined
zipSource === undefined
? await hashStableRootArtifact(path, signal)
: await hashStableRootArtifactHandle(
path,
rootSource.handle,
rootSource.initial,
zipSource.handle,
zipSource.initial,
signal,
);
if (
+8 -6
View File
@@ -18,7 +18,7 @@ import {
createArtifactNode,
createOccurrence,
noteUnreadContainerSuffix,
resolveOccurrenceParents,
nearestParent,
type MutableOccurrence,
} from "./ArtifactGraphConstruction.js";
import {
@@ -91,11 +91,13 @@ export const scanReader = async (
await visitArtifactEntries(context, reader, "");
// Archive directories may appear after their children. Resolve containment
// against the complete index before directory identities are materialized.
resolveOccurrenceParents(
occurrences,
context.occurrenceByPath,
context.expandedContainerIds,
);
for (const occurrence of occurrences)
occurrence.parent_occurrence_id =
nearestParent(
occurrence.logical_path,
context.occurrenceByPath,
context.expandedContainerIds,
)?.occurrence_id ?? null;
return {
nodes,
occurrences,
+11 -13
View File
@@ -68,20 +68,18 @@ export const closeCdpTargetSession = async (
// Best-effort domain cleanup; transport close is the definitive boundary.
}
}
if (!cleanup.signal.aborted && sessionId !== undefined)
try {
await connection.send(
"Target.detachFromTarget",
{ sessionId },
undefined,
cleanup.signal,
);
} catch {
// Best-effort detach; transport close is the definitive boundary.
}
}
// Cancellation skips optional domain shutdown but still releases the
// attached session using its independent, bounded cleanup signal.
if (!cleanup.signal.aborted && sessionId !== undefined)
try {
await connection.send(
"Target.detachFromTarget",
{ sessionId },
undefined,
cleanup.signal,
);
} catch {
// Best-effort detach; transport close is the definitive boundary.
}
} finally {
clearTimeout(timeout);
cleanup.abort();
+11 -40
View File
@@ -365,30 +365,20 @@ const ingestWebMcpEvent = (options: WebMcpIngestOptions): void => {
transientFrames,
resolvedTransientFrames,
} = options;
if (
event.method !== "WebMCP.toolsAdded" &&
event.method !== "WebMCP.toolsRemoved"
)
return;
const params = recordValue(event.params);
if (params === undefined) {
completeness.exclude("webmcp_tools", "invalid_protocol_value");
return;
}
if (params === undefined) return;
if (event.method === "WebMCP.toolsRemoved") {
for (const removed of webMcpEventTools(params.tools, completeness)) {
for (const removed of recordsValue(params.tools)) {
const name = cdpStringValue(removed.name);
const frameId = cdpStringValue(removed.frameId);
if (name === undefined || frameId === undefined) {
completeness.exclude("webmcp_tools", "invalid_protocol_value");
continue;
}
if (name === undefined || frameId === undefined) continue;
for (const [key, tool] of tools)
if (tool.name === name && tool.frame_id === frameId) tools.delete(key);
}
return;
}
for (const declared of webMcpEventTools(params.tools, completeness)) {
if (event.method !== "WebMCP.toolsAdded") return;
for (const declared of recordsValue(params.tools)) {
const frameId = cdpStringValue(declared.frameId);
if (frameId !== undefined && transientFrames.has(frameId)) {
if (!resolvedTransientFrames.has(frameId)) continue;
@@ -401,26 +391,6 @@ const ingestWebMcpEvent = (options: WebMcpIngestOptions): void => {
}
};
const webMcpEventTools = (
value: unknown,
completeness: CdpCaptureCompleteness,
): UnknownRecord[] => {
if (!Array.isArray(value)) {
completeness.exclude("webmcp_tools", "invalid_protocol_value");
return [];
}
const records: UnknownRecord[] = [];
for (const item of value) {
const record = recordValue(item);
if (record === undefined) {
completeness.exclude("webmcp_tools", "invalid_protocol_value");
continue;
}
records.push(record);
}
return records;
};
const normalizeTool = (
value: UnknownRecord,
input: DiscoverWebMcpToolsInput,
@@ -433,11 +403,12 @@ const normalizeTool = (
const frameId = cdpStringValue(value.frameId);
const name = cdpStringValue(value.name);
const frame = frameId === undefined ? undefined : frames.get(frameId);
if (frameId === undefined || name === undefined) {
completeness.exclude("webmcp_tools", "invalid_protocol_value");
return undefined;
}
if (frame?.origin === null || frame === undefined) {
if (
frameId === undefined ||
name === undefined ||
frame?.origin === null ||
frame === undefined
) {
completeness.exclude("webmcp_tools", "out_of_target_scope");
return undefined;
}
+5 -4
View File
@@ -42,6 +42,7 @@ export class PlaywrightScenarioBrowserCleanupOwner {
#profileRemoved: boolean;
#closePromise: Promise<void> | undefined;
#eventFinalizationPromise: Promise<void> | undefined;
#eventFinalizationFailureReported = false;
/** Keep the narrow provider actions needed to release one opened browser. */
constructor(private readonly resources: PlaywrightScenarioCleanupResources) {
@@ -97,10 +98,10 @@ export class PlaywrightScenarioBrowserCleanupOwner {
try {
await eventFinalization;
} catch (cause: unknown) {
// A failed finalization stays failed: the memoized attempt rejects on
// every retry so cleanup can never report success for unfinalized
// events.
failures.push(cause);
if (!this.#eventFinalizationFailureReported) {
failures.push(cause);
this.#eventFinalizationFailureReported = true;
}
}
if (!this.#browserClosed) {
try {
@@ -6,76 +6,61 @@ import { getHeapStatistics } from "node:v8";
import parser from "stream-json/parser.js";
import streamValues from "stream-json/streamers/stream-values.js";
import { RegularFileChangedError } from "../filesystem/RegularFile.js";
import { withRegularFile } from "./RegularFileRead.js";
import { withRegularFile } from "./application/RegularFileRead.js";
import {
JSON_BYTE_ORDER_MARK_MESSAGE,
JSON_INPUT_RESOURCE_REMEDIATION,
parseUtf8Json,
} from "./Utf8JsonInput.js";
} from "./application/Utf8JsonInput.js";
import {
AnalysisInputError,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import { err, ok, type Result } from "../domain/result.js";
} from "./domain/analysisErrorCore.js";
import { err, ok, type Result } from "./domain/result.js";
type JsonInputFileFailure =
| AnalysisInputError
| AnalysisResourceConstraintError;
type JsonFileFailure = AnalysisInputError | AnalysisResourceConstraintError;
const READ_CHUNK_BYTES = 64 * 1024;
const PACKED_TOKEN_PART_CODE_UNITS = 64 * 1024;
const PACKED_STRING_PART_CODE_UNITS = 64 * 1024;
// Retain native-parser speed for modest inputs while bounding its extra copies.
// This selects an implementation; larger files remain accepted through streaming.
const NATIVE_PARSE_READ_BUDGET = 8 * 1024 * 1024;
/** Keep native parsing only when input copies and value expansion fit live headroom. */
const nativeParseFits = (inputBytes: number): boolean =>
inputBytes <= NATIVE_PARSE_READ_BUDGET &&
// The assembler estimate reserves up to 128 bytes per two-byte container.
// Allow that 64x expansion plus byte/text copies before native JSON.parse,
// whose individual container allocations cannot be intercepted.
inputBytes * 72 + READ_CHUNK_BYTES <=
getHeapStatistics().total_available_size;
/** Parse strict UTF-8 JSON without requiring a whole-document string. */
export const readJsonInputFile = (
export const readCliJsonFile = (
path: string,
operation: string,
signal?: AbortSignal,
): Promise<Result<unknown, JsonInputFileFailure>> =>
): Promise<Result<unknown, JsonFileFailure>> =>
withRegularFile(
path,
async (handle, stats) => {
let found = false;
let value: unknown;
try {
const prefix = nativeParseFits(stats.size)
? await readPrefix(handle, stats.size + 1, signal)
: undefined;
if (prefix !== undefined) {
if (!prefix.complete || prefix.bytes.length !== stats.size)
throw new RegularFileChangedError(path);
const prefix =
stats.size <= NATIVE_PARSE_READ_BUDGET
? await readPrefix(handle, stats.size + 1, signal)
: undefined;
if (prefix?.complete === true) {
const parsed = parseUtf8Json(
prefix.bytes,
operation,
path,
"json-file-input",
"cli-json-input",
);
return parsed.ok
? ok(parsed.value)
: err(invalidJson(operation, parsed.error, parsed.cause));
}
await pipeline(
decodedChunks(handle, stats.size, path, signal),
decodedChunks(handle, signal, prefix?.bytes),
parser.asStream({
jsonStreaming: false,
streamValues: false,
packKeys: false,
packStrings: false,
packNumbers: false,
}),
packJsonScalars(operation, stats.size, path),
admitJsonAssembly(operation, stats.size, path),
packJsonStrings(operation, stats.size),
streamValues.asStream(),
async (rows: AsyncIterable<unknown>) => {
for await (const row of rows) {
@@ -122,9 +107,8 @@ export const readJsonInputFile = (
new AnalysisResourceConstraintError(
operation,
"memory",
"An individual JSON string or number token exceeds the runtime string limit",
"An individual JSON string exceeds the runtime string limit",
{
input_path: path,
input_file_bytes: stats.size,
max_string_code_units: constants.MAX_STRING_LENGTH,
},
@@ -137,16 +121,12 @@ export const readJsonInputFile = (
throw cause;
}
},
{ signal },
signal,
);
const packJsonScalars = (
operation: string,
inputFileBytes: number,
path: string,
) =>
const packJsonStrings = (operation: string, inputFileBytes: number) =>
async function* (tokens: AsyncIterable<unknown>): AsyncGenerator<unknown> {
let kind: "keyValue" | "stringValue" | "numberValue" | undefined;
let kind: "keyValue" | "stringValue" | undefined;
let length = 0;
let value = "";
let parts: string[] = [];
@@ -159,32 +139,23 @@ const packJsonScalars = (
case "startString":
kind = token.name === "startKey" ? "keyValue" : "stringValue";
break;
case "startNumber":
kind = "numberValue";
break;
case "numberChunk":
case "stringChunk":
if (
kind === undefined ||
!("value" in token) ||
typeof token.value !== "string"
)
throw new Error("Unexpected JSON scalar fragment");
throw new Error("Unexpected JSON string fragment");
length += token.value.length;
if (length > constants.MAX_STRING_LENGTH)
throw new RangeError("Invalid string length");
parts.push(token.value);
partLength += token.value.length;
if (partLength >= PACKED_TOKEN_PART_CODE_UNITS) {
if (partLength >= PACKED_STRING_PART_CODE_UNITS) {
// The tokenizer emits short fragments. Coalesce them before retaining
// a scalar so millions of substring/rope nodes cannot exhaust the heap
// a string so millions of substring/rope nodes cannot exhaust the heap
// before the native string-length constraint can be reported.
requireStringAssemblyHeadroom(
operation,
inputFileBytes,
path,
length,
);
requireStringAssemblyHeadroom(operation, inputFileBytes, length);
value += parts.join("");
parts = [];
partLength = 0;
@@ -192,14 +163,8 @@ const packJsonScalars = (
break;
case "endKey":
case "endString":
case "endNumber":
if (kind === undefined) throw new Error("Unexpected JSON scalar end");
requireStringAssemblyHeadroom(
operation,
inputFileBytes,
path,
length,
);
if (kind === undefined) throw new Error("Unexpected JSON string end");
requireStringAssemblyHeadroom(operation, inputFileBytes, length);
yield { name: kind, value: value + parts.join("") };
kind = undefined;
length = 0;
@@ -213,78 +178,22 @@ const packJsonScalars = (
}
};
/** Admit the retained value graph before the upstream assembler grows it. */
const admitJsonAssembly = (
operation: string,
inputFileBytes: number,
path: string,
) =>
async function* (tokens: AsyncIterable<unknown>): AsyncGenerator<unknown> {
let projectedBytes = 0;
let nextCheck = 0;
for await (const token of tokens) {
if (typeof token !== "object" || token === null || !("name" in token))
throw new Error("Unexpected JSON assembly token");
if (token.name === "startObject" || token.name === "startArray") {
projectedBytes += 128;
} else if (token.name === "keyValue" || token.name === "stringValue") {
if (!("value" in token) || typeof token.value !== "string")
throw new Error("Unexpected packed JSON string");
projectedBytes += 64 + token.value.length * 2;
} else if (
token.name === "numberValue" ||
token.name === "nullValue" ||
token.name === "trueValue" ||
token.name === "falseValue"
) {
projectedBytes += 64;
}
if (projectedBytes >= nextCheck) {
const heap = getHeapStatistics();
// Conservative capacity for containers, property/array slots and UTF-16
// values. Keep another whole projection available for container growth,
// plus the next chunk of estimates between samples. This is admission
// against live runtime headroom, not a fixed document-size policy.
const requiredBytes = projectedBytes + READ_CHUNK_BYTES;
if (requiredBytes > heap.total_available_size)
throw new AnalysisResourceConstraintError(
operation,
"memory",
"Insufficient heap headroom to materialize the complete JSON value",
{
input_path: path,
input_file_bytes: inputFileBytes,
projected_value_bytes: projectedBytes,
assembly_headroom_bytes: requiredBytes,
available_heap_bytes: heap.total_available_size,
heap_size_limit_bytes: heap.heap_size_limit,
},
{ remediationAction: JSON_INPUT_RESOURCE_REMEDIATION },
);
nextCheck = projectedBytes + READ_CHUNK_BYTES;
}
yield token;
}
};
const requireStringAssemblyHeadroom = (
operation: string,
inputFileBytes: number,
path: string,
stringCodeUnits: number,
): void => {
if (stringCodeUnits < PACKED_TOKEN_PART_CODE_UNITS) return;
if (stringCodeUnits < PACKED_STRING_PART_CODE_UNITS) return;
const heap = getHeapStatistics();
// Concatenation or key interning may flatten the retained rope. Reserve its
// UTF-16 representation and the next coalesced part before that allocation.
const requiredBytes = 2 * (stringCodeUnits + PACKED_TOKEN_PART_CODE_UNITS);
const requiredBytes = 2 * (stringCodeUnits + PACKED_STRING_PART_CODE_UNITS);
if (requiredBytes > heap.total_available_size)
throw new AnalysisResourceConstraintError(
operation,
"memory",
"Insufficient heap headroom to assemble a complete JSON string",
{
input_path: path,
input_file_bytes: inputFileBytes,
string_code_units: stringCodeUnits,
string_assembly_headroom_bytes: requiredBytes,
@@ -299,9 +208,8 @@ const requireStringAssemblyHeadroom = (
async function* decodedChunks(
handle: FileHandle,
admittedBytes: number,
path: string,
signal: AbortSignal | undefined,
prefix?: Uint8Array,
): AsyncGenerator<string> {
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
let leading = true;
@@ -312,25 +220,25 @@ async function* decodedChunks(
}
return text;
};
if (prefix !== undefined)
for (let offset = 0; offset < prefix.length; offset += READ_CHUNK_BYTES) {
signal?.throwIfAborted();
yield checked(
decoder.decode(prefix.subarray(offset, offset + READ_CHUNK_BYTES), {
stream: true,
}),
);
}
const bytes = Buffer.allocUnsafe(READ_CHUNK_BYTES);
let observedBytes = 0;
while (true) {
signal?.throwIfAborted();
const read = await handle.read(
bytes,
0,
Math.min(bytes.length, admittedBytes - observedBytes + 1),
null,
);
const read = await handle.read(bytes, 0, bytes.length, null);
signal?.throwIfAborted();
if (read.bytesRead === 0) break;
observedBytes += read.bytesRead;
if (observedBytes > admittedBytes) throw new RegularFileChangedError(path);
yield checked(
decoder.decode(bytes.subarray(0, read.bytesRead), { stream: true }),
);
}
if (observedBytes !== admittedBytes) throw new RegularFileChangedError(path);
yield checked(decoder.decode());
}
@@ -356,7 +264,8 @@ const readPrefix = async (
return { bytes: bytes.subarray(0, offset), complete: true };
offset += read.bytesRead;
}
// One byte beyond the admitted extent proves the selected file changed.
// The file grew after admission. Continue from this same handle, preserving
// the prefix already read rather than reopening or dropping its bytes.
return { bytes, complete: false };
};
+2 -2
View File
@@ -1,6 +1,6 @@
import { resolve } from "node:path";
import { readJsonInputFile } from "./application/JsonInputFile.js";
import { readCliJsonFile } from "./cliJsonFile.js";
import { NonRegularFileReadError } from "./filesystem/RegularFile.js";
import {
AnalysisAccessDeniedError,
@@ -21,7 +21,7 @@ export const parseCliJsonInput = async (
const inline = safeParseJson(value);
if (inline.ok) return { ok: true, value: inline.value };
try {
const parsed = await readJsonInputFile(value, operation);
const parsed = await readCliJsonFile(value, operation);
return parsed.ok
? { ok: true, value: parsed.value }
: {
-36
View File
@@ -699,39 +699,3 @@ export const adbResultSchemas = {
package_name: packageNameSchema,
}),
} as const;
/** Acquired package facts retained when cancellation leaves caller-owned output. */
export type AdbPackagePullPartialObservation = {
readonly provider_id: "adb";
readonly operation: "pull_adb_package";
readonly result: z.infer<typeof adbResultSchemas.pull_adb_package>;
};
/** A completed acquisition whose digest read was cancelled before verification. */
export type AdbAcquiredFilePartialObservation =
| {
readonly provider_id: "adb";
readonly operation: "pull_adb_file";
readonly result: {
readonly client: z.infer<
typeof adbResultSchemas.pull_adb_file
>["client"];
readonly serial: string;
readonly local_path: string;
readonly device_path: string;
readonly digest_status: "unknown";
};
}
| {
readonly provider_id: "adb";
readonly operation: "collect_adb_bugreport";
readonly result: {
readonly client: z.infer<
typeof adbResultSchemas.collect_adb_bugreport
>["client"];
readonly serial: string;
readonly local_path: string;
readonly adb_reported_path: string;
readonly digest_status: "unknown";
};
};
+1 -7
View File
@@ -99,10 +99,6 @@ import type { BrowserScenarioPartialObservation } from "./browserScenarioCapture
import type { Evidence } from "./evidence.js";
import type { AndroidPartialObservation } from "./android/androidPartialObservation.js";
import type { FirmwarePartialObservation } from "./firmware/firmwareAnalysis.js";
import type {
AdbAcquiredFilePartialObservation,
AdbPackagePullPartialObservation,
} from "./adb/adbDeviceAnalysis.js";
import type { NativeUiObservationResult } from "./native/nativeUiObservation.js";
/** Provider-neutral evidence collected before a typed analysis failure. */
@@ -117,6 +113,4 @@ export type AnalysisPartialObservation =
| Evidence
| AndroidPartialObservation
| FirmwarePartialObservation
| NativeUiObservationResult
| AdbAcquiredFilePartialObservation
| AdbPackagePullPartialObservation;
| NativeUiObservationResult;
@@ -39,8 +39,6 @@ export type ElectronBrowserWindowFinding = ElectronBrowserWindowPreload & {
readonly web_preferences_status: "object-literal" | "dynamic" | "missing";
readonly web_preferences: readonly ElectronWebPreference[];
readonly module_key: string | null;
/** Recovered factory runtime; absent in older or unbundled findings. */
readonly module_runtime?: string;
readonly location: JavaScriptSourceRange;
};
@@ -58,8 +56,6 @@ export type ElectronContextBridgeFinding = ElectronContextBridgeApiKey & {
readonly members: readonly string[];
readonly unknown_members: number;
readonly module_key: string | null;
/** Recovered factory runtime; absent in older or unbundled findings. */
readonly module_runtime?: string;
readonly location: JavaScriptSourceRange;
};
@@ -113,8 +109,6 @@ type ElectronIpcHandler =
interface ElectronIpcFindingContext {
readonly module_key: string | null;
/** Recovered factory runtime; absent in older or unbundled findings. */
readonly module_runtime?: string;
readonly location: JavaScriptSourceRange;
}
@@ -140,16 +134,12 @@ export interface ElectronSenderValidationFinding {
readonly expected: ElectronStaticValue;
readonly enforcement: "unknown";
readonly module_key: string | null;
/** Recovered factory runtime; absent in older or unbundled findings. */
readonly module_runtime?: string;
readonly location: JavaScriptSourceRange;
}
interface ElectronUtilityProcessFindingState {
readonly service_name: string | null;
readonly module_key: string | null;
/** Recovered factory runtime; absent in older or unbundled findings. */
readonly module_runtime?: string;
readonly location: JavaScriptSourceRange;
}
@@ -179,8 +169,6 @@ export interface ElectronNativeAddonBindingFinding {
readonly namespace_access?: boolean;
readonly dynamic_member_access?: boolean;
readonly module_key: string | null;
/** Recovered factory runtime; absent in older or unbundled findings. */
readonly module_runtime?: string;
readonly location: JavaScriptSourceRange;
}
@@ -0,0 +1,53 @@
import { parse } from "@babel/parser";
import { expect, it } from "vitest";
import { collectJavaScriptExports } from "./javascriptAstFingerprint.js";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
it.each([
"exports[key] = 1;",
"module[key].value = 1;",
'module["exports.value"] = 1;',
"module.exports = { [key]: 1 };",
'Object[method](exports, "value", {});',
"function d() {} d(exports, { invented: 1 });",
])("does not invent an export name for %s", (source) => {
expect(collectJavaScriptExports(parse(source)).values).toEqual([]);
});
it.each([
'exports[""] = 1;',
'module.exports = { "": 1 };',
'Object.defineProperty(exports, "", {});',
])("preserves the exact empty export name for %s", (source) => {
expect(collectJavaScriptExports(parse(source)).values).toEqual([""]);
});
it("retains static computed and ordinary names", () => {
expect(
collectJavaScriptExports(
parse('module["exports"]["ready"] = 1; exports.done = 2;'),
).values,
).toEqual(["done", "ready"]);
});
it("retains member helper exports on this", () => {
expect(
collectJavaScriptExports(parse("this.d(exports, { ready: () => 1 });"))
.values,
).toEqual(["ready"]);
});
it("does not expose invented names in bundler module analysis", () => {
const analysis = analyzeJavaScriptStaticSource(`
globalThis.webpackChunkApp.push([[1], { 1: function(module, exports) {
exports[key] = 1;
module["exports.value"] = 2;
module.exports = { [key]: 3, ready: 4 };
} }]);
`);
expect(analysis.parse_status).toBe("complete");
expect(analysis.bundler_registrations[0]?.modules[0]?.exports).toEqual([
"ready",
]);
});
@@ -5,7 +5,10 @@ import * as t from "@babel/types";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
import { propertyName } from "./javascriptAstValues.js";
import {
propertyName,
semanticStaticPropertyKey,
} from "./javascriptAstValues.js";
import { stringValue } from "./javascriptStaticAnalysisHelpers.js";
/** Static CommonJS export names. */
@@ -71,15 +74,20 @@ const collectAssignmentExports = (
output: Set<string>,
): void => {
const path = memberPath(left);
if (path === "exports" || path === "module.exports") {
if (path === null) return;
const exportOffset =
path[0] === "exports"
? 1
: path[0] === "module" && path[1] === "exports"
? 2
: null;
if (exportOffset === null) return;
if (path.length === exportOffset) {
if (t.isObjectExpression(right)) collectObjectKeys(right, output);
else addExport(output, "default");
return;
}
if (path.startsWith("exports."))
addExport(output, path.slice("exports.".length));
if (path.startsWith("module.exports."))
addExport(output, path.slice("module.exports.".length));
addExport(output, path.slice(exportOffset).join("."));
};
const collectCallExports = (
@@ -87,19 +95,28 @@ const collectCallExports = (
output: Set<string>,
): void => {
const callee = memberPath(call.callee);
if (callee === "Object.defineProperty") {
const target = memberPath(call.arguments[0]);
const name = stringValue(call.arguments[1]);
if ((target === "exports" || target === "module.exports") && name)
addExport(output, name);
}
if (!callee.endsWith(".d")) return;
if (callee === null) return;
const definePropertyCall =
callee.length === 2 &&
callee[0] === "Object" &&
callee[1] === "defineProperty";
const declarationCall = callee.length >= 2 && callee.at(-1) === "d";
if (!definePropertyCall && !declarationCall) return;
const target = memberPath(call.arguments[0]);
const exportTarget =
target !== null &&
((target.length === 1 && target[0] === "exports") ||
(target.length === 2 &&
target[0] === "module" &&
target[1] === "exports"));
if (!exportTarget) return;
if (definePropertyCall) {
const name = stringValue(call.arguments[1]);
if (name !== undefined) addExport(output, name);
return;
}
const declarations = call.arguments[1];
if (
(target === "exports" || target === "module.exports") &&
t.isObjectExpression(declarations)
)
if (t.isObjectExpression(declarations))
collectObjectKeys(declarations, output);
};
@@ -109,8 +126,8 @@ const collectObjectKeys = (
): void => {
for (const property of object.properties) {
if (!t.isObjectProperty(property) && !t.isObjectMethod(property)) continue;
const name = propertyName(property.key);
if (name !== "") addExport(output, name);
const name = semanticStaticPropertyKey(property.key, property.computed);
if (name !== null) addExport(output, name);
}
};
@@ -118,7 +135,9 @@ const addExport = (output: Set<string>, name: string): void => {
output.add(name);
};
const memberPath = (node: t.Node | null | undefined): string => {
const memberPath = (
node: t.Node | null | undefined,
): readonly string[] | null => {
const properties: string[] = [];
let current = node;
while (
@@ -126,23 +145,19 @@ const memberPath = (node: t.Node | null | undefined): string => {
current !== null &&
(t.isMemberExpression(current) || t.isOptionalMemberExpression(current))
) {
properties.push(propertyName(current.property));
current = t.isNode(current.object) ? current.object : undefined;
const property = semanticStaticPropertyKey(
current.property,
current.computed,
);
if (property === null) return null;
properties.push(property);
current = current.object;
}
const root =
current !== undefined && t.isIdentifier(current)
? current.name
: current !== undefined && t.isThisExpression(current)
? "this"
: "";
if (properties.length === 0) return root;
properties.reverse();
const path = root === "" ? [] : [root];
for (const property of properties) {
if (path.length === 0 && property === "") continue;
path.push(property);
}
return path.join(".");
if (current === undefined || current === null) return null;
const root = t.isIdentifier(current)
? current.name
: t.isThisExpression(current)
? "this"
: null;
return root === null ? null : [root, ...properties.reverse()];
};
@@ -62,7 +62,9 @@ const classifyLayer = (
options.reconciliationComplete &&
layer.graph.coverage.status === "complete" &&
scopedEntities.length > 0 &&
scopedEntities.every(({ capture }) => capture.scriptsCompleteWithinScope);
scopedEntities.every(({ capture }) =>
captureScriptsComplete(capture.evidence.evidence_id, runtimeEntities),
);
const states = nodes.map((node) => {
const runtimeNodeIds = uniqueSorted(direct.get(node.node_id) ?? []);
if (runtimeNodeIds.length > 0)
@@ -132,6 +134,13 @@ const append = (
else current.push(value);
};
const captureScriptsComplete = (
evidenceId: string,
entities: readonly RuntimeReconciliationEntity[],
): boolean =>
entities.find(({ capture }) => capture.evidence.evidence_id === evidenceId)
?.capture.scriptsCompleteWithinScope === true;
const isLoadStateNode = (
node: ApplicationNode,
): node is ApplicationNode & {
@@ -212,11 +212,8 @@ const graphCoverage = (
const mergeNodes = (nodes: readonly ApplicationNode[]): ApplicationNode[] => {
const grouped = new Map<string, ApplicationNode[]>();
for (const node of nodes) {
const group = grouped.get(node.node_id);
if (group === undefined) grouped.set(node.node_id, [node]);
else group.push(node);
}
for (const node of nodes)
grouped.set(node.node_id, [...(grouped.get(node.node_id) ?? []), node]);
const merged = [...grouped.values()].map((group) => {
const first = group[0];
if (first === undefined) throw new TypeError("Empty JAG node group");
@@ -23,12 +23,7 @@ it("preserves a 12,000-member native import through both analyzers", () => {
const ir = analyzeParsedJavaScriptSemantics(parsed);
expect(staticAnalysis.parse_status).toBe("complete");
expect(staticAnalysis.electron.native_addon_bindings).toEqual([
expect.objectContaining({
specifier: "./addon.node",
members: ["next"],
namespace_access: false,
dynamic_member_access: false,
}),
expect.objectContaining({ specifier: "./addon.node", members: ["last"] }),
]);
expect(ir.coverage).toEqual({ status: "complete", omittedCount: 0 });
const path = origin(onlyBinding(ir, "value"));
@@ -19,11 +19,15 @@ import {
JAVASCRIPT_SEMANTIC_RELATION_FAMILIES,
JAVASCRIPT_SEMANTIC_RELATION_FAMILY,
javaScriptSemanticFingerprintInputSchema,
javaScriptSemanticFingerprintSchema,
javaScriptSemanticGraphInputSchema,
javaScriptSemanticGraphRecordSchema,
javaScriptSemanticNodeInputSchema,
javaScriptSemanticNodeSchema,
javaScriptSemanticRelationInputSchema,
javaScriptSemanticRelationSchema,
javaScriptSemanticUnknownInputSchema,
javaScriptSemanticUnknownSchema,
type JavaScriptSemanticGraphInput,
type JavaScriptSemanticGraphNode,
type JavaScriptSemanticGraphRelation,
@@ -128,10 +132,10 @@ export const createJavaScriptSemanticGraphNode = (
application_node_ids: uniqueSorted(parsed.application_node_ids),
evidence: evidenceContexts.intern(parsed.evidence),
};
return {
return javaScriptSemanticNodeSchema.parse({
...semantic,
node_id: javaScriptSemanticNodeId(semantic),
};
});
};
/** Normalize one semantic relationship and derive its exact identifier. */
@@ -144,10 +148,10 @@ export const createJavaScriptSemanticGraphRelation = (
...parsed,
evidence: evidenceContexts.intern(parsed.evidence),
};
return {
return javaScriptSemanticRelationSchema.parse({
...semantic,
relation_id: `jsrg_relation_${digestCanonicalValue(semantic, "JavaScript semantic graph")}`,
};
});
};
/** Normalize one unresolved semantic frontier and derive its identifier. */
@@ -162,10 +166,10 @@ export const createJavaScriptSemanticGraphUnknown = (
candidate_node_ids: uniqueSorted(parsed.candidate_node_ids),
evidence: evidenceContexts.intern(parsed.evidence),
};
return {
return javaScriptSemanticUnknownSchema.parse({
...semantic,
unknown_id: `jsrg_unknown_${digestCanonicalValue(semantic, "JavaScript semantic graph")}`,
};
});
};
/** Normalize one function fingerprint and derive its component commitment. */
@@ -187,7 +191,7 @@ export const createJavaScriptSemanticFingerprint = (
semantic.components,
"JavaScript semantic graph",
);
return {
return javaScriptSemanticFingerprintSchema.parse({
...semantic,
fingerprint_sha256: fingerprintSha256,
fingerprint_id: `jsrg_fingerprint_${digestCanonicalValue(
@@ -198,7 +202,7 @@ export const createJavaScriptSemanticFingerprint = (
},
"JavaScript semantic graph",
)}`,
};
});
};
type GraphRecord = z.infer<typeof javaScriptSemanticGraphRecordSchema>;
@@ -4,7 +4,6 @@ import { z } from "zod";
import { digestCanonicalValue } from "../canonicalDigest.js";
import type { ApplicationGraphEvidence } from "./javascriptApplicationEvidenceSchemas.js";
import {
JAVASCRIPT_SEMANTIC_RELATION_FAMILY,
JAVASCRIPT_SEMANTIC_NODE_KINDS,
JAVASCRIPT_SEMANTIC_RELATION_FAMILIES,
JAVASCRIPT_SEMANTIC_RELATIONS,
@@ -28,7 +27,6 @@ import {
parseJavaScriptSemanticGraph,
serializeJavaScriptSemanticGraph,
} from "./javascriptSemanticGraphSerialization.js";
import { queryJavaScriptSemanticGraph } from "./javascriptSemanticQuery.js";
const SHA = "a".repeat(64);
const JAG_ID = `jag_${"b".repeat(64)}`;
@@ -142,44 +140,6 @@ it("accepts semantic module paths longer than the former schema ceiling", () =>
).toBe(modulePath);
});
it("captures caller-owned node identity, properties, and provenance", () => {
const contexts = new JavaScriptSemanticEvidenceContextRegistry();
const input = {
kind: "literal",
identity: {
artifact_sha256: SHA,
module_path: "bundle.js",
source_range: null,
role_key: "literal",
},
function_node_id: null,
application_node_ids: [],
label: "literal",
properties: { nested: ["original"] },
evidence: evidence(),
};
const captured = createJavaScriptSemanticGraphNode(input, contexts);
const expected = structuredClone(captured);
const expectedEvidence = structuredClone(input.evidence);
input.identity.module_path = "replaced.js";
input.properties.nested[0] = "replaced";
input.evidence.limitations.push("later mutation");
if (input.evidence.location.available)
input.evidence.location.value = {
kind: "artifact-path",
path: "replaced.js",
};
expect(captured).toEqual(expected);
expect(
resolveJavaScriptSemanticEvidence(
{ evidence_contexts: contexts.contexts },
captured.evidence,
),
).toEqual(expectedEvidence);
});
const fixtureInput = (withUnknown = false): JavaScriptSemanticGraphInput => {
const evidenceContexts = new JavaScriptSemanticEvidenceContextRegistry();
const module = node("module", "module", {}, evidenceContexts);
@@ -325,104 +285,6 @@ const registryForGraph = (
return registry;
};
const graphWithCandidateRelations = (): JavaScriptSemanticGraph => {
const base = fixtureGraph();
const evidenceContexts = registryForGraph(base);
const relations = base.relations.map(
({ relation_id: _relationId, ...relation }) =>
createJavaScriptSemanticGraphRelation(
{
...relation,
resolution: "candidate",
evidence: resolveJavaScriptSemanticEvidence(base, relation.evidence),
},
evidenceContexts,
),
);
const binding = base.nodes.find(({ label }) => label === "binding");
const callable = base.nodes.find(({ label }) => label === "function");
if (binding === undefined || callable === undefined)
throw new Error("Expected candidate-cycle endpoints");
for (const [source, target] of [
[binding, callable],
[callable, binding],
] as const)
relations.push(
createJavaScriptSemanticGraphRelation(
{
source_node_id: source.node_id,
target_node_id: target.node_id,
relation: "owns",
resolution: "candidate",
properties: {},
evidence: evidence("inferred"),
},
evidenceContexts,
),
);
const { graph_id: _graphId, ...input } = base;
return createJavaScriptSemanticGraph({
...input,
evidence_contexts: evidenceContexts.contexts,
relations,
coverage: {
...input.coverage,
families: input.coverage.families.map((family) => ({
...family,
retained_relations: relations.filter(
({ relation }) =>
JAVASCRIPT_SEMANTIC_RELATION_FAMILY[relation] === family.family,
).length,
})),
},
});
};
it("counts candidate edges by query direction and relation filter", () => {
const graph = graphWithCandidateRelations();
const binding = graph.nodes.find(({ label }) => label === "binding");
if (binding === undefined) throw new Error("Expected binding seed");
const query = (
direction: "forward-influence" | "backward-provenance",
allowedRelations: (typeof JAVASCRIPT_SEMANTIC_RELATIONS)[number][],
) =>
queryJavaScriptSemanticGraph(graph, {
seed: { kind: "semantic-node", node_id: binding.node_id },
direction,
allowed_relations: allowedRelations,
});
expect(query("forward-influence", ["captures"]).status).toBe("ambiguous");
expect(query("forward-influence", ["defines"]).status).toBe("found");
expect(query("backward-provenance", ["defines"]).status).toBe("ambiguous");
expect(query("backward-provenance", ["captures"]).status).toBe("found");
});
it("counts candidate ownership cycles once and traverses them finitely", () => {
const graph = graphWithCandidateRelations();
const binding = graph.nodes.find(({ label }) => label === "binding");
if (binding === undefined) throw new Error("Expected binding seed");
const result = queryJavaScriptSemanticGraph(graph, {
seed: { kind: "semantic-node", node_id: binding.node_id },
direction: "ownership",
allowed_relations: ["owns"],
include_ambiguous_dynamic_edges: true,
});
expect(result.status).toBe("ambiguous");
expect(result.relations).toHaveLength(2);
expect(result.summary.traversed_nodes).toBe(2);
expect(result.summary.traversed_relations).toBe(2);
expect(
queryJavaScriptSemanticGraph(graph, {
seed: { kind: "semantic-node", node_id: binding.node_id },
direction: "ownership",
allowed_relations: ["constructs-request"],
}).status,
).toBe("found");
});
it("defines every required relation family and canonicalizes records", () => {
expect(JAVASCRIPT_SEMANTIC_RELATIONS).toContain("argument-to-parameter");
expect(JAVASCRIPT_SEMANTIC_RELATIONS).toContain("detaches-task");
@@ -316,20 +316,6 @@ it("returns every relevant unknown inline", () => {
expect(result.unknowns).toHaveLength(1);
});
it("does not admit a semantic-node seed absent from the graph", () => {
const result = queryJavaScriptSemanticGraph(fixtureGraph(), {
seed: { kind: "semantic-node", node_id: `jsrg_node_${"f".repeat(64)}` },
direction: "forward-influence",
});
expect(result).toMatchObject({
status: "no-match",
seed_node_ids: [],
nodes: [],
summary: { total_seed_matches: 0, traversed_nodes: 0 },
});
});
it("returns the complete deterministic forward influence result inline", () => {
const graph = fixtureGraph();
const input = {
@@ -30,6 +30,10 @@ interface Traversal {
readonly modules: Set<string>;
}
interface SeedAdmission {
readonly nodeIds: string[];
}
interface QueryResult {
readonly retainedNodes: JavaScriptSemanticGraphNode[];
readonly retainedRelations: JavaScriptSemanticGraphRelation[];
@@ -43,10 +47,10 @@ export const queryJavaScriptSemanticGraph = (
const input = javaScriptSemanticQueryInputSchema.parse(rawInput);
const queryId = queryIdentifier(graph, input);
const seeds = resolveSeeds(graph, input);
const nodes = new Map(graph.nodes.map((node) => [node.node_id, node]));
const retainedSeeds = admitSeeds(nodes, seeds);
const admission = admitSeeds(graph, seeds);
const retainedSeeds = admission.nodeIds;
const adjacency = buildAdjacency(graph.relations, input);
const traversal = traverse(nodes, retainedSeeds, adjacency);
const traversal = traverse(graph, retainedSeeds, adjacency);
const result = createQueryResult(graph, traversal);
const allRelevantUnknowns = relevantUnknownFrontiers(
graph,
@@ -118,16 +122,17 @@ const createQueryResult = (
};
const admitSeeds = (
nodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
graph: JavaScriptSemanticGraph,
seeds: readonly string[],
): string[] => {
): SeedAdmission => {
const nodes = new Map(graph.nodes.map((node) => [node.node_id, node]));
const retained: string[] = [];
for (const nodeId of seeds) {
const node = nodes.get(nodeId);
if (node === undefined) continue;
retained.push(nodeId);
}
return retained;
return { nodeIds: retained };
};
const buildAdjacency = (
@@ -217,10 +222,11 @@ const addEntry = (
};
const traverse = (
nodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
graph: JavaScriptSemanticGraph,
seeds: readonly string[],
adjacency: ReadonlyMap<string, TraversalEntry[]>,
): Traversal => {
const nodes = new Map(graph.nodes.map((node) => [node.node_id, node]));
const nodeIds = new Set(seeds);
const relationIds = new Set<string>();
const functionIds = new Set<string>();
@@ -283,32 +289,15 @@ const relevantCandidateRelationCount = (
nodeIds: ReadonlySet<string>,
input: JavaScriptSemanticQueryInput,
): number => {
const allowed =
input.allowed_relations === undefined
? null
: new Set(input.allowed_relations);
let relevant = 0;
for (const relation of graph.relations) {
if (
relation.resolution !== "candidate" ||
(allowed !== null && !allowed.has(relation.relation))
)
continue;
const sourceIsRelevant = nodeIds.has(relation.source_node_id);
const targetIsRelevant = nodeIds.has(relation.target_node_id);
if (
(input.direction === "callers" &&
relation.relation === "calls" &&
targetIsRelevant) ||
(input.direction === "ownership" &&
ownershipRelation(relation.relation) &&
(sourceIsRelevant || targetIsRelevant)) ||
(input.direction === "backward-provenance" && targetIsRelevant) ||
(input.direction === "forward-influence" && sourceIsRelevant)
)
relevant += 1;
}
return relevant;
const adjacency = buildAdjacency(
graph.relations.filter(({ resolution }) => resolution === "candidate"),
{ ...input, include_ambiguous_dynamic_edges: true },
);
const relevant = new Set<string>();
for (const nodeId of nodeIds)
for (const { relation } of adjacency.get(nodeId) ?? [])
relevant.add(relation.relation_id);
return relevant.size;
};
const expectedMatchesFor = (
@@ -15,7 +15,7 @@ it("collects a 12,000-member CommonJS export path without recursion", () => {
]);
});
it("preserves empty, dynamic, this, and private member path behavior", () => {
it("preserves empty keys without inventing dynamic, this, or private exports", () => {
const file = parseJavaScriptSource(`
exports[""].value = 1;
exports[""] = 0;
@@ -24,11 +24,7 @@ it("preserves empty, dynamic, this, and private member path behavior", () => {
class Example { #private = 0; read() { this.#private = 1; } }
`);
if (file === null) throw new Error("Expected valid JavaScript");
expect(collectJavaScriptExports(file).values).toEqual([
"",
".value",
"key.value",
]);
expect(collectJavaScriptExports(file).values).toEqual(["", ".value"]);
});
it("keeps left-first and outer-member-first bundler runtime matches", () => {
@@ -40,7 +40,7 @@ export const inspectBundlerRegistration = (
const table = entry.elements[1];
const runtimeValue = runtimeMetadata(entry.elements[2]);
if (!t.isArrayExpression(chunkIds) || !t.isObjectExpression(table)) return;
const recovered = recoverBundlerModules(source, table, runtime, accumulator);
const recovered = recoverBundlerModules(source, table, accumulator);
const chunkKeys = staticArrayValues(chunkIds);
accumulator.unknownFindings += chunkKeys.unknown;
accumulator.unknownFindings +=
@@ -92,13 +92,12 @@ export const inspectEsbuildWrapper = (
: null;
const table = call.arguments[0];
if (wrapperKind === null || !t.isObjectExpression(table)) return;
const runtime = `esbuild-${wrapperKind}`;
const recovered = recoverBundlerModules(source, table, runtime, accumulator);
const recovered = recoverBundlerModules(source, table, accumulator);
if (recovered.modules.length === 0) return;
accumulator.unknownFindings += recovered.unknownAsyncChunkKeys;
const registration: JavaScriptBundlerRegistration = {
bundler: "esbuild",
runtime,
runtime: `esbuild-${wrapperKind}`,
chunk_keys: [`${wrapperKind}@${String(call.start ?? 0)}`],
unknown_chunk_keys: 0,
runtime_require_name: null,
@@ -128,19 +127,13 @@ interface RecoveredBundlerModules {
const recoverBundlerModules = (
source: string,
table: t.ObjectExpression,
runtime: string,
accumulator: AnalysisAccumulator,
): RecoveredBundlerModules => {
const modules: JavaScriptBundlerModule[] = [];
const asyncChunkKeys: string[] = [];
let unknownAsyncChunkKeys = 0;
for (const property of table.properties) {
const recovered = recoverBundlerModule(
source,
property,
runtime,
accumulator,
);
const recovered = recoverBundlerModule(source, property, accumulator);
if (recovered === null) continue;
modules.push(recovered.module);
asyncChunkKeys.push(...recovered.asyncChunkKeys);
@@ -156,7 +149,6 @@ const recoverBundlerModules = (
const recoverBundlerModule = (
source: string,
property: t.ObjectMethod | t.ObjectProperty | t.SpreadElement,
runtime: string,
accumulator: AnalysisAccumulator,
): {
readonly module: JavaScriptBundlerModule;
@@ -177,7 +169,6 @@ const recoverBundlerModule = (
start: factory.start,
end: factory.end,
key,
runtime,
requireName,
};
accumulator.moduleRangeIndex.add(moduleRange);
@@ -130,11 +130,7 @@ export const finalizeLocatedFindings = <
values: readonly LocatedJavaScriptFinding<Value>[],
modules: JavaScriptModuleRangeIndex,
): Value[] =>
values.map(({ offset, value }) => {
const module = modules.find(offset);
return {
...value,
module_key: module?.key ?? null,
...(module === undefined ? {} : { module_runtime: module.runtime }),
};
});
values.map(({ offset, value }) => ({
...value,
module_key: modules.find(offset)?.key ?? null,
}));
@@ -6,27 +6,9 @@ import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
it("preserves first insertion precedence and inclusive interval bounds", () => {
const nested = new JavaScriptModuleRangeIndex(100);
expect(nested.find(0)).toBeUndefined();
nested.add({
start: 10,
end: 90,
key: "outer-first",
runtime: "webpackChunkApp",
requireName: null,
});
nested.add({
start: 20,
end: 30,
key: "inner-second",
runtime: "webpackChunkApp",
requireName: null,
});
nested.add({
start: 30,
end: 40,
key: "later-overlap",
runtime: "webpackChunkApp",
requireName: null,
});
nested.add({ start: 10, end: 90, key: "outer-first", requireName: null });
nested.add({ start: 20, end: 30, key: "inner-second", requireName: null });
nested.add({ start: 30, end: 40, key: "later-overlap", requireName: null });
expect(nested.find(9)).toBeUndefined();
expect(nested.find(10)?.key).toBe("outer-first");
expect(nested.find(20)?.key).toBe("outer-first");
@@ -38,13 +20,7 @@ it("preserves first insertion precedence and inclusive interval bounds", () => {
it("rejects missing and out-of-range lookup offsets", () => {
const index = new JavaScriptModuleRangeIndex(20);
index.add({
start: 5,
end: 10,
key: "bounded",
runtime: "webpackChunkApp",
requireName: null,
});
index.add({ start: 5, end: 10, key: "bounded", requireName: null });
expect(index.find(undefined)).toBeUndefined();
expect(index.find(null)).toBeUndefined();
expect(index.find(-1)).toBeUndefined();
@@ -82,11 +58,8 @@ it("keeps module references attributed across many real factory ranges", () => {
expect(
analysis.references.find(
({ specifier }) => specifier === `dependency-${index}`,
),
).toMatchObject({
module_key: `module-${index}`,
module_runtime: "webpackChunkApp",
});
)?.module_key,
).toBe(`module-${index}`);
}
expect(
analysis.references.find(
@@ -23,7 +23,6 @@ export interface JavaScriptModuleRange {
readonly start: number;
readonly end: number;
readonly key: string;
readonly runtime: string;
readonly requireName: string | null;
}
@@ -25,8 +25,6 @@ interface JavaScriptStaticReferenceState {
| "worker"
| "service-worker";
readonly module_key: string | null;
/** Recovered factory runtime; absent in older or unbundled findings. */
readonly module_runtime?: string;
readonly location: JavaScriptSourceRange;
}
@@ -43,8 +41,6 @@ export interface JavaScriptStaticEndpoint {
readonly value: string;
readonly mechanism: string;
readonly module_key: string | null;
/** Recovered factory runtime; absent in older or unbundled findings. */
readonly module_runtime?: string;
readonly location: JavaScriptSourceRange;
}
@@ -59,8 +55,6 @@ export interface JavaScriptStaticStorage {
readonly name: string | null;
readonly mechanism: string;
readonly module_key: string | null;
/** Recovered factory runtime; absent in older or unbundled findings. */
readonly module_runtime?: string;
readonly location: JavaScriptSourceRange;
}
@@ -101,8 +95,6 @@ export interface JavaScriptRolePath {
readonly resolution_context: JavaScriptStaticPathContext;
readonly mechanism: string;
readonly module_key: string | null;
/** Recovered factory runtime; absent in older or unbundled findings. */
readonly module_runtime?: string;
readonly location: JavaScriptSourceRange;
}
-9
View File
@@ -16,15 +16,6 @@ export class NonRegularFileReadError extends Error {
}
}
/** A regular input no longer matches the extent or identity admitted for reading. */
export class RegularFileChangedError extends Error {
readonly code = "ESTALE";
constructor(readonly path: string) {
super(`Selected input changed while being read: ${path}`);
this.name = "RegularFileChangedError";
}
}
/** One admitted file descriptor and the metadata that identified its contents. */
export interface StableRegularFileDescriptor {
readonly handle: FileHandle;
-55
View File
@@ -1,55 +0,0 @@
import { createHash } from "node:crypto";
import { stat } from "node:fs/promises";
import {
openRegularFile,
RegularFileChangedError,
sameRegularFileState,
} from "./RegularFile.js";
/** Hash one regular file from its admitted descriptor and verify its path state. */
export const hashStableFile = async (
path: string,
signal?: AbortSignal,
): Promise<{ readonly sha256: string; readonly bytes: number }> => {
const handle = await openRegularFile(path, {
symlinks: "follow",
signal,
});
try {
const initial = await handle.stat();
if (!Number.isSafeInteger(initial.size) || initial.size < 0)
throw new RangeError(
`Regular file size cannot be represented safely: ${path}`,
);
const hash = createHash("sha256");
const chunk = Buffer.allocUnsafe(64 * 1024);
let position = 0;
while (position <= initial.size) {
signal?.throwIfAborted();
const length = Math.min(chunk.length, initial.size - position + 1);
if (length <= 0) break;
const read = await handle.read(chunk, 0, length, position);
signal?.throwIfAborted();
if (read.bytesRead === 0) break;
hash.update(chunk.subarray(0, read.bytesRead));
position += read.bytesRead;
if (position > initial.size) throw new RegularFileChangedError(path);
}
signal?.throwIfAborted();
const [opened, currentPath] = await Promise.all([
handle.stat(),
stat(path),
]);
signal?.throwIfAborted();
if (
position !== initial.size ||
!sameRegularFileState(initial, opened) ||
!sameRegularFileState(initial, currentPath)
)
throw new RegularFileChangedError(path);
return { sha256: hash.digest("hex"), bytes: position };
} finally {
await handle.close();
}
};
+2 -8
View File
@@ -262,17 +262,11 @@ export const createDarwinProcessRunTokenReader = (
const read = async (
processes: readonly ProcessTableEntry[],
signal?: AbortSignal,
): Promise<ReadonlyMap<number, ProcessRunTokenObservation>> => {
signal?.throwIfAborted();
if (processes.length === 0) return new Map();
const binary = await compile(signal);
const binary = await compile();
const pids = processes.map(({ pid }) => String(pid));
const { stdout } = await execFileOutput(binary, pids, {
timeout: 15_000,
...(signal === undefined ? {} : { signal }),
});
signal?.throwIfAborted();
const { stdout } = await execFileOutput(binary, pids, { timeout: 15_000 });
const decoded = safeParseJson(stdout);
if (!decoded.ok)
throw new Error(
+13 -18
View File
@@ -377,7 +377,7 @@ describe("process identity during token validation", () => {
expect(reusedDuringFinalRead).toBe(true);
});
it("fails closed when candidate PIDs change identity during token reading", async () => {
it("fails closed when a candidate PID changes identity during token reading", async () => {
let identityReads = 0;
const process: ProcessTableEntry = {
pid: 911,
@@ -386,10 +386,8 @@ describe("process identity during token validation", () => {
state: "S",
command: "reused-during-token-read",
};
const processes = [process, { ...process, pid: 912, processGroupId: 912 }];
const listProcesses = vi.fn(async () => processes);
const host: ProcessOwnershipHost = {
listProcesses,
listProcesses: () => Promise.resolve([process]),
environment: () => Promise.resolve({}),
processIdentities: (entries) => {
identityReads += 1;
@@ -416,24 +414,21 @@ describe("process identity during token validation", () => {
};
await expect(
verifyNoTokenOwnedProcesses(
"run-token",
host,
processes.map(({ pid }) => ({ pid, identity: "old-start" })),
),
verifyNoTokenOwnedProcesses("run-token", host, [
{ pid: process.pid, identity: "old-start" },
]),
).resolves.toEqual({
cleaned: false,
reason: "process identity could not be revalidated",
failures: processes.map(({ pid }) => ({
pid,
reason: "process-identity-unavailable",
diagnostic:
"process identity changed or became unavailable during token validation",
})),
failures: [
{
pid: process.pid,
reason: "process-identity-unavailable",
diagnostic:
"process identity changed or became unavailable during token validation",
},
],
});
// Initial observation, final token classification, then one fresh recheck
// shared by both changed identities.
expect(listProcesses).toHaveBeenCalledTimes(3);
});
});
@@ -1,328 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
cleanupOwnedProcessGroup,
type ProcessOwnershipHost,
} from "./ProcessOwnership.js";
import {
observeOwnedProcessGroups,
observeOwnedProcessLineage,
} from "./ProcessOwnershipObservation.js";
import { host, ownership } from "./ProcessOwnership.fixture.js";
const processes = [100, 101].map((pid) => ({
pid,
parentPid: pid === 100 ? 1 : 100,
processGroupId: 100,
state: "S",
command: "fixture",
}));
describe("multi-group ownership observation", () => {
it("observes multiple groups from one process snapshot and token batch", async () => {
const rows = [
...processes,
{
pid: 200,
parentPid: 1,
processGroupId: 200,
state: "S",
command: "other-owned-group",
},
{
pid: 300,
parentPid: 1,
processGroupId: 300,
state: "Z",
command: "[exited]",
},
];
const listProcesses = vi.fn(() => Promise.resolve(rows));
const environment = vi.fn(() => Promise.resolve({}));
const runTokens = vi.fn<NonNullable<ProcessOwnershipHost["runTokens"]>>(
(members) =>
Promise.resolve(
new Map(
members.map(({ pid }) => [
pid,
{
state: "readable" as const,
runId: pid === 200 ? "other-run" : ownership.runId,
},
]),
),
),
);
await expect(
observeOwnedProcessGroups(ownership.runId, [100, 200, 300], {
listProcesses,
environment,
runTokens,
signalGroup: vi.fn(),
}),
).resolves.toEqual(
new Map([
[100, { state: "alive" }],
[
200,
{ state: "unverifiable", reason: "process ownership did not match" },
],
[300, { state: "empty" }],
]),
);
expect(listProcesses).toHaveBeenCalledTimes(1);
expect(runTokens).toHaveBeenCalledTimes(1);
expect(runTokens).toHaveBeenCalledWith(
rows.filter(({ state }) => state !== "Z"),
);
expect(environment).not.toHaveBeenCalled();
});
it("rechecks unavailable members individually before classifying their groups", async () => {
const rows = [
...processes,
{
pid: 200,
parentPid: 1,
processGroupId: 200,
state: "S",
command: "unreadable-group",
},
];
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce(rows)
.mockResolvedValueOnce(rows.filter(({ pid }) => pid !== 101))
.mockResolvedValueOnce(rows);
const runTokens = vi.fn<NonNullable<ProcessOwnershipHost["runTokens"]>>(
(members) =>
Promise.resolve(
new Map(
members.map(({ pid }) => [
pid,
pid === 101 || pid === 200
? {
state: "unavailable" as const,
reason: `unreadable-${String(pid)}`,
}
: { state: "readable" as const, runId: ownership.runId },
]),
),
),
);
await expect(
observeOwnedProcessGroups(ownership.runId, [100, 200], {
listProcesses,
environment: vi.fn(() => Promise.resolve({})),
runTokens,
signalGroup: vi.fn(),
}),
).resolves.toEqual(
new Map([
[100, { state: "alive" }],
[
200,
{
state: "unverifiable",
reason:
"process ownership could not be revalidated for PID 200: unreadable-200",
},
],
]),
);
expect(listProcesses).toHaveBeenCalledTimes(3);
expect(runTokens).toHaveBeenCalledTimes(1);
});
});
describe("multi-group observation short-circuit", () => {
it("stops missing-row fallbacks once every live group is unverifiable", async () => {
const listProcesses = vi.fn(() => Promise.resolve(processes));
const environment = vi.fn((pid: number) =>
Promise.resolve({
REA_PROCESS_RUN_ID: pid === 100 ? "other-run" : ownership.runId,
}),
);
const runTokens = vi.fn<NonNullable<ProcessOwnershipHost["runTokens"]>>(
() => Promise.resolve(new Map()),
);
await expect(
observeOwnedProcessGroups(ownership.runId, [100], {
listProcesses,
environment,
runTokens,
signalGroup: vi.fn(),
}),
).resolves.toEqual(
new Map([
[
100,
{ state: "unverifiable", reason: "process ownership did not match" },
],
]),
);
expect(runTokens).toHaveBeenCalledTimes(1);
expect(environment.mock.calls).toEqual([[100]]);
expect(listProcesses).toHaveBeenCalledTimes(1);
});
});
describe("batched ownership observation", () => {
it("observes group and lineage with one batch each and falls back only for missing rows", async () => {
const { adapter } = host({
100: { REA_PROCESS_RUN_ID: ownership.runId },
101: { REA_PROCESS_RUN_ID: ownership.runId },
});
const environment = vi.fn(adapter.environment);
const runTokens = vi.fn<NonNullable<ProcessOwnershipHost["runTokens"]>>(
() =>
Promise.resolve(
new Map([[100, { state: "readable", runId: ownership.runId }]]),
),
);
const observedHost = { ...adapter, environment, runTokens };
await expect(
observeOwnedProcessGroups(
ownership.runId,
[ownership.processGroupId],
observedHost,
),
).resolves.toEqual(
new Map([[ownership.processGroupId, { state: "alive" }]]),
);
await expect(
observeOwnedProcessLineage(ownership, observedHost),
).resolves.toMatchObject({
status: "verified",
lineage: {
descendants: [{ pid: 101, parentPid: 100, processGroupId: 100 }],
},
});
expect(runTokens.mock.calls).toEqual([[processes], [processes]]);
expect(environment.mock.calls).toEqual([[101], [101]]);
});
it.each(["batch", "environment"] as const)(
"preserves cancellation during %s reads, including successful completion races",
async (boundary) => {
const controller = new AbortController();
const reason = new Error("cancelled ownership read");
const listProcesses = vi.fn(() => Promise.resolve(processes));
const environment = vi.fn<ProcessOwnershipHost["environment"]>(
async (_pid, signal) => {
expect(signal).toBe(controller.signal);
controller.abort(reason);
return { REA_PROCESS_RUN_ID: ownership.runId };
},
);
const runTokens = vi.fn<NonNullable<ProcessOwnershipHost["runTokens"]>>(
async (_processes, signal) => {
expect(signal).toBe(controller.signal);
if (boundary === "batch") controller.abort(reason);
return new Map();
},
);
await expect(
observeOwnedProcessGroups(
ownership.runId,
[ownership.processGroupId],
{ listProcesses, environment, runTokens, signalGroup: vi.fn() },
controller.signal,
),
).rejects.toBe(reason);
expect(listProcesses).toHaveBeenCalledTimes(1);
expect(environment).toHaveBeenCalledTimes(boundary === "batch" ? 0 : 1);
},
);
it.each([100, 101])(
"rechecks unavailable batch rows for exited PID %i without overriding them through fallback",
async (exitedPid) => {
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce(processes)
.mockResolvedValue(processes.filter(({ pid }) => pid !== exitedPid));
const environment = vi.fn(() =>
Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }),
);
const observedHost: ProcessOwnershipHost = {
listProcesses,
environment,
signalGroup: vi.fn(),
runTokens: () =>
Promise.resolve(
new Map(
processes.map(({ pid }) => [
pid,
pid === exitedPid
? { state: "unavailable" as const, reason: "process exited" }
: { state: "readable" as const, runId: ownership.runId },
]),
),
),
};
const result = await observeOwnedProcessLineage(ownership, observedHost);
if (exitedPid === 100)
expect(result).toMatchObject({
status: "unavailable",
reason: "owned launcher exited during lineage validation",
});
else
expect(result).toMatchObject({
status: "verified",
lineage: { descendants: [] },
});
expect(environment).not.toHaveBeenCalled();
},
);
});
describe("token sweep batch failures", () => {
it("cleans token-owned groups when a failed batch has readable fallback observations", async () => {
const { adapter, signalGroup } = host({
100: { REA_PROCESS_RUN_ID: ownership.runId },
101: { REA_PROCESS_RUN_ID: ownership.runId },
});
await expect(
cleanupOwnedProcessGroup(
{ ...ownership, sweepTokenOwnedProcesses: true, captureBaseline: [] },
{
...adapter,
runTokens: () =>
Promise.reject(new Error("batch reader unavailable")),
},
),
).resolves.toEqual({ cleaned: true, signaled: true });
expect(signalGroup.mock.calls).toEqual([[100, "SIGKILL"]]);
});
it("uses fallback ownership when the initial batch fails and preserves both failures through retries", async () => {
const signalGroup = vi.fn();
const adapter: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve(processes),
environment: (pid) =>
pid === 100
? Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId })
: Promise.reject(new Error("individual unreadable")),
runTokens: () => Promise.reject(new Error("batch reader unavailable")),
signalGroup,
};
const result = await cleanupOwnedProcessGroup(
{ ...ownership, sweepTokenOwnedProcesses: true, captureBaseline: [] },
adapter,
);
expect(result).toMatchObject({
cleaned: false,
failures: expect.arrayContaining([
expect.objectContaining({
pid: 101,
diagnostic:
"individual unreadable; run-token batch failed: batch reader unavailable",
}),
]),
});
expect(signalGroup).not.toHaveBeenCalled();
});
});
+103 -93
View File
@@ -7,7 +7,6 @@ import {
} from "./ProcessOwnershipObservation.js";
import { launcherIdentityFailure } from "./ProcessOwnershipIdentity.js";
import { descendantsOf, liveProcesses } from "./ProcessOwnershipProcessTree.js";
import { readProcessRunTokens } from "./ProcessRunTokenObservations.js";
const execFileAsync = promisify(execFile);
@@ -91,14 +90,10 @@ export interface ProcessOwnershipHost {
close?(): Promise<void>;
/** List current processes; startup inspection may be cancelled by the caller. */
listProcesses(signal?: AbortSignal): Promise<readonly ProcessTableEntry[]>;
environment(
pid: number,
signal?: AbortSignal,
): Promise<Readonly<Record<string, string>>>;
environment(pid: number): Promise<Readonly<Record<string, string>>>;
/** Read only run-token values in one host operation when the OS supports it. */
runTokens?(
processes: readonly ProcessTableEntry[],
signal?: AbortSignal,
): Promise<ReadonlyMap<number, ProcessRunTokenObservation>>;
/** Read stable identities; omit the signal during cleanup so it can finish. */
processIdentities?(
@@ -628,7 +623,6 @@ const processOwnershipFailures = async (
expectedIdentities.has(pid),
);
const observed = await host.processIdentities(expectedMembers);
const identityFailures: ProcessOwnershipValidationFailure[] = [];
for (const member of expectedMembers) {
const expected = expectedIdentities.get(member.pid);
const current = observed.get(member.pid);
@@ -637,7 +631,8 @@ const processOwnershipFailures = async (
current?.state !== "readable" ||
current.identity !== expected
) {
identityFailures.push({
if (await processIsGone(host, member.pid)) continue;
failures.push({
pid: member.pid,
reason: "process-identity-unavailable",
diagnostic:
@@ -645,9 +640,30 @@ const processOwnershipFailures = async (
});
}
}
failures.push(...(await recheckOwnershipFailures(identityFailures, host)));
}
failures.push(...(await processRunTokenFailures(members, runId, host)));
for (const member of members) {
try {
if ((await host.environment(member.pid)).REA_PROCESS_RUN_ID !== runId)
failures.push({ pid: member.pid, reason: "run-token-mismatch" });
} catch (cause: unknown) {
try {
const live = liveProcesses(await host.listProcesses());
if (!live.some(({ pid }) => pid === member.pid)) continue;
} catch (recheckCause: unknown) {
failures.push({
pid: member.pid,
reason: "environment-unreadable",
diagnostic: `${errorMessage(cause)}; process liveness recheck failed: ${errorMessage(recheckCause)}`,
});
continue;
}
failures.push({
pid: member.pid,
reason: "environment-unreadable",
diagnostic: errorMessage(cause),
});
}
}
if (
failures.length === 0 &&
expectedIdentities.size > 0 &&
@@ -657,7 +673,6 @@ const processOwnershipFailures = async (
expectedIdentities.has(pid),
);
const observed = await host.processIdentities(expectedMembers);
const identityFailures: ProcessOwnershipValidationFailure[] = [];
for (const member of expectedMembers) {
const expected = expectedIdentities.get(member.pid);
const current = observed.get(member.pid);
@@ -667,72 +682,18 @@ const processOwnershipFailures = async (
current.identity === expected
)
continue;
identityFailures.push({
if (await processIsGone(host, member.pid)) continue;
failures.push({
pid: member.pid,
reason: "process-identity-unavailable",
diagnostic:
"process identity changed or became unavailable during token validation",
});
}
failures.push(...(await recheckOwnershipFailures(identityFailures, host)));
}
return failures;
};
const processRunTokenFailures = async (
members: readonly ProcessTableEntry[],
runId: string,
host: ProcessOwnershipHost,
): Promise<readonly ProcessOwnershipValidationFailure[]> => {
const failures: ProcessOwnershipValidationFailure[] = [];
for await (const { process: member, observation } of readProcessRunTokens(
host,
members,
)) {
if (observation.state === "readable") {
if (observation.runId !== runId)
failures.push({ pid: member.pid, reason: "run-token-mismatch" });
continue;
}
failures.push({
pid: member.pid,
reason: "environment-unreadable",
diagnostic: observation.reason,
});
}
return recheckOwnershipFailures(failures, host);
};
/**
* Take a fresh liveness snapshot after each ownership-read phase. Only an
* absent PID resolves an unreadable observation; a readable token mismatch
* remains a failure. Never reuse this snapshot across identity/token reads.
*/
const recheckOwnershipFailures = async (
failures: readonly ProcessOwnershipValidationFailure[],
host: ProcessOwnershipHost,
): Promise<readonly ProcessOwnershipValidationFailure[]> => {
if (!failures.some(({ reason }) => reason !== "run-token-mismatch"))
return failures;
try {
const live = new Set(
liveProcesses(await host.listProcesses()).map(({ pid }) => pid),
);
return failures.filter(
({ pid, reason }) => reason === "run-token-mismatch" || live.has(pid),
);
} catch (cause: unknown) {
return failures.map((failure) =>
failure.reason === "run-token-mismatch"
? failure
: {
...failure,
diagnostic: `${failure.diagnostic}; process liveness recheck failed: ${errorMessage(cause)}`,
},
);
}
};
interface TokenOwnedProcessScan {
readonly owned: readonly ProcessTableEntry[];
readonly failures: readonly ProcessOwnershipValidationFailure[];
@@ -806,19 +767,18 @@ const scanTokenOwnedProcesses = async (
const failures: ProcessOwnershipValidationFailure[] = [];
if (captureBaseline !== undefined && host.processIdentities !== undefined) {
const stableCandidates: ProcessTableEntry[] = [];
const identityFailures: ProcessOwnershipValidationFailure[] = [];
for (const process of candidates) {
if (candidateIdentities.get(process.pid)?.state === "readable") {
stableCandidates.push(process);
continue;
}
identityFailures.push({
pid: process.pid,
reason: "process-identity-unavailable",
diagnostic: "process identity was unavailable during token scan",
});
if (!(await processIsGone(host, process.pid)))
failures.push({
pid: process.pid,
reason: "process-identity-unavailable",
diagnostic: "process identity was unavailable during token scan",
});
}
failures.push(...(await recheckOwnershipFailures(identityFailures, host)));
candidates = stableCandidates;
}
const owned: ProcessTableEntry[] = [];
@@ -831,14 +791,26 @@ const scanTokenOwnedProcesses = async (
identity?.state === "readable" ? identity.identity : null,
);
};
let bulkTokens: ReadonlyMap<number, ProcessRunTokenObservation> | undefined;
if (host.runTokens !== undefined)
bulkTokens = await host.runTokens(candidates);
const unreadable: UnreadableTokenCandidate[] = [];
for await (const { process, observation } of readProcessRunTokens(
host,
candidates,
)) {
if (observation.state === "readable") {
if (observation.runId === runId) recordOwned(process);
} else unreadable.push({ process, diagnostic: observation.reason });
for (const process of candidates) {
const bulkToken = bulkTokens?.get(process.pid);
if (bulkToken !== undefined) {
if (bulkToken.state === "readable") {
if (bulkToken.runId === runId) recordOwned(process);
continue;
}
unreadable.push({ process, diagnostic: bulkToken.reason });
continue;
}
try {
if ((await host.environment(process.pid)).REA_PROCESS_RUN_ID === runId)
recordOwned(process);
} catch (cause: unknown) {
unreadable.push({ process, diagnostic: errorMessage(cause) });
}
}
const remaining = await settleUnreadableTokenCandidates(
unreadable,
@@ -904,7 +876,6 @@ const scanTokenOwnedProcesses = async (
if (candidates.length > 0 && host.processIdentities !== undefined) {
const afterRead = await host.processIdentities(candidates);
const stillOwned: ProcessTableEntry[] = [];
const identityFailures: ProcessOwnershipValidationFailure[] = [];
const ownedPids = new Set(owned.map(({ pid }) => pid));
for (const process of candidates) {
const before = candidateIdentities.get(process.pid);
@@ -917,14 +888,14 @@ const scanTokenOwnedProcesses = async (
if (ownedPids.has(process.pid)) stillOwned.push(process);
continue;
}
identityFailures.push({
if (await processIsGone(host, process.pid)) continue;
failures.push({
pid: process.pid,
reason: "process-identity-unavailable",
diagnostic:
"process identity changed or became unavailable during token validation",
});
}
failures.push(...(await recheckOwnershipFailures(identityFailures, host)));
return {
owned: stillOwned,
failures,
@@ -965,20 +936,31 @@ const settleUnreadableTokenCandidates = async (
void cause;
live = undefined;
}
let observations:
| ReadonlyMap<number, ProcessRunTokenObservation>
| undefined;
if (host.runTokens !== undefined) {
try {
observations = await host.runTokens(
remaining.map(({ process }) => process),
);
} catch (cause: unknown) {
void cause;
observations = undefined;
}
}
const stillUnreadable: UnreadableTokenCandidate[] = [];
const candidates = remaining
.map(({ process }) => process)
.filter(({ pid }) => live === undefined || live.has(pid));
for await (const { process, observation } of readProcessRunTokens(
host,
candidates,
)) {
for (const candidate of remaining) {
const pid = candidate.process.pid;
if (live !== undefined && !live.has(pid)) continue;
const observation =
observations?.get(pid) ?? (await readEnvironmentRunToken(host, pid));
if (observation.state === "readable") {
if (observation.runId === runId) recordOwned(process);
if (observation.runId === runId) recordOwned(candidate.process);
continue;
}
stillUnreadable.push({
process,
process: candidate.process,
diagnostic: observation.reason,
});
}
@@ -986,3 +968,31 @@ const settleUnreadableTokenCandidates = async (
}
return remaining;
};
const readEnvironmentRunToken = async (
host: ProcessOwnershipHost,
pid: number,
): Promise<ProcessRunTokenObservation> => {
try {
return {
state: "readable",
runId: (await host.environment(pid)).REA_PROCESS_RUN_ID,
};
} catch (cause: unknown) {
return { state: "unavailable", reason: errorMessage(cause) };
}
};
const processIsGone = async (
host: ProcessOwnershipHost,
pid: number,
): Promise<boolean> => {
try {
return !liveProcesses(await host.listProcesses()).some(
(process) => process.pid === pid,
);
} catch (cause: unknown) {
void cause;
return false;
}
};
+6 -234
View File
@@ -6,7 +6,7 @@ import {
type WindowsProcessTreeHost,
} from "./ProcessOwnership.js";
import {
observeOwnedProcessGroups,
observeOwnedProcessGroup,
observeOwnedProcessLineage,
} from "./ProcessOwnershipObservation.js";
import { host, ownership } from "./ProcessOwnership.fixture.js";
@@ -115,101 +115,6 @@ describe("owned process-group cleanup validation: ownership and lineage", () =>
});
expect(signalGroup).not.toHaveBeenCalled();
});
});
describe("owned process-group cleanup liveness rechecks", () => {
it("rechecks all unreadable members after the token phase in one fresh snapshot", async () => {
const members = [100, 101, 102, 103].map((pid) => ({
pid,
parentPid: pid === 100 ? 1 : 100,
processGroupId: 100,
state: "S",
command: "fixture",
}));
const tokenReads: number[] = [];
const listProcesses = vi.fn(async () => {
if (tokenReads.length > 0)
expect(tokenReads).toEqual([100, 101, 102, 103]);
return members;
});
const signalGroup = vi.fn();
const adapter: ProcessOwnershipHost = {
listProcesses,
environment: async (pid) => {
tokenReads.push(pid);
throw new Error(`unreadable ${pid}`);
},
signalGroup,
};
await expect(cleanupOwnedProcessGroup(ownership, adapter)).resolves.toEqual(
{
cleaned: false,
reason:
"process ownership token could not be read for 4 live process(es): other_unavailable=4; live candidates 100=other_unavailable, 101=other_unavailable, 102=other_unavailable, 103=other_unavailable",
failures: members.map(({ pid }) => ({
pid,
reason: "environment-unreadable",
diagnostic: `unreadable ${pid}`,
})),
},
);
expect(listProcesses).toHaveBeenCalledTimes(2);
expect(signalGroup).not.toHaveBeenCalled();
});
it.each([false, true])(
"retains mismatches and fails closed when the liveness recheck fails: %s",
async (recheckFails) => {
const members = [100, 101, 102].map((pid) => ({
pid,
parentPid: pid === 100 ? 1 : 100,
processGroupId: 100,
state: "S",
command: "fixture",
}));
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce(members);
if (recheckFails)
listProcesses.mockRejectedValue(new Error("process table denied"));
else listProcesses.mockResolvedValue([]);
const signalGroup = vi.fn();
const adapter: ProcessOwnershipHost = {
listProcesses,
environment: async (pid) => {
if (pid === 101) return { REA_PROCESS_RUN_ID: "other-run" };
throw new Error(`unreadable ${pid}`);
},
signalGroup,
};
await expect(
cleanupOwnedProcessGroup(ownership, adapter),
).resolves.toEqual({
cleaned: false,
reason: "process tree contains an unowned or PID-reused process",
failures: recheckFails
? [
{
pid: 100,
reason: "environment-unreadable",
diagnostic:
"unreadable 100; process liveness recheck failed: process table denied",
},
{ pid: 101, reason: "run-token-mismatch" },
{
pid: 102,
reason: "environment-unreadable",
diagnostic:
"unreadable 102; process liveness recheck failed: process table denied",
},
]
: [{ pid: 101, reason: "run-token-mismatch" }],
});
expect(listProcesses).toHaveBeenCalledTimes(2);
expect(signalGroup).not.toHaveBeenCalled();
},
);
it("accepts a member that exits during ownership revalidation", async () => {
const liveLauncher = {
pid: 100,
@@ -255,12 +160,7 @@ describe("owned process-group cleanup validation: exited members", () => {
},
};
await expect(
observeOwnedProcessGroups(
ownership.runId,
[ownership.processGroupId],
adapter,
controller.signal,
),
observeOwnedProcessGroup(ownership, adapter, controller.signal),
).rejects.toBe(reason);
});
it("does not inspect a process group after observation was cancelled", async () => {
@@ -277,12 +177,7 @@ describe("owned process-group cleanup validation: exited members", () => {
},
};
await expect(
observeOwnedProcessGroups(
ownership.runId,
[ownership.processGroupId],
adapter,
controller.signal,
),
observeOwnedProcessGroup(ownership, adapter, controller.signal),
).rejects.toBe(reason);
});
it("ignores exited zombie members during live ownership checks", async () => {
@@ -334,13 +229,9 @@ describe("owned process-group cleanup validation: exited members", () => {
environment,
signalGroup: vi.fn(),
};
expect(
await observeOwnedProcessGroups(
ownership.runId,
[ownership.processGroupId],
adapter,
),
).toEqual(new Map([[ownership.processGroupId, { state: "empty" }]]));
expect(await observeOwnedProcessGroup(ownership, adapter)).toEqual({
state: "empty",
});
expect(environment).not.toHaveBeenCalled();
});
it("is idempotent when the owned group has already exited", async () => {
@@ -387,125 +278,6 @@ describe("owned process-group cleanup validation: exited members", () => {
});
});
describe("owned process-group cleanup token batches", () => {
const root = {
pid: 100,
parentPid: 1,
processGroupId: 100,
state: "S",
command: "fixture",
};
it("batches validation and falls back only for a missing member row", async () => {
const child = { ...root, pid: 101, parentPid: root.pid };
const runTokens = vi.fn(() =>
Promise.resolve(
new Map([
[root.pid, { state: "readable" as const, runId: ownership.runId }],
]),
),
);
const environment = vi.fn(() =>
Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }),
);
const signalGroup = vi.fn();
const adapter: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve([root, child]),
environment,
runTokens,
signalGroup,
};
await expect(cleanupOwnedProcessGroup(ownership, adapter)).resolves.toEqual(
{ cleaned: true, signaled: true },
);
expect(runTokens).toHaveBeenCalledTimes(2);
expect(runTokens).toHaveBeenNthCalledWith(1, [root, child]);
expect(environment.mock.calls).toEqual([[child.pid], [child.pid]]);
expect(signalGroup).toHaveBeenCalledWith(root.processGroupId, "SIGKILL");
});
it("keeps explicit unreadable rows fail-closed without environment fallback", async () => {
const environment = vi.fn(() =>
Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }),
);
const signalGroup = vi.fn();
const adapter: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve([root]),
environment,
runTokens: () =>
Promise.resolve(
new Map([
[
root.pid,
{ state: "unavailable" as const, reason: "fixture unreadable" },
],
]),
),
signalGroup,
};
await expect(
cleanupOwnedProcessGroup(ownership, adapter),
).resolves.toMatchObject({
cleaned: false,
failures: [
{
pid: root.pid,
reason: "environment-unreadable",
diagnostic: "fixture unreadable",
},
],
});
expect(environment).not.toHaveBeenCalled();
expect(signalGroup).not.toHaveBeenCalled();
});
it("falls back to the environment reader when the batch operation throws", async () => {
const environment = vi.fn(() =>
Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }),
);
const signalGroup = vi.fn();
const adapter: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve([root]),
environment,
runTokens: () => Promise.reject(new Error("batch reader unavailable")),
signalGroup,
};
await expect(cleanupOwnedProcessGroup(ownership, adapter)).resolves.toEqual(
{ cleaned: true, signaled: true },
);
expect(environment).toHaveBeenCalledTimes(2);
expect(signalGroup).toHaveBeenCalledWith(root.processGroupId, "SIGKILL");
});
it("retains both failures when batch and individual token reads fail", async () => {
const signalGroup = vi.fn();
const adapter: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve([root]),
environment: () => Promise.reject(new Error("individual unreadable")),
runTokens: () => Promise.reject(new Error("batch reader unavailable")),
signalGroup,
};
await expect(
cleanupOwnedProcessGroup(ownership, adapter),
).resolves.toMatchObject({
cleaned: false,
failures: [
{
pid: root.pid,
reason: "environment-unreadable",
diagnostic:
"individual unreadable; run-token batch failed: batch reader unavailable",
},
],
});
expect(signalGroup).not.toHaveBeenCalled();
});
});
describe("Windows P0 process-tree cleanup", () => {
it("reports whether taskkill signaled or found an exited tree", async () => {
const terminated: WindowsProcessTreeHost = {
+40 -76
View File
@@ -5,7 +5,6 @@ import { launcherIdentityFailure } from "./ProcessOwnershipIdentity.js";
import { descendantsOf, liveProcesses } from "./ProcessOwnershipProcessTree.js";
import { execFileOutput } from "./ExecFileOutput.js";
import { createDarwinProcessRunTokenReader } from "./DarwinProcessRunTokenReader.js";
import { readProcessRunTokens } from "./ProcessRunTokenObservations.js";
import type {
OwnedProcessGroup,
ProcessGroupObservation,
@@ -16,78 +15,49 @@ import type {
ProcessTableEntry,
} from "./ProcessOwnership.js";
/** Observe multiple groups from one process snapshot and one token batch. */
export const observeOwnedProcessGroups = async (
runId: string,
processGroupIds: readonly number[],
/** Observe one group without signaling it, failing closed on identity doubt. */
export const observeOwnedProcessGroup = async (
ownership: OwnedProcessGroup,
host: ProcessOwnershipHost = systemProcessOwnershipHost,
signal?: AbortSignal,
): Promise<ReadonlyMap<number, ProcessGroupObservation>> => {
): Promise<ProcessGroupObservation> => {
signal?.throwIfAborted();
const groupIds = [...new Set(processGroupIds)];
if (groupIds.length === 0) return new Map();
let processes: readonly ProcessTableEntry[];
let members: readonly ProcessTableEntry[];
try {
processes = liveProcesses(await host.listProcesses(signal));
members = (await host.listProcesses(signal)).filter(
({ processGroupId }) => processGroupId === ownership.processGroupId,
);
} catch (cause: unknown) {
signal?.throwIfAborted();
const failure = {
state: "unverifiable" as const,
return {
state: "unverifiable",
reason: `process group could not be inspected: ${errorMessage(cause)}`,
};
return new Map(groupIds.map((groupId) => [groupId, failure]));
}
signal?.throwIfAborted();
const membersByGroup = new Map(
groupIds.map((groupId) => [groupId, [] as ProcessTableEntry[]]),
);
for (const process of processes)
membersByGroup.get(process.processGroupId)?.push(process);
const observations = new Map<number, ProcessGroupObservation>(
groupIds.map((groupId) => [
groupId,
(membersByGroup.get(groupId)?.length ?? 0) > 0
? { state: "alive" }
: { state: "empty" },
]),
);
const liveMembers = groupIds.flatMap(
(groupId) => membersByGroup.get(groupId) ?? [],
);
const pendingGroups = new Set(
groupIds.filter(
(groupId) => (membersByGroup.get(groupId)?.length ?? 0) > 0,
),
);
for await (const { process: member, observation } of readProcessRunTokens(
host,
liveMembers,
signal,
)) {
if (!pendingGroups.has(member.processGroupId)) continue;
if (observation.state === "readable") {
if (observation.runId !== runId) {
observations.set(member.processGroupId, {
const liveMembers = liveProcesses(members);
if (liveMembers.length === 0) return { state: "empty" };
for (const member of liveMembers) {
signal?.throwIfAborted();
try {
const runId = (await host.environment(member.pid)).REA_PROCESS_RUN_ID;
signal?.throwIfAborted();
if (runId !== ownership.runId)
return {
state: "unverifiable",
reason: "process ownership did not match",
});
pendingGroups.delete(member.processGroupId);
}
} else {
const gone = await processIsGone(host, member.pid);
};
} catch (cause: unknown) {
signal?.throwIfAborted();
if (!gone) {
observations.set(member.processGroupId, {
if (!(await processIsGone(host, member.pid))) {
return {
state: "unverifiable",
reason: `process ownership could not be revalidated for PID ${member.pid}: ${observation.reason}`,
});
pendingGroups.delete(member.processGroupId);
reason: `process ownership could not be revalidated for PID ${member.pid}: ${errorMessage(cause)}`,
};
}
}
if (pendingGroups.size === 0) break;
}
signal?.throwIfAborted();
return observations;
return { state: "alive" };
};
/**
@@ -118,18 +88,18 @@ export const observeOwnedProcessLineage = async (
return unavailableLineage(ownership, identityFailure);
const descendants = descendantsOf(launcher.pid, processes);
const verifiedDescendants: ProcessTableEntry[] = [];
for await (const { process: member, observation } of readProcessRunTokens(
host,
[launcher, ...descendants],
)) {
if (observation.state === "readable") {
if (observation.runId !== ownership.runId)
for (const member of [launcher, ...descendants]) {
try {
if (
(await host.environment(member.pid)).REA_PROCESS_RUN_ID !==
ownership.runId
)
return unavailableLineage(
ownership,
"process lineage contains an unowned or PID-reused process",
);
if (member.pid !== launcher.pid) verifiedDescendants.push(member);
} else {
} catch (cause: unknown) {
if (await processIsGone(host, member.pid)) {
if (member.pid === launcher.pid)
return unavailableLineage(
@@ -140,7 +110,7 @@ export const observeOwnedProcessLineage = async (
}
return unavailableLineage(
ownership,
`process ownership could not be revalidated for PID ${member.pid}: ${observation.reason}`,
`process ownership could not be revalidated for PID ${member.pid}: ${errorMessage(cause)}`,
);
}
}
@@ -360,21 +330,18 @@ export const createSystemProcessOwnershipHost = (
}
},
listProcesses,
async environment(pid, signal) {
signal?.throwIfAborted();
async environment(pid) {
if (platform === "linux")
return parseProcessEnvironment(
await readFile(`/proc/${pid}/environ`, { encoding: "utf8", signal }),
await readFile(`/proc/${pid}/environ`, "utf8"),
);
if (platform === "darwin") {
const process = (await listProcesses(signal)).find(
const process = (await listProcesses()).find(
(entry) => entry.pid === pid,
);
if (process === undefined)
throw new Error(`process ${String(pid)} is not live`);
const observation = (await darwinTokens?.read([process], signal))?.get(
pid,
);
const observation = (await darwinTokens?.read([process]))?.get(pid);
if (observation === undefined || observation.state === "unavailable")
throw new Error(
observation?.reason ?? "process run token could not be read",
@@ -388,7 +355,6 @@ export const createSystemProcessOwnershipHost = (
["eww", "-p", String(pid)],
{
env: hostEnvironment,
...(signal === undefined ? {} : { signal }),
},
);
const observedEnvironment: Record<string, string> = {};
@@ -400,13 +366,11 @@ export const createSystemProcessOwnershipHost = (
}
return observedEnvironment;
},
async runTokens(processes, signal) {
signal?.throwIfAborted();
async runTokens(processes) {
if (platform !== "darwin" || processes.length === 0) return new Map();
try {
return (await darwinTokens?.read(processes, signal)) ?? new Map();
return (await darwinTokens?.read(processes)) ?? new Map();
} catch (cause: unknown) {
if (isExpectedAbort(cause, signal)) throw cause;
void cause;
return new Map(
processes.map(({ pid }) => [
@@ -1,62 +0,0 @@
import type {
ProcessOwnershipHost,
ProcessRunTokenObservation,
ProcessTableEntry,
} from "./ProcessOwnership.js";
const errorMessage = (cause: unknown): string =>
cause instanceof Error ? cause.message : String(cause);
/** Read only ownership tokens, using per-process fallback for missing batch rows. */
export async function* readProcessRunTokens(
host: ProcessOwnershipHost,
processes: readonly ProcessTableEntry[],
signal?: AbortSignal,
): AsyncGenerator<{
readonly process: ProcessTableEntry;
readonly observation: ProcessRunTokenObservation;
}> {
signal?.throwIfAborted();
if (processes.length === 0) return;
let batch: ReadonlyMap<number, ProcessRunTokenObservation> | undefined;
let batchFailure: string | undefined;
if (host.runTokens !== undefined) {
try {
batch =
signal === undefined
? await host.runTokens(processes)
: await host.runTokens(processes, signal);
} catch (cause: unknown) {
signal?.throwIfAborted();
batchFailure = errorMessage(cause);
}
}
for (const process of processes) {
signal?.throwIfAborted();
let observation = batch?.get(process.pid);
if (observation === undefined) {
try {
const environment =
signal === undefined
? await host.environment(process.pid)
: await host.environment(process.pid, signal);
observation = {
state: "readable",
runId: environment.REA_PROCESS_RUN_ID,
};
} catch (cause: unknown) {
signal?.throwIfAborted();
const reason = errorMessage(cause);
observation = {
state: "unavailable",
reason:
batchFailure === undefined
? reason
: `${reason}; run-token batch failed: ${batchFailure}`,
};
}
}
signal?.throwIfAborted();
yield { process, observation };
}
}
+13 -25
View File
@@ -16,41 +16,29 @@ import { classifyFilesystemEffects } from "./ProcessFilesystemEffects.js";
it.each(["after", "before"] as const)(
"keeps absence unknown when the %s snapshot omits an existing file",
async (partialSide) => {
// Enumeration order is filesystem-dependent. Choose a genuinely omitted
// entry by comparing a complete snapshot with a capacity-limited one.
const root = await createTestTempDirectory("rea-fs-partial-absence-");
await writeFile(join(root, "a1.txt"), "first");
await writeFile(join(root, "a2.txt"), "second");
await writeFile(join(root, "z.txt"), "target");
await writeFile(join(root, "z.txt"), "unchanged");
const scenario = parseProcessScenario({
executable: process.execPath,
working_directory: root,
filesystem_observation_paths: [root],
limits: { files: 2 },
});
const complete = await snapshotRoots({
...scenario,
limits: { ...scenario.limits, files: 4 },
});
const partial = await snapshotRoots(scenario);
expect(complete.completeRoots).toEqual(["root_0"]);
expect(partial.truncated).toBe(true);
const omitted = complete.files.find(
(file) => !partial.files.some(({ path }) => path === file.path),
);
if (omitted === undefined) throw new Error("Expected an omitted entry");
const before = partialSide === "before" ? partial : complete;
const after = partialSide === "after" ? partial : complete;
if (partialSide === "before") await writeFile(join(root, "a.txt"), "other");
const before = await snapshotRoots(scenario);
if (partialSide === "after") await writeFile(join(root, "a.txt"), "other");
else await rm(join(root, "a.txt"));
const after = await snapshotRoots(scenario);
expect(await readFile(join(root, "z.txt"), "utf8")).toBe("unchanged");
const effect = classifyFilesystemEffects(before, after).find(
({ path }) => path === omitted.path,
({ path }) => path === "root_0:z.txt",
);
expect(effect?.status).toBe("unknown");
expect(effect).toMatchObject({
...(partialSide === "after"
? { before: omitted, after: null }
: { before: null, after: omitted }),
reason: expect.stringContaining("did not exhaust path enumeration"),
});
expect(effect).toMatchObject(
partialSide === "after"
? { before: { path: "root_0:z.txt" }, after: null }
: { before: null, after: { path: "root_0:z.txt" } },
);
},
);
+11 -10
View File
@@ -25,7 +25,7 @@ import {
type ProcessOwnershipBaseline,
} from "../ProcessOwnership.js";
import {
observeOwnedProcessGroups,
observeOwnedProcessGroup,
prepareProcessOwnershipInspection,
systemProcessOwnershipHost,
} from "../ProcessOwnershipObservation.js";
@@ -616,20 +616,21 @@ export const observeSettlement = async (
while (!deadlineReached) {
onLiveProgress?.();
assertNotCancelled(signal);
const observations = await observeOwnedProcessGroups(
runId,
processGroupIds,
undefined,
signal,
const observations = await Promise.all(
[...new Set(processGroupIds)].map((processGroupId) =>
observeOwnedProcessGroup(
{ runId, leaderPid: processGroupId, processGroupId },
undefined,
signal,
),
),
);
assertNotCancelled(signal);
if (
[...observations.values()].some(({ state }) => state === "unverifiable")
) {
if (observations.some(({ state }) => state === "unverifiable")) {
recordEvent("lifecycle", 1);
return { state: "unverifiable", elapsed_ms: Date.now() - started };
}
if ([...observations.values()].every(({ state }) => state === "empty")) {
if (observations.every(({ state }) => state === "empty")) {
consecutiveEmpty += 1;
if (consecutiveEmpty >= 2) {
recordEvent("lifecycle", 1);
+7 -23
View File
@@ -18,7 +18,6 @@ import {
} from "./ProcessCaptureError.js";
import {
releaseProcessResources,
ProcessCaptureResourceScope,
createProcessCaptureObservationBuffer,
prepareProcessCapture,
resolveProcessResult,
@@ -292,31 +291,26 @@ it("keeps empty cleanup exception messages actionable in the report", async () =
});
it.each(["termination", "verification"] as const)(
"retains observations and retries the temporary root when process %s throws",
"retains observations and releases the temporary root when process %s throws",
async (stage) => {
const cleanupFailure = new Error(`${stage} inspection failed`);
const removeTemporaryRoot = vi.fn(async () => undefined);
let failing = true;
const scope = new ProcessCaptureResourceScope();
const host: ProcessCaptureCleanupHost = {
platform: "linux",
cleanupProcessGroup: async () => {
if (failing && stage === "termination") throw cleanupFailure;
if (stage === "termination") throw cleanupFailure;
return {
cleaned: true,
signaled: true,
unverified: failing
? [{ pid: 900, diagnostic: "environment unavailable" }]
: [],
unverified: [{ pid: 900, diagnostic: "environment unavailable" }],
};
},
verifyTokenOwnedProcesses: async () => {
if (failing) throw cleanupFailure;
return { cleaned: true, signaled: false };
throw cleanupFailure;
},
removeTemporaryRoot,
};
const cleanup = await scope.release({
const cleanup = await releaseProcessResources({
timers: new Set(),
terminal: { pid: 321 },
renderer: undefined,
@@ -325,11 +319,8 @@ it.each(["termination", "verification"] as const)(
host,
});
expect(removeTemporaryRoot).not.toHaveBeenCalled();
expect(cleanup.temporary_root).toEqual({
state: "unverified",
reason: `temporary root retained because owned process cleanup is failed: ${cleanupFailure.message}`,
});
expect(removeTemporaryRoot).toHaveBeenCalledWith("/fixture/root");
expect(cleanup.temporary_root).toEqual({ state: "cleaned", reason: null });
expect(cleanup.owned_process_group).toMatchObject({
state: "failed",
reason: cleanupFailure.message,
@@ -366,13 +357,6 @@ it.each(["termination", "verification"] as const)(
},
});
}
failing = false;
await scope.retryCleanup();
expect(removeTemporaryRoot).toHaveBeenCalledExactlyOnceWith(
"/fixture/root",
);
await scope.close();
expect(removeTemporaryRoot).toHaveBeenCalledTimes(1);
},
);
-1
View File
@@ -242,7 +242,6 @@ export const createServer = (
android.close(),
apktool.close(),
jeb.close(),
adbDevice.close(),
evmInterface.close(),
webSourceLocation.close(),
javascriptRecovery.close?.() ?? Promise.resolve(),
@@ -1,47 +0,0 @@
import { stat, truncate, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { snapshotAndroidTarget } from "../../../src/android/AndroidTargetSnapshot.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
it.skipIf(process.platform === "win32")(
"cancels an in-flight APK snapshot and removes its partial output",
async () => {
const root = await createTestTempDirectory("rea-android-snapshot-cancel-");
const source = join(root, "large.apk");
const snapshot = join(root, "target.apk");
await writeFile(source, "");
// A sparse extent keeps fixture setup cheap while ensuring the copy cannot
// finish before the test observes its first output bytes and aborts it.
await truncate(source, 64 * 1024 * 1024 * 1024);
const controller = new AbortController();
const pending = snapshotAndroidTarget(
source,
"0".repeat(64),
root,
"inspect_android_package",
controller.signal,
);
try {
await expect
.poll(
() =>
stat(snapshot).then(
({ size }) => size > 0,
() => false,
),
{ timeout: 10_000, interval: 10 },
)
.toBe(true);
controller.abort();
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
await expect(stat(snapshot)).rejects.toMatchObject({ code: "ENOENT" });
} finally {
controller.abort();
await pending.catch(() => undefined);
}
},
);
@@ -5,8 +5,7 @@ import { expect, it as test } from "vitest";
import {
inspectJadxAvailability,
resolveJadxConfigurationInputs,
validateJadxConfiguration,
resolveJadxConfiguration,
} from "../../../src/android/JadxConfiguration.js";
import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
@@ -15,18 +14,6 @@ import { writeJadxJarInventory } from "../../fixtures/android/jadxJar.js";
const it = test.skipIf(process.platform === "win32");
const resolveConfiguration = async (
environment: Parameters<typeof resolveJadxConfigurationInputs>[0],
operation: Parameters<typeof resolveJadxConfigurationInputs>[1],
) => {
const configuration = await resolveJadxConfigurationInputs(
environment,
operation,
);
await validateJadxConfiguration(configuration, environment, operation);
return configuration;
};
it("rejects malformed or unrelated engine archives with JAR-specific recovery", async () => {
const root = await createTestTempDirectory("rea-jadx-invalid-jar-");
const javaHome = await createFakeJdk(root);
@@ -42,7 +29,7 @@ it("rejects malformed or unrelated engine archives with JAR-specific recovery",
diagnostics: { jar_path: await realpath(jar), phase: "jar-inspection" },
});
await expect(
resolveConfiguration(environment, "inspect_android_package"),
resolveJadxConfiguration(environment, "inspect_android_package"),
).rejects.toMatchObject({
_tag: "AnalysisCapabilityUnavailableError",
reason: expect.stringContaining("REA_JADX_MCP_JAR"),
@@ -58,7 +45,7 @@ it("rejects an explicit JAVA_HOME without an executable java binary", async () =
await writeJadxJarInventory(jar);
await expect(
resolveConfiguration(
resolveJadxConfiguration(
{ REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome },
"inspect_android_package",
),
@@ -75,7 +62,7 @@ it("accepts a full JDK and canonicalizes the selected JAR", async () => {
await writeJadxJarInventory(jar);
await expect(
resolveConfiguration(
resolveJadxConfiguration(
{ REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome },
"inspect_android_package",
),
@@ -91,7 +78,7 @@ it("configures heap and visible processors independently without forcing either"
const jar = join(root, "engine.jar");
const javaHome = await createFakeJdk(root);
await writeJadxJarInventory(jar);
const configuration = await resolveConfiguration(
const configuration = await resolveJadxConfiguration(
{
REA_JADX_MCP_JAR: jar,
JAVA_HOME: javaHome,
@@ -105,7 +92,7 @@ it("configures heap and visible processors independently without forcing either"
"-XX:ActiveProcessorCount=4",
]);
await expect(
resolveConfiguration(
resolveJadxConfiguration(
{
REA_JADX_MCP_JAR: jar,
JAVA_HOME: javaHome,
@@ -120,7 +107,7 @@ it("configures heap and visible processors independently without forcing either"
for (const name of ["REA_JADX_HEAP_MIB", "REA_JADX_ACTIVE_PROCESSOR_COUNT"])
for (const value of ["", "0", "-1", "1.5", "8g", " 4", "9007199254740992"])
await expect(
resolveConfiguration(
resolveJadxConfiguration(
{
REA_JADX_MCP_JAR: jar,
JAVA_HOME: javaHome,
@@ -140,7 +127,7 @@ it("distinguishes a runnable JRE from a missing Java executable", async () => {
await writeJadxJarInventory(jar);
const jreHome = await createFakeJdk(root, false);
await expect(
resolveConfiguration(
resolveJadxConfiguration(
{ REA_JADX_MCP_JAR: jar, JAVA_HOME: jreHome },
"inspect_android_package",
),
@@ -188,7 +175,7 @@ it("preserves exact Java probe output and marks max-buffer capture as truncated"
);
await chmod(java, 0o700);
const failure = await resolveConfiguration(
const failure = await resolveJadxConfiguration(
{ REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome },
"inspect_android_package",
).then(
@@ -254,7 +241,7 @@ it("distinguishes an unreported Java version from an unsupported old version", a
);
await chmod(join(oldHome, "bin", "java"), 0o700);
await expect(
resolveConfiguration(
resolveJadxConfiguration(
{ REA_JADX_MCP_JAR: jar, JAVA_HOME: oldHome },
"inspect_android_package",
),
@@ -294,15 +294,6 @@ describe("CdpBrowserProvider: WebMCP discovery and completeness", () => {
],
},
});
expect(result.value.completeness.excluded).toContainEqual(
expect.objectContaining({
section: "webmcp_tools",
reason: "out_of_target_scope",
}),
);
expect(result.value.completeness.unavailable_sections).not.toContain(
"webmcp_tools",
);
const serialized = JSON.stringify(result.value);
expect(serialized).toContain("tool-secret");
expect(serialized).toContain("tool-source-secret");
@@ -363,9 +354,7 @@ describe("CdpBrowserProvider: WebMCP discovery and completeness", () => {
"WebMCP.invokeTool",
);
});
});
describe("CdpBrowserProvider: WebMCP replay inventory", () => {
it("returns every WebMCP registration in the replay", async () => {
const browser = await startFakeCdpBrowser({
webMcpTools: true,
@@ -421,98 +410,7 @@ describe("CdpBrowserProvider: WebMCP replay inventory", () => {
});
});
describe("CdpBrowserProvider WebMCP malformed replay completeness", () => {
it.each(["toolsAdded", "toolsRemoved"] as const)(
"keeps malformed %s replay entries from proving WebMCP equality",
async (malformedEvent) => {
let malformed = false;
const registration = {
name: "read_item",
frameId: "frame-main",
inputSchema: { type: "object" },
};
const browser = await startFakeCdpBrowser({
commandEvents: (command) => {
if (command.method !== "WebMCP.enable") return undefined;
const events: {
readonly method: string;
readonly params: unknown;
readonly sessionId?: string;
}[] = [
{
method: "WebMCP.toolsAdded",
...(command.sessionId === undefined
? {}
: { sessionId: command.sessionId }),
params: {
tools:
malformed && malformedEvent === "toolsAdded"
? [registration, null]
: [registration],
},
},
];
if (malformed && malformedEvent === "toolsRemoved")
events.push({
method: "WebMCP.toolsRemoved",
...(command.sessionId === undefined
? {}
: { sessionId: command.sessionId }),
params: { tools: [{ name: registration.name }, null] },
});
return events;
},
});
trackBrowser(browser);
const provider = new CdpBrowserProvider();
const input = {
cdp_endpoint: browser.endpoint,
allowed_origins: [browser.allowedOrigin],
target_id: "allowed-page",
observation_ms: 0,
};
const inspection = await provider.inspectPage(
inspectWebPageInputSchema.parse(input),
);
if (!inspection.ok) throw inspection.error;
const discover = async () => {
const result = await provider.discoverWebMcpTools(
discoverWebMcpToolsInputSchema.parse(input),
);
if (!result.ok) throw result.error;
return result.value;
};
const before = await discover();
expect(before.tools.items).toHaveLength(1);
expect(before.completeness.unavailable_sections).not.toContain(
"webmcp_tools",
);
malformed = true;
const after = await discover();
expect(after.tools.items).toHaveLength(1);
expect(after.completeness.unavailable_sections).toContain("webmcp_tools");
expect(after.completeness.excluded).toContainEqual(
expect.objectContaining({
section: "webmcp_tools",
reason: "invalid_protocol_value",
}),
);
const comparison = compareWebCaptures(
compareWebCapturesInputSchema.parse({
before: { inspection: inspection.value, webmcp: before },
after: { inspection: inspection.value, webmcp: after },
}),
);
expect(comparison.dimensions.webmcp).toMatchObject({
status: "unknown",
total_changes: 0,
});
},
);
});
describe("CdpBrowserProvider WebMCP schema comparison", () => {
describe("CdpBrowserProvider WebMCP inventory completeness", () => {
it("compares full untrusted declarations independently of object-key order", async () => {
const declaration: JsonValue = {
type: "object",
@@ -47,7 +47,6 @@ it.each(["Page.disable", "Target.detachFromTarget"])(
expect(methods).toContain("Target.detachFromTarget");
expect(methods).not.toContain("Target.closeTarget");
expect(methods).not.toContain("Browser.close");
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
},
);
@@ -74,11 +73,9 @@ it("releases cleanup when cancellation arrives after capture completes", async (
controller.abort();
const result = await pending;
expect(result.ok).toBe(true);
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
const methods = browser.commands.map(({ method }) => method);
expect(methods).not.toContain("Target.closeTarget");
expect(methods).not.toContain("Browser.close");
expect((await fetch(`${browser.endpoint}/json/version`)).ok).toBe(true);
expect(browser.commands.map(({ method }) => method)).toContain(
"Target.detachFromTarget",
);
});
it("closes an Electron inspection session when cleanup is cancelled", async () => {
@@ -125,10 +122,11 @@ it("closes an Electron inspection session when cleanup is cancelled", async () =
operation: "inspect_electron_page",
},
});
expect(browser.commands.map(({ method }) => method)).toContain(
"Page.disable",
);
const methods = browser.commands.map(({ method }) => method);
expect(methods).toContain("Target.detachFromTarget");
expect(methods).not.toContain("Target.closeTarget");
expect(methods).not.toContain("Browser.close");
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
expect((await fetch(`${browser.endpoint}/json/version`)).ok).toBe(true);
});
@@ -1,241 +0,0 @@
import { expect } from "vitest";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js";
const runtimeSource = (
runtime: string,
name: string,
entry = 1,
dependency = 2,
) => `
globalThis.${runtime}.push([["main"], {
${String(entry)}: (module, exports, req) => {
req(${String(dependency)}); req.e("lazy"); fetch("/${name}");
localStorage.setItem("${name}", "value");
const { BrowserWindow } = require("electron"); new BrowserWindow({});
},
${String(dependency)}: (module) => { module.exports = "${name}"; }
}, (req) => req(${String(entry)})]);
globalThis.${runtime}.push([["lazy"], {9: () => {}}]);
`;
const appA = runtimeSource("webpackChunkAppA", "a");
const appB = runtimeSource("webpackChunkAppB", "b");
cliTest(
"keeps source-derived runtime, module and chunk tuples collision-free",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-bundler-runtime-tuple-");
const registrations = [
{
runtime: "webpackChunkA",
module: "x\0y",
chunk: "x\0lazy",
endpoint: "/a",
},
{
runtime: "webpackChunkA\0x",
module: "y",
chunk: "lazy",
endpoint: "/b",
},
];
const source = registrations
.map(
({ runtime, module, chunk, endpoint }) => `
globalThis[${JSON.stringify(runtime)}].push([["main"], {
[${JSON.stringify(module)}]: (m, e, req) => { fetch(${JSON.stringify(endpoint)}); req.e(${JSON.stringify(chunk)}); }
}, (req) => req(${JSON.stringify(module)})]);
globalThis[${JSON.stringify(runtime)}].push([[${JSON.stringify(chunk)}], {}]);
`,
)
.join("\n");
await writeFixtureFiles(root, { "bundle.js": source });
const output = await cli.run({
arguments: ["analyze-javascript-application", root, "--json"],
});
expect(output.exitCode).toBe(0);
const { graph } = javascriptApplicationAnalysisResultSchema.parse(
parseEvidence(output.json).normalized_result,
);
const nodes = new Map(graph.nodes.map((node) => [node.node_id, node]));
for (const { runtime, module, chunk, endpoint } of registrations) {
const entry = graph.edges.find(
(edge) =>
edge.properties.kind === "bundler-entry-module" &&
edge.properties.runtime === runtime,
);
expect(nodes.get(entry?.target_node_id ?? "")?.identity).toMatchObject({
namespace: `${runtime}:module`,
key: module,
});
const call = graph.edges.find(
(edge) =>
edge.relation === "calls" &&
nodes.get(edge.target_node_id)?.observations[0]?.properties.value ===
endpoint,
);
expect(call?.source_node_id).toBe(entry?.target_node_id);
const lazy = graph.edges.find(
(edge) =>
edge.source_node_id === entry?.target_node_id &&
edge.properties.specifier === `chunk:${chunk}`,
);
expect(
nodes.get(lazy?.target_node_id ?? "")?.observations[0]?.properties,
).toMatchObject({ runtime, chunk_keys: [chunk] });
}
},
);
for (const scenario of [
{
name: "colliding module IDs",
source: appA + appB,
runtimes: ["webpackChunkAppA", "webpackChunkAppB"],
keys: [1, 1],
},
{
name: "reversed registrations",
source: appB + appA,
runtimes: ["webpackChunkAppA", "webpackChunkAppB"],
keys: [1, 1],
},
{
name: "single runtime",
source: appA,
runtimes: ["webpackChunkAppA"],
keys: [1],
},
{
name: "disjoint module IDs",
source: appA + runtimeSource("webpackChunkAppB", "b", 3, 4),
runtimes: ["webpackChunkAppA", "webpackChunkAppB"],
keys: [1, 3],
},
{
name: "Webpack and Rspack collision",
source: appA + runtimeSource("rspackChunkAppB", "b"),
runtimes: ["webpackChunkAppA", "rspackChunkAppB"],
keys: [1, 1],
},
]) {
cliTest(
`keeps bundled relationships within their runtime for ${scenario.name}`,
async ({ cli }) => {
const root = await createTestTempDirectory("rea-bundler-runtime-scope-");
await writeFixtureFiles(root, { "bundle.js": scenario.source });
const output = await cli.run({
arguments: ["analyze-javascript-application", root, "--json"],
});
expect(output.exitCode).toBe(0);
const { graph } = javascriptApplicationAnalysisResultSchema.parse(
parseEvidence(output.json).normalized_result,
);
const nodes = new Map(graph.nodes.map((node) => [node.node_id, node]));
expect(graph.coverage.status).toBe("complete");
for (const [index, runtime] of scenario.runtimes.entries()) {
const name = index === 0 ? "a" : "b";
const key = String(scenario.keys[index]);
const module = graph.nodes.find(
(node) =>
node.identity.strategy === "artifact-local-key" &&
node.identity.namespace === `${runtime}:module` &&
node.identity.key === key,
);
expect(module).toBeDefined();
const entry = graph.edges.find(
(edge) =>
edge.properties.kind === "bundler-entry-module" &&
edge.properties.runtime === runtime,
);
expect(entry?.target_node_id).toBe(module?.node_id);
expect(entry?.properties.resolution_status).toBe("resolved");
const ownedEdges = graph.edges.filter(
(edge) => edge.source_node_id === module?.node_id,
);
expect(
ownedEdges.find(
(edge) =>
edge.relation === "calls" &&
nodes.get(edge.target_node_id)?.observations[0]?.properties
.value === `/${name}`,
),
).toBeDefined();
expect(
ownedEdges.find(
(edge) =>
edge.relation === "persists_to" &&
nodes.get(edge.target_node_id)?.observations[0]?.properties
.name === name,
),
).toBeDefined();
expect(
ownedEdges.find(
(edge) =>
edge.relation === "contains" &&
nodes.get(edge.target_node_id)?.kind === "browser-window",
),
).toBeDefined();
const dependency = ownedEdges.find(
(edge) =>
edge.properties.kind === "require" &&
edge.properties.specifier === String(Number(key) + 1),
);
expect(
nodes.get(dependency?.target_node_id ?? "")?.identity,
).toMatchObject({
namespace: `${runtime}:module`,
key: String(Number(key) + 1),
});
const lazy = ownedEdges.find(
(edge) => edge.properties.specifier === "chunk:lazy",
);
expect(
nodes.get(lazy?.target_node_id ?? "")?.observations[0]?.properties,
).toMatchObject({ runtime, chunk_keys: ["lazy"] });
}
},
);
}
cliTest(
"shares module IDs across chunks only within the same runtime",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-bundler-shared-runtime-");
await writeFixtureFiles(root, {
"bundle.js": `
globalThis.webpackChunkApp.push([["main"], {1: (module, exports, req) => { req(2); }} , (req) => req(1)]);
globalThis.webpackChunkApp.push([["other"], {2: (module) => { module.exports = "dependency"; }}]);
`,
});
const output = await cli.run({
arguments: ["analyze-javascript-application", root, "--json"],
});
expect(output.exitCode).toBe(0);
const { graph } = javascriptApplicationAnalysisResultSchema.parse(
parseEvidence(output.json).normalized_result,
);
const module = (key: string) =>
graph.nodes.find(
(node) =>
node.identity.strategy === "artifact-local-key" &&
node.identity.namespace === "webpackChunkApp:module" &&
node.identity.key === key,
);
expect(
graph.edges.find(
(edge) =>
edge.properties.kind === "require" &&
edge.properties.specifier === "2",
),
).toMatchObject({
source_node_id: module("1")?.node_id,
target_node_id: module("2")?.node_id,
});
},
);
+12 -7
View File
@@ -8,18 +8,23 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
cliTest(
"rejects malformed oversized JSON through both CLI and Evidence readers",
"rejects malformed oversized CLI input and preserves Evidence decoder resource failures",
async ({ cli, processes }) => {
const root = await createTestTempDirectory("rea-json-string-limit-");
const input = join(root, "oversized.json");
const file = await open(input, "wx");
try {
// Sparse zero bytes are valid UTF-8 but invalid JSON. Both readers
// stream the input and reject the syntax before assembling a value.
// Sparse zero bytes are valid UTF-8. The Evidence reader reaches the
// decoded-length limit; the streamed CLI rejects their invalid JSON syntax.
await file.truncate(constants.MAX_STRING_LENGTH + 1);
} finally {
await file.close();
}
const limits = {
input_path: input,
input_bytes: constants.MAX_STRING_LENGTH + 1,
max_string_code_units: constants.MAX_STRING_LENGTH,
};
// Run readers sequentially in owned child processes, releasing each large
// read buffer when its process exits instead of retaining it in Vitest.
const result = await cli.run({
@@ -43,11 +48,11 @@ cliTest(
input,
]);
expect(JSON.parse(evidence.stdout)).toMatchObject({
code: "evidence_integrity_mismatch",
code: "resource_constraint",
details: {
operation: "read",
reason: "invalid-json",
path: input,
operation: "read_evidence_file",
resource: "memory",
reported_limits: limits,
},
});
expect(evidence.stderr).toBe("");
@@ -7,7 +7,6 @@ import { projectAnalysisError } from "../../../src/domain/analysisErrorProjectio
import { evmInterfaceSchema } from "../../../src/domain/evm/evmInterface.js";
import { EvmoleInterfaceProvider } from "../../../src/evm/EvmoleInterfaceProvider.js";
import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js";
import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js";
import { waitForProviderProcessReady } from "../../fixtures/providerProcess.js";
import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js";
import {
@@ -129,7 +128,6 @@ it.runIf(!unsupportedHost)(
async () => {
const { path } = await fixture();
let ownedPath = "";
let failCleanupReport = true;
const provider = new EvmoleInterfaceProvider({}, async (spawn) => {
ownedPath = spawn.cwd ?? "";
const launched = await spawnOwnedProviderProcess({
@@ -137,48 +135,24 @@ it.runIf(!unsupportedHost)(
command: process.execPath,
arguments: ["-e", 'process.stdout.write("x".repeat(1048577))'],
});
const ownership = launched.ownership;
if (ownership === undefined)
throw new Error("Owned launcher must expose process identity");
const cleanup =
launched.cleanup ?? (() => cleanupOwnedProcessGroup(ownership));
return {
...launched,
cleanup: async () => {
const result = await cleanup();
if (failCleanupReport)
throw new Error("injected post-cleanup reporting failure");
return result;
await launched.cleanup?.();
throw new Error("injected post-cleanup reporting failure");
},
};
});
onTestFinished(async () => {
failCleanupReport = false;
await provider.close();
});
const result = await provider.inspect({ path, encoding: "hex" });
if (result.ok) throw new Error("Expected compounded lifecycle failure");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cleanup_incomplete",
details: {
diagnostics: {
reason: "Owned EVM worker cleanup could not be confirmed",
previous_error: {
code: "cleanup_incomplete",
details: {
diagnostics: {
reason: "injected post-cleanup reporting failure",
previous_error: { failure_kind: "output-limit" },
captured_output: { truncated: true, stderr: "" },
},
},
},
},
expect(result.error).toMatchObject({
cleanupIncomplete: true,
diagnostics: {
reason: "injected post-cleanup reporting failure",
previous_error: { failure_kind: "output-limit" },
captured_output: { truncated: true, stderr: "" },
},
});
await access(ownedPath);
failCleanupReport = false;
await provider.close();
await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" });
},
);
@@ -69,35 +69,6 @@ describe("artifact inventory entry order", () => {
},
);
it("resolves deep ZIP members without inventing missing directories", async () => {
const root = await createTestTempDirectory("rea-deep-archive-parent-");
const archive = join(root, "deep.zip");
// Fits the ZIP filename field while exceeding the formerly expensive depth.
const members = ["data.txt", "second.txt", "third.bin"].map(
(name) => `observed/${"d/".repeat(30_000)}${name}`,
);
await writeOrderedZip(archive, [...members, "observed/", "unrelated/"]);
const inventory = await inventoryArtifact(archive);
expect(artifactParentPaths(inventory)).toEqual({
".": null,
observed: ".",
unrelated: ".",
...Object.fromEntries(members.map((member) => [member, "observed"])),
});
const observedParent = artifactOccurrenceAt(inventory, "observed");
for (const member of members) {
const occurrence = artifactOccurrenceAt(inventory, member);
expect(occurrence.hash_status).toBe("verified");
expect(inventory.edges).toContainEqual(
expect.objectContaining({
occurrence_id: occurrence.occurrence_id,
parent_artifact_id: observedParent.artifact_id,
relation: "contains",
}),
);
}
});
it.each([
{
entries: ["pkg/sub/data.txt"],
@@ -158,61 +129,6 @@ describe("entry-order validation and reader controls", () => {
}
});
it("retains high-cardinality case-folded directories and their descendants", async () => {
const root = await createTestTempDirectory(
"rea-entry-order-case-capacity-",
);
const archive = join(root, "case-capacity.zip");
const variants = Array.from({ length: 256 }, (_, mask) =>
[..."abcdefgh"]
.map((character, index) =>
(mask & (1 << index)) === 0 ? character : character.toUpperCase(),
)
.join(""),
);
await writeOrderedZip(
archive,
variants.flatMap((variant) => [variant + "/", variant + "/entry.txt"]),
);
const inventory = await inventoryArtifact(archive);
const directories = variants.map((variant) =>
artifactOccurrenceAt(inventory, variant),
);
const descendants = variants.map((variant) =>
artifactOccurrenceAt(inventory, `${variant}/entry.txt`),
);
const ordered = [...variants].sort();
const first = ordered[0];
const second = ordered[1];
expect(inventory.occurrences).toHaveLength(1 + variants.length * 2);
expect(
new Set(inventory.occurrences.map(({ logical_path }) => logical_path))
.size,
).toBe(1 + variants.length * 2);
expect(
new Set(
[...directories, ...descendants].map(
({ occurrence_id }) => occurrence_id,
),
).size,
).toBe(variants.length * 2);
expect(
directories.every(({ limitations }) => limitations.length === 1),
).toBe(true);
expect(
descendants.every(({ limitations }) => limitations.length === 1),
).toBe(true);
expect(
directories.find(({ logical_path }) => logical_path === first)
?.limitations[0],
).toContain(`with ${second} and 254 other spellings;`);
expect(descendants[0]?.limitations[0]).toContain(
`is under ${variants[0]}, which collides under English (en-US) Unicode case folding with ${first} and 254 other spellings;`,
);
});
it("preserves ordinary directory and ASAR structure", async () => {
const root = await createTestTempDirectory("rea-entry-order-readers-");
const directory = join(root, "source");
@@ -7,7 +7,7 @@ import { describe, expect, it, onTestFinished } from "vitest";
import { createCli } from "../../../src/cli.js";
import { parseCliJsonInput } from "../../../src/cliJsonInput.js";
import { readJsonInputFile } from "../../../src/application/JsonInputFile.js";
import { readCliJsonFile } from "../../../src/cliJsonFile.js";
import { analysisCliErrorEnvelopeSchema } from "../../../src/contracts/errorSchemas.js";
import { JSON_BYTE_ORDER_MARK_MESSAGE } from "../../../src/application/Utf8JsonInput.js";
import { readWithoutFifoWriter } from "../../fixtures/fifoInput.js";
@@ -203,11 +203,11 @@ describe("CLI JSON streamed input", () => {
const path = join(root, "input.json");
await writeFile(path, `${" ".repeat(9 * 1024 * 1024)}{"after":true}`);
const controller = new AbortController();
const cancelled = readJsonInputFile(path, "test-input", controller.signal);
const cancelled = readCliJsonFile(path, "test-input", controller.signal);
setImmediate(() => controller.abort(new Error("cancel JSON file parse")));
await expect(cancelled).rejects.toThrow("cancel JSON file parse");
await writeFile(path, '{"after":true}');
expect(await readJsonInputFile(path, "test-input")).toEqual({
expect(await readCliJsonFile(path, "test-input")).toEqual({
ok: true,
value: { after: true },
});
@@ -17,7 +17,6 @@ import { parse as parseJsonc, type ParseError } from "jsonc-parser";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js";
import { supportedClients } from "../../../src/application/SupportedClients.js";
import { PRODUCT_IDENTITY } from "../../../src/identity.js";
import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js";
import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js";
@@ -32,7 +31,6 @@ import {
} from "../../../src/application/Uninstall.js";
const roots: string[] = [];
const ownedSkillManifest = `---\nname: ${PRODUCT_IDENTITY.skillName}\n---\n# REA\n`;
beforeEach(clearClientLocationEnvironment);
afterEach(async () => {
vi.unstubAllEnvs();
@@ -1355,7 +1353,7 @@ describe("client configuration filesystem removal", () => {
},
}),
);
await writeFile(skill, ownedSkillManifest);
await writeFile(skill, "managed");
await mkdir(join(home, ".rea"), { recursive: true });
await import("node:fs/promises").then(({ symlink }) =>
symlink(home, join(home, ".rea/cache")),
@@ -1424,9 +1422,7 @@ describe("client configuration filesystem removal", () => {
readFile(`${configPath}.rea.backup`, "utf8"),
).rejects.toThrow();
});
});
describe("uninstall configuration preflight", () => {
it("fails closed on malformed client configuration", async () => {
const home = await createTestTempDirectory("rea-uninstall-bad-");
roots.push(home);
@@ -1451,12 +1447,8 @@ describe("uninstall configuration preflight", () => {
roots.push(home);
const codex = join(home, ".codex/config.toml");
const cursor = join(home, ".cursor/mcp.json");
const skillMarker = join(
home,
".agents/skills/reverse-engineer-anything/marker.txt",
);
const managed = [
skillMarker,
join(home, ".agents/skills/reverse-engineer-anything/marker.txt"),
join(home, ".rea/cache/marker.txt"),
join(home, ".rea/state/marker.txt"),
];
@@ -1471,8 +1463,6 @@ describe("uninstall configuration preflight", () => {
await writeFile(codex, "[mcp_servers.rea]\ncommand = [broken\n");
await writeFile(cursor, cursorConfig);
for (const path of managed) await writeFile(path, "marker");
const manifest = join(dirname(skillMarker), "SKILL.md");
await writeFile(manifest, ownedSkillManifest);
const stopped = await runUninstall(true, systemUninstallHost(home));
@@ -1488,7 +1478,6 @@ describe("uninstall configuration preflight", () => {
expect(await readFile(cursor, "utf8")).toBe(cursorConfig);
for (const path of managed)
expect(await readFile(path, "utf8")).toBe("marker");
expect(await readFile(manifest, "utf8")).toBe(ownedSkillManifest);
await writeFile(codex, "");
const repaired = await runUninstall(true, systemUninstallHost(home));
@@ -1496,12 +1485,7 @@ describe("uninstall configuration preflight", () => {
expect(JSON.parse(await readFile(cursor, "utf8"))).toEqual({
mcpServers: { other: { command: "echo", args: ["hello"] } },
});
// Uninstall removes only the managed bundle, never arbitrary skill files.
expect(await readFile(skillMarker, "utf8")).toBe("marker");
await expect(readFile(manifest, "utf8")).rejects.toMatchObject({
code: "ENOENT",
});
for (const path of managed.slice(1))
for (const path of managed)
await expect(readFile(path, "utf8")).rejects.toThrow();
});
});
@@ -1641,7 +1625,6 @@ describe("client configuration filesystem failure reporting", () => {
roots.push(home);
const skillRoot = join(home, ".agents/skills/reverse-engineer-anything");
await mkdir(skillRoot, { recursive: true });
await writeFile(join(skillRoot, "SKILL.md"), ownedSkillManifest);
const result = await runUninstall(
false,
systemUninstallHost(home, {
@@ -1654,14 +1637,11 @@ describe("client configuration filesystem failure reporting", () => {
name: "skill",
status: "failed",
detail: expect.stringContaining(
`Managed file could not be removed: ${join(skillRoot, "SKILL.md")}`,
"This item could not be removed. Check file permissions, then rerun uninstall.",
),
}),
);
expect(JSON.stringify(result)).not.toContain("SECRET");
expect(await readFile(join(skillRoot, "SKILL.md"), "utf8")).toBe(
ownedSkillManifest,
);
});
it("uninstalls all managed skill roots, including a custom Claude Code directory", async () => {
@@ -1673,11 +1653,7 @@ describe("client configuration filesystem failure reporting", () => {
join(customClaudeDirectory, "skills/reverse-engineer-anything"),
];
await Promise.all(
rootsToRemove.map(async (path) => {
await mkdir(path, { recursive: true });
await writeFile(join(path, "SKILL.md"), ownedSkillManifest);
await writeFile(join(path, "user-notes.txt"), "user-authored");
}),
rootsToRemove.map((path) => mkdir(path, { recursive: true })),
);
const result = await systemUninstallHost(home, testFileSystem, {
@@ -1687,12 +1663,7 @@ describe("client configuration filesystem failure reporting", () => {
expect(result).toMatchObject({ status: "removed" });
await Promise.all(
rootsToRemove.map(async (path) => {
await expect(
readFile(join(path, "SKILL.md"), "utf8"),
).rejects.toMatchObject({ code: "ENOENT" });
expect(await readFile(join(path, "user-notes.txt"), "utf8")).toBe(
"user-authored",
);
await expect(lstat(path)).rejects.toMatchObject({ code: "ENOENT" });
}),
);
});
@@ -157,8 +157,6 @@ describe("Qwen Code profile setup", () => {
expect(await setup.clientNeedsConfigure(client, {}, command)).toBe(false);
expect(await setup.skillNeedsInstall([client.name])).toBe(false);
expect(await setup.installSkill([client.name])).toBe("unchanged");
const userFile = join(skill, "user-note.txt");
await writeFile(userFile, "caller-owned skill notes\n");
expect(
(
await readClientRegistrationStatuses(
@@ -175,12 +173,7 @@ describe("Qwen Code profile setup", () => {
expect(registered).toEqual(client);
expect((await uninstall.removeClient(client)).status).toBe("removed");
expect((await uninstall.removeSkill()).status).toBe("removed");
await expect(access(join(skill, "SKILL.md"))).rejects.toMatchObject({
code: "ENOENT",
});
expect(await readFile(userFile, "utf8")).toBe(
"caller-owned skill notes\n",
);
await expect(access(skill)).rejects.toMatchObject({ code: "ENOENT" });
if (customHome)
await expect(access(join(home, ".qwen"))).rejects.toMatchObject({
code: "ENOENT",
@@ -84,14 +84,6 @@ it("reads a linked worktree's own branch and detached HEAD through shared Git re
head: mainHead,
dirty: null,
});
// A non-regular loose ref must remain unknown even when packed-refs has
// a readable value. The library's promise-API probe is not a source failure.
await mkdir(join(repository, ".git", "refs", "heads", "main"));
expect(await readReferenceSourceVcs(repository)).toEqual({
kind: "unknown",
head: null,
dirty: null,
});
const controller = new AbortController();
controller.abort();
expect(await readReferenceSourceVcs(linked, controller.signal)).toEqual({
@@ -324,7 +324,7 @@ it("cancels a waiting request promptly while keeping later launches behind the a
await assertFirmwareCleanup(fixture.launches);
});
it("retains an uncertain worker's workspace and blocks later launches", async () => {
it("retains an uncertain workspace and lets the next operation create a new root", async () => {
const fixture = await firmwareFixture("cleanup-failure");
const first = await fixture.service.execute("inspect_firmware_regions", {
path: fixture.path,
@@ -335,15 +335,18 @@ it("retains an uncertain worker's workspace and blocks later launches", async ()
});
const workspace = fixture.launches[0]?.cwd;
expect(workspace).toBeDefined();
if (workspace === undefined) throw new Error("Expected retained workspace");
if (workspace === undefined) return;
await access(workspace);
const acquiredLaunches = [...fixture.launches];
const next = await fixture.service.execute("inspect_firmware_regions", {
path: fixture.path,
});
expect(next).toMatchObject({ ok: false, error: { cleanupIncomplete: true } });
expect(fixture.launches).toEqual(acquiredLaunches);
expect(fixture.launches).toHaveLength(2);
const nextWorkspace = fixture.launches[1]?.cwd;
expect(nextWorkspace).toBeDefined();
expect(nextWorkspace).not.toBe(workspace);
await access(workspace);
if (nextWorkspace !== undefined) await access(nextWorkspace);
});
it("accepts another build on the verified release line and reports it", async () => {
@@ -567,9 +567,9 @@ it.skipIf(process.platform === "win32")(
},
);
it.skipIf(process.platform === "win32").each(["baseline", "tokens"] as const)(
"preserves cancellation through system-host %s inspection",
async (kind) => {
it.skipIf(process.platform === "win32")(
"preserves cancellation through system-host baseline identity inspection",
async () => {
const directory = await mkdtemp(
join(tmpdir(), "rea-process-baseline-abort-test-"),
);
@@ -579,23 +579,8 @@ it.skipIf(process.platform === "win32").each(["baseline", "tokens"] as const)(
});
const controller = new AbortController();
let helperRoot: string | undefined;
const inspection =
kind === "baseline"
? host.captureBaseline?.(controller.signal)
: host.runTokens?.(
[
{
pid: process.pid,
parentPid: process.ppid,
processGroupId: process.pid,
state: "S",
command: process.execPath,
},
],
controller.signal,
);
const baseline =
inspection?.then(
host.captureBaseline?.(controller.signal).then(
() => ({ state: "fulfilled" as const }),
(cause: unknown) => ({ state: "rejected" as const, cause }),
) ?? Promise.resolve({ state: "missing" as const });
+8 -10
View File
@@ -4,8 +4,10 @@ import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver
import { AndroidAnalysisService } from "../../../src/application/android/AndroidAnalysisService.js";
import { JadxProvider } from "../../../src/android/JadxProvider.js";
import { androidResultSchemas } from "../../../src/domain/android/androidAnalysis.js";
import { snapshotAndroidEngine } from "../../../src/android/AndroidTargetSnapshot.js";
import { hashStableFile } from "../../../src/filesystem/StableFileHash.js";
import {
hashAndroidFile,
snapshotAndroidEngine,
} from "../../../src/android/AndroidTargetSnapshot.js";
import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js";
import {
createJadxProtocolFixture as setup,
@@ -41,7 +43,7 @@ it("cancels Android admission while hashing the selected APK", async () => {
it("rejects a copied JAR that differs from its admitted session identity", async () => {
const { jar } = await setup();
const admitted = (await hashStableFile(jar)).sha256;
const admitted = await hashAndroidFile(jar);
await writeFile(jar, "engine changed between hashing and copying");
const root = await PrivateRuntimeRoot.create({
prefix: "rea-jadx-admission-",
@@ -91,9 +93,8 @@ it("reuses immutable engine state while keeping each Evidence observation separa
await verifyCleanup(launches);
});
it("bounds output per operation without repeating prerequisite probes for a retained session", async () => {
const { service, apk, launches, provider, javaProbeCount } =
await setup("large-manifest");
it("bounds output per operation across a retained session rather than accumulating earlier captures", async () => {
const { service, apk, launches, provider } = await setup("large-manifest");
const expectedManifest = "x".repeat(1024 * 1024);
for (let index = 0; index < 34; index += 1) {
const result = await service.execute("inspect_android_package", {
@@ -107,14 +108,12 @@ it("bounds output per operation without repeating prerequisite probes for a reta
).toBe(expectedManifest);
}
expect(launches).toHaveLength(1);
expect(await javaProbeCount()).toBe(1);
await provider.close();
await verifyCleanup(launches);
});
it("retire sessions when APK bytes, engine bytes or JVM options change", async () => {
const { service, apk, jar, launches, provider, environment, javaProbeCount } =
await setup();
const { service, apk, jar, launches, provider, environment } = await setup();
const inspect = async () => {
const result = await service.execute("inspect_android_package", {
path: apk,
@@ -136,7 +135,6 @@ it("retire sessions when APK bytes, engine bytes or JVM options change", async (
environment._JAVA_OPTIONS = "-XX:+UseG1GC";
await inspect();
expect(launches).toHaveLength(4);
expect(await javaProbeCount()).toBe(4);
expect(launches[3]).toMatchObject({
javaToolOptions: "-Xmx8g",
legacyJavaOptions: "-XX:+UseG1GC",
@@ -59,10 +59,7 @@ const expectUnverifiedHostCleanup = (error: ProcessCaptureError): void => {
/^(?:[1-9][0-9]*=(?:environment_unavailable|environment_errno_(?:EACCES|EPERM)))(?:, [1-9][0-9]*=(?:environment_unavailable|environment_errno_(?:EACCES|EPERM)))*$/u,
);
expect(report?.terminal_renderer.state).toBe("cleaned");
expect(report?.temporary_root).toEqual({
state: "unverified",
reason: `temporary root retained because owned process cleanup is unverified: ${report?.owned_process_group.reason}`,
});
expect(report?.temporary_root.state).toBe("cleaned");
};
const captureObservations = (
@@ -278,10 +275,7 @@ itWithCaptureCapability(
const projected = projectAnalysisError(result.error);
expect(projected).toMatchObject({
code: "cleanup_incomplete",
details: {
cleanup: "incomplete",
resources: ["owned_process_group", "temporary_root"],
},
details: { cleanup: "incomplete", resources: ["owned_process_group"] },
});
expect(projected).not.toHaveProperty("stack");
expect(projected).not.toHaveProperty("cause");
@@ -169,13 +169,7 @@ itWithCaptureCapability.each(["cli", "mcp"] as const)(
await writeFile(join(root, "z.txt"), "unchanged");
const scenario = {
executable: process.execPath,
// Delete the known file and overflow the remaining directory capacity.
// Incomplete enumeration still cannot prove its absence, regardless of
// which new entry the filesystem returns first.
arguments: [
"-e",
"const fs=require('node:fs');fs.unlinkSync('z.txt');fs.writeFileSync('a.txt','added');fs.writeFileSync('b.txt','added');",
],
arguments: ["-e", "require('node:fs').writeFileSync('a.txt','added')"],
working_directory: root,
filesystem_observation_paths: [root],
limits: { files: 2 },
@@ -185,9 +179,7 @@ itWithCaptureCapability.each(["cli", "mcp"] as const)(
);
const capture = parseProcessCapture(evidence.normalized_result);
expect(capture.truncated).toBe(true);
await expect(readFile(join(root, "z.txt"), "utf8")).rejects.toMatchObject({
code: "ENOENT",
});
expect(await readFile(join(root, "z.txt"), "utf8")).toBe("unchanged");
expect(
capture.filesystem_effects.find(({ path }) => path === "root_0:z.txt")
?.status,
@@ -194,14 +194,9 @@ itWithCaptureCapability.each(["cli", "mcp"] as const)(
expect(details.filesystem_after.enumeration_reasons).toContain(
"files_limit",
);
// The truncated subset follows filesystem enumeration order. The large
// file may be retained without a digest, or not enumerated at all.
const largeFile = result.files_after.find(
({ path }) => path === "root_0:b.txt",
);
if (largeFile !== undefined) expect(largeFile.sha256).toBeNull();
expect(details.filesystem_after.enumeration_truncated).toBe(true);
expect(result.files_after.length).toBeLessThanOrEqual(3);
expect(
result.files_after.find(({ path }) => path === "root_0:b.txt")?.sha256,
).toBeNull();
expect(processSourceTruncated(result, "terminal_rendered")).toBe(true);
expect(compareProcessCaptures(result, result)).toMatchObject({
terminal: "unknown",
@@ -1,7 +1,7 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it, onTestFinished } from "vitest";
import { describe, expect, it } from "vitest";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
@@ -40,7 +40,6 @@ describe("artifact Mach-O slices", () => {
),
};
const reader = new MachOSliceArtifactReader(binary, {}, runner);
onTestFinished(() => reader.close());
const enumerate = async (): Promise<void> => {
for await (const _entry of reader.entries()) {
// Enumeration must reject out-of-bounds lipo metadata before yielding.
@@ -79,7 +78,6 @@ describe("artifact Mach-O slices", () => {
),
};
const reader = new MachOSliceArtifactReader(binary, {}, runner);
onTestFinished(() => reader.close());
const entries = [];
for await (const entry of reader.entries()) entries.push(entry);
expect(entries).toHaveLength(2);
@@ -107,9 +105,7 @@ describe("artifact Mach-O slices", () => {
Reflect.set(provenance[0], "tool", "forged");
expect(reader.provenance()[0]?.tool).toBe("lipo");
});
});
describe("artifact Mach-O slice integrity", () => {
it.each([
["CPU type", "cputype 16777223"],
["CPU subtype", "cpusubtype 2"],
@@ -143,7 +139,6 @@ describe("artifact Mach-O slice integrity", () => {
{},
lipoRunner(`${details.join("\n")}\n`),
);
onTestFinished(() => reader.close());
await expect(async () => {
for await (const _entry of reader.entries()) {
// The structural comparison runs before the reader yields a slice.
@@ -166,7 +161,6 @@ describe("artifact Mach-O slice integrity", () => {
`architecture arm64\n cputype 16777228\n cpusubtype 0\n offset 4096\n size ${thin.length}\n align 2^12 (4096)\n`,
),
);
onTestFinished(() => reader.close());
await expect(async () => {
for await (const _entry of reader.entries()) {
// Malformed structural facts must fail before any slice is yielded.
@@ -221,7 +215,6 @@ describe("artifact Mach-O arm64e variant slices", () => {
`architecture x86_64\n cputype CPU_TYPE_X86_64\n cpusubtype CPU_SUBTYPE_X86_64_ALL\n offset 4096\n size ${String(x86.length)}\n align 2^12 (4096)\n${lipo(arm.length)}`,
),
);
onTestFinished(() => reader.close());
const entries = [];
for await (const entry of reader.entries()) entries.push(entry);
return entries.map(({ path, byteOffset }) => [path, byteOffset]);
@@ -72,16 +72,12 @@ describe("static Electron application analysis", () => {
const result = await reconstructJavaScriptArtifact({ input_path: root });
const graph = parseJavaScriptApplicationGraph(result.graph);
// Computed access keeps no known members; the unknown access is an
// explicit dynamic flag rather than a "*" sentinel inside members.
const accesses = graph.nodes.flatMap((node) =>
const requestedMembers = graph.nodes.flatMap((node) =>
node.kind === "native-export"
? node.observations.map(({ properties }) => ({
members: Array.isArray(properties.requested_members)
? properties.requested_members
: [],
dynamicMemberAccess: properties.dynamic_member_access ?? false,
}))
? node.observations.flatMap(({ properties }) => {
const members = properties.requested_members;
return Array.isArray(members) ? members : [];
})
: [],
);
@@ -90,7 +86,7 @@ describe("static Electron application analysis", () => {
native_addon_bindings: 1,
resolved_native_addon_bindings: 1,
});
expect(accesses).toEqual([{ members: [], dynamicMemberAccess: true }]);
expect(requestedMembers).toEqual(["*"]);
});
it("maps Electron boundaries through bundler-renamed bindings", async () => {
@@ -186,40 +186,6 @@ it("matches renderer, frame, script bytes, and worker without claiming execution
);
});
it("keeps static load states unknown when one scoped capture omits scripts", async () => {
const fixture = await applicationFixture();
const staticEvidence = await analyzeFixture(fixture);
const result = reconcileInput({
static_layers: [{ role: "application", analysis: staticEvidence }],
runtime_observations: [
electronRuntimeEvidence(fixture, SOURCE, {
includeWorker: false,
targetId: "target-complete",
}),
electronRuntimeEvidence(fixture, SOURCE, {
includeWorker: false,
scriptsUnavailable: true,
targetId: "target-incomplete",
}),
],
});
expect(
result.runtime_captures.map(
({ scripts_complete_within_scope }) => scripts_complete_within_scope,
),
).toEqual(expect.arrayContaining([false, true]));
expect(result.summary.static_not_observed).toBe(0);
expect(result.static_load_states).toEqual(
expect.arrayContaining([
expect.objectContaining({
status: "unknown",
reason: "static-or-runtime-coverage-incomplete",
}),
]),
);
});
it("reports a captured digest disagreement instead of accepting a path match", async () => {
const fixture = await applicationFixture();
const staticEvidence = await analyzeFixture(fixture);
@@ -350,7 +316,7 @@ it("imports an operator-provided cache layer through an explicit file mapping",
const application = await applicationFixture();
const cache = await createTestTempDirectory("rea-runtime-cache-static-");
const runtimeCache = await createTestTempDirectory("rea-runtime-cache-live-");
const cacheSource = SOURCE;
const cacheSource = "export const cachedFeature = 'fixture';\n";
await mkdir(join(cache, "mapped"));
await Promise.all([
writeFile(join(cache, "mapped", "chunk.js"), cacheSource),
@@ -407,16 +373,6 @@ it("imports an operator-provided cache layer through an explicit file mapping",
status: "unknown",
reason: "layer-outside-runtime-scope",
});
const sharedFile = result.graph.nodes.find(
({ observations }) =>
observations.some(({ properties }) => properties.path === "app.js") &&
observations.some(
({ properties }) => properties.path === "mapped/chunk.js",
),
);
expect(
sharedFile?.observations.map(({ properties }) => properties.path),
).toEqual(expect.arrayContaining(["app.js", "mapped/chunk.js"]));
});
const applicationFixture = async (): Promise<string> => {
@@ -454,7 +410,6 @@ const electronRuntimeEvidence = (
readonly targetId?: string;
readonly sourceIncluded?: boolean;
readonly workersUnavailable?: boolean;
readonly scriptsUnavailable?: boolean;
} = {},
) => {
const scriptFile = options.scriptFile ?? "app.js";
@@ -490,7 +445,15 @@ const electronRuntimeEvidence = (
ended_at: "2026-07-15T00:00:00.100Z",
observation_ms: 100,
},
completeness: captureCompleteness(options),
completeness: options.workersUnavailable
? {
...completeCapture(),
status: "attach_limited" as const,
conditions: ["attach_limited" as const],
attach_limited_sections: ["workers" as const],
unavailable_sections: ["workers" as const],
}
: completeCapture(),
frames: [
{
frame_id: "frame-main",
@@ -559,21 +522,3 @@ const completeCapture = () => ({
total: 0,
},
});
const captureCompleteness = (options: {
readonly workersUnavailable?: boolean;
readonly scriptsUnavailable?: boolean;
}) => {
const unavailableSections = [
...(options.scriptsUnavailable ? (["scripts"] as const) : []),
...(options.workersUnavailable ? (["workers"] as const) : []),
];
if (unavailableSections.length === 0) return completeCapture();
return {
...completeCapture(),
status: "attach_limited" as const,
conditions: ["attach_limited" as const],
attach_limited_sections: unavailableSections,
unavailable_sections: unavailableSections,
};
};
+2 -14
View File
@@ -1,11 +1,4 @@
import {
access,
chmod,
mkdir,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { access, chmod, mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, onTestFinished } from "vitest";
@@ -34,11 +27,10 @@ export const createJadxProtocolFixture = async (
const javaHome = join(root, "jdk");
const javaBin = join(javaHome, "bin");
const java = join(javaBin, "java");
const javaProbeLog = join(root, "java-probes.log");
await mkdir(javaBin, { recursive: true });
await writeFile(
java,
`#!/usr/bin/env node\nif (process.argv[2] === "--list-modules") { require("node:fs").appendFileSync(${JSON.stringify(javaProbeLog)}, "probe\\n"); console.log("java.base@21.0.0\\njdk.compiler@21.0.0"); }\n`,
`#!/usr/bin/env node\nif (process.argv[2] === "--list-modules") console.log("java.base@21.0.0\\njdk.compiler@21.0.0");\n`,
);
await chmod(java, 0o755);
await writeOrderedZip(apk, ["AndroidManifest.xml", "classes.dex"]);
@@ -123,10 +115,6 @@ export const createJadxProtocolFixture = async (
jar,
environment,
launches,
javaProbeCount: async () => {
const contents = await readFile(javaProbeLog, "utf8");
return contents.split("\n").filter(Boolean).length;
},
};
};
-1
View File
@@ -143,7 +143,6 @@ export const startFakeCdpBrowser = async (
browserWebSocketUrl: `ws://127.0.0.1:${String(port)}/devtools/browser/fake`,
allowedOrigin: endpoint,
commands,
activeConnectionCount: () => sockets.size,
emitRawMessage(message) {
for (const socket of sockets) socket.send(message);
},
-2
View File
@@ -11,8 +11,6 @@ export interface FakeCdpBrowser {
readonly browserWebSocketUrl: string;
readonly allowedOrigin: string;
readonly commands: readonly FakeCdpCommand[];
/** Actual WebSocket connections still held by the wire fixture. */
activeConnectionCount(): number;
/** Deliver actual malformed wire data without bypassing the production transport parser. */
emitRawMessage(message: string): void;
emitEvent(event: {
+1 -10
View File
@@ -148,7 +148,6 @@ describe("existing REA integration maintenance", () => {
).resolves.toEqual({
clients: ["codex", "vscode"],
skill: false,
skillDestinations: [],
});
});
@@ -158,7 +157,6 @@ describe("existing REA integration maintenance", () => {
).resolves.toEqual({
clients: [],
skill: false,
skillDestinations: [],
});
await writeSkill("---\nname: another-skill\n---\nUser-authored content\n");
await expect(
@@ -166,7 +164,6 @@ describe("existing REA integration maintenance", () => {
).resolves.toEqual({
clients: [],
skill: false,
skillDestinations: [],
});
});
@@ -180,12 +177,6 @@ describe("existing REA integration maintenance", () => {
).resolves.toEqual({
clients: [],
skill: true,
skillDestinations: [
{
client: "shared",
path: join(home, ".agents", "skills", PRODUCT_IDENTITY.skillName),
},
],
});
expect(await readFile(path, "utf8")).toBe(original);
});
@@ -233,7 +224,7 @@ describe("existing REA integration maintenance", () => {
]);
expect(result).toEqual({
status: "planned",
scope: { clients: ["codex"], skill: false, skillDestinations: [] },
scope: { clients: ["codex"], skill: false },
command: recorded[0]?.slice(0, -2),
plannedActions: [action],
});