mirror of
https://github.com/morluto/rea.git
synced 2026-11-13 18:22:42 +00:00
refactor: consolidate shared analysis boundaries (#10293)
This commit is contained in:
1 parent
6878a84796
commit
c836f57b7d
70 files changed
+1240
-1131
No files matched your search
@@ -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]) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 [] {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user