98 lines
6.2 KiB
TypeScript
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 };
|
|
}
|