rea/src/domain/analysisView/webNetworkCaptureView.ts

265 lines
8.5 KiB
TypeScript

import { z } from "zod";
import type { AnalysisError } from "../analysisErrorBase.js";
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
import type { JsonValue } from "../jsonValue.js";
import { err, ok, type Result } from "../result.js";
import {
webNetworkCaptureRecordSchema,
webNetworkCaptureBinaryDescriptorSchema,
webNetworkCaptureSchema,
webNetworkCaptureArtifactSchema,
webNetworkCaptureDecoderSchema,
WEB_NETWORK_CAPTURE_FORMATS,
type WebNetworkCaptureRecord,
type WebNetworkCapture,
} from "../webNetworkCapture.js";
import {
analysisViewInputError,
type AnalysisViewParent,
type AnalysisViewRequest,
type UnsignedAnalysisView,
} from "./analysisView.js";
import {
completeWithinViewCoverage,
pageViewCoverage,
} from "./analysisViewCoverage.js";
import {
qlogSummarySchema,
summarizeQlogCapture,
projectQlogCapturePage,
} from "./qlogCaptureView.js";
/** Offsets page the parent's retained record order, preserving producer ordinals. */
export const captureViewRequestSchema = z.strictObject({
kind: z.literal("capture"),
detail: z.enum(["preview", "complete"]).default("preview"),
offset: z.number().int().nonnegative().default(0),
limit: z.number().int().positive().default(64),
});
const previewSchema = webNetworkCaptureRecordSchema.extend({
binary_fields: z.array(webNetworkCaptureBinaryDescriptorSchema),
});
export const captureViewItemsSchema = z.array(
z.union([webNetworkCaptureRecordSchema, previewSchema]),
);
/** Count/identity overview; bodies and the archive member inventory stay in the parent. */
export const captureSummarySchema = z.strictObject({
artifact: webNetworkCaptureArtifactSchema,
decoder: webNetworkCaptureDecoderSchema,
format: z.enum(WEB_NETWORK_CAPTURE_FORMATS),
total_records: z.number().int().nonnegative(),
retained_records: z.number().int().nonnegative(),
runtime_attribution: z.literal("unknown"),
qlog: qlogSummarySchema.optional(),
});
const object = (
value: JsonValue,
): Readonly<Record<string, JsonValue>> | null =>
value !== null && typeof value === "object" && !Array.isArray(value)
? value
: null;
/** Select already parsed producer facts without interpreting HTTP or payload bytes again. */
const preview = (record: WebNetworkCaptureRecord) => {
const source = object(record.reported);
const reported: Record<string, JsonValue> = {};
const selectedPointers = new Set<string>();
if (source !== null) {
for (const key of ["session_id", "name", "time", "startedDateTime", "type"])
if (Object.hasOwn(source, key) && source[key] !== undefined) {
reported[key] = source[key];
selectedPointers.add(`/${key}`);
}
for (const key of ["request", "response"]) {
const value = source[key];
if (!Object.hasOwn(source, key) || value === undefined) continue;
const fields = object(value);
if (fields === null) {
// Preserve a reported null/scalar instead of inventing HTTP fields.
reported[key] = value;
continue;
}
const selected: Record<string, JsonValue> = {};
for (const field of [
"method",
"url",
"httpVersion",
"http_version",
"status",
"statusText",
"reason",
])
if (Object.hasOwn(fields, field) && fields[field] !== undefined) {
selected[field] = fields[field];
selectedPointers.add(`/${key}/${field}`);
}
reported[key] = selected;
}
}
return {
...record,
reported: source === null ? record.reported : reported,
binary_fields: record.binary_fields.map(
({ content_base64: _content, ...field }) => field,
),
numeric_literals:
source === null
? record.numeric_literals
: record.numeric_literals.filter(({ pointer }) =>
selectedPointers.has(pointer),
),
};
};
/** Authenticate the retained report and its subject at one owning boundary. */
const parseCaptureParent = (
parent: AnalysisViewParent,
): Result<WebNetworkCapture, AnalysisError> => {
const parsed = webNetworkCaptureSchema.safeParse(parent.normalizedResult);
if (!parsed.success)
return err(
analysisInputErrorFromIssues(
"inspect_analysis_view",
parsed.error.issues.map((issue) => ({
...issue,
path: ["source", "normalized_result", ...issue.path],
})),
{ source: { normalized_result: parent.normalizedResult } },
{ cause: parsed.error },
),
);
const report = parsed.data;
if (
report.artifact.sha256 !== null &&
(parent.subject == null ||
parent.subject.local_path !== report.artifact.path ||
parent.subject.digest.sha256 !== report.artifact.sha256)
)
return err(
analysisViewInputError([
{
path: ["source", "subject"],
reason: "invalid_value",
message:
"Capture parent subject must identify the report's exact path and SHA-256.",
},
]),
);
return ok(report);
};
/** Page immutable retained facts; no capture file, provider or network is consulted. */
export const projectWebNetworkCaptureView = (
parent: AnalysisViewParent,
view: AnalysisViewRequest,
): Result<UnsignedAnalysisView, AnalysisError> => {
if (
view.kind !== "summary" &&
view.kind !== "capture" &&
view.kind !== "qlog"
)
return err(
analysisViewInputError([
{
path: ["view"],
reason: "invalid_value",
message:
"Historical captures support summary, capture record pages, qlog fact pages and admitted api-surface views.",
},
]),
);
const parsed = parseCaptureParent(parent);
if (!parsed.ok) return parsed;
const report = parsed.value;
const shared = {
parent_evidence_id: parent.evidenceId,
parent_operation: "inspect_web_network_capture" as const,
parent_digest: parent.evidenceId.slice(3),
artifact: { path: report.artifact.path, sha256: report.artifact.sha256 },
limitations: [
...new Set([
...parent.limitations,
...report.limitations,
"Capture record pages cover the records selected into the parent Evidence, in that selected order; total_records counts the original decoded capture.",
]),
],
unknowns: [
"Historical records do not establish live runtime attribution or deployment authenticity.",
...(report.artifact.sha256 === null
? ["The explicitly excluded artifact digest remains unknown."]
: []),
],
};
if (view.kind === "summary")
return ok({
kind: "summary",
view,
...shared,
summary: {
artifact: report.artifact,
decoder: report.decoder,
format: report.format,
total_records: report.total_records,
retained_records: report.records.length,
runtime_attribution: report.runtime_attribution,
...(report.format === "qlog"
? { qlog: summarizeQlogCapture(report.qlog) }
: {}),
},
coverage: completeWithinViewCoverage(
report.records.length,
report.records.length,
),
});
if (view.kind === "qlog") {
if (report.format !== "qlog")
return err(
analysisViewInputError([
{
path: ["view", "kind"],
reason: "invalid_value",
message: "Qlog fact views require retained qlog Evidence.",
},
]),
);
const projected = projectQlogCapturePage(report.qlog, view);
return projected.ok
? ok({
kind: "qlog",
view,
...shared,
...projected.value,
limitations: [
...shared.limitations,
"Qlog fact pages retain all-recorded-events-before-selection scope. Exhausting a page does not establish network completeness, body recovery, retransmission or actual loss.",
],
})
: projected;
}
const records = report.records.slice(view.offset, view.offset + view.limit);
return ok({
kind: "capture",
view,
...shared,
items: captureViewItemsSchema.parse(
view.detail === "complete" ? records : records.map(preview),
),
coverage: pageViewCoverage(
view.offset,
records.length,
report.records.length,
),
limitations: [
...shared.limitations,
...(view.detail === "preview"
? [
"Preview selects recorded headline fields and byte descriptors; headers, XML, bodies, message data and other fields remain in complete record pages. Missing preview fields do not establish their absence from the producer record.",
]
: []),
],
});
};