import { z } from "zod"; import type { AnalysisError } from "../analysisErrorBase.js"; import { AnalysisResourceConstraintError } from "../analysisErrorCore.js"; import { jsonValueByteLength } from "../jsonValueByteLength.js"; import type { JsonValue } from "../jsonValue.js"; import { err, ok, type Result } from "../result.js"; import { webBundleAnalysisSchema, type WebBundleAnalysis, } from "../webBundleAnalysis.js"; import { recordedBrowserOriginSchema } from "../browserObservation.js"; import type { AnalysisViewParent, AnalysisViewRequest, UnsignedAnalysisView, } from "./analysisView.js"; import { boundedViewMessages, completeWithinViewCoverage, pageViewCoverage, } from "./analysisViewCoverage.js"; import { analysisViewInputError } from "./analysisViewErrors.js"; import { createAnalysisViewResultReader } from "./analysisViewParent.js"; const PAGE_BYTES = 1024 * 1024; const DIGEST_BYTES = jsonValueByteLength({ view_digest: "0".repeat(64) }) - 1; const PROJECTION_BYTES = PAGE_BYTES - DIGEST_BYTES; const endpointSchema = webBundleAnalysisSchema.shape.observations.shape.endpoints.element; const inferenceSchema = webBundleAnalysisSchema.shape.inferences.element; const sourceLocationSchema = endpointSchema.shape.location; const basisSchema = inferenceSchema.shape.basis.element; const pageShape = { kind: z.literal("web-bundle"), offset: z.number().int().nonnegative().default(0), limit: z.number().int().positive().default(64), }; export const webBundleViewRequestSchema = z.discriminatedUnion("collection", [ z.strictObject({ ...pageShape, collection: z.literal("endpoints"), indices: z.array(z.number().int().nonnegative()).optional(), values: z.array(z.string()).optional(), script_keys: z.array(z.string()).optional(), mechanisms: z.array(z.string()).optional(), fields: z.array(endpointSchema.keyof()).optional(), }), z.strictObject({ ...pageShape, collection: z.literal("inferences"), indices: z.array(z.number().int().nonnegative()).optional(), kinds: z.array(inferenceSchema.shape.kind).optional(), values: z.array(z.string()).optional(), script_keys: z.array(z.string()).optional(), fields: z.array(inferenceSchema.keyof()).optional(), }), z.strictObject({ kind: z.literal("web-bundle"), collection: z.literal("summary"), }), ]); export const webBundleSummarySchema = z.strictObject({ target_url: z.string(), target_origin: recordedBrowserOriginSchema.nullable().optional(), scripts_observed: z.number().int().nonnegative(), scripts_analyzed: z.number().int().nonnegative(), counts: z.strictObject({ endpoints: z.number().int().nonnegative(), inferences: z.number().int().nonnegative(), endpoint_candidates: z.number().int().nonnegative(), chunks: z.number().int().nonnegative(), routes: z.number().int().nonnegative(), }), completeness: webBundleAnalysisSchema.shape.completeness, limitation_count: z.number().int().nonnegative(), }); export const webBundleEndpointRowSchema = z.strictObject({ source_index: z.number().int().nonnegative(), value: endpointSchema.shape.value.optional(), mechanism: endpointSchema.shape.mechanism.optional(), location: sourceLocationSchema.optional(), }); export const webBundleInferenceRowSchema = z.strictObject({ source_index: z.number().int().nonnegative(), kind: inferenceSchema.shape.kind.optional(), value: inferenceSchema.shape.value.optional(), confidence: inferenceSchema.shape.confidence.optional(), basis: z.array(basisSchema).optional(), }); export const webBundleViewItemsSchema = z.union([ z.array(webBundleEndpointRowSchema), z.array(webBundleInferenceRowSchema), ]); const readReport = createAnalysisViewResultReader(webBundleAnalysisSchema); const choose = ( row: T, fields: ReadonlySet | undefined, ): Partial => { if (fields === undefined) return { ...row }; const out: Partial = {}; for (const key of Object.keys(row) as (keyof T)[]) { if (fields.has(String(key))) out[key] = row[key]; } return out; }; /** Historical projection of completed analyze_web_bundle Evidence. */ export const projectWebBundleView = ( parent: AnalysisViewParent, selected: AnalysisViewRequest, ): Result => { if (selected.kind !== "web-bundle" && selected.kind !== "summary") return err( analysisViewInputError([ { path: ["view", "kind"], reason: "invalid_value", message: "Select a web-bundle or summary view.", }, ]), ); if (parent.operation !== "analyze_web_bundle") return err( analysisViewInputError([ { path: ["source", "operation"], reason: "invalid_value", message: "Web-bundle views require analyze_web_bundle Evidence.", }, ]), ); const parsed = readReport(parent); if (!parsed.ok) return parsed; const report: WebBundleAnalysis = parsed.value; const targetOrigin = report.capture.target_origin === undefined ? {} : { target_origin: report.capture.target_origin }; const sourceLimitations = [ ...new Set([...parent.limitations, ...report.limitations]), ]; const shared = { parent_evidence_id: parent.evidenceId, parent_operation: "analyze_web_bundle" as const, parent_digest: parent.evidenceId.slice(3), artifact: parent.artifact ?? null, target_url: report.capture.target_url, ...targetOrigin, source_unknowns: report.unknowns, limitations: boundedViewMessages( sourceLimitations, parent.evidenceId, "limitations", ), unknowns: boundedViewMessages( [ "Static bundle findings are observations or bounded inferences; REA does not execute captured JavaScript.", "String-built routes and endpoints, encrypted configuration, and server-side behavior may remain unknown.", ...report.unknowns.map((u) => `${u.dimension}: ${u.reason}`), ], parent.evidenceId, "unknowns", ), }; const tooLarge = (collection: string): Result => err( new AnalysisResourceConstraintError( "inspect_analysis_view", "transport", "The requested web-bundle page cannot fit the analysis-view byte budget; select fewer fields or a smaller selection. The complete parent is retained.", { parent_evidence_id: parent.evidenceId, limit_bytes: PAGE_BYTES, collection, }, ), ); const buildSummary = (): z.output => ({ target_url: report.capture.target_url, ...targetOrigin, scripts_observed: report.capture.scripts_observed, scripts_analyzed: report.capture.scripts_analyzed, counts: { endpoints: report.observations.endpoints.length, inferences: report.inferences.length, endpoint_candidates: report.inferences.reduce( (count, inference) => count + Number(inference.kind === "endpoint_candidate"), 0, ), chunks: report.observations.chunks.nodes.length, routes: report.observations.routes.length, }, completeness: report.completeness, limitation_count: sourceLimitations.length, }); if (selected.kind === "summary") { const summary = buildSummary(); const result: UnsignedAnalysisView = { kind: "summary", view: selected, ...shared, summary, coverage: completeWithinViewCoverage(1, 1), }; return jsonValueByteLength( result as unknown as JsonValue, PROJECTION_BYTES, ) > PROJECTION_BYTES ? tooLarge("summary") : ok(result); } const view = selected; if (view.collection === "summary") { const summary = buildSummary(); const result: UnsignedAnalysisView = { kind: "web-bundle", view, ...shared, completeness: report.completeness, items: [], summary, byte_limited: false, coverage: completeWithinViewCoverage(1, 1), }; return jsonValueByteLength( result as unknown as JsonValue, PROJECTION_BYTES, ) > PROJECTION_BYTES ? tooLarge("summary") : ok(result); } type Item = | z.output | z.output; const fields = view.fields === undefined ? undefined : new Set(view.fields); const deliverRows = ( rows: readonly T[], matches: ((row: T, source_index: number) => boolean) | undefined, project: (row: T, source_index: number) => Item, ): Result => { const baseEnvelope = { ...shared, kind: "web-bundle" as const, view, completeness: report.completeness, items: [], summary: null, byte_limited: false, coverage: { status: "complete-within-view" as const, examined: Number.MAX_SAFE_INTEGER, total: Number.MAX_SAFE_INTEGER, next_offset: Number.MAX_SAFE_INTEGER, exhausted: false, }, }; let bytes = DIGEST_BYTES + jsonValueByteLength( baseEnvelope as unknown as JsonValue, PROJECTION_BYTES, ); if (bytes > PAGE_BYTES) return tooLarge(view.collection); const items: Item[] = []; let byte_limited = false; let total = matches === undefined ? rows.length : 0; // Unfiltered pages use the producer's array length and direct offsets. // Filtered pages count every match but only allocate selected output rows. for ( let source_index = matches === undefined ? Math.min(view.offset, rows.length) : 0; source_index < rows.length; source_index++ ) { const row = rows[source_index]!; if (matches !== undefined && !matches(row, source_index)) continue; const ordinal = matches === undefined ? source_index : total++; if (ordinal < view.offset) continue; if (items.length >= view.limit || byte_limited) { if (matches === undefined) break; continue; } const item = project(row, source_index); const addedBytes = jsonValueByteLength(item as JsonValue, PAGE_BYTES - bytes) + (items.length > 0 ? 1 : 0); if (bytes + addedBytes > PAGE_BYTES) { if (items.length === 0) return tooLarge(view.collection); byte_limited = true; if (matches === undefined) break; continue; } bytes += addedBytes; items.push(item); } const coverage = pageViewCoverage(view.offset, items.length, total); const result: UnsignedAnalysisView = { ...shared, kind: "web-bundle" as const, view, completeness: report.completeness, items: webBundleViewItemsSchema.parse(items), summary: null, byte_limited, coverage, }; return jsonValueByteLength( result as unknown as JsonValue, PROJECTION_BYTES, ) > PROJECTION_BYTES ? tooLarge(view.collection) : ok(result); }; if (view.collection === "endpoints") { const indices = view.indices !== undefined ? new Set(view.indices) : undefined; const values = view.values !== undefined ? new Set(view.values) : undefined; const scriptKeys = view.script_keys !== undefined ? new Set(view.script_keys) : undefined; const mechanisms = view.mechanisms !== undefined ? new Set(view.mechanisms) : undefined; const filters = [indices, values, scriptKeys, mechanisms]; return deliverRows( filters.some((filter) => filter?.size === 0) ? [] : report.observations.endpoints, filters.every((filter) => filter === undefined) ? undefined : (row, index) => (indices === undefined || indices.has(index)) && (values === undefined || values.has(row.value)) && (scriptKeys === undefined || scriptKeys.has(row.location.script_key)) && (mechanisms === undefined || mechanisms.has(row.mechanism)), (row, source_index) => ({ source_index, ...choose(row, fields), }), ); } const indices = view.indices !== undefined ? new Set(view.indices) : undefined; const kinds = view.kinds !== undefined ? new Set(view.kinds) : undefined; const values = view.values !== undefined ? new Set(view.values) : undefined; const scriptKeys = view.script_keys !== undefined ? new Set(view.script_keys) : undefined; const filters = [indices, kinds, values, scriptKeys]; return deliverRows( filters.some((filter) => filter?.size === 0) ? [] : report.inferences, filters.every((filter) => filter === undefined) ? undefined : (row, index) => (indices === undefined || indices.has(index)) && (kinds === undefined || kinds.has(row.kind)) && (values === undefined || values.has(row.value)) && (scriptKeys === undefined || row.basis.some((basis) => scriptKeys.has(basis.script_key))), (row, source_index) => ({ source_index, ...choose(row, fields), }), ); };