refactor: consolidate shared analysis boundaries (#10293)

This commit is contained in:
morluto authored and GitHub committed 2026-11-12 22:05:32 -05:00
1 parent 6878a84796
commit c836f57b7d
70 files changed
+1240 -1131

No files matched your search

-9
View File
@@ -958,15 +958,6 @@ class _BridgeServer(threading.Thread):
proof.update(fragment.encode("utf-8"))
return proof.hexdigest()
def _encode_authenticated_response(
self, value: dict[str, Any], client_nonce: str, server_nonce: str
) -> bytes | None:
"""Return one exact frame. The socket path writes the same pieces."""
proof = self._authenticated_proof(value, client_nonce, server_nonce)
if proof is None:
return None
return b"".join(self._iter_authenticated_frame(value, proof))
def _iter_authenticated_frame(self, value: dict[str, Any], proof: str):
yield _AUTHENTICATED_PREFIX
for fragment in _response_fragments(value):
@@ -1,15 +1,9 @@
import { validateRegistrationEnvironment } from "./RegistrationEnvironmentValidation.js";
import { clientRegistrationSource } from "./ClientRegistrationSource.js";
import type { SetupClient } from "./SupportedClients.js";
import {
inspectAnalysisSettingSeeds,
launchAnalysisSetting,
} from "../config/analysisSettings.js";
import {
parseEnvironment,
parseEnvironmentValues,
type Environment,
} from "../config/environment.js";
import { launchAnalysisSetting } from "../config/analysisSettings.js";
import type { Environment } from "../config/environment.js";
import {
DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS,
DEFAULT_HOPPER_STARTUP_TIMEOUT_MS,
@@ -314,34 +308,24 @@ export const clientRegistrationEnvironmentDetailsWithBudget = async (
const validatedDetails = (
environment: Readonly<Record<string, string>>,
validation: "fragment" | "effective",
platform: NodeJS.Platform = process.platform,
expressionSettings: ReadonlySet<string> = new Set(),
platform: NodeJS.Platform,
expressionSettings: ReadonlySet<string>,
): Result<ClientRegistrationEnvironmentDetails, ConfigurationError> => {
const runtimeEnvironment = snapshotEnvironment(environment, platform);
const unresolved = new Set(
Object.keys(
snapshotEnvironment(
Object.fromEntries([...expressionSettings].map((name) => [name, ""])),
platform,
),
),
const validated = validateRegistrationEnvironment(
environment,
expressionSettings,
validation,
platform,
);
const validated =
validation === "fragment"
? parseEnvironmentValues(runtimeEnvironment, unresolved)
: parseEnvironment(runtimeEnvironment, unresolved);
if (!validated.ok) return validated;
const seedIssues = inspectAnalysisSettingSeeds(
runtimeEnvironment,
unresolved,
);
if (seedIssues.length > 0)
return err(
new ConfigurationError("Invalid REA environment configuration", {
settings: seedIssues,
}),
);
return ok(environmentDetails(environment, validated.value, unresolved));
return validated.ok
? ok(
environmentDetails(
environment,
validated.value.configuration,
validated.value.unresolvedSettings,
),
)
: validated;
};
const admittedTimeout = (
@@ -397,7 +381,7 @@ const registrationTimeoutInputs = (
const environmentDetails = (
environment: Readonly<Record<string, string>>,
configuration: Environment,
unresolvedSettings: ReadonlySet<string> = new Set(),
unresolvedSettings: ReadonlySet<string>,
): ClientRegistrationEnvironmentDetails => ({
values: Object.fromEntries(
Object.entries(environment).sort(([left], [right]) =>
+1 -1
View File
@@ -649,7 +649,7 @@ const packageVersionRemediation = (
const unavailableStatus = (
client: SetupClient,
state: "missing" | "invalid",
configPath: string = client.configPath,
configPath: string,
): ClientRegistrationStatus => ({
client: client.name,
config_path: configPath,
+11 -38
View File
@@ -1,18 +1,11 @@
import { inspectAnalysisSettingSeeds } from "../config/analysisSettings.js";
import { validateRegistrationEnvironment } from "./RegistrationEnvironmentValidation.js";
import { isMap, isSeq, type Node, type YAMLMap } from "yaml";
import {
parseEnvironment,
parseEnvironmentValues,
type Environment,
} from "../config/environment.js";
import type { Environment } from "../config/environment.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { err, ok, type Result } from "../domain/result.js";
import type { ClientConfigurationDocument } from "./ClientConfigurationDocument.js";
import { CordisExpression, DshCordisYaml } from "./DshCordisYaml.js";
import {
mergeEnvironmentOverrides,
snapshotEnvironment,
} from "../process/snapshotEnvironment.js";
import { mergeEnvironmentOverrides } from "../process/snapshotEnvironment.js";
export const DSH_ENTRY_ID = "rea-mcp";
export const DSH_CLIENT_PACKAGE = "@deepseek-ai/dsh-mcp-client";
@@ -255,35 +248,15 @@ export class DshCordisDocument {
);
literals[key] = value;
}
const runtimeEnvironment = snapshotEnvironment(literals, platform);
const unresolved = new Set(
Object.keys(
snapshotEnvironment(
Object.fromEntries([...unresolvedSettings].map((key) => [key, ""])),
platform,
),
),
const validated = validateRegistrationEnvironment(
literals,
unresolvedSettings,
validation,
platform,
);
const validated =
validation === "fragment"
? parseEnvironmentValues(runtimeEnvironment, unresolved)
: parseEnvironment(runtimeEnvironment, unresolved);
if (!validated.ok) return validated;
const seedIssues = inspectAnalysisSettingSeeds(
runtimeEnvironment,
unresolved,
);
if (seedIssues.length > 0)
return err(
new ConfigurationError("Invalid REA environment configuration", {
settings: seedIssues,
}),
);
return ok({
values: literals,
configuration: validated.value,
unresolvedSettings: unresolved,
});
return validated.ok
? ok({ values: literals, ...validated.value })
: validated;
}
desired(
+4 -18
View File
@@ -1,3 +1,4 @@
import { readElfAttributeUleb } from "../domain/elfAttributeUleb.js";
import type { MipsGnuAttributes } from "../domain/binaryTargetTypes.js";
import { err, ok, type Result } from "../domain/result.js";
@@ -22,21 +23,6 @@ export const parseMipsGnuAttributes = (
const readU32 = (offset: number): number =>
littleEndian ? bytes.readUInt32LE(offset) : bytes.readUInt32BE(offset);
const readUleb = (
offset: number,
end: number,
): { readonly value: number; readonly next: number } | undefined => {
let value = 0;
for (let index = 0; index < 5 && offset + index < end; index += 1) {
const byte = bytes[offset + index]!;
value += (byte & 0x7f) * 2 ** (7 * index);
if ((byte & 0x80) === 0)
return value <= 0xffffffff
? { value, next: offset + index + 1 }
: undefined;
}
return undefined;
};
let fpAbi: number | null = null;
let msa: number | null = null;
@@ -65,7 +51,7 @@ export const parseMipsGnuAttributes = (
let cursor = vendorEnd + 1;
while (cursor < end) {
const scopeStart = cursor;
const scope = readUleb(cursor, end);
const scope = readElfAttributeUleb(bytes, cursor, end);
if (scope === undefined)
return err("malformed MIPS GNU attribute scope tag");
if (scope.value !== TAG_FILE)
@@ -85,7 +71,7 @@ export const parseMipsGnuAttributes = (
const scopeEnd = scopeStart + scopeLength;
cursor = scope.next + 4;
while (cursor < scopeEnd) {
const tag = readUleb(cursor, scopeEnd);
const tag = readElfAttributeUleb(bytes, cursor, scopeEnd);
if (tag === undefined) return err("malformed MIPS GNU attribute tag");
if (
tag.value !== TAG_GNU_MIPS_ABI_FP &&
@@ -94,7 +80,7 @@ export const parseMipsGnuAttributes = (
return err(
`MIPS ELF declares GNU attribute tag ${tag.value}, which REA does not inspect`,
);
const value = readUleb(tag.next, scopeEnd);
const value = readElfAttributeUleb(bytes, tag.next, scopeEnd);
if (value === undefined)
return err("malformed MIPS GNU attribute value");
if (tag.value === TAG_GNU_MIPS_ABI_FP) {
@@ -0,0 +1,49 @@
import { inspectAnalysisSettingSeeds } from "../config/analysisSettings.js";
import {
parseEnvironment,
parseEnvironmentValues,
type Environment,
} from "../config/environment.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { err, ok, type Result } from "../domain/result.js";
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
/** Validate literal registration settings while retaining expression-owned unknowns. */
export const validateRegistrationEnvironment = (
values: Readonly<Record<string, string>>,
expressionSettings: ReadonlySet<string>,
validation: "fragment" | "effective",
platform: NodeJS.Platform,
): Result<
{
readonly configuration: Environment;
readonly unresolvedSettings: ReadonlySet<string>;
},
ConfigurationError
> => {
const runtimeEnvironment = snapshotEnvironment(values, platform);
const unresolvedSettings = new Set(
Object.keys(
snapshotEnvironment(
Object.fromEntries([...expressionSettings].map((name) => [name, ""])),
platform,
),
),
);
const validated =
validation === "fragment"
? parseEnvironmentValues(runtimeEnvironment, unresolvedSettings)
: parseEnvironment(runtimeEnvironment, unresolvedSettings);
if (!validated.ok) return validated;
const seedIssues = inspectAnalysisSettingSeeds(
runtimeEnvironment,
unresolvedSettings,
);
if (seedIssues.length > 0)
return err(
new ConfigurationError("Invalid REA environment configuration", {
settings: seedIssues,
}),
);
return ok({ configuration: validated.value, unresolvedSettings });
};
+2 -2
View File
@@ -420,8 +420,8 @@ export const validateCanonicalSkillDirectories = async (
const canonicalSkillFiles = async (
home: string,
clientIds: readonly string[],
environment: Readonly<NodeJS.ProcessEnv> = {},
platform: NodeJS.Platform = process.platform,
environment: Readonly<NodeJS.ProcessEnv>,
platform: NodeJS.Platform,
resources?: ArtifactResourceScope,
): Promise<readonly CanonicalSkillFile[]> => {
const destinations = skillDestinations(
+1 -1
View File
@@ -872,7 +872,7 @@ const readClientConfiguration = async (
client: UninstallClient,
fileSystem: UninstallFileSystem,
resources: ArtifactResourceScope,
ownershipEstablished = false,
ownershipEstablished: boolean,
): Promise<ClientConfigurationRead> => {
const applicabilityUnverified =
client.applicability === "unverified" && !ownershipEstablished;
@@ -17,7 +17,7 @@ import {
AnalysisCapabilityUnavailableError,
} from "../../domain/analysisErrorCore.js";
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
import { validateAnalysisViewSubject } from "../../domain/analysisView/analysisViewParent.js";
import { safeParseEvidence } from "../../domain/evidenceValidation.js";
import {
createEvidence,
@@ -249,15 +249,11 @@ const projectResolvedAnalysisView = (
},
});
if (!projected.ok) return projected;
if (
parent.subject !== null &&
parent.subject.digest.sha256 !== projected.value.artifact?.sha256
)
return err(
new EvidenceIntegrityError(
`Parent subject SHA-256 ${parent.subject.digest.sha256} differs from analysis artifact SHA-256 ${projected.value.artifact?.sha256 ?? "unknown"}.`,
),
);
const identity = validateAnalysisViewSubject(
parent.subject,
projected.value.artifact?.sha256,
);
if (!identity.ok) return identity;
return ok(createAnalysisViewEvidence(input, presentation, projected.value));
};
@@ -60,7 +60,7 @@ interface PrepareSessionExecutionInput {
/** Recovery for an operation absent from the selected binary route. */
const undeclaredOperationGuidance = (
operation: string,
entryCommentAvailable = false,
entryCommentAvailable: boolean,
): string => {
const entryComment =
entryCommentAvailable &&
@@ -235,10 +235,6 @@ type CheckpointTaken = Omit<
readonly more_changed_files: number | null;
};
/** The capture's progress event in a line the controller waited for. */
const eventOf = (line: string): DesktopCaptureEvent | undefined =>
captureProgressEvent(line);
/**
* One capture in the computer. `unreleased` is a capture that may be
* running although its start failed, kept until a stop is confirmed;
@@ -441,7 +437,8 @@ export class DesktopBuildCapture {
),
answer,
});
const event = answer.line === undefined ? undefined : eventOf(answer.line);
const event =
answer.line === undefined ? undefined : captureProgressEvent(answer.line);
if (event?.kind !== "window_open")
return err({
failure: await endedCapture(
@@ -501,7 +498,9 @@ export class DesktopBuildCapture {
{ timeoutS: 600, signal },
);
const event =
answer.line === undefined ? undefined : eventOf(answer.line);
answer.line === undefined
? undefined
: captureProgressEvent(answer.line);
if (event?.kind === "checkpoint_refused")
return err(refused(MARK, event.reason, "Use another checkpoint name."));
if (event?.kind !== "checkpoint")
+3 -5
View File
@@ -4,14 +4,12 @@ import { jsonValueSchema } from "../domain/jsonValue.js";
import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js";
import { OwnedCommandFailure } from "../process/OwnedCommand.js";
import {
DARWIN_PROCESS_OWNERSHIP_REMEDIATION,
LINUX_PROCESS_OWNERSHIP_REMEDIATION,
LinuxOwnedProcessPrerequisiteError,
} from "../process/ProcessOwnershipObservation.js";
import type { ProviderAvailability } from "./AnalysisProvider.js";
const DARWIN_REMEDIATION =
"Check the selected Xcode or Command Line Tools and Swift compiler configuration, then restart REA and retry.";
// Provider adapters re-attribute capability errors and keep the original as cause.
const prerequisiteRefusal = (
cause: unknown,
@@ -94,8 +92,8 @@ export const ownedProcessPrerequisiteAvailability = (
return {
status: "unavailable",
code: "runtime_unavailable",
reason: `${darwin.message}. ${DARWIN_REMEDIATION}`,
remediation: DARWIN_REMEDIATION,
reason: `${darwin.message}. ${DARWIN_PROCESS_OWNERSHIP_REMEDIATION}`,
remediation: DARWIN_PROCESS_OWNERSHIP_REMEDIATION,
diagnostics: {
stage: "ownership_preparation",
...(darwin.compilerOutcome === undefined
+2 -7
View File
@@ -20,6 +20,7 @@ import { processScenarioSchema } from "../../domain/process/processScenario.js";
import {
compareParsedProcessEvidence,
readProcessComparisonSource,
unsupportedProcessCaptureProvider,
} from "./ProcessComparisonService.js";
import { createProgressReporter } from "../ProgressReporter.js";
import { captureProcessScenario } from "../../process/capture/ProcessHarness.js";
@@ -287,13 +288,7 @@ const projectCaptureFileFailure = (
"invalid_value",
);
case "unsupported-provider":
return new AnalysisInputError("compare_process_captures", undefined, [
{
path: [side, "provider"],
reason: "invalid_value",
message: `The ${side} Evidence provider ${failure.provider.id} v${failure.provider.version} (${failure.provider.name}) is not the current or supported legacy ${PROCESS_PROVIDER.id} v${PROCESS_PROVIDER.version} process capture provider.`,
},
]);
return unsupportedProcessCaptureProvider(side, failure);
case "invalid-capture":
return invalidCaptureEvidence(failure.cause, side);
}
@@ -124,15 +124,7 @@ export const readProcessComparisonSource = (
]),
);
case "unsupported-provider":
return err(
new AnalysisInputError(operation, undefined, [
{
path: [side, "provider"],
reason: "invalid_value",
message: `The ${side} Evidence provider ${failure.provider.id} v${failure.provider.version} (${failure.provider.name}) is not the current or supported legacy ${PROCESS_PROVIDER.id} v${PROCESS_PROVIDER.version} process capture provider.`,
},
]),
);
return err(unsupportedProcessCaptureProvider(side, failure));
case "invalid-capture":
return err(
new EvidenceIntegrityError(
@@ -146,6 +138,22 @@ export const readProcessComparisonSource = (
}
};
/** Report the same provider admission failure at both public adapters. */
export const unsupportedProcessCaptureProvider = (
side: "left" | "right",
failure: Extract<
ProcessCaptureEvidenceFailure,
{ kind: "unsupported-provider" }
>,
): AnalysisInputError =>
new AnalysisInputError("compare_process_captures", undefined, [
{
path: [side, "provider"],
reason: "invalid_value",
message: `The ${side} Evidence provider ${failure.provider.id} v${failure.provider.version} (${failure.provider.name}) is not the current or supported legacy ${PROCESS_PROVIDER.id} v${PROCESS_PROVIDER.version} process capture provider.`,
},
]);
const comparisonUnknownInput = (
parsed: {
readonly left_evidence_id: string;
+55
View File
@@ -0,0 +1,55 @@
import type { FileHandle } from "node:fs/promises";
import { createHash } from "node:crypto";
import { readFileHandleRange } from "../filesystem/readFileHandleRange.js";
import { abortIfNeeded } from "./ArtifactHash.js";
interface ArtifactRangeCarrier {
readonly handle: FileHandle;
readonly fileSize: number;
readonly signal: AbortSignal | undefined;
}
/** Read exactly one bounded carrier range, returning undefined for a short read. */
export const readArtifactRange = async (
carrier: ArtifactRangeCarrier,
offset: number,
length: number,
): Promise<Buffer | undefined> => {
abortIfNeeded(carrier.signal);
if (
!Number.isSafeInteger(offset) ||
!Number.isSafeInteger(length) ||
offset < 0 ||
length < 0 ||
offset > carrier.fileSize ||
length > carrier.fileSize - offset
)
return undefined;
const bytes = await readFileHandleRange(carrier.handle, offset, length, () =>
abortIfNeeded(carrier.signal),
);
// The shared reader reports EOF before a late cancellation. These carrier
// readers have always observed cancellation after every read, including EOF.
abortIfNeeded(carrier.signal);
return bytes.length === length ? bytes : undefined;
};
/** Hash one carrier range in bounded chunks; undefined means it was truncated. */
export const hashArtifactRange = async (
carrier: ArtifactRangeCarrier,
offset: number,
length: number,
): Promise<string | undefined> => {
const hash = createHash("sha256");
let remaining = length;
let position = offset;
while (remaining > 0) {
const chunkLength = Math.min(64 * 1024, remaining);
const chunk = await readArtifactRange(carrier, position, chunkLength);
if (chunk === undefined) return undefined;
hash.update(chunk);
position += chunkLength;
remaining -= chunkLength;
}
return hash.digest("hex");
};
+81
View File
@@ -0,0 +1,81 @@
import { expect, it, vi } from "vitest";
import { ArtifactPreparation } from "./ArtifactPreparation.js";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
it.each(["physical", "cleanup", "waiter-cancelled"] as const)(
"does not retry another caller's %s failure",
async (kind) => {
const owner = new ArtifactPreparation();
const first = new AbortController();
const waiter = new AbortController();
const reason = new Error("first caller cancelled");
let reject!: (cause: unknown) => void;
const pending = new Promise<void>((_resolve, rejectPromise) => {
reject = rejectPromise;
});
const prepare = vi.fn(() => pending);
const assertOpen = vi.fn();
const initial = owner.run(first.signal, prepare, assertOpen);
const peer = owner.run(waiter.signal, prepare, assertOpen);
first.abort(reason);
if (kind === "waiter-cancelled") waiter.abort("waiter stopped");
const failure =
kind === "physical"
? new Error("read failed")
: new ArtifactReaderFailure("cancelled", "cancelled", {
cause: reason,
...(kind === "cleanup"
? { cleanup: { reason: "close failed", resources: ["file"] } }
: {}),
});
reject(failure);
await expect(initial).rejects.toBe(failure);
await expect(peer).rejects.toBe(failure);
expect(prepare).toHaveBeenCalledTimes(1);
expect(assertOpen).not.toHaveBeenCalled();
// A later call can try again after the failed shared preparation settles.
await expect(
owner.run(
undefined,
async () => {},
() => {},
),
).resolves.toBeUndefined();
},
);
it("shares one replacement when multiple callers survive a peer cancellation", async () => {
const owner = new ArtifactPreparation();
const first = new AbortController();
const reason = new Error("first caller cancelled");
let reject!: (cause: unknown) => void;
const initialWork = new Promise<void>((_resolve, rejectPromise) => {
reject = rejectPromise;
});
let complete!: () => void;
const replacement = new Promise<void>((resolve) => {
complete = resolve;
});
const retry = vi.fn(() => replacement);
const initial = owner.run(
first.signal,
() => initialWork,
() => {},
);
const left = owner.run(undefined, retry, () => {});
const right = owner.run(undefined, retry, () => {});
first.abort(reason);
const failure = new ArtifactReaderFailure("cancelled", "cancelled", {
cause: reason,
});
reject(failure);
await expect(initial).rejects.toBe(failure);
expect(retry).toHaveBeenCalledTimes(1);
complete();
await expect(Promise.all([left, right])).resolves.toEqual([
undefined,
undefined,
]);
expect(retry).toHaveBeenCalledTimes(1);
});
+40
View File
@@ -0,0 +1,40 @@
import { artifactReadCancelledBy } from "./ArtifactFileOperation.js";
/** Share one preparation while allowing another caller to retry its cancellation. */
export class ArtifactPreparation {
#pending:
| {
readonly signal: AbortSignal | undefined;
readonly promise: Promise<void>;
}
| undefined;
async run(
signal: AbortSignal | undefined,
prepare: () => Promise<void>,
assertOpen: () => void,
): Promise<void> {
for (;;) {
const pending = (this.#pending ??= { signal, promise: prepare() });
try {
await pending.promise;
return;
} catch (cause: unknown) {
if (this.#pending === pending) this.#pending = undefined;
if (
pending.signal !== signal &&
signal?.aborted !== true &&
artifactReadCancelledBy(cause, pending.signal)
) {
assertOpen();
continue;
}
throw cause;
}
}
}
clear(): void {
this.#pending = undefined;
}
}
+9 -32
View File
@@ -1,3 +1,4 @@
import { ArtifactPreparation } from "./ArtifactPreparation.js";
import {
DEFAULT_BUN_READER_LIMITS,
type BunReaderLimits,
@@ -16,10 +17,7 @@ import type {
import { jsonValueByteLength } from "../domain/jsonValueByteLength.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
import {
artifactReadCancelledBy,
assertArtifactReadActive,
} from "./ArtifactFileOperation.js";
import { assertArtifactReadActive } from "./ArtifactFileOperation.js";
import {
normalizeArtifactPath,
ArtifactPathRegistry,
@@ -62,12 +60,7 @@ export class BunStandaloneArtifactReader implements ArtifactReader {
readonly #streams = new Set<Readable>();
readonly #source: ArtifactFileSource;
#admission: BunStandaloneAdmission | undefined;
#prepared:
| {
readonly signal: AbortSignal | undefined;
readonly promise: Promise<void>;
}
| undefined;
readonly #preparation = new ArtifactPreparation();
#header: BunStandaloneMetadata | undefined;
#closing = false;
@@ -108,27 +101,11 @@ export class BunStandaloneArtifactReader implements ArtifactReader {
): AsyncIterable<ArtifactEntry> {
await this.#scope.run(async () => {
this.#assertOpen(signal);
for (;;) {
const pending = (this.#prepared ??= {
signal,
promise: this.#prepare(signal),
});
try {
await pending.promise;
break;
} catch (cause: unknown) {
if (this.#prepared === pending) this.#prepared = undefined;
if (
pending.signal !== signal &&
signal?.aborted !== true &&
artifactReadCancelledBy(cause, pending.signal)
) {
this.#assertOpen(signal);
continue;
}
throw cause;
}
}
await this.#preparation.run(
signal,
() => this.#prepare(signal),
() => this.#assertOpen(signal),
);
await this.#source.readSelectedPath(signal);
});
let index = 0;
@@ -209,7 +186,7 @@ export class BunStandaloneArtifactReader implements ArtifactReader {
this.#entries.clear();
this.#header = undefined;
this.#admission = undefined;
this.#prepared = undefined;
this.#preparation.clear();
}
provenance(): readonly [] {
+8 -28
View File
@@ -1,3 +1,4 @@
import { ArtifactPreparation } from "./ArtifactPreparation.js";
import { Readable } from "node:stream";
import { setImmediate } from "node:timers/promises";
@@ -18,10 +19,7 @@ import {
type ArtifactTraversalOptions,
} from "./ArtifactReader.js";
import { ArtifactResourceScope } from "./ArtifactResourceScope.js";
import {
artifactReadCancelledBy,
assertArtifactReadActive,
} from "./ArtifactFileOperation.js";
import { assertArtifactReadActive } from "./ArtifactFileOperation.js";
import { ArtifactFileSource } from "./ArtifactFileSource.js";
import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
import type { StableRegularFileDescriptor } from "../filesystem/RegularFile.js";
@@ -72,9 +70,7 @@ export class DecodedRegionArtifactReader<Image> implements ArtifactReader {
readonly #streams = new Set<Readable>();
readonly #source: ArtifactFileSource;
readonly #decoding: RegionDecoding<Image>;
#prepared:
| { signal: AbortSignal | undefined; promise: Promise<void> }
| undefined;
readonly #preparation = new ArtifactPreparation();
/** Whole-file facts only; the region map is the sole owner of region bytes. */
#described: Omit<DecodedRegions, "regions"> | undefined;
#closing = false;
@@ -115,27 +111,11 @@ export class DecodedRegionArtifactReader<Image> implements ArtifactReader {
): AsyncIterable<ArtifactEntry> {
await this.#scope.run(async () => {
this.#assertOpen(signal);
for (;;) {
const pending = (this.#prepared ??= {
signal,
promise: this.#prepare(signal),
});
try {
await pending.promise;
break;
} catch (cause: unknown) {
if (this.#prepared === pending) this.#prepared = undefined;
if (
pending.signal !== signal &&
signal?.aborted !== true &&
artifactReadCancelledBy(cause, pending.signal)
) {
this.#assertOpen(signal);
continue;
}
throw cause;
}
}
await this.#preparation.run(
signal,
() => this.#prepare(signal),
() => this.#assertOpen(signal),
);
await this.#source.read(signal);
});
const coverage = this.#described?.coverage;
+13 -38
View File
@@ -1,3 +1,4 @@
import { ArtifactPreparation } from "./ArtifactPreparation.js";
import { Readable, Transform, pipeline } from "node:stream";
import { setImmediate } from "node:timers/promises";
@@ -11,10 +12,7 @@ import type {
} from "../domain/artifactFormatMetadata.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { jsonValueByteLength } from "../domain/jsonValueByteLength.js";
import {
assertArtifactReadActive,
artifactReadCancelledBy,
} from "./ArtifactFileOperation.js";
import { assertArtifactReadActive } from "./ArtifactFileOperation.js";
import { ArtifactFileSource } from "./ArtifactFileSource.js";
import {
ArtifactPathRegistry,
@@ -42,6 +40,8 @@ import { ArtifactEmbeddedFormatProbes } from "./ArtifactEmbeddedFormatProbes.js"
import {
DENO_CHILD_SUFFIXES,
DENO_PATH_LENGTH_LIMITATION,
DENO_PATH_PARTS_LIMITATION,
DENO_UNSAFE_PATH_LIMITATION,
denoLogicalPathFits,
MAX_DENO_LOGICAL_PATH_PARTS,
type DenoVfsMember,
@@ -73,12 +73,7 @@ export class DenoStandaloneArtifactReader implements ArtifactReader {
readonly #streams = new Set<Readable>();
readonly #source: ArtifactFileSource;
#admission: DenoStandaloneAdmission | undefined;
#prepared:
| {
readonly signal: AbortSignal | undefined;
readonly promise: Promise<void>;
}
| undefined;
readonly #preparation = new ArtifactPreparation();
#header: DenoStandaloneMetadata | undefined;
#closing = false;
@@ -117,27 +112,11 @@ export class DenoStandaloneArtifactReader implements ArtifactReader {
): AsyncIterable<ArtifactEntry> {
await this.#scope.run(async () => {
this.#assertOpen(signal);
for (;;) {
const pending = (this.#prepared ??= {
signal,
promise: this.#prepare(signal),
});
try {
await pending.promise;
break;
} catch (cause: unknown) {
if (this.#prepared === pending) this.#prepared = undefined;
if (
pending.signal !== signal &&
signal?.aborted !== true &&
artifactReadCancelledBy(cause, pending.signal)
) {
this.#assertOpen(signal);
continue;
}
throw cause;
}
}
await this.#preparation.run(
signal,
() => this.#prepare(signal),
() => this.#assertOpen(signal),
);
await this.#source.readSelectedPath(signal);
});
if (this.#header?.vfs_listing === "unsupported")
@@ -247,7 +226,7 @@ export class DenoStandaloneArtifactReader implements ArtifactReader {
this.#entries.clear();
this.#header = undefined;
this.#admission = undefined;
this.#prepared = undefined;
this.#preparation.clear();
}
provenance(): readonly [] {
@@ -612,9 +591,7 @@ const remoteDestination = (
return {
path: fallback,
sourcePath: text,
limitations: [
`The virtual name exceeds the fixed ${String(MAX_DENO_LOGICAL_PATH_PARTS)}-part logical path allowance; an indexed path is used and the original name is retained.`,
],
limitations: [DENO_PATH_PARTS_LIMITATION],
};
let normalized: string;
try {
@@ -625,9 +602,7 @@ const remoteDestination = (
return {
path: fallback,
sourcePath: text,
limitations: [
"The virtual name is not a safe relative path; an indexed path is used and the original name is retained.",
],
limitations: [DENO_UNSAFE_PATH_LIMITATION],
};
}
// A specifier is generated into a path, so its spelling can follow every
+8 -28
View File
@@ -1,3 +1,4 @@
import { ArtifactPreparation } from "./ArtifactPreparation.js";
import { Readable, Transform, pipeline } from "node:stream";
import { setImmediate } from "node:timers/promises";
@@ -13,10 +14,7 @@ import {
type ArtifactTraversalOptions,
} from "./ArtifactReader.js";
import { ArtifactResourceScope } from "./ArtifactResourceScope.js";
import {
artifactReadCancelledBy,
assertArtifactReadActive,
} from "./ArtifactFileOperation.js";
import { assertArtifactReadActive } from "./ArtifactFileOperation.js";
import { ArtifactFileSource } from "./ArtifactFileSource.js";
import {
createFitDigest,
@@ -37,9 +35,7 @@ export class FitArtifactReader implements ArtifactReader {
readonly #streams = new Set<Readable>();
readonly #path: string;
readonly #source: ArtifactFileSource;
#prepared:
| { signal: AbortSignal | undefined; promise: Promise<void> }
| undefined;
readonly #preparation = new ArtifactPreparation();
#metadata: FitMetadata | undefined;
#coverage: ArtifactReaderTraversal | undefined;
#closing = false;
@@ -71,27 +67,11 @@ export class FitArtifactReader implements ArtifactReader {
): AsyncIterable<ArtifactEntry> {
await this.#scope.run(async () => {
this.#assertOpen(signal);
for (;;) {
const pending = (this.#prepared ??= {
signal,
promise: this.#prepare(signal),
});
try {
await pending.promise;
break;
} catch (cause: unknown) {
if (this.#prepared === pending) this.#prepared = undefined;
if (
pending.signal !== signal &&
signal?.aborted !== true &&
artifactReadCancelledBy(cause, pending.signal)
) {
this.#assertOpen(signal);
continue;
}
throw cause;
}
}
await this.#preparation.run(
signal,
() => this.#prepare(signal),
() => this.#assertOpen(signal),
);
await this.#source.read(signal);
});
if (this.#coverage !== undefined)
@@ -273,3 +273,45 @@ it("delivers acquired extension metadata through the canonical inventory schema"
},
});
});
it("does not finish a traversal when close begins during final source verification", async () => {
const reader = new NtfsMftArtifactReader(
await writeMft("$MFT", recordBytes({})),
);
const read = ArtifactFileSource.prototype.read;
let calls = 0;
let entered!: () => void;
let resume!: () => void;
const started = new Promise<void>((resolve) => {
entered = resolve;
});
const gate = new Promise<void>((resolve) => {
resume = resolve;
});
const spy = vi
.spyOn(ArtifactFileSource.prototype, "read")
.mockImplementation(async function (this: ArtifactFileSource, signal) {
const result = await read.call(this, signal);
if (++calls === 2) {
entered();
await gate;
}
return result;
});
try {
const traversal = reader.entries()[Symbol.asyncIterator]().next();
const rejected = expect(traversal).rejects.toMatchObject({
reason: "unavailable",
});
await started;
const closing = reader.close();
resume();
await rejected;
await closing;
expect(calls).toBe(2);
} finally {
resume();
spy.mockRestore();
await reader.close();
}
});
+8 -28
View File
@@ -1,3 +1,4 @@
import { ArtifactPreparation } from "./ArtifactPreparation.js";
import type { Readable } from "node:stream";
import {
@@ -17,10 +18,7 @@ import {
type ArtifactTraversalOptions,
} from "./ArtifactReader.js";
import { ArtifactResourceScope } from "./ArtifactResourceScope.js";
import {
artifactReadCancelledBy,
assertArtifactReadActive,
} from "./ArtifactFileOperation.js";
import { assertArtifactReadActive } from "./ArtifactFileOperation.js";
import { ArtifactFileSource } from "./ArtifactFileSource.js";
import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
import type { StableRegularFileDescriptor } from "../filesystem/RegularFile.js";
@@ -34,9 +32,7 @@ export class NtfsMftArtifactReader implements ArtifactReader {
readonly format = "ntfs-mft" as const;
readonly #scope = new ArtifactResourceScope();
readonly #source: ArtifactFileSource;
#prepared:
| { signal: AbortSignal | undefined; promise: Promise<void> }
| undefined;
readonly #preparation = new ArtifactPreparation();
#metadata: ArtifactFormatMetadata | undefined;
#limitations: readonly string[] = [];
#closing = false;
@@ -67,27 +63,11 @@ export class NtfsMftArtifactReader implements ArtifactReader {
): AsyncIterable<ArtifactEntry> {
await this.#scope.run(async () => {
this.#assertOpen(signal);
for (;;) {
const pending = (this.#prepared ??= {
signal,
promise: this.#prepare(signal),
});
try {
await pending.promise;
break;
} catch (cause: unknown) {
if (this.#prepared === pending) this.#prepared = undefined;
if (
pending.signal !== signal &&
signal?.aborted !== true &&
artifactReadCancelledBy(cause, pending.signal)
) {
this.#assertOpen(signal);
continue;
}
throw cause;
}
}
await this.#preparation.run(
signal,
() => this.#prepare(signal),
() => this.#assertOpen(signal),
);
this.#assertOpen(signal);
await this.#source.read(signal);
});
+8 -31
View File
@@ -1,3 +1,4 @@
import { ArtifactPreparation } from "./ArtifactPreparation.js";
import { Readable, Transform, pipeline } from "node:stream";
import { setImmediate } from "node:timers/promises";
@@ -11,10 +12,7 @@ import {
type ArtifactTraversalOptions,
} from "./ArtifactReader.js";
import { ArtifactResourceScope } from "./ArtifactResourceScope.js";
import {
artifactReadCancelledBy,
assertArtifactReadActive,
} from "./ArtifactFileOperation.js";
import { assertArtifactReadActive } from "./ArtifactFileOperation.js";
import { ArtifactFileSource } from "./ArtifactFileSource.js";
import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
import type { StableRegularFileDescriptor } from "../filesystem/RegularFile.js";
@@ -45,12 +43,7 @@ export abstract class PositionalArtifactReader implements ArtifactReader {
readonly #streams = new Set<Readable>();
readonly #label: string;
readonly #source: ArtifactFileSource;
#prepared:
| {
readonly signal: AbortSignal | undefined;
readonly promise: Promise<void>;
}
| undefined;
readonly #preparation = new ArtifactPreparation();
#coverage: ArtifactReaderTraversal | undefined;
#closing = false;
@@ -84,27 +77,11 @@ export abstract class PositionalArtifactReader implements ArtifactReader {
): AsyncIterable<ArtifactEntry> {
await this.#scope.run(async () => {
this.assertOpen(signal);
for (;;) {
const pending = (this.#prepared ??= {
signal,
promise: this.#prepare(signal),
});
try {
await pending.promise;
break;
} catch (cause: unknown) {
if (this.#prepared === pending) this.#prepared = undefined;
if (
pending.signal !== signal &&
signal?.aborted !== true &&
artifactReadCancelledBy(cause, pending.signal)
) {
this.assertOpen(signal);
continue;
}
throw cause;
}
}
await this.#preparation.run(
signal,
() => this.#prepare(signal),
() => this.assertOpen(signal),
);
await this.#source.read(signal);
});
this.assertOpen(signal);
+8 -28
View File
@@ -1,3 +1,4 @@
import { ArtifactPreparation } from "./ArtifactPreparation.js";
import { Readable, Transform, pipeline } from "node:stream";
import { crc32 } from "node:zlib";
import { readArtifactFileBytes } from "./ArtifactFileRange.js";
@@ -8,10 +9,7 @@ import type {
UImageMetadata,
} from "../domain/artifactFormatMetadata.js";
import { ArtifactFileSource } from "./ArtifactFileSource.js";
import {
artifactReadCancelledBy,
assertArtifactReadActive,
} from "./ArtifactFileOperation.js";
import { assertArtifactReadActive } from "./ArtifactFileOperation.js";
import {
ArtifactReaderFailure,
copyArtifactEntry,
@@ -50,9 +48,7 @@ export class UImageArtifactReader implements ArtifactReader {
readonly #entries = new Map<string, UImageEntry>();
readonly #streams = new Set<Readable>();
readonly #source: ArtifactFileSource;
#prepared:
| { signal: AbortSignal | undefined; promise: Promise<void> }
| undefined;
readonly #preparation = new ArtifactPreparation();
#header: UImageMetadata | undefined;
#closing = false;
@@ -80,27 +76,11 @@ export class UImageArtifactReader implements ArtifactReader {
): AsyncIterable<ArtifactEntry> {
await this.#scope.run(async () => {
this.#assertOpen(signal);
for (;;) {
const pending = (this.#prepared ??= {
signal,
promise: this.#prepare(signal),
});
try {
await pending.promise;
break;
} catch (cause: unknown) {
if (this.#prepared === pending) this.#prepared = undefined;
if (
pending.signal !== signal &&
signal?.aborted !== true &&
artifactReadCancelledBy(cause, pending.signal)
) {
this.#assertOpen(signal);
continue;
}
throw cause;
}
}
await this.#preparation.run(
signal,
() => this.#prepare(signal),
() => this.#assertOpen(signal),
);
await this.#source.read(signal);
});
this.#assertOpen(signal);
+8 -28
View File
@@ -1,3 +1,4 @@
import { ArtifactPreparation } from "./ArtifactPreparation.js";
import { artifactExpansionBudgetBytes } from "../domain/artifactExpansionBudget.js";
import type { FileHandle } from "node:fs/promises";
import { setImmediate } from "node:timers/promises";
@@ -12,10 +13,7 @@ import {
type ArtifactTraversalOptions,
} from "./ArtifactReader.js";
import { ArtifactResourceScope } from "./ArtifactResourceScope.js";
import {
artifactReadCancelledBy,
assertArtifactReadActive,
} from "./ArtifactFileOperation.js";
import { assertArtifactReadActive } from "./ArtifactFileOperation.js";
import { readArtifactFileBytes } from "./ArtifactFileRange.js";
import type {
ArtifactFormatMetadata,
@@ -58,9 +56,7 @@ export class UnityFsArtifactReader implements ArtifactReader {
#entries = new Map<string, UnityFsEntryState>();
readonly #streams = new Map<Readable, AbortController>();
readonly #source: ArtifactFileSource;
#prepared:
| { signal: AbortSignal | undefined; promise: Promise<void> }
| undefined;
readonly #preparation = new ArtifactPreparation();
#header: UnityFsMetadata | undefined;
#blocks: readonly UnityFsBlock[] = [];
#dataStart = 0;
@@ -93,27 +89,11 @@ export class UnityFsArtifactReader implements ArtifactReader {
): AsyncIterable<ArtifactEntry> {
await this.#scope.run(async () => {
this.#assertOpen(signal);
for (;;) {
const pending = (this.#prepared ??= {
signal,
promise: this.#prepare(signal),
});
try {
await pending.promise;
break;
} catch (cause: unknown) {
if (this.#prepared === pending) this.#prepared = undefined;
if (
pending.signal !== signal &&
signal?.aborted !== true &&
artifactReadCancelledBy(cause, pending.signal)
) {
this.#assertOpen(signal);
continue;
}
throw cause;
}
}
await this.#preparation.run(
signal,
() => this.#prepare(signal),
() => this.#assertOpen(signal),
);
await this.#source.read(signal);
});
this.#assertOpen(signal);
+8 -28
View File
@@ -1,3 +1,4 @@
import { ArtifactPreparation } from "./ArtifactPreparation.js";
import { artifactExpansionBudgetBytes } from "../domain/artifactExpansionBudget.js";
import type { FileHandle } from "node:fs/promises";
import { setImmediate } from "node:timers/promises";
@@ -12,10 +13,7 @@ import {
type ArtifactTraversalOptions,
} from "./ArtifactReader.js";
import { ArtifactResourceScope } from "./ArtifactResourceScope.js";
import {
artifactReadCancelledBy,
assertArtifactReadActive,
} from "./ArtifactFileOperation.js";
import { assertArtifactReadActive } from "./ArtifactFileOperation.js";
import { readArtifactFileBytes } from "./ArtifactFileRange.js";
import type {
ArtifactFormatMetadata,
@@ -54,9 +52,7 @@ export class WxapkgArtifactReader implements ArtifactReader {
#entries = new Map<string, WxapkgEntryState>();
readonly #streams = new Map<Readable, AbortController>();
readonly #source: ArtifactFileSource;
#prepared:
| { signal: AbortSignal | undefined; promise: Promise<void> }
| undefined;
readonly #preparation = new ArtifactPreparation();
#header: WxapkgMetadata | undefined;
#remainingReadOperations = 0;
#admittedReadOperations = 0;
@@ -86,27 +82,11 @@ export class WxapkgArtifactReader implements ArtifactReader {
): AsyncIterable<ArtifactEntry> {
await this.#scope.run(async () => {
this.#assertOpen(signal);
for (;;) {
const pending = (this.#prepared ??= {
signal,
promise: this.#prepare(signal),
});
try {
await pending.promise;
break;
} catch (cause: unknown) {
if (this.#prepared === pending) this.#prepared = undefined;
if (
pending.signal !== signal &&
signal?.aborted !== true &&
artifactReadCancelledBy(cause, pending.signal)
) {
this.#assertOpen(signal);
continue;
}
throw cause;
}
}
await this.#preparation.run(
signal,
() => this.#prepare(signal),
() => this.#assertOpen(signal),
);
await this.#source.read(signal);
});
this.#assertOpen(signal);
+7 -55
View File
@@ -1,4 +1,7 @@
import { createHash } from "node:crypto";
import {
hashArtifactRange,
readArtifactRange as readExact,
} from "../ArtifactCarrierRange.js";
import type { FileHandle } from "node:fs/promises";
import {
@@ -895,7 +898,9 @@ const collapseRanges = async (
"capacity",
);
remainingBytes -= range.length;
const hash = await digestRange(carrier, range.offset, range.length);
const hash = await hashArtifactRange(carrier, range.offset, range.length);
if (hash === undefined)
throw denoCarrierFailure("Deno compile section is truncated");
if (chosenHash === undefined) chosenHash = hash;
else if (hash !== chosenHash) throw conflict();
}
@@ -908,59 +913,6 @@ const collapseRanges = async (
};
};
const digestRange = async (
carrier: Carrier,
offset: number,
length: number,
): Promise<string> => {
const hash = createHash("sha256");
let remaining = length;
let position = offset;
while (remaining > 0) {
const chunkLength = Math.min(64 * 1024, remaining);
const chunk = await readExact(carrier, position, chunkLength);
if (chunk === undefined)
throw denoCarrierFailure("Deno compile section is truncated");
hash.update(chunk);
position += chunkLength;
remaining -= chunkLength;
}
return hash.digest("hex");
};
const readExact = async (
carrier: Carrier,
offset: number,
length: number,
): Promise<Buffer | undefined> => {
abortIfNeeded(carrier.signal);
if (
!Number.isSafeInteger(offset) ||
!Number.isSafeInteger(length) ||
offset < 0 ||
length < 0 ||
offset > carrier.fileSize ||
length > carrier.fileSize - offset
)
return undefined;
if (length === 0) return Buffer.alloc(0);
const bytes = Buffer.alloc(length);
let filled = 0;
while (filled < length) {
abortIfNeeded(carrier.signal);
const read = await carrier.handle.read(
bytes,
filled,
length - filled,
offset + filled,
);
abortIfNeeded(carrier.signal);
if (read.bytesRead === 0) return undefined;
filled += read.bytesRead;
}
return bytes;
};
const fixedCString = (bytes: Uint8Array): string => {
const end = bytes.indexOf(0);
return Buffer.from(end < 0 ? bytes : bytes.subarray(0, end)).toString(
+5 -5
View File
@@ -37,10 +37,10 @@ export const DENO_CHILD_SUFFIXES = {
sourceMap: ".deno-sourcemap",
} as const;
const INDEXED_PATH_LIMITATION = `The virtual name exceeds the fixed ${String(MAX_DENO_LOGICAL_PATH_PARTS)}-part logical path allowance; an indexed path is used and the original name is retained.`;
export const DENO_PATH_PARTS_LIMITATION = `The virtual name exceeds the fixed ${String(MAX_DENO_LOGICAL_PATH_PARTS)}-part logical path allowance; an indexed path is used and the original name is retained.`;
const NAME_LIMITATION = `The virtual name exceeds the fixed ${String(MAX_DENO_VFS_NAME_UNITS)}-unit name allowance; an indexed path is used and the original name is not retained.`;
export const DENO_PATH_LENGTH_LIMITATION = `The virtual name exceeds the fixed ${String(MAX_DENO_PATH_COMPONENT_BYTES)}-byte path component or ${String(MAX_DENO_LOGICAL_PATH_BYTES)}-byte logical path allowance; an indexed path is used and the original name is retained.`;
const UNSAFE_PATH_LIMITATION =
export const DENO_UNSAFE_PATH_LIMITATION =
"The virtual name is not a safe relative path; an indexed path is used and the original name is retained.";
const MTIME_LIMITATION = "The file mtime is not a safe integer; it is omitted.";
const SYMLINK_PART_LIMITATION = `A symlink target part exceeds the fixed ${String(MAX_DENO_VFS_NAME_UNITS)}-unit name allowance; the target parts are not retained.`;
@@ -384,10 +384,10 @@ const logicalDestination = (
if (parent.limitation !== null) return indexed(parent.limitation, source);
if (name.length > MAX_DENO_VFS_NAME_UNITS)
return indexed(NAME_LIMITATION, null);
if (unsafeName(name)) return indexed(UNSAFE_PATH_LIMITATION, source);
if (unsafeName(name)) return indexed(DENO_UNSAFE_PATH_LIMITATION, source);
const parts = parent.parts + 1;
if (parts > MAX_DENO_LOGICAL_PATH_PARTS)
return indexed(INDEXED_PATH_LIMITATION, source);
return indexed(DENO_PATH_PARTS_LIMITATION, source);
const joined = parent.path === null ? name : `${parent.path}/${name}`;
try {
const path = normalizeArtifactPath(joined);
@@ -402,7 +402,7 @@ const logicalDestination = (
};
} catch (cause) {
if (cause instanceof ArtifactReaderFailure && cause.reason === "path")
return indexed(UNSAFE_PATH_LIMITATION, source);
return indexed(DENO_UNSAFE_PATH_LIMITATION, source);
throw cause;
}
};
+9 -54
View File
@@ -1,3 +1,7 @@
import {
hashArtifactRange,
readArtifactRange as readExact,
} from "../ArtifactCarrierRange.js";
import { readMachoSegmentHeader } from "../../domain/apple/machoSegmentHeader.js";
import { readMachoSectionHeader } from "../../domain/apple/machoSectionHeader.js";
import { readElfSectionHeader } from "../../domain/elfSectionHeader.js";
@@ -214,8 +218,11 @@ const collapseRanges = async (
if (chosen === undefined) return undefined;
if (unique.length > 1) {
const hashes = new Set<string>();
for (const range of unique)
hashes.add(await digestRange(carrier, range.offset, range.length));
for (const range of unique) {
const hash = await hashArtifactRange(carrier, range.offset, range.length);
if (hash === undefined) throw formatFailure(NODE_SEA_TRUNCATED);
hashes.add(hash);
}
if (hashes.size !== 1) throw formatFailure(NODE_SEA_DUPLICATE_BLOB);
}
return {
@@ -694,58 +701,6 @@ const locatePeResources = async (carrier: Carrier): Promise<BlobRange[]> => {
});
};
const digestRange = async (
carrier: Carrier,
offset: number,
length: number,
): Promise<string> => {
const hash = createHash("sha256");
let remaining = length;
let position = offset;
while (remaining > 0) {
const chunkLength = Math.min(64 * 1024, remaining);
const chunk = await readExact(carrier, position, chunkLength);
if (chunk === undefined) throw formatFailure(NODE_SEA_TRUNCATED);
hash.update(chunk);
position += chunkLength;
remaining -= chunkLength;
}
return hash.digest("hex");
};
const readExact = async (
carrier: Carrier,
offset: number,
length: number,
): Promise<Buffer | undefined> => {
abortIfNeeded(carrier.signal);
if (
!Number.isSafeInteger(offset) ||
!Number.isSafeInteger(length) ||
offset < 0 ||
length < 0 ||
offset > carrier.fileSize ||
length > carrier.fileSize - offset
)
return undefined;
if (length === 0) return Buffer.alloc(0);
const bytes = Buffer.alloc(length);
let filled = 0;
while (filled < length) {
abortIfNeeded(carrier.signal);
const read = await carrier.handle.read(
bytes,
filled,
length - filled,
offset + filled,
);
abortIfNeeded(carrier.signal);
if (read.bytesRead === 0) return undefined;
filled += read.bytesRead;
}
return bytes;
};
const cString = (bytes: Buffer, offset: number, length: number): string => {
let end = offset;
const limit = Math.min(bytes.length, offset + length);
+2 -2
View File
@@ -474,11 +474,11 @@ export class TsharkCaptureProvider implements PacketCapturePort {
workspacePath: string | null,
effectiveLimits: LinuxResourceLimits | undefined,
observedOutput: AnalysisCapturedOutput | undefined,
operation = OPERATION,
operation: string,
limits: {
readonly output_bytes: number;
readonly command_timeout_ms: number;
} = LIMITS,
},
): AnalysisError {
if (cause instanceof AnalysisError) return cause;
if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined)
+9 -14
View File
@@ -18,6 +18,7 @@ import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { redactExplicitFailure } from "../domain/explicitSensitiveFailure.js";
import { err, ok, type Result } from "../domain/result.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { parseCliJsonOption } from "./cliJsonOption.js";
import {
createEvidenceBundle,
createImmutableEvidenceBundle,
@@ -102,20 +103,14 @@ export const parseCliAnalysisEvidenceDelivery = <
Delivery & { readonly evidenceOutput: string | undefined },
AnalysisError
> => {
let view: unknown;
try {
view = options.view === undefined ? undefined : JSON.parse(options.view);
} catch (cause) {
return err(
new AnalysisInputError(operation, { cause }, [
{
path: ["view"],
reason: "invalid_format",
message: "view must be valid JSON",
},
]),
);
}
const decoded = parseCliJsonOption(
operation,
"view",
options.view,
"view must be valid JSON",
);
if (!decoded.ok) return decoded;
const view = decoded.value;
const raw = {
detail: options.detail,
...(view === undefined ? {} : { view }),
+9 -16
View File
@@ -17,7 +17,7 @@ import {
} from "./analysisEvidenceDelivery.js";
import { androidPackageDeliverySchema } from "../domain/android/androidPackageQuery.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { parseCliJsonOption } from "./cliJsonOption.js";
import { parseAnalysisInput } from "../application/AnalysisInput.js";
import { androidJniSelectionSchema } from "../domain/android/androidJniBindings.js";
import { androidJniDeliverySchema } from "../domain/android/androidJniQuery.js";
@@ -105,21 +105,14 @@ export const registerAndroidCommands = (
androidJniDeliverySchema,
);
if (!delivery.ok) return projectAnalysisError(delivery.error);
let query: unknown = {};
try {
if (options.query !== undefined)
query = JSON.parse(options.query);
} catch (cause: unknown) {
return projectAnalysisError(
new AnalysisInputError(operation, { cause }, [
{
path: ["query"],
reason: "invalid_format",
message: "query must be a JSON selection object.",
},
]),
);
}
const decoded = parseCliJsonOption(
operation,
"query",
options.query,
"query must be a JSON selection object.",
);
if (!decoded.ok) return projectAnalysisError(decoded.error);
const query = decoded.value === undefined ? {} : decoded.value;
const selected = parseAnalysisInput(
operation,
query,
+8 -20
View File
@@ -1,12 +1,11 @@
import { parseCliJsonOption } from "./cliJsonOption.js";
import { z } from "incur";
import type { Logger } from "pino";
import { APK_SIGNATURE_PROVIDER_IDENTITY } from "../apkSignature/ApkSignatureRelease.js";
import { apkSignatureModule } from "../composition/apkSignature.js";
import { inspectApkSignatureInputSchema } from "../domain/apkSignature/apkSignatureQuery.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
import { safeParseJson } from "../domain/safeJson.js";
import { logCliCommand } from "../cliLogging.js";
import { localPathStringSchema } from "../domain/localPath.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
@@ -41,24 +40,13 @@ export const registerApkSignatureCommands = (
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, apkSignatureModule.cliCommand, async () => {
const decoded =
options.view === undefined
? { ok: true as const, value: undefined }
: safeParseJson(options.view);
if (!decoded.ok)
return projectAnalysisError(
new AnalysisInputError(
"inspect_apk_signature",
{ cause: decoded.cause },
[
{
path: ["view"],
reason: "invalid_format",
message: "view must be a JSON APK signature selection",
},
],
),
);
const decoded = parseCliJsonOption(
"inspect_apk_signature",
"view",
options.view,
"view must be a JSON APK signature selection",
);
if (!decoded.ok) return projectAnalysisError(decoded.error);
const input = inspectApkSignatureInputSchema.safeParse({
path: absoluteCallerPath(args.path),
detail: options.detail,
+21
View File
@@ -0,0 +1,21 @@
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { err, ok, type Result } from "../domain/result.js";
import { safeParseJson } from "../domain/safeJson.js";
/** Decode one named CLI option before using the selected inputs. */
export const parseCliJsonOption = (
operation: string,
name: string,
text: string | undefined,
message: string,
): Result<unknown, AnalysisInputError> => {
if (text === undefined) return ok(undefined);
const parsed = safeParseJson(text);
return parsed.ok
? parsed
: err(
new AnalysisInputError(operation, { cause: parsed.cause }, [
{ path: [name], reason: "invalid_format", message },
]),
);
};
+8 -18
View File
@@ -1,8 +1,7 @@
import { inspectGpuCodeObjectsInputSchema } from "../domain/gpu/gpuCodeObjects.js";
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { safeParseJson } from "../domain/safeJson.js";
import { parseCliJsonOption } from "./cliJsonOption.js";
import { z } from "incur";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
@@ -61,22 +60,13 @@ export const registerGpuCommands = (cli: CliInstance, logger: Logger): void => {
for (const name of ["object_ids", "targets", "view"] as const) {
const value = options[name];
if (value === undefined) continue;
const decoded = safeParseJson(value);
if (!decoded.ok) {
return projectAnalysisError(
new AnalysisInputError(
"inspect_gpu_code_objects",
{ cause: decoded.cause },
[
{
path: [name],
reason: "invalid_format",
message: `${name} must be valid JSON`,
},
],
),
);
}
const decoded = parseCliJsonOption(
"inspect_gpu_code_objects",
name,
value,
`${name} must be valid JSON`,
);
if (!decoded.ok) return projectAnalysisError(decoded.error);
selectors[name] = decoded.value;
}
const rawInput = {
+8 -21
View File
@@ -1,8 +1,7 @@
import { parseCliJsonOption } from "./cliJsonOption.js";
import { inspectModelArtifactInputSchema } from "../domain/model/modelArtifactQuery.js";
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { safeParseJson } from "../domain/safeJson.js";
import { z } from "incur";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
@@ -48,25 +47,13 @@ export const registerModelCommands = (
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.inspectModelArtifact, async () => {
const decoded =
options.view === undefined
? { ok: true as const, value: undefined }
: safeParseJson(options.view);
if (!decoded.ok) {
return projectAnalysisError(
new AnalysisInputError(
"inspect_model_artifact",
{ cause: decoded.cause },
[
{
path: ["view"],
reason: "invalid_format",
message: "view must be a JSON model page selection",
},
],
),
);
}
const decoded = parseCliJsonOption(
"inspect_model_artifact",
"view",
options.view,
"view must be a JSON model page selection",
);
if (!decoded.ok) return projectAnalysisError(decoded.error);
const view = decoded.value;
const selected = inspectModelArtifactInputSchema.safeParse({
path: absoluteCallerPath(args.path),
+8 -21
View File
@@ -1,3 +1,4 @@
import { parseCliJsonOption } from "./cliJsonOption.js";
import { z } from "incur";
import { producerLimitsOption } from "./producerLimitsOption.js";
import { rustProducerLimitsSchema } from "../domain/rust/rustResourceLimits.js";
@@ -12,9 +13,7 @@ import {
} from "./analysisEvidenceDelivery.js";
import { inspectRustBinaryRequestSchema } from "../domain/rust/rustBinaryQuery.js";
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { safeParseJson } from "../domain/safeJson.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
@@ -62,25 +61,13 @@ export const registerRustCommands = (
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.inspectRustBinary, async () => {
const decoded =
options.view === undefined
? { ok: true as const, value: undefined }
: safeParseJson(options.view);
if (!decoded.ok) {
return projectAnalysisError(
new AnalysisInputError(
"inspect_rust_binary",
{ cause: decoded.cause },
[
{
path: ["view"],
reason: "invalid_format",
message: "view must be a JSON package or edge selection",
},
],
),
);
}
const decoded = parseCliJsonOption(
"inspect_rust_binary",
"view",
options.view,
"view must be a JSON package or edge selection",
);
if (!decoded.ok) return projectAnalysisError(decoded.error);
const view = decoded.value;
const input = inspectRustBinaryRequestSchema.safeParse({
path: absoluteCallerPath(args.path),
+7 -14
View File
@@ -7,6 +7,7 @@ import {
schemaChildren,
schemaMaps,
schemaLiteralValues,
schemaSubschemas,
} from "./jsonSchemaKeywords.js";
// Shared compact JSON Schema projection primitives; canonical validation stays separate.
@@ -486,28 +487,20 @@ export const hasGatewayIncompatibleShape = (schema: unknown): boolean => {
)
return true;
}
return schemaChildrenAndMaps(schema).some(hasGatewayIncompatibleShape);
return schemaSubschemas(schema, { includeDefinitions: true }).some(
hasGatewayIncompatibleShape,
);
};
const containsGatewayCombinator = (schema: unknown): boolean => {
if (Array.isArray(schema)) return schema.some(containsGatewayCombinator);
if (!isSchemaObject(schema)) return false;
if (schema.oneOf !== undefined || schema.allOf !== undefined) return true;
return schemaChildrenAndMaps(schema).some(containsGatewayCombinator);
return schemaSubschemas(schema, { includeDefinitions: true }).some(
containsGatewayCombinator,
);
};
/** Traverse only schema-valued positions; property names remain opaque data. */
const schemaChildrenAndMaps = (
schema: Readonly<Record<string, unknown>>,
): readonly unknown[] =>
Object.entries(schema).flatMap(([key, value]) => {
if (schemaMaps.has(key) && isSchemaObject(value))
return Object.values(value);
if (schemaArrays.has(key) && Array.isArray(value)) return value;
if (schemaChildren.has(key)) return Array.isArray(value) ? value : [value];
return [];
});
/**
* Present a reduced property's reference-resolved types. Reduced inputs omit
* nested validation, but clients still require an item schema for an array
+7 -17
View File
@@ -8,6 +8,7 @@ import {
schemaArrays,
schemaChildren,
schemaMaps,
schemaSubschemas,
} from "./jsonSchemaKeywords.js";
const isObject = (value: unknown): value is Readonly<Record<string, unknown>> =>
@@ -269,21 +270,6 @@ const preserveSharedDefault = (
schema.default = defaults[0];
};
/** Schema children, excluding definitions when following validation dependencies. */
const normalizationChildren = (
schema: Readonly<Record<string, unknown>>,
definitions: boolean,
): unknown[] =>
Object.entries(schema).flatMap(([key, value]) => {
if (schemaMaps.has(key) && isObject(value))
return !definitions && (key === "$defs" || key === "definitions")
? []
: Object.values(value);
if (schemaArrays.has(key) && Array.isArray(value)) return value;
if (schemaChildren.has(key)) return Array.isArray(value) ? value : [value];
return [];
});
const hasNormalizedLiteral = (schema: Readonly<Record<string, unknown>>) =>
typeof schema.const === "boolean" ||
schema.const === "" ||
@@ -320,7 +306,9 @@ const normalizationContext = (root: Readonly<Record<string, unknown>>) => {
if (seen.has(entry.schema)) continue;
seen.add(entry.schema);
entries.push({ schema: entry.schema, scope });
for (const schema of normalizationChildren(entry.schema, true))
for (const schema of schemaSubschemas(entry.schema, {
includeDefinitions: true,
}))
pending.push({ schema, scope });
}
const indexed = new Map<JsonSchemaScope | undefined, WeakMap<object, Fact>>();
@@ -378,7 +366,9 @@ const normalizationContext = (root: Readonly<Record<string, unknown>>) => {
}
}
if (!ignoresSiblings)
for (const child of normalizationChildren(schema, false))
for (const child of schemaSubschemas(schema, {
includeDefinitions: false,
}))
factAt(child, scope)?.dependents.add(fact);
if (!ignoresSiblings)
for (const key of [
+21
View File
@@ -21,6 +21,27 @@ export const schemaChildren = new Set([
"else",
]);
/** Visit schema-valued positions without interpreting instance-valued keywords. */
export const schemaSubschemas = (
schema: Readonly<Record<string, unknown>>,
options: { readonly includeDefinitions: boolean },
): unknown[] =>
Object.entries(schema).flatMap(([key, value]) => {
if (
schemaMaps.has(key) &&
typeof value === "object" &&
value !== null &&
!Array.isArray(value)
)
return !options.includeDefinitions &&
(key === "$defs" || key === "definitions")
? []
: Object.values(value);
if (schemaArrays.has(key) && Array.isArray(value)) return value;
if (schemaChildren.has(key)) return Array.isArray(value) ? value : [value];
return [];
});
/** Instance values, unless explicitly reached by a schema reference. */
export const schemaLiteralValues = new Set([
"const",
+1 -69
View File
@@ -106,74 +106,6 @@ import {
type InspectAnalysisViewInput,
} from "./analysisViewRequest.js";
import {
webSourceLocationSummaryViewResultSchema,
webSourceLocationMatchesViewResultSchema,
webSourceLocationTextViewResultSchema,
procedurePseudocodeSummaryViewResultSchema,
nativeObservationSummaryViewResultSchemas,
byteEntropySummaryViewResultSchema,
nativeExportsSummaryViewResultSchema,
devicetreeSummaryViewResultSchema,
erlangBeamSummaryViewResultSchema,
erlangBeamTableViewResultSchema,
pythonPycSummaryViewResultSchema,
pythonPycCodeObjectsViewResultSchema,
gdbCommandSummaryViewResultSchema,
objdumpSummaryViewResultSchema,
fridaSessionStatusSummaryViewResultSchema,
goBinarySummaryViewResultSchema,
rustBinarySummaryViewResultSchema,
rustBinaryPageViewResultSchema,
binaryLayoutSummaryViewResultSchema,
javascriptApplicationSummaryViewResultSchema,
artifactInventorySummaryViewResultSchema,
desktopApplicationSummaryViewResultSchema,
wasmArtifactSummaryViewResultSchema,
machoSummaryViewResultSchema,
interfaceBuilderSummaryViewResultSchema,
evmInterfaceSummaryViewResultSchema,
managedArtifactSummaryViewResultSchema,
managedMemberSummaryViewResultSchema,
webNetworkCaptureSummaryViewResultSchema,
browserScenarioSummaryViewResultSchema,
androidPackageSummaryViewResultSchema,
androidJniSummaryViewResultSchema,
androidJniPageViewResultSchema,
androidClassSummaryViewResultSchema,
androidMethodSummaryViewResultSchema,
androidManifestViewResultSchema,
androidComponentsViewResultSchema,
androidComponentSummaryViewResultSchema,
pcapCaptureSummaryViewResultSchema,
dartAotSummaryViewResultSchema,
javaScriptRoutesViewResultSchema,
webBundleSummaryViewResultSchema,
webBundlePageViewResultSchema,
gpuCodeObjectsSummaryViewResultSchema,
gpuCodeObjectsPageViewResultSchema,
apkSignatureSummaryViewResultSchema,
apkSignaturePageViewResultSchema,
modelArtifactSummaryViewResultSchema,
modelArtifactPageViewResultSchema,
il2cppMetadataSummaryViewResultSchema,
nativeInventorySummaryViewResultSchema,
nativeFeatureTraceSummaryViewResultSchema,
nativeProcedureReferencesSummaryViewResultSchema,
nativeLoadImageSummaryViewResultSchema,
nativeFunctionMatchingSummaryViewResultSchema,
nativeFunctionSummaryViewResultSchema,
sourceToBundleSummaryViewResultSchema,
sourceToBundlePageViewResultSchema,
endpointComparisonSummaryViewResultSchema,
apiContractSummaryViewResultSchema,
playwrightTraceSummaryViewResultSchema,
playwrightTracePageViewResultSchema,
apiContractPageViewResultSchema,
graphqlContractPageViewResultSchema,
endpointComparisonPageViewResultSchema,
featureTraceSummaryViewResultSchema,
batchDecompileSummaryViewResultSchema,
featureTraceViewResultSchema,
analysisViewResultSchema,
type AnalysisViewResult,
type UnsignedAnalysisView,
@@ -256,7 +188,7 @@ export {
analysisViewResultSchema,
type AnalysisViewResult,
type UnsignedAnalysisView,
};
} from "./analysisViewResult.js";
/** Attach the content digest that must change when projected bytes change. */
export const sealAnalysisView = (
@@ -5,6 +5,20 @@ import type { Evidence } from "../evidence.js";
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
import { err, ok, type Result } from "../result.js";
import { ownedJsonValueSchema } from "../jsonValue.js";
import { EvidenceIntegrityError } from "../evidenceErrors.js";
/** Bind a projected artifact to the parent subject when one is recorded. */
export const validateAnalysisViewSubject = (
subject: Evidence["subject"] | undefined,
artifactSha256: string | null | undefined,
): Result<void, EvidenceIntegrityError> =>
subject == null || subject.digest.sha256 === artifactSha256
? ok(undefined)
: err(
new EvidenceIntegrityError(
`Parent subject SHA-256 ${subject.digest.sha256} differs from analysis artifact SHA-256 ${artifactSha256 ?? "unknown"}.`,
),
);
/** Parent record already resolved from inline Evidence or a session reference. */
export interface AnalysisViewParent {
+17 -1
View File
@@ -18,7 +18,11 @@ import {
import { z } from "zod";
import { managedMembersViewRequestSchema } from "./managedMemberViewSchemas.js";
import { managedViewIdentityUnknowns } from "./managedArtifactView.js";
import { createAnalysisViewResultReader } from "./analysisViewParent.js";
import {
createAnalysisViewResultReader,
validateAnalysisViewSubject,
} from "./analysisViewParent.js";
import { validateManagedTokenScope } from "../managed/managedInspectionEvidence.js";
import type {
ManagedMembersViewRequest,
@@ -296,6 +300,18 @@ export const projectManagedMemberView = (
const parsed = parseMemberReport(parent);
if (!parsed.ok) return parsed;
const report = parsed.value;
// Match the public view boundary's subject check before scope validation.
const identity = validateAnalysisViewSubject(
parent.subject,
report.artifact.sha256,
);
if (!identity.ok) return identity;
const scope = validateManagedTokenScope(
report,
parent.operation,
parent.evidenceId,
);
if (!scope.ok) return scope;
const shared = {
parent_evidence_id: parent.evidenceId,
parent_operation: "inspect_managed_members" as const,
+3 -6
View File
@@ -28,13 +28,10 @@ const isRecord = (
): value is Record<string, JsonValue> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const isJsonRecord = (value: JsonValue): value is Record<string, JsonValue> =>
isRecord(value);
/** Whether every member holds a string at a key a selector can carry, each its own. */
const namesEach = (members: readonly JsonValue[], key: string) => {
const values = members.map((member) =>
isJsonRecord(member) ? member[key] : undefined,
isRecord(member) ? member[key] : undefined,
);
return (
values.every(
@@ -222,7 +219,7 @@ export const pairRecordsById = (
}
| undefined => {
const [first] = [...newer, ...older];
if (!isJsonRecord(first ?? null) || ![...newer, ...older].every(isJsonRecord))
if (!isRecord(first) || ![...newer, ...older].every(isRecord))
return undefined;
const key = Object.keys(first ?? {})
.filter(
@@ -240,7 +237,7 @@ export const pairRecordsById = (
})[0];
if (key === undefined) return undefined;
const idOf = (member: JsonValue) =>
isJsonRecord(member) ? String(member[key]) : "";
isRecord(member) ? String(member[key]) : "";
const counts = { newer: tally(newer), older: tally(older) };
const partnersOf = (
members: readonly JsonValue[],
+17
View File
@@ -0,0 +1,17 @@
/** Read a bounded 32-bit ULEB value within an ELF attribute scope. */
export const readElfAttributeUleb = (
bytes: Uint8Array,
offset: number,
end: number,
): { readonly value: number; readonly next: number } | undefined => {
let value = 0;
for (let index = 0; index < 5 && offset + index < end; index += 1) {
const byte = bytes[offset + index]!;
value += (byte & 0x7f) * 2 ** (7 * index);
if ((byte & 0x80) === 0)
return value <= 0xffffffff
? { value, next: offset + index + 1 }
: undefined;
}
return undefined;
};
@@ -16,6 +16,10 @@ export const isErasedJavaScriptDeclaration = (node: t.Node): boolean =>
export const isUnmodeledJavaScriptRuntimeScope = (node: t.Node): boolean =>
t.isTSModuleDeclaration(node) || t.isTSEnumDeclaration(node);
/** Compiler options determine whether an inline type-only declaration retains a side effect. */
export const TYPE_ONLY_MODULE_SIDE_EFFECT_LIMITATION =
"Compiler options are unavailable; inline type-only imports or re-exports may preserve a module side-effect dependency.";
/** Whether TypeScript explicitly erases this entire import declaration. */
export const isTypeOnlyImportDeclaration = (
node: t.ImportDeclaration,
@@ -92,6 +92,7 @@ import {
} from "./javascriptSemanticAmbientWrites.js";
import {
hasUncertainTypeOnlyModuleSideEffect,
TYPE_ONLY_MODULE_SIDE_EFFECT_LIMITATION,
isTypeOnlyImportDeclaration,
isTypeOnlyImportSpecifier,
semanticImportEqualsSpecifier,
@@ -507,9 +508,7 @@ export function* analyzeParsedJavaScriptSemanticsSteps(
]
: []),
...(hasUncertainTypeOnlyModuleSideEffect(file.program)
? [
"Compiler options are unavailable; inline type-only imports or re-exports may preserve a module side-effect dependency.",
]
? [TYPE_ONLY_MODULE_SIDE_EFFECT_LIMITATION]
: []),
"Values and aliases were recovered from inert syntax only; no JavaScript was executed.",
"Return sites include only direct callable returns; nested callable returns remain separate.",
@@ -95,6 +95,19 @@ export const collectJavaScriptSemanticResources = (
];
};
/** Literal kinds Node rejects for both open flags and modes. */
const rejectsFlagOrModeLiteral = (value: t.Node): boolean => {
return (
t.isBigIntLiteral(value) ||
t.isBooleanLiteral(value) ||
t.isRegExpLiteral(value) ||
t.isObjectExpression(value) ||
t.isArrayExpression(value) ||
(t.isUnaryExpression(value) &&
t.isBigIntLiteral(unwrapJavaScriptExpression(value.argument).node))
);
};
/**
* `fs.open`/`fs.openSync` validate the flag before touching the filesystem, so
* a flag Node rejects at the call site throws synchronously and no descriptor
@@ -136,15 +149,7 @@ const rejectsOpenFlagLiteral = (
if (isNonFiniteNumber(value, context)) return true;
// BigInt, boolean, object and array literals all fail Node's flag check. A
// unary operator over a BigInt literal is a BigInt too.
return (
t.isBigIntLiteral(value) ||
t.isBooleanLiteral(value) ||
t.isRegExpLiteral(value) ||
t.isObjectExpression(value) ||
t.isArrayExpression(value) ||
(t.isUnaryExpression(value) &&
t.isBigIntLiteral(unwrapJavaScriptExpression(value.argument).node))
);
return rejectsFlagOrModeLiteral(value);
};
/**
@@ -325,15 +330,7 @@ const rejectsOpenModeLiteral = (
// BigInt, boolean, object and array literals all fail Node's mode check. A
// unary operator over a BigInt literal is a BigInt too, and the range helpers
// above only price numeric operands.
return (
t.isBigIntLiteral(value) ||
t.isBooleanLiteral(value) ||
t.isRegExpLiteral(value) ||
t.isObjectExpression(value) ||
t.isArrayExpression(value) ||
(t.isUnaryExpression(value) &&
t.isBigIntLiteral(unwrapJavaScriptExpression(value.argument).node))
);
return rejectsFlagOrModeLiteral(value);
};
/** `fs.open`, `fs.openSync` and `fs/promises.open` take the flag in slot 1. */
@@ -72,6 +72,7 @@ import type { ParsedJavaScriptSource } from "./javascriptSourceParser.js";
import { createJavaScriptRpcScan } from "./javascriptRpcEndpoints.js";
import {
hasUncertainTypeOnlyModuleSideEffect,
TYPE_ONLY_MODULE_SIDE_EFFECT_LIMITATION,
semanticStaticModuleSource,
semanticImportEqualsSpecifier,
} from "./javascriptModuleDeclaration.js";
@@ -279,11 +280,7 @@ const finalizeStaticAnalysis = (
: [
"One or more static keys, expressions, or Electron boundary values were dynamic and remain unknown.",
]),
...(typeOnlySideEffect
? [
"Compiler options are unavailable; inline type-only imports or re-exports may preserve a module side-effect dependency.",
]
: []),
...(typeOnlySideEffect ? [TYPE_ONLY_MODULE_SIDE_EFFECT_LIMITATION] : []),
...(electronObserved ? [ELECTRON_IDENTITY_LIMITATION] : []),
...(candidateElectron
? [
@@ -64,11 +64,34 @@ export const readManagedInspectionEvidence = <
`Managed Evidence ${operation} (${evidence.evidence_id}) subject SHA-256 ${subjectSha256} does not match normalized artifact SHA-256 ${artifactSha256}`,
),
);
const scope = validateManagedTokenScope(
result,
operation,
evidence.evidence_id,
);
if (!scope.ok) return scope;
return ok({ evidence, result });
};
/** Bind recorded token scope to the artifact and module that gave it meaning. */
export const validateManagedTokenScope = (
result: {
readonly artifact: { readonly sha256: string };
readonly identity_scope?: {
readonly requires_artifact_sha256: string;
readonly requires_mvid: string | null;
};
readonly module?: { readonly mvid: string | null } | null;
},
operation: string,
evidenceId: string,
): Result<void, EvidenceIntegrityError> => {
const artifactSha256 = result.artifact.sha256;
const scope = result.identity_scope;
if (scope !== undefined && scope.requires_artifact_sha256 !== artifactSha256)
return err(
integrityFailure(
`Managed Evidence ${operation} (${evidence.evidence_id}) token scope SHA-256 ${scope.requires_artifact_sha256} does not match normalized artifact SHA-256 ${artifactSha256}`,
`Managed Evidence ${operation} (${evidenceId}) token scope SHA-256 ${scope.requires_artifact_sha256} does not match normalized artifact SHA-256 ${artifactSha256}`,
),
);
const moduleMvid = result.module?.mvid ?? null;
@@ -79,10 +102,10 @@ export const readManagedInspectionEvidence = <
)
return err(
integrityFailure(
`Managed Evidence ${operation} (${evidence.evidence_id}) token scope MVID ${String(scopedMvid)} does not match module MVID ${String(moduleMvid)}`,
`Managed Evidence ${operation} (${evidenceId}) token scope MVID ${String(scopedMvid)} does not match module MVID ${String(moduleMvid)}`,
),
);
return ok({ evidence, result });
return ok(undefined);
};
const integrityFailure = (message: string): EvidenceIntegrityError =>
+4 -18
View File
@@ -1,3 +1,4 @@
import { readElfAttributeUleb } from "./elfAttributeUleb.js";
import type { RiscvIsaDeclaration } from "./binaryTargetTypes.js";
import {
readElfTableDeclarations,
@@ -52,21 +53,6 @@ export const parseRiscvAttributes = (
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const readU32 = (offset: number): number =>
view.getUint32(offset, littleEndian);
const readUleb = (
offset: number,
end: number,
): { readonly value: number; readonly next: number } | undefined => {
let value = 0;
for (let index = 0; index < 5 && offset + index < end; index += 1) {
const byte = bytes[offset + index]!;
value += (byte & 0x7f) * 2 ** (7 * index);
if ((byte & 0x80) === 0)
return value <= 0xffffffff
? { value, next: offset + index + 1 }
: undefined;
}
return undefined;
};
const readString = (
offset: number,
end: number,
@@ -103,7 +89,7 @@ export const parseRiscvAttributes = (
let cursor = vendor.next;
while (cursor < end) {
const scopeStart = cursor;
const scope = readUleb(cursor, end);
const scope = readElfAttributeUleb(bytes, cursor, end);
if (scope === undefined)
return err("malformed RISC-V attribute scope tag");
if (scope.value !== TAG_FILE)
@@ -121,7 +107,7 @@ export const parseRiscvAttributes = (
const scopeEnd = scopeStart + scopeLength;
cursor = scope.next + 4;
while (cursor < scopeEnd) {
const tag = readUleb(cursor, scopeEnd);
const tag = readElfAttributeUleb(bytes, cursor, scopeEnd);
if (tag === undefined) return err("malformed RISC-V attribute tag");
if (tag.value % 2 === 1) {
const value = readString(tag.next, scopeEnd);
@@ -136,7 +122,7 @@ export const parseRiscvAttributes = (
}
cursor = value.next;
} else {
const value = readUleb(tag.next, scopeEnd);
const value = readElfAttributeUleb(bytes, tag.next, scopeEnd);
if (value === undefined)
return err(
`malformed integer value for RISC-V attribute tag ${tag.value}`,
+2 -3
View File
@@ -6,6 +6,7 @@ import type { WebModuleImport } from "./webModuleTrace.js";
import type { ExportedWebScript } from "./webScriptExport.js";
import {
hasUncertainTypeOnlyModuleSideEffect,
TYPE_ONLY_MODULE_SIDE_EFFECT_LIMITATION,
semanticStaticModuleSource,
} from "./javascript/javascriptModuleDeclaration.js";
@@ -74,9 +75,7 @@ export const collectWebModuleImports = (
diagnostics: [
...(ast.errors?.map((error) => error.message) ?? []),
...(hasUncertainTypeOnlyModuleSideEffect(ast.program)
? [
"Compiler options are unavailable; inline type-only imports or re-exports may preserve a module side-effect dependency.",
]
? [TYPE_ONLY_MODULE_SIDE_EFFECT_LIMITATION]
: []),
],
};
+1 -3
View File
@@ -41,7 +41,7 @@ import {
AnalysisProtocolError,
} from "../domain/analysisErrorCore.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
import { fixedAllowanceOptions as flutterBudget } from "../domain/fixedAllowanceRemediation.js";
import {
AnalysisError,
type AnalysisPartialObservation,
@@ -84,8 +84,6 @@ const APK_MAX_BYTES = 2 * 1024 * 1024 * 1024;
// engine labels this keeps expanded result metadata below 512 KiB for ordinary
// ABI names, independently of the archive's compressed-byte budget.
const MAX_FLUTTER_ABIS = 32;
const flutterBudget = (allowance: string, kind: "parser" | "input") =>
fixedAllowanceOptions(allowance, kind);
/** A complete AOT image carries exactly one VM and one isolate section. */
const SYMBOL_PAIR_COUNT = 2;
const IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/u;
+3 -6
View File
@@ -2219,7 +2219,9 @@ export class KunaSessionClient implements AnalysisClient {
},
admitSpan: (operation, signal, open) =>
this.#admitSpan(operation, signal, open),
finishSpan: (finish) => this.#finishSpan(finish),
// Finalization shares the admission queue so the next read observes
// either completed cleanup or its retained lease.
finishSpan: (finish) => this.#enqueueSpan(finish),
});
}
@@ -2265,11 +2267,6 @@ export class KunaSessionClient implements AnalysisClient {
});
}
/** Release or retain this descriptor before the next span is admitted. */
#finishSpan<T>(finish: () => Promise<T>): Promise<T> {
return this.#enqueueSpan(finish);
}
/**
* Retry span descriptors before another read. Installation cleanup stays on
* the shared scope and is not re-adjudicated here.
+154
View File
@@ -0,0 +1,154 @@
import {
stableArtifactInputRecovery,
projectStableArtifactFailure,
} from "../../artifacts/StableArtifactFailure.js";
import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import type { StableArtifactSnapshot } from "../../artifacts/readStableArtifact.js";
import {
artifactFilePrimaryFacts,
artifactFileCancellationCause,
type ArtifactFilePrimaryFacts,
} from "../../artifacts/ArtifactFileOperation.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import {
AnalysisAccessDeniedError,
AnalysisArtifactChangedError,
AnalysisCancelledError,
AnalysisInputError,
AnalysisResourceConstraintError,
AnalysisUnsupportedTargetError,
type ResourceConstraintRecovery,
} from "../../domain/analysisErrorCore.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import {
BinaryFormatFailure,
BinaryResourceFailure,
} from "../metadata/BinaryContainer.js";
interface PeBinaryInput {
readonly path: string;
readonly max_file_bytes: number;
}
interface PeBinaryFailureOptions<Limit extends string> {
readonly operation: string;
readonly providerId: string;
readonly fileSizeRemediation: () => string;
readonly limit: (cause: BinaryResourceFailure) => Limit | null;
readonly limitRecovery: (limit: Limit | null) => ResourceConstraintRecovery;
}
const peBinaryFailurePrimary = <Limit extends string>(
facts: ArtifactFilePrimaryFacts,
input: PeBinaryInput,
options: PeBinaryFailureOptions<Limit>,
): AnalysisError => {
const { operation, providerId } = options;
const { cause, origin } = facts;
if (origin === "owned-or-classified" && cause instanceof AnalysisError)
return cause;
if (
origin === "owned-or-classified" &&
cause instanceof ArtifactReaderFailure &&
cause.reason === "integrity"
)
return new AnalysisArtifactChangedError(
operation,
input.path,
cause.message,
{ cause },
);
if (
origin === "owned-or-classified" &&
cause instanceof ArtifactReaderFailure &&
cause.reason === "cancelled"
)
return new AnalysisCancelledError(operation, {
cause: artifactFileCancellationCause(cause),
});
if (cause instanceof BinaryResourceFailure) {
const limit = options.limit(cause);
return new AnalysisResourceConstraintError(
operation,
"capacity",
`Selected artifact ${input.path}: ${cause.message}`,
{ boundary: cause.boundary, maximum: cause.maximum_bytes, limit },
{ cause, ...options.limitRecovery(limit) },
);
}
if (cause instanceof BinaryFormatFailure)
return cause.kind === "unsupported"
? new AnalysisUnsupportedTargetError(
operation,
input.path,
cause.message,
{ cause },
)
: new AnalysisInputError(operation, { cause }, [
{
path: ["path"],
reason: "invalid_format",
message: `Selected artifact ${input.path}: ${cause.message}`,
},
]);
if (
origin === "owned-or-classified" &&
cause instanceof ArtifactReaderFailure
) {
if (cause.reason === "limit")
return new AnalysisResourceConstraintError(
operation,
"file-size",
cause.message,
{ max_file_bytes: input.max_file_bytes },
{
cause,
retryable: false,
remediationAction: options.fileSizeRemediation(),
},
);
if (cause.reason === "path" || cause.reason === "format")
return new AnalysisInputError(
operation,
{ cause, ...stableArtifactInputRecovery(cause) },
[{ path: ["path"], reason: "invalid_format", message: cause.message }],
);
}
if (cause instanceof Error && "code" in cause) {
if (cause.code === "EACCES" || cause.code === "EPERM")
return new AnalysisAccessDeniedError(operation, input.path, cause.code, {
cause,
});
if (isSelectedRegularFileError(cause))
return new AnalysisInputError(operation, { cause }, [
{
path: ["path"],
reason: "invalid_value",
message: `Selected image could not be read (${String(cause.code)}): ${input.path}.`,
},
]);
}
return new ProviderAdapterError(providerId, operation, {
cause,
diagnostics: {
path: input.path,
reason: cause instanceof Error ? cause.message : String(cause),
},
});
};
/** Keep one PE error interpretation while preserving the provider's cleanup and partial facts. */
export const projectPeBinaryFailure = <Limit extends string>(
cause: unknown,
input: PeBinaryInput,
options: PeBinaryFailureOptions<Limit>,
snapshot?: StableArtifactSnapshot,
): AnalysisError =>
projectStableArtifactFailure(
cause,
peBinaryFailurePrimary(artifactFilePrimaryFacts(cause), input, options),
input.path,
options.providerId,
options.operation,
snapshot,
);
+5 -21
View File
@@ -1,3 +1,8 @@
import {
fitsPeRvaSpan as fitsRva,
appendPeConsumedSpan as consume,
PE_RVA_END,
} from "./PeRvaSpans.js";
import { createPefileReadCompatibility } from "./PefileReadCompatibility.js";
import { setImmediate } from "node:timers/promises";
import { assertArtifactReadActive } from "../../artifacts/ArtifactFileOperation.js";
@@ -35,15 +40,6 @@ const MAXIMUM_NAME_BYTES = 4096;
const ENTRIES_PER_YIELD = 1024;
const ORDINAL_FLAG32 = 0x80000000n;
const ORDINAL_FLAG64 = 0x8000000000000000n;
const PE_RVA_END = 0x100000000;
/** PE32+ keeps 32-bit RVAs even though preferred virtual addresses are 64-bit. */
const fitsRva = (rva: number, size: number): boolean =>
Number.isSafeInteger(rva) &&
rva >= 0 &&
Number.isSafeInteger(size) &&
size > 0 &&
rva <= PE_RVA_END - size;
const requireRva = (rva: number, size: number): void => {
if (!fitsRva(rva, size))
@@ -111,18 +107,6 @@ const failureMessage = (cause: unknown): string => {
throw cause;
};
/** Append one consumed file span, merging it into the previous span when contiguous. */
const consume = (
spans: { offset: number; bytes: number }[],
offset: number,
bytes: number,
): void => {
const last = spans.at(-1);
if (last !== undefined && last.offset + last.bytes === offset)
last.bytes += bytes;
else spans.push({ offset, bytes });
};
const coverage = (
issues: readonly PeImportIssue[],
examinedDlls: number,
+16 -136
View File
@@ -1,33 +1,15 @@
import {
projectStableArtifactFailure,
stableArtifactInputRecovery,
projectArtifactCloseFailure,
} from "../../artifacts/StableArtifactFailure.js";
import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import { projectPeBinaryFailure } from "./PeBinaryFailure.js";
import { projectArtifactCloseFailure } from "../../artifacts/StableArtifactFailure.js";
import {
type StableArtifactSnapshot,
readStableArtifact,
} from "../../artifacts/readStableArtifact.js";
import {
artifactFilePrimaryFacts,
type ArtifactFilePrimaryFacts,
assertArtifactReadActive,
artifactFileCancellationCause,
} from "../../artifacts/ArtifactFileOperation.js";
import { assertArtifactReadActive } from "../../artifacts/ArtifactFileOperation.js";
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import type { PeImportsPort } from "../../application/binaryDiagnostics/PeImportsPort.js";
import type { ExecutionOptions } from "../../application/AnalysisProvider.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import {
AnalysisAccessDeniedError,
AnalysisArtifactChangedError,
AnalysisCancelledError,
AnalysisInputError,
AnalysisResourceConstraintError,
AnalysisUnsupportedTargetError,
type ResourceConstraintRecovery,
} from "../../domain/analysisErrorCore.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type { ResourceConstraintRecovery } from "../../domain/analysisErrorCore.js";
import { fixedAllowanceOptions } from "../../domain/fixedAllowanceRemediation.js";
import {
MAX_INSPECT_PE_IMPORT_DLLS,
@@ -36,13 +18,8 @@ import {
MAX_INSPECT_PE_IMPORT_RETAINED_TEXT_BYTES,
type InspectPeImportsInput,
} from "../../domain/native/peImports.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import { err, ok } from "../../domain/result.js";
import { PE_IMPORTS_PROVIDER } from "../../domain/investigationProviders.js";
import {
BinaryFormatFailure,
BinaryResourceFailure,
} from "../metadata/BinaryContainer.js";
import {
parsePeImports,
PeImportLimitFailure,
@@ -140,118 +117,21 @@ const limitRecovery = (
}
};
const peImportsFailurePrimary = (
facts: ArtifactFilePrimaryFacts,
input: InspectPeImportsInput,
): AnalysisError => {
const { cause, origin } = facts;
if (origin === "owned-or-classified" && cause instanceof AnalysisError)
return cause;
if (
origin === "owned-or-classified" &&
cause instanceof ArtifactReaderFailure &&
cause.reason === "integrity"
)
return new AnalysisArtifactChangedError(
OPERATION,
input.path,
cause.message,
{ cause },
);
if (
origin === "owned-or-classified" &&
cause instanceof ArtifactReaderFailure &&
cause.reason === "cancelled"
)
return new AnalysisCancelledError(OPERATION, {
cause: artifactFileCancellationCause(cause),
});
if (cause instanceof BinaryResourceFailure) {
const limit = cause instanceof PeImportLimitFailure ? cause.limit : null;
return new AnalysisResourceConstraintError(
OPERATION,
"capacity",
`Selected artifact ${input.path}: ${cause.message}`,
{ boundary: cause.boundary, maximum: cause.maximum_bytes, limit },
{ cause, ...limitRecovery(limit, input) },
);
}
if (cause instanceof BinaryFormatFailure)
return cause.kind === "unsupported"
? new AnalysisUnsupportedTargetError(
OPERATION,
input.path,
cause.message,
{ cause },
)
: new AnalysisInputError(OPERATION, { cause }, [
{
path: ["path"],
reason: "invalid_format",
message: `Selected artifact ${input.path}: ${cause.message}`,
},
]);
if (
origin === "owned-or-classified" &&
cause instanceof ArtifactReaderFailure
) {
if (cause.reason === "limit")
return new AnalysisResourceConstraintError(
OPERATION,
"file-size",
cause.message,
{ max_file_bytes: input.max_file_bytes },
{
cause,
retryable: false,
remediationAction: fileSizeRemediation(input),
},
);
if (cause.reason === "path" || cause.reason === "format")
return new AnalysisInputError(
OPERATION,
{ cause, ...stableArtifactInputRecovery(cause) },
[{ path: ["path"], reason: "invalid_format", message: cause.message }],
);
}
if (cause instanceof Error && "code" in cause) {
if (cause.code === "EACCES" || cause.code === "EPERM")
return new AnalysisAccessDeniedError(OPERATION, input.path, cause.code, {
cause,
});
if (isSelectedRegularFileError(cause))
return new AnalysisInputError(OPERATION, { cause }, [
{
path: ["path"],
reason: "invalid_value",
message: `Selected image could not be read (${String(cause.code)}): ${input.path}.`,
},
]);
}
return new ProviderAdapterError(PE_IMPORTS_PROVIDER.id, OPERATION, {
cause,
diagnostics: {
path: input.path,
reason: cause instanceof Error ? cause.message : String(cause),
},
});
};
const peImportsFailure = (
cause: unknown,
input: InspectPeImportsInput,
snapshot?: StableArtifactSnapshot,
): AnalysisError => {
const primary = peImportsFailurePrimary(
artifactFilePrimaryFacts(cause),
input,
);
return projectStableArtifactFailure(
): AnalysisError =>
projectPeBinaryFailure<PeImportLimit>(
cause,
primary,
input.path,
PE_IMPORTS_PROVIDER.id,
OPERATION,
input,
{
operation: OPERATION,
providerId: PE_IMPORTS_PROVIDER.id,
fileSizeRemediation: () => fileSizeRemediation(input),
limit: (failure) =>
failure instanceof PeImportLimitFailure ? failure.limit : null,
limitRecovery: (limit) => limitRecovery(limit, input),
},
snapshot,
);
};
+4
View File
@@ -34,6 +34,10 @@ describe.each([32, 64] as const)("PE%s load configuration", (bits) => {
const report = await parse({ bits });
expect(peLoadConfigSchema.safeParse(report).success).toBe(true);
expect(report.tls?.callbacks).toHaveLength(3);
// The callback array starts at file offset 0x800 and includes its null slot.
expect(report.tls?.callback_array_locations).toEqual([
{ offset: 0x800, bytes: bits === 32 ? 16 : 32 },
]);
expect(report.tls?.callbacks[0]).toMatchObject({
rva: 0x2000,
file_offset: 0x1600,
+4 -20
View File
@@ -1,3 +1,7 @@
import {
fitsPeRvaSpan as fitsRva,
appendPeConsumedSpan as consume,
} from "./PeRvaSpans.js";
import { setImmediate } from "node:timers/promises";
import { assertArtifactReadActive } from "../../artifacts/ArtifactFileOperation.js";
import {
@@ -29,7 +33,6 @@ import {
const PE_TLS_DIRECTORY = 9;
const PE_LOAD_CONFIG_DIRECTORY = 10;
const PE_RVA_END = 0x100000000;
/** Callback slots decoded between event-loop yields and cancellation checks. */
const ENTRIES_PER_YIELD = 1024;
@@ -50,25 +53,6 @@ export class PeLoadConfigLimitFailure extends BinaryResourceFailure {
const hex = (value: bigint | number): string => `0x${value.toString(16)}`;
const fitsRva = (rva: number, size: number): boolean =>
Number.isSafeInteger(rva) &&
rva >= 0 &&
Number.isSafeInteger(size) &&
size > 0 &&
rva <= PE_RVA_END - size;
/** Append one consumed file span, merging it when the previous span abuts it. */
const consume = (
spans: { offset: number; bytes: number }[],
offset: number,
bytes: number,
): void => {
const last = spans.at(-1);
if (last !== undefined && last.offset + last.bytes === offset)
last.bytes += bytes;
else spans.push({ offset, bytes });
};
type DirectorySpan = PeLoadConfigTls["directory"];
/** One run of RVAs with a constant set of owning sections. */
+16 -137
View File
@@ -1,46 +1,23 @@
import {
projectStableArtifactFailure,
stableArtifactInputRecovery,
projectArtifactCloseFailure,
} from "../../artifacts/StableArtifactFailure.js";
import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import { projectPeBinaryFailure } from "./PeBinaryFailure.js";
import { projectArtifactCloseFailure } from "../../artifacts/StableArtifactFailure.js";
import {
type StableArtifactSnapshot,
readStableArtifact,
} from "../../artifacts/readStableArtifact.js";
import {
artifactFilePrimaryFacts,
type ArtifactFilePrimaryFacts,
assertArtifactReadActive,
artifactFileCancellationCause,
} from "../../artifacts/ArtifactFileOperation.js";
import { assertArtifactReadActive } from "../../artifacts/ArtifactFileOperation.js";
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import type { PeLoadConfigPort } from "../../application/binaryDiagnostics/PeLoadConfigPort.js";
import type { ExecutionOptions } from "../../application/AnalysisProvider.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import {
AnalysisAccessDeniedError,
AnalysisArtifactChangedError,
AnalysisCancelledError,
AnalysisInputError,
AnalysisResourceConstraintError,
AnalysisUnsupportedTargetError,
type ResourceConstraintRecovery,
} from "../../domain/analysisErrorCore.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type { ResourceConstraintRecovery } from "../../domain/analysisErrorCore.js";
import { fixedAllowanceOptions } from "../../domain/fixedAllowanceRemediation.js";
import {
MAX_INSPECT_PE_LOAD_CONFIG_FILE_BYTES,
MAX_INSPECT_PE_LOAD_CONFIG_CALLBACKS,
type InspectPeLoadConfigInput,
} from "../../domain/native/peLoadConfig.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import { err, ok } from "../../domain/result.js";
import { PE_LOAD_CONFIG_PROVIDER } from "../../domain/investigationProviders.js";
import {
BinaryFormatFailure,
BinaryResourceFailure,
} from "../metadata/BinaryContainer.js";
import {
parsePeLoadConfig,
PeLoadConfigLimitFailure,
@@ -116,119 +93,21 @@ const limitRecovery = (
}
: fixedAllowanceOptions("The PE structural decoding allowance", "parser");
const peLoadConfigFailurePrimary = (
facts: ArtifactFilePrimaryFacts,
input: InspectPeLoadConfigInput,
): AnalysisError => {
const { cause, origin } = facts;
if (origin === "owned-or-classified" && cause instanceof AnalysisError)
return cause;
if (
origin === "owned-or-classified" &&
cause instanceof ArtifactReaderFailure &&
cause.reason === "integrity"
)
return new AnalysisArtifactChangedError(
OPERATION,
input.path,
cause.message,
{ cause },
);
if (
origin === "owned-or-classified" &&
cause instanceof ArtifactReaderFailure &&
cause.reason === "cancelled"
)
return new AnalysisCancelledError(OPERATION, {
cause: artifactFileCancellationCause(cause),
});
if (cause instanceof BinaryResourceFailure) {
const limit =
cause instanceof PeLoadConfigLimitFailure ? cause.limit : null;
return new AnalysisResourceConstraintError(
OPERATION,
"capacity",
`Selected artifact ${input.path}: ${cause.message}`,
{ boundary: cause.boundary, maximum: cause.maximum_bytes, limit },
{ cause, ...limitRecovery(limit, input) },
);
}
if (cause instanceof BinaryFormatFailure)
return cause.kind === "unsupported"
? new AnalysisUnsupportedTargetError(
OPERATION,
input.path,
cause.message,
{ cause },
)
: new AnalysisInputError(OPERATION, { cause }, [
{
path: ["path"],
reason: "invalid_format",
message: `Selected artifact ${input.path}: ${cause.message}`,
},
]);
if (
origin === "owned-or-classified" &&
cause instanceof ArtifactReaderFailure
) {
if (cause.reason === "limit")
return new AnalysisResourceConstraintError(
OPERATION,
"file-size",
cause.message,
{ max_file_bytes: input.max_file_bytes },
{
cause,
retryable: false,
remediationAction: fileSizeRemediation(input),
},
);
if (cause.reason === "path" || cause.reason === "format")
return new AnalysisInputError(
OPERATION,
{ cause, ...stableArtifactInputRecovery(cause) },
[{ path: ["path"], reason: "invalid_format", message: cause.message }],
);
}
if (cause instanceof Error && "code" in cause) {
if (cause.code === "EACCES" || cause.code === "EPERM")
return new AnalysisAccessDeniedError(OPERATION, input.path, cause.code, {
cause,
});
if (isSelectedRegularFileError(cause))
return new AnalysisInputError(OPERATION, { cause }, [
{
path: ["path"],
reason: "invalid_value",
message: `Selected image could not be read (${String(cause.code)}): ${input.path}.`,
},
]);
}
return new ProviderAdapterError(PE_LOAD_CONFIG_PROVIDER.id, OPERATION, {
cause,
diagnostics: {
path: input.path,
reason: cause instanceof Error ? cause.message : String(cause),
},
});
};
const peLoadConfigFailure = (
cause: unknown,
input: InspectPeLoadConfigInput,
snapshot?: StableArtifactSnapshot,
): AnalysisError => {
const primary = peLoadConfigFailurePrimary(
artifactFilePrimaryFacts(cause),
input,
);
return projectStableArtifactFailure(
): AnalysisError =>
projectPeBinaryFailure<PeLoadConfigLimit>(
cause,
primary,
input.path,
PE_LOAD_CONFIG_PROVIDER.id,
OPERATION,
input,
{
operation: OPERATION,
providerId: PE_LOAD_CONFIG_PROVIDER.id,
fileSizeRemediation: () => fileSizeRemediation(input),
limit: (failure) =>
failure instanceof PeLoadConfigLimitFailure ? failure.limit : null,
limitRecovery: (limit) => limitRecovery(limit, input),
},
snapshot,
);
};
+22
View File
@@ -0,0 +1,22 @@
/** PE32+ retains 32-bit RVAs even where virtual addresses are 64-bit. */
export const PE_RVA_END = 0x100000000;
/** Whether a nonempty span fits within the 32-bit RVA space. */
export const fitsPeRvaSpan = (rva: number, size: number): boolean =>
Number.isSafeInteger(rva) &&
rva >= 0 &&
Number.isSafeInteger(size) &&
size > 0 &&
rva <= PE_RVA_END - size;
/** Append a consumed file span, merging only with a contiguous predecessor. */
export const appendPeConsumedSpan = (
spans: { offset: number; bytes: number }[],
offset: number,
bytes: number,
): void => {
const last = spans.at(-1);
if (last !== undefined && last.offset + last.bytes === offset)
last.bytes += bytes;
else spans.push({ offset, bytes });
};
+2 -2
View File
@@ -473,11 +473,11 @@ export class SigrokRecordingProvider implements SignalRecordingPort {
workspacePath: string | null,
effectiveLimits: LinuxResourceLimits | undefined,
observedOutput: AnalysisCapturedOutput | undefined,
operation = OPERATION,
operation: string,
limits: {
readonly output_bytes: number;
readonly command_timeout_ms: number;
} = LIMITS,
},
): AnalysisError {
if (cause instanceof AnalysisError) return cause;
if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined)
@@ -388,3 +388,34 @@ cliTest(
});
},
);
cliTest(
"rejects malformed APK signature view JSON before opening the selected path",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-apk-view-json-");
const result = await cli.run({
arguments: [
"inspect-apk-signature",
join(root, "missing.apk"),
"--view",
"{",
"--json",
],
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
code: "invalid_request",
details: {
operation: "inspect_apk_signature",
issues: [
{
path: ["view"],
reason: "invalid_format",
message: "view must be a JSON APK signature selection",
},
],
},
});
expect(JSON.stringify(result.json)).not.toContain("ENOENT");
},
);
+31
View File
@@ -718,3 +718,34 @@ cliTest(
expect(report.auditable_metadata?.packages).toHaveLength(2);
},
);
cliTest(
"rejects malformed Rust view JSON before opening the selected path",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-rust-view-json-");
const result = await cli.run({
arguments: [
"inspect-rust-binary",
join(root, "missing"),
"--view",
"{",
"--json",
],
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
code: "invalid_request",
details: {
operation: "inspect_rust_binary",
issues: [
{
path: ["view"],
reason: "invalid_format",
message: "view must be a JSON package or edge selection",
},
],
},
});
expect(JSON.stringify(result.json)).not.toContain("ENOENT");
},
);
@@ -104,3 +104,36 @@ cliTest.for([
});
},
);
cliTest(
"rejects malformed JNI query JSON before provider work",
async ({ cli }) => {
const result = await cli.run({
arguments: [
"inspect-android-jni-bindings",
"/nonexistent/selected.apk",
"--query",
"{",
"--json",
],
environment: {
REA_JADX_MCP_JAR: "/nonexistent/engine.jar",
REA_LOG_LEVEL: "silent",
},
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
code: "invalid_request",
details: {
operation: "inspect_android_jni_bindings",
issues: [
{
path: ["query"],
reason: "invalid_format",
message: "query must be a JSON selection object.",
},
],
},
});
},
);
@@ -12,6 +12,7 @@ import {
} from "yaml";
import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js";
import { DshCordisDocument } from "../../../src/application/DshCordisPatch.js";
import {
configureClientConfiguration,
@@ -36,6 +37,37 @@ const command = currentClientRegistrationCommand({
beforeEach(clearClientLocationEnvironment);
afterEach(() => vi.unstubAllEnvs());
it("normalizes DSH expression identity on Windows and accepts a literal alias override", () => {
const document = new DshCordisDocument(`- insert:
- id: rea-mcp
name: "@deepseek-ai/dsh-mcp-client"
config:
env:
rea_ghidra_startup_timeout_ms: !!js process.env.STARTUP
caller_value: keep
`);
const selected = document.environment({}, "effective", "win32");
expect(selected.ok).toBe(true);
if (!selected.ok) throw selected.error;
expect([...selected.value.unresolvedSettings]).toEqual([
"REA_GHIDRA_STARTUP_TIMEOUT_MS",
]);
expect(selected.value.values).toEqual({ caller_value: "keep" });
const overridden = document.environment(
{ REA_GHIDRA_STARTUP_TIMEOUT_MS: "45000" },
"effective",
"win32",
);
expect(overridden.ok).toBe(true);
if (!overridden.ok) throw overridden.error;
expect([...overridden.value.unresolvedSettings]).toEqual([]);
expect(overridden.value.values).toEqual({
caller_value: "keep",
REA_GHIDRA_STARTUP_TIMEOUT_MS: "45000",
});
});
const dshClient = (
env: Parameters<typeof supportedClients>[2],
platform: NodeJS.Platform = "linux",
@@ -0,0 +1,32 @@
import { open, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, onTestFinished, vi } from "vitest";
import { locateDenoStandalone } from "../../../src/artifacts/deno/DenoStandaloneLocation.js";
import { locateNodeSea } from "../../../src/artifacts/nodeSea/NodeSeaLocation.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
it.each([
["Deno", locateDenoStandalone],
["Node SEA", locateNodeSea],
] as const)(
"retains cancellation arriving with carrier EOF for %s",
async (_name, locate) => {
const root = await createTestTempDirectory("rea-carrier-eof-");
const path = join(root, "carrier");
await writeFile(path, Buffer.alloc(64));
const handle = await open(path, "r");
onTestFinished(() => handle.close());
const controller = new AbortController();
const reason = new Error("cancelled while reading");
vi.spyOn(handle, "read").mockImplementationOnce((async (
buffer: unknown,
) => {
controller.abort(reason);
return { bytesRead: 0, buffer };
}) as typeof handle.read);
await expect(locate(handle, 64, controller.signal)).rejects.toMatchObject({
reason: "cancelled",
cause: reason,
});
},
);
@@ -25,6 +25,8 @@ import {
type ApplicationMcpHarness,
} from "../../fixtures/applicationMcpHarness.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { MANAGED_RECONSTRUCTION_IMPORT_EXAMPLE } from "../../../src/contracts/managed/managedWorkflowExamples.js";
const COLLECTIONS = [
"types",
@@ -360,3 +362,176 @@ it("keeps collections that historical member Evidence never examined unknown", a
}),
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
}, 30000);
it("binds managed member views to their recorded token scope", async () => {
const original = MANAGED_RECONSTRUCTION_IMPORT_EXAMPLE.static_members;
const report = managedMemberInspectionSchema.parse(
original.normalized_result,
);
if (report.module?.mvid == null) throw new Error("Fixture requires an MVID");
const mismatchedDigest = "f".repeat(64);
const mismatchedMvid = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
const cases = [
{
name: "digest",
result: {
...report,
identity_scope: {
...report.identity_scope,
requires_artifact_sha256: mismatchedDigest,
},
},
error: "token scope SHA-256",
},
{
name: "MVID",
result: {
...report,
identity_scope: {
...report.identity_scope,
requires_mvid: mismatchedMvid,
},
},
error: "token scope MVID",
},
{
name: "missing module",
result: { ...report, module: null },
error: "token scope MVID",
},
{
name: "case-insensitive MVID",
result: {
...report,
module: {
...report.module,
mvid: "abcdef12-3456-4abc-8def-123456789abc",
},
identity_scope: {
...report.identity_scope,
requires_mvid: "ABCDEF12-3456-4ABC-8DEF-123456789ABC",
},
},
error: undefined,
},
{
name: "null MVID",
result: {
...report,
module: null,
identity_scope: { ...report.identity_scope, requires_mvid: null },
},
error: undefined,
},
];
for (const test of cases) {
for (const subject of [true, false]) {
const evidence = createEvidence(
subject
? {
path: report.artifact.path,
format: "pe",
sha256: report.artifact.sha256,
}
: undefined,
original.provider,
{
operation: original.operation,
parameters: original.parameters,
result: test.result,
},
);
for (const view of [
{ kind: "summary" as const },
{
kind: "managed-members" as const,
collection: "methods" as const,
offset: 0,
limit: 1,
},
]) {
const actual = await inspectAnalysisView({
source: { kind: "inline", evidence },
view,
});
if (test.error === undefined) {
expect(actual.ok, test.name).toBe(true);
if (!actual.ok) continue;
if (!subject)
expect(JSON.stringify(actual.value.normalized_result)).toContain(
"no recorded subject",
);
} else {
expect(actual, test.name).toMatchObject({
ok: false,
error: {
_tag: "EvidenceIntegrityError",
message: expect.stringContaining(test.error),
},
});
}
}
}
}
const incompatible = createEvidence(
{ path: report.artifact.path, format: "pe", sha256: mismatchedDigest },
original.provider,
{
operation: original.operation,
parameters: original.parameters,
result: {
...report,
identity_scope: {
...report.identity_scope,
requires_artifact_sha256: mismatchedDigest,
},
},
},
);
const actual = await inspectAnalysisView({
source: { kind: "inline", evidence: incompatible },
view: { kind: "summary" },
});
expect(actual).toMatchObject({
ok: false,
error: {
_tag: "EvidenceIntegrityError",
message: expect.stringContaining("Parent subject SHA-256"),
},
});
});
it("rejects a contradictory managed token scope through MCP for inline and retained Evidence", async () => {
const original = MANAGED_RECONSTRUCTION_IMPORT_EXAMPLE.static_members;
const report = managedMemberInspectionSchema.parse(
original.normalized_result,
);
const evidence = createEvidence(undefined, original.provider, {
operation: original.operation,
parameters: original.parameters,
result: {
...report,
identity_scope: {
...report.identity_scope,
requires_artifact_sha256: "f".repeat(64),
},
},
});
const { client, session } = await createHarness();
expect(session.recordEvidence(evidence).ok).toBe(true);
for (const source of [
{ kind: "inline" as const, evidence },
{ kind: "retained-evidence" as const, evidence_id: evidence.evidence_id },
]) {
const response = await client.callTool({
name: "inspect_analysis_view",
arguments: { source, view: { kind: "summary" } },
});
expect(parseMcpToolError(response).error).toMatchObject({
code: "evidence_integrity_mismatch",
category: "integrity_mismatch",
details: { reason: expect.stringContaining("token scope SHA-256") },
});
}
});
+25 -11
View File
@@ -39,6 +39,19 @@ sys.modules[spec.name] = bridge
spec.loader.exec_module(bridge)
def _encoded_response(server, value, client_nonce, server_nonce):
"""Capture the production delivery path without its fallback response."""
output = bytearray()
class Capture:
def sendall(self, data):
output.extend(data)
if not server._deliver_authenticated(Capture(), value, client_nonce, server_nonce):
return None
return bytes(output)
def _frame(connection):
data = bytearray()
while b"\n" not in data:
@@ -251,23 +264,22 @@ class ResponseEncodingTest(unittest.TestCase):
with self.subTest(suffix=suffix):
output = "a" * (5 * 1024 * 1024 - 1) + suffix
value = {"ok": True, "output": output, "execution_state": "complete"}
encoded = self.server._encode_authenticated_response(value, "a" * 64, "b" * 64)
encoded = _encoded_response(self.server, value, "a" * 64, "b" * 64)
self.assertIsNotNone(encoded)
self.assertLessEqual(len(encoded), bridge.MAX_FRAME_BYTES)
frame = json.loads(encoded)
self.assertEqual(self.assert_authenticated_response(frame), value)
self.assertIsNone(self.server._encode_authenticated_response({
self.assertIsNone(_encoded_response(self.server, {
"ok": True, "output": "😀" * (5 * 1024 * 1024),
"execution_state": "complete",
}, "a" * 64, "b" * 64))
def test_streamed_frame_matches_the_encoded_frame(self):
def test_streamed_frame_preserves_unicode_and_authentication(self):
value = {
"ok": True,
"output": 'say "hi"\\\n雪\u2028',
"execution_state": "complete",
}
encoded = self.server._encode_authenticated_response(value, "a" * 64, "b" * 64)
sender, receiver = socket.socketpair()
try:
receiver.settimeout(2)
@@ -282,8 +294,8 @@ class ResponseEncodingTest(unittest.TestCase):
finally:
sender.close()
receiver.close()
self.assertEqual(bytes(streamed), encoded)
self.assertEqual(json.loads(json.loads(encoded)["response"]), value)
frame = json.loads(bytes(streamed))
self.assertEqual(self.assert_authenticated_response(frame), value)
def test_json_pieces_stay_within_one_chunk(self):
output = 'say "hi"\\\n雪\u0001' * 20000
@@ -891,7 +903,8 @@ class BridgeRequestCeilingTest(unittest.TestCase):
server.session_id = "00000000-0000-4000-8000-000000000001"
def encode(length):
return server._encode_authenticated_response(
return _encoded_response(
server,
{
"ok": True,
"output": "A" * length,
@@ -942,10 +955,11 @@ class BridgeRequestCeilingTest(unittest.TestCase):
"completed_connection": "f" * 16,
}
self.assertIsNone(
server._encode_authenticated_response(status, "a" * 64, "b" * 64)
_encoded_response(server, status, "a" * 64, "b" * 64)
)
required = server._bounded_status_response(status)
encoded = server._encode_authenticated_response(
encoded = _encoded_response(
server,
required, "a" * 64, "b" * 64
)
self.assertIsNotNone(encoded)
@@ -993,7 +1007,7 @@ class BridgeRequestCeilingTest(unittest.TestCase):
"identity_status": "partial",
}
self.assertIsNone(
server._encode_authenticated_response(value, "a" * 64, "b" * 64)
_encoded_response(server, value, "a" * 64, "b" * 64)
)
captured = bytearray()
@@ -1028,7 +1042,7 @@ class BridgeRequestCeilingTest(unittest.TestCase):
server = module._BridgeServer.__new__(module._BridgeServer)
server.token = "token"
server.session_id = "00000000-0000-4000-8000-000000000001"
encoded = server._encode_authenticated_response(value, "a" * 64, "b" * 64)
encoded = _encoded_response(server, value, "a" * 64, "b" * 64)
self.assertIsNotNone(encoded)
response = json.loads(json.loads(encoded)["response"])
self.assertEqual(response["output"], zeros)