Files
workinf_Blender_Wasm/web/protocol/archive-conflicts.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

98 lines
6.2 KiB
TypeScript

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<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new ArchiveConflictError(`${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 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<string>(); 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 };
}