Files
reverse-engineering-anything/src/domain/analysisView/scenarioCaptureRecords.ts
T

304 lines
11 KiB
TypeScript

import { AnalysisUnsupportedTargetError } from "../analysisErrorCore.js";
import type { AnalysisError } from "../analysisErrorBase.js";
import type {
BrowserNetworkBody,
BrowserNetworkHeaders,
} from "../browserNetworkEvidence.js";
import {
browserScenarioCaptureSchema,
type BrowserScenarioCapture,
} from "../browserScenarioCapture.js";
import type { BrowserScenarioEvent } from "../browserScenarioCaptureValues.js";
import {
indexBrowserScenarioTransactions,
type BrowserScenarioTransactionIndex,
} from "../browserScenarioTransactions.js";
import type { JsonValue } from "../jsonValue.js";
import { err, ok, type Result } from "../result.js";
import type { WebNetworkCaptureRecord } from "../webNetworkCapture.js";
import { analysisViewInputError } from "./analysisViewErrors.js";
import type { AnalysisViewParent } from "./analysisView.js";
import type { WebNetworkProjectionAccumulator } from "./WebNetworkProjectionAccumulator.js";
import { textBytes } from "./webNetworkRouteTemplates.js";
type RequestEvent = Extract<BrowserScenarioEvent, { kind: "request" }>;
type ContentEvent = Extract<BrowserScenarioEvent, { kind: "network-content" }>;
type CapturedBody = Extract<BrowserNetworkBody, { state: "captured" }>;
/** A scenario capture's network transactions in the record shape HAR captures project from. */
export interface ScenarioCaptureRecords {
readonly records: readonly WebNetworkCaptureRecord[];
readonly totalRecords: number;
readonly limitations: readonly string[];
}
/** The one content event of an exchange phase, or undefined when absent or ambiguous. */
const onlyContent = (
index: BrowserScenarioTransactionIndex,
sequence: number | undefined,
): ContentEvent | undefined => {
const found =
sequence === undefined ? undefined : index.contents.get(sequence);
return found?.length === 1 ? found[0] : undefined;
};
const headerValue = (
headers: BrowserNetworkHeaders | undefined,
name: string,
): string | undefined => {
if (headers?.state !== "captured") return undefined;
const item = headers.items.find(
(candidate) => candidate.name.toLowerCase() === name,
);
return item?.value ?? undefined;
};
const captured = (
content: ContentEvent | undefined,
): CapturedBody | undefined =>
content?.body.state === "captured" ? content.body : undefined;
/** A captured response body, unless redaction emptied it because a secret could not be cut out. */
const retainedBody = (
content: ContentEvent | undefined,
): CapturedBody | undefined => {
const body = captured(content);
return body?.redacted === true && body.bytes === 0 ? undefined : body;
};
/** Strictly decoded text, keeping a byte-order mark; undefined for bytes that are not UTF-8. */
const utf8Text = (body: CapturedBody): string | undefined => {
try {
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
Buffer.from(body.content, "base64"),
);
} catch {
return undefined;
}
};
const mediaType = (content: ContentEvent | undefined): string | undefined =>
captured(content)?.media_type ??
headerValue(content?.headers, "content-type");
/** The request object HAR readers expect; a redacted URL is withheld so no path is templated from it. */
const reportedRequest = (
request: RequestEvent,
content: ContentEvent | undefined,
purpose: "manifest" | "replay",
): { readonly [key: string]: JsonValue } => {
const body = purpose === "manifest" ? captured(content) : undefined;
const text = body === undefined ? undefined : utf8Text(body);
const mimeType = mediaType(content);
return {
method: request.method,
url: request.url.redacted ? null : request.url.url,
...(text === undefined || mimeType === undefined
? {}
: { postData: { mimeType, text } }),
};
};
const record = (
ordinal: number,
observed: { readonly event: RequestEvent; readonly item: number },
index: BrowserScenarioTransactionIndex,
purpose: "manifest" | "replay",
): {
readonly record: WebNetworkCaptureRecord;
/** The record keeps a usable request line, so the manifest may cite it. */
readonly represented: boolean;
readonly body: boolean;
readonly redacted: boolean;
} => {
const { item, event: request } = observed;
const location = {
kind: "json-pointer" as const,
pointer: `/events/items/${String(item)}`,
};
const empty = {
binary_fields: [],
numeric_literals: [],
redactions: [],
limitations: [],
};
const responses = index.responses.get(request.transaction_id) ?? [];
const response = responses.length === 1 ? responses[0] : undefined;
// One request, at most one response and one content event per phase: otherwise the
// transaction cannot be attributed and is left unrepresented.
if (
index.requestCounts.get(request.transaction_id) !== 1 ||
responses.length > 1 ||
(index.contents.get(request.sequence)?.length ?? 0) > 1 ||
(response !== undefined &&
(index.contents.get(response.sequence)?.length ?? 0) > 1)
)
return {
record: { ordinal, location, reported: {}, ...empty },
represented: false,
body: false,
redacted: false,
};
const requestContent = onlyContent(index, request.sequence);
const responseContent = onlyContent(index, response?.sequence);
const body = retainedBody(responseContent);
const mimeType = mediaType(responseContent) ?? "";
return {
record: {
ordinal,
location,
reported: {
request: reportedRequest(request, requestContent, purpose),
...(response === undefined
? {}
: {
response: {
status: response.status,
content:
body === undefined
? { mimeType }
: { mimeType, text: body.content, encoding: "base64" },
},
}),
},
...empty,
binary_fields:
body === undefined
? []
: [
{
pointer: "/response/content/text",
representation: "har-base64-content",
state: "retained",
bytes: body.bytes,
sha256: body.sha256,
content_base64: body.content,
},
],
},
represented: !request.url.redacted,
body: body !== undefined,
redacted: body?.redacted === true,
};
};
/** Project each network transaction, in request order, as one record; the ordinal is its zero-based position. */
export const scenarioCaptureRecords = (
capture: BrowserScenarioCapture,
purpose: "manifest" | "replay",
): ScenarioCaptureRecords => {
const index = indexBrowserScenarioTransactions(capture.events.items);
const records: WebNetworkCaptureRecord[] = [];
let withoutBody = 0;
let representedCount = 0;
let redactedBodies = 0;
for (const [item, event] of capture.events.items.entries()) {
if (event.kind !== "request") continue;
const projected = record(records.length, { event, item }, index, purpose);
records.push(projected.record);
if (!projected.represented) continue;
representedCount += 1;
if (!projected.body) withoutBody += 1;
if (projected.redacted) redactedBodies += 1;
}
return {
records,
totalRecords: records.length,
limitations: [
"Scenario records are the capture's network transactions in request order; each record ordinal is the transaction's zero-based position.",
...(capture.events.dropped > 0
? [
`The scenario capture omitted ${String(capture.events.dropped)} events; the manifest covers only retained transactions.`,
]
: []),
...(withoutBody > 0
? [
`${String(withoutBody)} of ${String(representedCount)} scenario transactions with a usable request line retained no response body; tools citing them replay only the recorded status and content type, either of which may be absent.`,
]
: []),
...(redactedBodies > 0
? [
`${String(redactedBodies)} of ${String(representedCount)} scenario transactions with a usable request line retained a response body with declared secret values replaced by redaction markers; tools citing them replay the redacted bytes.`,
]
: []),
],
};
};
/** An own data member of admitted parent data; the clone admission already refused accessors. */
const member = (value: unknown, key: string): unknown =>
typeof value === "object" && value !== null && Object.hasOwn(value, key)
? (value as Readonly<Record<string, unknown>>)[key]
: undefined;
/**
* Bytes the manifest derives from a borrowed scenario: validation decodes and re-encodes one body at
* a time (two bytes per base64 character), and projection keeps the decoded text of every request
* body. Response bodies stay borrowed, as HAR response text does.
*/
const derivedTextBytes = (value: unknown): number => {
const items = member(member(value, "events"), "items");
let largestBody = 0;
let requestText = 0;
// Each event is read on its own, so a body without content leaves the others counted.
for (const event of Array.isArray(items) ? items : []) {
const content = member(member(event, "body"), "content");
if (
member(event, "kind") !== "network-content" ||
typeof content !== "string"
)
continue;
largestBody = Math.max(largestBody, content.length);
if (member(event, "phase") === "request")
requestText += textBytes(Math.ceil((content.length * 3) / 4));
}
return 64 + largestBody * 2 + requestText;
};
/** Admit a capture_browser_scenario parent whose network content identifies response media types; state also needs bodies. */
export const admitScenarioCaptureParent = (
parent: AnalysisViewParent,
viewKind: "environment-manifest" | "environment-state",
accumulator: WebNetworkProjectionAccumulator,
): Result<ScenarioCaptureRecords, AnalysisError> => {
const admitted = accumulator.admitCaptureClone(parent.normalizedResult);
if (!admitted.ok) return admitted;
// Reserve body validation and request text before either allocation, even for a parent read from
// a file, without charging the borrowed response bodies a large capture is made of.
const derived = accumulator.admitDerivedBytes(
derivedTextBytes(parent.normalizedResult),
);
if (!derived.ok) return derived;
const parsed = browserScenarioCaptureSchema.safeParse(
parent.normalizedResult,
);
if (!parsed.success)
return err(
analysisViewInputError([
{
path: ["source"],
reason: "invalid_value",
message:
"Parent Evidence result does not match capture_browser_scenario.",
},
]),
);
const content = parsed.data.scenario.network_content;
// Tools are chosen by response media type, which only bodies or header values carry; state is read from bodies.
if (
viewKind === "environment-state"
? !content.response_body
: !content.response_body && !content.header_values
)
return err(
new AnalysisUnsupportedTargetError(
"inspect_analysis_view",
parent.evidenceId,
viewKind === "environment-state"
? "environment-state needs a capture_browser_scenario capture with capture.network.response_body selected."
: "environment-manifest needs a capture_browser_scenario capture with capture.network.response_body or header_values selected.",
),
);
return ok(scenarioCaptureRecords(parsed.data, "manifest"));
};