Files
rea/src/application/analysisView/NativeObservationViews.test.ts
T

591 lines
18 KiB
TypeScript

import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { nativeGraphViewItemsSchema } from "../../domain/analysisView/nativeObservationSchemas.js";
import { expect, it } from "vitest";
import {
createEvidence,
rebindEvidenceToTargetPath,
type Evidence,
} from "../../domain/evidence.js";
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
import { ghidraFunctionIdentity } from "../../domain/ghidraValues.fixture.js";
import {
nativeCallGraphSchema,
type NativeCallGraph,
} from "../../domain/native/nativeCallGraph.js";
import {
analysisViewResultSchema,
type AnalysisViewRequest,
type AnalysisViewResult,
} from "../../domain/analysisView/analysisView.js";
import {
inspectAnalysisView,
projectRetainedAnalysis,
} from "./AnalysisViewService.js";
import { EvidenceLedger } from "../investigation/EvidenceLedger.js";
const provider = { id: "fixture", name: "Fixture provider", version: "1" };
const parent = (
operation: string,
result: unknown,
limitations = ["Source caveat"],
profileValue = 1,
) =>
createEvidence(
{
path: "/selected/fixture.elf",
sha256: "a".repeat(64),
format: "elf",
architecture: "x86_64",
},
provider,
{
operation,
parameters: { procedure: "selected_symbol" },
result,
limitations,
analysisProfile: createAnalysisProfile(provider, {
profile: profileValue,
}),
...(operation === "procedure_callees"
? {
relationshipCoverage: {
status: "partial" as const,
limitations: ["Indirect calls remain unknown"],
limited_directions: ["callees"] as ["callees"],
},
}
: {}),
},
{ immutable: true },
);
const project = async (source: Evidence, view: AnalysisViewRequest) => {
const result = await inspectAnalysisView({
source: { kind: "inline", evidence: source },
view,
});
if (!result.ok) throw result.error;
return {
evidence: result.value,
value: analysisViewResultSchema.parse(result.value.normalized_result),
};
};
const contextDigest = (value: AnalysisViewResult): string => {
if (!("source_context" in value))
throw new Error("Expected native source context");
return value.source_context.digest;
};
const graph = (): NativeCallGraph =>
nativeCallGraphSchema.parse({
graph: {
"2": [
{
address: "ram:0x0010",
status: "ok",
calls: ["ram:0x0020"],
relationship_coverage: {
status: "complete",
diagnostics: ["Shared diagnostic 雪🧭"],
},
},
],
"10": [
{
address: "ram:0x0010",
status: "ok",
calls: [],
limitations: [],
relationship_coverage: {
status: "unknown",
reason: "Shared caveat",
limited_directions: ["callers"],
diagnostics: ["Shared diagnostic 雪🧭"],
},
},
],
"01": [
{
address: "ram:0x0010",
status: "ok",
calls: ["ram:0x0020", "ram:0x0020"],
limitations: ["Shared caveat", "Shared caveat"],
relationship_coverage: {
status: "partial",
limitations: ["Shared caveat", "Shared caveat"],
limited_directions: ["callees"],
diagnostics: ["Shared diagnostic 雪🧭"],
},
},
],
},
limitations: ["Graph caveat", "Graph caveat"],
});
it.each(["procedure_callees", "xrefs"])(
"pages %s addresses with exact source context and duplicate identity",
async (operation) => {
const source = parent(operation, [
"ram:0x0010",
"ram:0x0010",
"ram:0x0020",
]);
const before = JSON.stringify(source);
const summary = await project(source, { kind: "summary" });
expect(summary.value).toMatchObject({
kind: "summary",
parent_evidence_id: source.evidence_id,
source_provider: source.provider,
source_parameters: source.parameters,
source_analysis_profile_digest: source.analysis_profile?.digest,
source_context: {
evidence_limitation_count: 1,
result_limitation_count: null,
},
summary: { observations: 3 },
items: {
type: "addresses",
rows: [
{ source_index: 0, address: "ram:0x0010" },
{ source_index: 1, address: "ram:0x0010" },
{ source_index: 2, address: "ram:0x0020" },
],
},
});
expect(summary.evidence.relationship_coverage).toEqual(
source.relationship_coverage,
);
const page = await project(source, {
kind: "native-observation",
offset: 1,
limit: 1,
});
expect(page.value).toMatchObject({
items: { rows: [{ source_index: 1, address: "ram:0x0010" }] },
coverage: { total: 3, next_offset: 2, exhausted: false },
});
expect(page.value).toHaveProperty(
"source_context.digest",
contextDigest(summary.value),
);
expect(JSON.stringify(summary.value)).not.toContain('"analysis_profile":');
expect(JSON.stringify(source)).toBe(before);
},
);
it("pages complete context fields and changes its digest when source profile or caveats change", async () => {
const source = parent("procedure_callers", []);
const overview = await project(source, {
kind: "native-context",
facet: "overview",
offset: 0,
limit: 1,
});
expect(overview.value).toMatchObject({
items: {
type: "overview",
rows: [
{
record: {
subject: source.subject,
provider,
analysis_profile_digest: source.analysis_profile?.digest,
},
},
],
},
});
const profile = await project(source, {
kind: "native-context",
facet: "analysis-profile",
offset: 0,
limit: 1,
});
expect(profile.value).toMatchObject({
items: { rows: [{ source_index: 0, record: source.analysis_profile }] },
});
const caveat = await project(source, {
kind: "native-context",
facet: "limitations",
offset: 0,
limit: 1,
});
expect(caveat.value).toMatchObject({
items: { rows: [{ source_index: 0, text: "Source caveat" }] },
});
for (const changed of [
parent("procedure_callers", [], ["Changed caveat"]),
parent("procedure_callers", [], ["Source caveat"], 2),
]) {
const changedView = await project(changed, { kind: "summary" });
expect(changedView.value).not.toHaveProperty(
"source_context.digest",
contextDigest(overview.value),
);
}
});
it("keeps absent profile and inapplicable result caveats distinct from observed empty data", async () => {
const source = createEvidence(undefined, provider, {
operation: "procedure_address",
parameters: {},
result: "0x0000",
});
expect(
(
await project(source, {
kind: "native-context",
facet: "analysis-profile",
offset: 0,
limit: 64,
})
).value,
).toMatchObject({ items: { rows: [{ source_index: 0, record: null }] } });
expect(
(
await project(source, {
kind: "native-context",
facet: "result-limitations",
offset: 0,
limit: 64,
})
).value,
).toMatchObject({
source_context: { result_limitation_count: null },
items: { rows: null },
unknowns: expect.arrayContaining([
expect.stringContaining("not applicable"),
]),
});
expect(
(
await project(parent("procedure_callers", []), {
kind: "native-observation",
offset: 0,
limit: 64,
})
).value,
).toMatchObject({
summary: { observations: 0 },
items: { rows: [] },
coverage: { status: "empty", exhausted: true },
});
});
it.each([true, false])(
"preserves the complete containing-procedure outcome (found=%s)",
async (found) => {
const report = found
? {
query_address: "0x401001",
found: true,
procedure: ghidraFunctionIdentity(),
}
: {
query_address: "0x9999",
found: false,
procedure: null,
reason: "not_in_procedure",
};
const result = await project(
parent("resolve_containing_procedure", report),
{ kind: "summary" },
);
expect(result.value).toMatchObject({
summary: { observations: 1 },
items: { rows: [{ source_index: 0, record: report }] },
});
},
);
it("reconstructs every graph field from dictionary pages without changing nulls, duplicate messages or level identities", async () => {
const report = graph();
const source = parent("get_call_graph", report);
const decoded: NativeCallGraph["graph"] = {};
for (let offset = 0; offset < 3; offset++) {
const { value } = await project(source, {
kind: "native-observation",
offset,
limit: 1,
});
if (
value.kind !== "native-observation" ||
value.parent_operation !== "get_call_graph"
)
throw new Error("Expected graph page");
const { rows, messages } = value.items;
expect(rows).toHaveLength(1);
const row = rows[0]!;
expect(row.source_index).toBe(0);
expect(row.level).toBe(["01", "10", "2"][offset]);
expect(value.coverage).toMatchObject({ total: 3, exhausted: offset === 2 });
if (row.status !== "ok") throw new Error("Expected recorded node");
const coverage = row.relationship_coverage;
const diagnostics =
coverage.diagnostic_ids === undefined
? {}
: { diagnostics: coverage.diagnostic_ids.map((i) => messages[i]!) };
const directions =
"limited_directions" in coverage &&
coverage.limited_directions !== undefined
? { limited_directions: coverage.limited_directions }
: {};
const originalCoverage =
coverage.status === "complete"
? { status: "complete" as const, ...diagnostics }
: coverage.status === "partial"
? {
status: "partial" as const,
limitations: coverage.limitation_ids.map((i) => messages[i]!),
...directions,
...diagnostics,
}
: {
status: "unknown" as const,
reason: messages[coverage.reason_id]!,
...directions,
...diagnostics,
};
decoded[row.level] = [
{
address: row.address,
status: row.status,
calls: row.calls,
relationship_coverage: originalCoverage,
...(row.limitation_ids === undefined
? {}
: { limitations: row.limitation_ids.map((i) => messages[i]!) }),
},
];
expect(messages).toHaveLength(coverage.status === "complete" ? 1 : 2);
}
expect(decoded).toEqual(report.graph);
const caveats = await project(source, {
kind: "native-context",
facet: "result-limitations",
offset: 1,
limit: 1,
});
expect(caveats.value).toMatchObject({
source_context: { result_limitation_count: 2 },
items: { rows: [{ source_index: 1, text: "Graph caveat" }] },
});
});
it("rejects malformed unselected source facts and preserves exact source paths", async () => {
const malformed = {
...graph(),
graph: {
"0": [
{
address: "0x10",
status: "ok",
calls: [],
relationship_coverage: { status: "complete" },
},
{
address: "0x20",
status: "ok",
calls: [42],
relationship_coverage: { status: "complete" },
},
],
},
};
const result = await inspectAnalysisView({
source: { kind: "inline", evidence: parent("get_call_graph", malformed) },
view: { kind: "native-observation", offset: 0, limit: 1 },
});
expect(result).toMatchObject({
ok: false,
error: {
_tag: "AnalysisInputError",
issues: expect.arrayContaining([
expect.objectContaining({
path: ["source", "normalized_result", "graph", "0", 1, "calls", 0],
}),
]),
},
});
});
it("retains failed graph nodes and rejects broken message references", async () => {
const source = graph();
const failure = {
address: "ram:0x0010",
status: "error" as const,
error: projectAnalysisError(
new AnalysisCapabilityUnavailableError(
"fixture",
"procedure_callees",
"No call references",
),
),
};
source.graph.failure = [failure];
const result = await project(parent("get_call_graph", source), {
kind: "native-observation",
offset: 3,
limit: 1,
});
expect(result.value).toMatchObject({
items: {
type: "graph-nodes",
messages: [],
rows: [{ level: "failure", source_index: 0, ...failure }],
},
coverage: { total: 4, exhausted: true },
});
const normal = await project(parent("get_call_graph", graph()), {
kind: "native-observation",
offset: 0,
limit: 1,
});
if (
normal.value.kind !== "native-observation" ||
normal.value.parent_operation !== "get_call_graph"
)
throw new Error("Expected graph page");
expect(
nativeGraphViewItemsSchema.safeParse({
...normal.value.items,
messages: [],
}).success,
).toBe(false);
});
it("keeps graph row identity stable when source object key insertion order changes", async () => {
const report = graph();
report.graph.beta = [report.graph["2"]![0]!];
const first = parent("get_call_graph", report);
const reversed = parent("get_call_graph", {
...report,
graph: Object.fromEntries(Object.entries(report.graph).reverse()),
});
expect(Object.keys(report.graph)).not.toEqual(
Object.keys(Object.fromEntries(Object.entries(report.graph).reverse())),
);
expect(first.evidence_id).toBe(reversed.evidence_id);
expect(
await project(first, { kind: "native-observation", offset: 0, limit: 64 }),
).toEqual(
await project(reversed, {
kind: "native-observation",
offset: 0,
limit: 64,
}),
);
});
it("keeps context identity across authenticated target path aliases while preserving selected paths", async () => {
const original = parent("procedure_callers", []);
const rebound = rebindEvidenceToTargetPath(original, {
path: "/other/alias.elf",
sha256: "a".repeat(64),
format: "elf",
architecture: "x86_64",
});
expect(rebound.evidence_id).toBe(original.evidence_id);
const view = {
kind: "native-context" as const,
facet: "overview" as const,
offset: 0,
limit: 1,
};
const first = await project(original, view);
const second = await project(rebound, view);
expect(contextDigest(second.value)).toBe(contextDigest(first.value));
expect(second.value).toMatchObject({
items: {
rows: [{ record: { subject: { local_path: "/other/alias.elf" } } }],
},
});
expect(second.evidence.subject?.local_path).toBe("/other/alias.elf");
expect(second.evidence.evidence_id).not.toBe(first.evidence.evidence_id);
const ledger = new EvidenceLedger();
expect(ledger.record(original).ok).toBe(true);
const retained = ledger.forAnalysis(original.evidence_id)!;
const delivered = projectRetainedAnalysis(
rebound,
{
recordEvidence: (evidence) => ledger.record(evidence),
evidenceById: (id) => ledger.forAnalysis(id),
},
view,
);
if (!delivered.ok) throw delivered.error;
expect(delivered.value.subject?.local_path).toBe("/other/alias.elf");
expect(delivered.value.normalized_result).toMatchObject({
artifact: { path: "/other/alias.elf" },
items: {
rows: [{ record: { subject: { local_path: "/other/alias.elf" } } }],
},
});
expect(ledger.forAnalysis(original.evidence_id)).toBe(retained);
expect(retained.subject?.local_path).toBe("/selected/fixture.elf");
const imported = new EvidenceLedger();
const exporting = new EvidenceLedger();
expect(exporting.record(rebound).ok).toBe(true);
expect(imported.import(exporting.export()).ok).toBe(true);
const replay = await inspectAnalysisView(
{
source: { kind: "retained-evidence", evidence_id: rebound.evidence_id },
view,
},
(id) => imported.forAnalysis(id),
);
if (!replay.ok) throw replay.error;
expect(replay.value.subject?.local_path).toBe("/other/alias.elf");
expect(replay.value.normalized_result).toMatchObject({
artifact: { path: "/other/alias.elf" },
items: {
rows: [{ record: { subject: { local_path: "/other/alias.elf" } } }],
},
});
});
it("pages recorded empty graph levels without inventing nodes or dropping their identity", async () => {
const report = { graph: { absent: [], empty: [] }, limitations: [] };
const result = await project(parent("get_call_graph", report), {
kind: "native-observation",
offset: 1,
limit: 1,
});
expect(result.value).toMatchObject({
items: {
messages: [],
rows: [{ level: "empty", source_index: null, status: "empty-level" }],
},
summary: { observations: 2 },
coverage: { total: 2, exhausted: true },
});
});
it.each([
{ status: "partial" as const, limitations: ["Unresolved indirect edges"] },
{ status: "unknown" as const, reason: "Provider did not establish coverage" },
])(
"keeps $status relationship coverage on every context facet",
async (coverage) => {
const source = createEvidence(undefined, provider, {
operation: "procedure_callees",
parameters: {},
result: [],
relationshipCoverage: coverage,
});
for (const facet of [
"overview",
"analysis-profile",
"limitations",
"result-limitations",
] as const) {
const result = await project(source, {
kind: "native-context",
facet,
offset: 0,
limit: 64,
});
expect(result.evidence.relationship_coverage).toEqual(coverage);
}
},
);