import type { ErrorCode } from "./error"; export const ARCHIVE_LINK_SAFETY_SCHEMA = 1 as const; export const ARCHIVE_ENTRY_KINDS = ["FILE", "DIRECTORY", "SYMLINK", "HARDLINK"] as const; export type ArchiveEntryKind = typeof ARCHIVE_ENTRY_KINDS[number]; export interface ArchiveLinkEntryIR { path: string; type: ArchiveEntryKind; target: string | null; } export interface ArchiveLinkRequestIR { schemaVersion: typeof ARCHIVE_LINK_SAFETY_SCHEMA; temporaryRootId: string; entries: ArchiveLinkEntryIR[]; } export interface ArchiveResolvedEntryIR extends ArchiveLinkEntryIR { resolvedPath: string; resolvedType: "FILE" | "DIRECTORY"; withinTemporaryRoot: true; } export interface ArchiveLinkResolutionIR { status: "READY"; schemaVersion: typeof ARCHIVE_LINK_SAFETY_SCHEMA; temporaryRootId: string; entries: ArchiveResolvedEntryIR[]; } export class ArchiveLinkSafetyError extends Error { readonly code: ErrorCode; readonly path?: string; constructor(message: string, path?: string) { super(`IO_ARCHIVE_UNSAFE: ${message}`); this.name = "ArchiveLinkSafetyError"; this.code = "IO_ARCHIVE_UNSAFE"; this.path = path; } } const MAX_ENTRIES = 10_000; const MAX_PATH_LENGTH = 1_024; const ROOT_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; const DRIVE_PATH = /^[A-Za-z]:[\\/]/; const URI_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:/; const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/; function record(value: unknown, path: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new ArchiveLinkSafetyError(`${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 ArchiveLinkSafetyError(`${path} contains undeclared fields`, path); } function text(value: unknown, path: string, maxLength: number): string { if (typeof value !== "string" || value.length === 0 || value.length > maxLength || CONTROL_CHARACTER.test(value)) throw new ArchiveLinkSafetyError(`${path} is invalid`, path); return value.normalize("NFC"); } function archivePath(value: unknown, path: string, allowRoot = false): string { const input = text(value, path, MAX_PATH_LENGTH); if (input.startsWith("/") || input.startsWith("\\") || DRIVE_PATH.test(input) || URI_SCHEME.test(input)) throw new ArchiveLinkSafetyError(`${path} escapes the temporary root`, path); if (input.includes("\\")) throw new ArchiveLinkSafetyError(`${path} contains a backslash`, path); const segments: string[] = []; for (const segment of input.split("/")) { if (segment === "" || segment === ".") continue; if (segment === "..") { if (segments.length === 0) throw new ArchiveLinkSafetyError(`${path} escapes the temporary root`, path); segments.pop(); continue; } if (CONTROL_CHARACTER.test(segment)) throw new ArchiveLinkSafetyError(`${path} contains a control character`, path); segments.push(segment); } const result = segments.join("/"); if (!result && !allowRoot) throw new ArchiveLinkSafetyError(`${path} is empty`, path); return result; } function relativeSymlinkTarget(linkPath: string, target: unknown, path: string): string { const targetText = text(target, path, MAX_PATH_LENGTH); if (targetText.startsWith("/") || targetText.startsWith("\\") || DRIVE_PATH.test(targetText) || URI_SCHEME.test(targetText)) throw new ArchiveLinkSafetyError(`${path} escapes the temporary root`, path); if (targetText.includes("\\")) throw new ArchiveLinkSafetyError(`${path} contains a backslash`, path); const parent = linkPath.includes("/") ? linkPath.slice(0, linkPath.lastIndexOf("/")) : ""; return archivePath(parent ? `${parent}/${targetText}` : targetText, path); } function parseEntry(value: unknown, index: number): ArchiveLinkEntryIR { const path = `entries[${index}]`; const entry = record(value, path); exactKeys(entry, ["path", "target", "type"], path); const entryPath = archivePath(entry.path, `${path}.path`); if (!ARCHIVE_ENTRY_KINDS.includes(entry.type as ArchiveEntryKind)) throw new ArchiveLinkSafetyError(`${path}.type is unsupported`, `${path}.type`); const type = entry.type as ArchiveEntryKind; if (type === "FILE" || type === "DIRECTORY") { if (entry.target !== null) throw new ArchiveLinkSafetyError(`${path}.target must be null for ${type}`, `${path}.target`); return { path: entryPath, type, target: null }; } if (typeof entry.target !== "string") throw new ArchiveLinkSafetyError(`${path}.target is required for ${type}`, `${path}.target`); return { path: entryPath, type, target: entry.target.normalize("NFC") }; } export function parseArchiveLinkRequest(value: unknown): ArchiveLinkRequestIR { const input = record(value, "input"); exactKeys(input, ["schemaVersion", "temporaryRootId", "entries"], "input"); if (input.schemaVersion !== ARCHIVE_LINK_SAFETY_SCHEMA) throw new ArchiveLinkSafetyError("unsupported archive link schema", "input.schemaVersion"); if (typeof input.temporaryRootId !== "string" || !ROOT_ID.test(input.temporaryRootId)) throw new ArchiveLinkSafetyError("temporaryRootId is invalid", "input.temporaryRootId"); if (!Array.isArray(input.entries) || input.entries.length === 0 || input.entries.length > MAX_ENTRIES) throw new ArchiveLinkSafetyError("entries exceeds its bound", "input.entries"); const entries = input.entries.map((entry, index) => parseEntry(entry, index)); const paths = new Set(); for (const entry of entries) { if (paths.has(entry.path)) throw new ArchiveLinkSafetyError(`duplicate archive path ${entry.path}`, "input.entries"); paths.add(entry.path); } return { schemaVersion: ARCHIVE_LINK_SAFETY_SCHEMA, temporaryRootId: input.temporaryRootId, entries }; } export function resolveArchiveLinkEntries(value: unknown): ArchiveLinkResolutionIR { const request = parseArchiveLinkRequest(value); const entries = new Map(request.entries.map((entry) => [entry.path, entry])); const active = new Set(); const resolved = new Map(); const visit = (entryPath: string): { path: string; type: "FILE" | "DIRECTORY" } => { const cached = resolved.get(entryPath); if (cached) return cached; if (active.has(entryPath)) throw new ArchiveLinkSafetyError(`link cycle includes ${entryPath}`, "input.entries"); const entry = entries.get(entryPath); if (!entry) throw new ArchiveLinkSafetyError(`link target ${entryPath} is missing`, "input.entries"); active.add(entryPath); let result: { path: string; type: "FILE" | "DIRECTORY" }; if (entry.type === "FILE" || entry.type === "DIRECTORY") { result = { path: entry.path, type: entry.type }; } else { const targetPath = entry.type === "SYMLINK" ? relativeSymlinkTarget(entry.path, entry.target, `entries[${request.entries.indexOf(entry)}].target`) : archivePath(entry.target, `entries[${request.entries.indexOf(entry)}].target`); const target = visit(targetPath); if (entry.type === "HARDLINK" && target.type !== "FILE") throw new ArchiveLinkSafetyError("hardlink target must resolve to a file", `entries[${request.entries.indexOf(entry)}].target`); result = target; } active.delete(entryPath); resolved.set(entryPath, result); return result; }; const output = request.entries.map((entry) => { const target = visit(entry.path); return { ...entry, resolvedPath: target.path, resolvedType: target.type, withinTemporaryRoot: true as const }; }); return { status: "READY", schemaVersion: ARCHIVE_LINK_SAFETY_SCHEMA, temporaryRootId: request.temporaryRootId, entries: output }; }