136 lines
7.1 KiB
TypeScript
136 lines
7.1 KiB
TypeScript
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<string, unknown> {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new ArchiveMetadataFirstError(`${path} must be an object`, path);
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function exactKeys(value: Record<string, unknown>, 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 };
|
|
}
|