Files
workinf_Blender_Wasm/web/protocol/archive-link-safety.ts
mes123456 380cbed4ff
Some checks are pending
M6 deployable RC / quick (push) Waiting to run
M6 deployable RC / chromium (push) Blocked by required conditions
M6 deployable RC / release (push) Blocked by required conditions
Checkpoint web parity through Chromium input tasks
2026-08-19 10:39:03 -04:00

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 };
}