import type { ErrorCode } from "./error"; export const ARCHIVE_CONFLICT_SCHEMA = 1 as const; export interface ArchiveConflictRangeIR { path: string; compressedBytes: number; uncompressedBytes: number; compressedOffset: number; } export interface ArchiveConflictRequestIR { schemaVersion: typeof ARCHIVE_CONFLICT_SCHEMA; byteLength: number | null; ranges: ArchiveConflictRangeIR[]; } export interface ArchiveConflictValidationIR { status: "VALID"; totalCompressedBytes: number; totalUncompressedBytes: number; nonOverlapping: true; uniquePaths: true; noPrefixConflicts: true; } export class ArchiveConflictError extends Error { readonly code: ErrorCode; readonly path?: string; constructor(message: string, path?: string) { super(`IO_ARCHIVE_UNSAFE: ${message}`); this.name = "ArchiveConflictError"; this.code = "IO_ARCHIVE_UNSAFE"; this.path = path; } } export const ARCHIVE_CONFLICT_BUDGET = { maxEntries: 100_000, maxEntryBytes: 2 * 1024 * 1024 * 1024, maxArchiveBytes: 4 * 1024 * 1024 * 1024, maxCompressionRatio: 100, } as const; const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/; const DRIVE_PATH = /^[A-Za-z]:[\\/]/; const URI_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:/; function record(value: unknown, path: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new ArchiveConflictError(`${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 ArchiveConflictError(`${path} contains undeclared fields`, path); } function integer(value: unknown, path: string, minimum = 0, maximum = Number.MAX_SAFE_INTEGER): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) throw new ArchiveConflictError(`${path} is outside its bounded range`, path); return value; } function archivePath(value: unknown, path: string): string { if (typeof value !== "string" || value.length === 0 || value.length > 2_048 || CONTROL_CHARACTER.test(value)) throw new ArchiveConflictError(`${path} is invalid`, path); if (value.startsWith("/") || value.startsWith("\\") || DRIVE_PATH.test(value) || URI_SCHEME.test(value) || value.includes("\\")) throw new ArchiveConflictError(`${path} escapes the project`, path); const segments: string[] = []; for (const segment of value.normalize("NFC").split("/")) { if (!segment || segment === ".") continue; if (segment === "..") { if (segments.length === 0) throw new ArchiveConflictError(`${path} escapes the project`, path); segments.pop(); continue; } segments.push(segment); } const result = segments.join("/"); if (!result) throw new ArchiveConflictError(`${path} is empty`, path); return result; } export function validateArchiveConflicts(value: unknown): ArchiveConflictValidationIR { const input = record(value, "input"); exactKeys(input, ["schemaVersion", "byteLength", "ranges"], "input"); if (input.schemaVersion !== ARCHIVE_CONFLICT_SCHEMA) throw new ArchiveConflictError("unsupported archive conflict schema", "input.schemaVersion"); const byteLength = input.byteLength === null ? null : integer(input.byteLength, "input.byteLength", 1, ARCHIVE_CONFLICT_BUDGET.maxArchiveBytes); if (!Array.isArray(input.ranges) || input.ranges.length === 0 || input.ranges.length > ARCHIVE_CONFLICT_BUDGET.maxEntries) throw new ArchiveConflictError("ranges exceeds its bound", "input.ranges"); const ranges = input.ranges.map((value, index) => { const path = `ranges[${index}]`; const item = record(value, path); exactKeys(item, ["path", "compressedBytes", "uncompressedBytes", "compressedOffset"], path); return { path: archivePath(item.path, `${path}.path`), compressedBytes: integer(item.compressedBytes, `${path}.compressedBytes`, 0, ARCHIVE_CONFLICT_BUDGET.maxEntryBytes), uncompressedBytes: integer(item.uncompressedBytes, `${path}.uncompressedBytes`, 0, ARCHIVE_CONFLICT_BUDGET.maxEntryBytes), compressedOffset: integer(item.compressedOffset, `${path}.compressedOffset`, 0, ARCHIVE_CONFLICT_BUDGET.maxArchiveBytes), }; }); const paths = new Set(); let totalCompressedBytes = 0; let totalUncompressedBytes = 0; for (const range of ranges) { if (paths.has(range.path) || [...paths].some((existing) => existing.startsWith(`${range.path}/`) || range.path.startsWith(`${existing}/`))) throw new ArchiveConflictError(`duplicate or file/directory prefix conflict at ${range.path}`, "input.ranges"); paths.add(range.path); if (range.uncompressedBytes > 0 && (range.compressedBytes === 0 || range.uncompressedBytes / range.compressedBytes > ARCHIVE_CONFLICT_BUDGET.maxCompressionRatio)) throw new ArchiveConflictError(`compression ratio exceeds the budget at ${range.path}`, "input.ranges"); totalCompressedBytes += range.compressedBytes; totalUncompressedBytes += range.uncompressedBytes; if (!Number.isSafeInteger(totalCompressedBytes) || !Number.isSafeInteger(totalUncompressedBytes) || totalCompressedBytes > ARCHIVE_CONFLICT_BUDGET.maxArchiveBytes || totalUncompressedBytes > ARCHIVE_CONFLICT_BUDGET.maxArchiveBytes) throw new ArchiveConflictError("archive total byte budget exceeded", "input.ranges"); if (range.compressedOffset + range.compressedBytes > ARCHIVE_CONFLICT_BUDGET.maxArchiveBytes || byteLength !== null && range.compressedOffset + range.compressedBytes > byteLength) throw new ArchiveConflictError(`range for ${range.path} exceeds the archive`, "input.ranges"); } const ordered = [...ranges].sort((left, right) => left.compressedOffset - right.compressedOffset); for (let index = 1; index < ordered.length; index++) { const previous = ordered[index - 1]; const current = ordered[index]; if (current.compressedOffset < previous.compressedOffset + previous.compressedBytes) throw new ArchiveConflictError(`compressed ranges overlap at ${current.path}`, "input.ranges"); } return { status: "VALID", totalCompressedBytes, totalUncompressedBytes, nonOverlapping: true, uniquePaths: true, noPrefixConflicts: true }; }