import type { ErrorCode } from "./error"; export const ARCHIVE_METADATA_FIRST_SCHEMA = 1 as const; export const ARCHIVE_METADATA_FORMATS = ["ZIP", "TAR"] as const; export type ArchiveMetadataFormat = typeof ARCHIVE_METADATA_FORMATS[number]; export interface ArchiveMetadataFirstRequestIR { schemaVersion: typeof ARCHIVE_METADATA_FIRST_SCHEMA; archiveId: string; format: ArchiveMetadataFormat; archiveByteLength: number; metadataOffset: number; metadataByteLength: number; } export interface ArchiveMetadataReadIR { kind: "CENTRAL_DIRECTORY" | "MANIFEST"; byteOffset: number; byteLength: number; } export interface ArchiveMetadataFirstPlanIR { status: "METADATA_ONLY"; schemaVersion: typeof ARCHIVE_METADATA_FIRST_SCHEMA; archiveId: string; format: ArchiveMetadataFormat; firstRead: ArchiveMetadataReadIR; payloadReads: []; } export interface ArchiveReadTraceItemIR { sequence: number; kind: "CENTRAL_DIRECTORY" | "MANIFEST" | "PAYLOAD"; byteOffset: number; byteLength: number; } export interface ArchiveReadTraceIR { schemaVersion: typeof ARCHIVE_METADATA_FIRST_SCHEMA; archiveId: string; reads: ArchiveReadTraceItemIR[]; } export interface ArchiveReadTraceValidationIR { status: "VALID"; metadataFirst: true; payloadReadsAfterMetadata: true; } export class ArchiveMetadataFirstError extends Error { readonly code: ErrorCode; readonly path?: string; constructor(message: string, path?: string) { super(`IO_ARCHIVE_UNSAFE: ${message}`); this.name = "ArchiveMetadataFirstError"; this.code = "IO_ARCHIVE_UNSAFE"; this.path = path; } } const ARCHIVE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; const MAX_ARCHIVE_BYTES = Number.MAX_SAFE_INTEGER; const MAX_METADATA_BYTES = 64 * 1024 * 1024; function record(value: unknown, path: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new ArchiveMetadataFirstError(`${path} must be an object`, path); return value as Record; } function exactKeys(value: Record, expected: readonly string[], path: string): void { const actual = Object.keys(value).sort(); const allowed = [...expected].sort(); if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) throw new ArchiveMetadataFirstError(`${path} contains undeclared fields`, path); } function integer(value: unknown, path: string, minimum = 0): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > MAX_ARCHIVE_BYTES) throw new ArchiveMetadataFirstError(`${path} must be a safe integer`, path); return value; } function parseRequest(value: unknown): ArchiveMetadataFirstRequestIR { const input = record(value, "input"); exactKeys(input, ["schemaVersion", "archiveId", "format", "archiveByteLength", "metadataOffset", "metadataByteLength"], "input"); if (input.schemaVersion !== ARCHIVE_METADATA_FIRST_SCHEMA) throw new ArchiveMetadataFirstError("unsupported archive metadata schema", "input.schemaVersion"); if (typeof input.archiveId !== "string" || !ARCHIVE_ID.test(input.archiveId)) throw new ArchiveMetadataFirstError("archiveId is invalid", "input.archiveId"); if (!ARCHIVE_METADATA_FORMATS.includes(input.format as ArchiveMetadataFormat)) throw new ArchiveMetadataFirstError("archive format is unsupported", "input.format"); const archiveByteLength = integer(input.archiveByteLength, "input.archiveByteLength", 1); const metadataOffset = integer(input.metadataOffset, "input.metadataOffset"); const metadataByteLength = integer(input.metadataByteLength, "input.metadataByteLength", 1); if (metadataByteLength > MAX_METADATA_BYTES || metadataOffset + metadataByteLength > archiveByteLength) throw new ArchiveMetadataFirstError("metadata range is outside the archive or exceeds its bound", "input.metadataByteLength"); return { schemaVersion: ARCHIVE_METADATA_FIRST_SCHEMA, archiveId: input.archiveId, format: input.format as ArchiveMetadataFormat, archiveByteLength, metadataOffset, metadataByteLength }; } export function planArchiveMetadataRead(value: unknown): ArchiveMetadataFirstPlanIR { const request = parseRequest(value); return { status: "METADATA_ONLY", schemaVersion: ARCHIVE_METADATA_FIRST_SCHEMA, archiveId: request.archiveId, format: request.format, firstRead: { kind: request.format === "ZIP" ? "CENTRAL_DIRECTORY" : "MANIFEST", byteOffset: request.metadataOffset, byteLength: request.metadataByteLength }, payloadReads: [], }; } function parseTraceItem(value: unknown, index: number): ArchiveReadTraceItemIR { const path = `reads[${index}]`; const item = record(value, path); exactKeys(item, ["sequence", "kind", "byteOffset", "byteLength"], path); const sequence = integer(item.sequence, `${path}.sequence`); if (!(["CENTRAL_DIRECTORY", "MANIFEST", "PAYLOAD"] as const).includes(item.kind as ArchiveReadTraceItemIR["kind"])) throw new ArchiveMetadataFirstError(`${path}.kind is unsupported`, `${path}.kind`); return { sequence, kind: item.kind as ArchiveReadTraceItemIR["kind"], byteOffset: integer(item.byteOffset, `${path}.byteOffset`), byteLength: integer(item.byteLength, `${path}.byteLength`, 1) }; } function parseTrace(value: unknown): ArchiveReadTraceIR { const input = record(value, "trace"); exactKeys(input, ["schemaVersion", "archiveId", "reads"], "trace"); if (input.schemaVersion !== ARCHIVE_METADATA_FIRST_SCHEMA) throw new ArchiveMetadataFirstError("unsupported archive trace schema", "trace.schemaVersion"); if (typeof input.archiveId !== "string" || !ARCHIVE_ID.test(input.archiveId)) throw new ArchiveMetadataFirstError("trace archiveId is invalid", "trace.archiveId"); if (!Array.isArray(input.reads) || input.reads.length === 0 || input.reads.length > 10_000) throw new ArchiveMetadataFirstError("trace reads exceeds its bound", "trace.reads"); const reads = input.reads.map(parseTraceItem).sort((left, right) => left.sequence - right.sequence); if (reads.some((item, index) => item.sequence !== index)) throw new ArchiveMetadataFirstError("trace sequence must be contiguous and unique", "trace.reads"); return { schemaVersion: ARCHIVE_METADATA_FIRST_SCHEMA, archiveId: input.archiveId, reads }; } export function validateArchiveReadTrace(planValue: unknown, traceValue: unknown): ArchiveReadTraceValidationIR { const plan = planArchiveMetadataRead(planValue); const trace = parseTrace(traceValue); if (trace.archiveId !== plan.archiveId) throw new ArchiveMetadataFirstError("trace archiveId does not match the plan", "trace.archiveId"); const first = trace.reads[0]; if (first.kind !== plan.firstRead.kind || first.byteOffset !== plan.firstRead.byteOffset || first.byteLength !== plan.firstRead.byteLength) throw new ArchiveMetadataFirstError("payload was read before the central directory or manifest", "trace.reads[0]"); if (trace.reads.slice(1).some((item) => item.kind === plan.firstRead.kind || item.kind === (plan.format === "ZIP" ? "MANIFEST" : "CENTRAL_DIRECTORY"))) throw new ArchiveMetadataFirstError("metadata read sequence is duplicated or out of order", "trace.reads"); return { status: "VALID", metadataFirst: true, payloadReadsAfterMetadata: true }; }