Files
rea/src/domain/analysisSnapshot.ts
T
N0zoM1z0andN0zoM1z0 21da71ec6d refactor(domain): remove provider-specific target and snapshot state
Move Hopper loader derivation and compatibility projection into the Hopper adapter so BinaryTarget carries only provider-neutral identity and format metadata.

Introduce bounded canonical analysis-profile commitments, exact provider/profile snapshot v2 bindings, legacy snapshot rejection guidance, and backward-compatible profiled Evidence v2 records. Partition cache and replay behavior by target, concrete provider build, and profile while preserving custom Hopper loader overrides.

Propagate profiles consistently through CLI, MCP, composed workflows, and session lifecycle; preserve the 78-tool contract and legacy loaderArgs response projection. Add migration documentation, generated API updates, property regressions, package verification, and real Hopper coverage.
2026-07-15 20:31:56 +09:00

327 lines
10 KiB
TypeScript

import { createHash } from "node:crypto";
import canonicalize from "canonicalize";
import { z } from "zod";
import {
analysisProfileSchema,
analysisProfilesEqual,
committedProviderSchema,
type AnalysisProfileCommitment,
} from "./analysisProfile.js";
import type { BinaryTarget } from "./binaryTarget.js";
import {
evidenceBundleForTarget,
evidenceBundleSchema,
parseEvidenceBundle,
} from "./evidenceBundle.js";
import {
evidenceLocationSchema,
type Evidence,
type EvidenceLocation,
type EvidenceSubjectTarget,
} from "./evidence.js";
import {
jsonObjectSchema,
jsonValueSchema,
type JsonValue,
} from "./jsonValue.js";
const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u);
const architectureSchema = z.enum(["x86", "x86_64", "arm", "arm64"]);
const formatSchema = z.enum([
"analysis-database",
"mach-o",
"elf",
"pe",
"zip",
"ipa",
"apk",
"asar",
"dmg",
"pkg",
"plist",
"javascript",
"source-map",
]);
const kindSchema = z.enum(["executable", "database", "archive", "artifact"]);
const subjectSchema = z.object({
path: z.string().min(1),
sha256: digestSchema,
format: z.enum([
...formatSchema.options,
"hopper",
"directory",
"file",
"unknown",
"mach-o-universal",
"javascript-bundle",
"entitlements",
]),
architecture: architectureSchema.nullable(),
});
const targetSchema = z.object({
sha256: digestSchema,
kind: kindSchema,
format: formatSchema,
architecture: architectureSchema.nullable(),
});
const bindingSchema = z
.object({
provider: committedProviderSchema,
analysis_profile: analysisProfileSchema,
})
.superRefine((binding, context) => {
if (
binding.provider.id !== binding.analysis_profile.provider.id ||
binding.provider.name !== binding.analysis_profile.provider.name ||
binding.provider.version !== binding.analysis_profile.provider.version
)
context.addIssue({
code: "custom",
path: ["provider"],
message: "Snapshot binding provider does not match analysis profile",
});
});
const entrySchema = z.object({
query_id: z.string().regex(/^query_[a-f0-9]{64}$/u),
operation: z.string().min(1),
parameters: jsonObjectSchema,
execution: z.object({
result: jsonValueSchema,
raw_result: jsonValueSchema.nullable(),
provider: committedProviderSchema,
limitations: z.array(z.string()),
locations: z.array(evidenceLocationSchema),
subject: subjectSchema.nullable(),
}),
});
/** Provider- and profile-exact cache of successful immutable analysis calls. */
export const analysisSnapshotSchema = z.object({
snapshot_version: z.literal(2),
target: targetSchema,
binding: bindingSchema,
entries: z.array(entrySchema).max(10_000),
evidence_bundle: evidenceBundleSchema,
});
export type AnalysisSnapshot = z.infer<typeof analysisSnapshotSchema>;
export type AnalysisSnapshotEntry = z.infer<typeof entrySchema>;
export type AnalysisSnapshotTarget = z.infer<typeof targetSchema>;
export type AnalysisSnapshotBinding = z.infer<typeof bindingSchema>;
interface SnapshotExecution {
readonly result: JsonValue;
readonly rawResult: JsonValue | null;
readonly provider: {
readonly id: string;
readonly name: string;
readonly version: string | null;
};
readonly analysisProfile?: AnalysisProfileCommitment;
readonly limitations: readonly string[];
readonly locations: readonly EvidenceLocation[];
readonly subject: EvidenceSubjectTarget | null;
}
const canonicalJson = (value: unknown): string => {
const json = jsonValueSchema.parse(value);
const encoded = canonicalize(json);
if (encoded === undefined)
throw new TypeError("RFC 8785 canonicalization rejected snapshot data");
return encoded;
};
/** Project a binary into the path-free identity used for cache invalidation. */
export const snapshotTarget = (
target: BinaryTarget,
): AnalysisSnapshotTarget => ({
sha256: target.sha256,
kind: target.kind,
format: target.format,
architecture: target.architecture ?? null,
});
/** Build the immutable provider/profile binding used by snapshot v2. */
export const snapshotBinding = (
profile: AnalysisProfileCommitment,
): AnalysisSnapshotBinding =>
bindingSchema.parse({
provider: profile.provider,
analysis_profile: profile,
});
/** Compare an open binary with the immutable target identity in a snapshot. */
export const snapshotMatchesTarget = (
snapshot: AnalysisSnapshotTarget,
target: BinaryTarget,
): boolean =>
snapshot.sha256 === target.sha256 &&
snapshot.kind === target.kind &&
snapshot.format === target.format &&
snapshot.architecture === (target.architecture ?? null);
/** Compare a snapshot binding with the selected concrete analysis profile. */
export const snapshotMatchesProfile = (
binding: AnalysisSnapshotBinding,
profile: AnalysisProfileCommitment,
): boolean =>
binding.provider.id === profile.provider.id &&
binding.provider.name === profile.provider.name &&
binding.provider.version === profile.provider.version &&
analysisProfilesEqual(binding.analysis_profile, profile);
/** Require an exact target and selected provider/profile cache partition. */
export const snapshotMatchesBinding = (
snapshot: Pick<AnalysisSnapshot, "target" | "binding">,
target: BinaryTarget,
profile: AnalysisProfileCommitment,
): boolean =>
snapshotMatchesTarget(snapshot.target, target) &&
snapshotMatchesProfile(snapshot.binding, profile);
/** Find exact persisted CLI Evidence without starting an analysis provider. */
export const snapshotEvidenceForQuery = (
snapshot: AnalysisSnapshot,
query: {
readonly target: BinaryTarget;
readonly bindingProfile: AnalysisProfileCommitment;
readonly operation: string;
readonly parameters: Readonly<Record<string, JsonValue>>;
readonly provider: SnapshotExecution["provider"];
readonly evidenceProfile: AnalysisProfileCommitment;
},
): Evidence | undefined => {
const {
target,
bindingProfile,
operation,
parameters,
provider,
evidenceProfile,
} = query;
if (!snapshotMatchesBinding(snapshot, target, bindingProfile))
return undefined;
const encodedParameters = canonicalJson(parameters);
return snapshot.evidence_bundle.records.find(
(record) =>
record.subject?.digest.sha256 === target.sha256 &&
record.operation === operation &&
record.provider.id === provider.id &&
record.provider.name === provider.name &&
record.provider.version === provider.version &&
"analysis_profile" in record &&
analysisProfilesEqual(record.analysis_profile, evidenceProfile) &&
canonicalJson(record.parameters) === encodedParameters,
);
};
/** Compute the stable lookup key for one provider/profile-specific query. */
export const analysisQueryId = (
target: AnalysisSnapshotTarget,
binding: AnalysisSnapshotBinding,
operation: string,
parameters: Readonly<Record<string, JsonValue>>,
): string =>
`query_${createHash("sha256")
.update(canonicalJson({ target, binding, operation, parameters }))
.digest("hex")}`;
/** Create one serializable snapshot entry from a successful provider call. */
export const createAnalysisSnapshotEntry = (input: {
readonly target: AnalysisSnapshotTarget;
readonly binding: AnalysisSnapshotBinding;
readonly operation: string;
readonly parameters: Readonly<Record<string, JsonValue>>;
readonly execution: SnapshotExecution;
}): AnalysisSnapshotEntry => {
const { target, binding, operation, parameters, execution } = input;
if (
execution.analysisProfile === undefined ||
!snapshotMatchesProfile(binding, execution.analysisProfile) ||
execution.provider.id !== binding.provider.id ||
execution.provider.name !== binding.provider.name ||
execution.provider.version !== binding.provider.version
)
throw new TypeError(
"Snapshot execution does not match its provider/profile binding",
);
return {
query_id: analysisQueryId(target, binding, operation, parameters),
operation,
parameters: jsonObjectSchema.parse(parameters),
execution: {
result: execution.result,
raw_result: execution.rawResult,
provider: binding.provider,
limitations: [...execution.limitations],
locations: [...execution.locations],
subject:
execution.subject === null
? null
: {
...execution.subject,
architecture: execution.subject.architecture ?? null,
},
},
};
};
/** Parse v2 and reject legacy snapshots, altered query IDs, or non-canonical order. */
export const parseAnalysisSnapshot = (input: unknown): AnalysisSnapshot => {
rejectLegacySnapshot(input);
const parsed = analysisSnapshotSchema.parse(input);
parseEvidenceBundle(parsed.evidence_bundle);
if (
JSON.stringify(
evidenceBundleForTarget(parsed.evidence_bundle, parsed.target.sha256),
) !== JSON.stringify(parsed.evidence_bundle)
)
throw new TypeError(
"Analysis snapshot evidence contains records for another target",
);
const ids = new Set<string>();
for (const entry of parsed.entries) {
if (
entry.execution.provider.id !== parsed.binding.provider.id ||
entry.execution.provider.name !== parsed.binding.provider.name ||
entry.execution.provider.version !== parsed.binding.provider.version
)
throw new TypeError("Analysis snapshot entry provider does not match");
const expected = analysisQueryId(
parsed.target,
parsed.binding,
entry.operation,
entry.parameters,
);
if (entry.query_id !== expected)
throw new TypeError("Analysis snapshot query identifier does not match");
if (ids.has(entry.query_id))
throw new TypeError("Analysis snapshot contains duplicate queries");
ids.add(entry.query_id);
}
const sorted = [...parsed.entries].sort((left, right) =>
left.query_id.localeCompare(right.query_id),
);
if (JSON.stringify(parsed.entries) !== JSON.stringify(sorted))
throw new TypeError("Analysis snapshot entries are not canonical");
return parsed;
};
/** Serialize a validated snapshot with byte-stable canonical entry ordering. */
export const serializeAnalysisSnapshot = (snapshot: AnalysisSnapshot): string =>
`${canonicalJson(parseAnalysisSnapshot(snapshot))}\n`;
const rejectLegacySnapshot = (input: unknown): void => {
if (
typeof input === "object" &&
input !== null &&
"snapshot_version" in input &&
input.snapshot_version === 1
)
throw new TypeError(
"Analysis snapshot v1 is incompatible with profile-exact replay; recapture it as snapshot v2. Its Evidence bundle may be imported separately.",
);
};