161 lines
7.8 KiB
TypeScript
161 lines
7.8 KiB
TypeScript
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<string, unknown> {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new ArchiveLinkSafetyError(`${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 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<string>();
|
|
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<string>();
|
|
const resolved = new Map<string, { path: string; type: "FILE" | "DIRECTORY" }>();
|
|
|
|
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 };
|
|
}
|