import { normalizeProjectAssetPath } from "./asset-path"; import type { ErrorCode } from "./error"; export const LIBRARY_SOURCE_ORIGIN_SCHEMA = 1 as const; export type LibrarySourceKind = "HTTPS_ORIGIN" | "PROJECT_ASSET" | "USER_SELECTED_FILE"; export interface LibrarySourcePolicyIR { schemaVersion: typeof LIBRARY_SOURCE_ORIGIN_SCHEMA; declaredHttpsOrigins: string[]; } export type LibrarySourceRequestIR = | { schemaVersion: typeof LIBRARY_SOURCE_ORIGIN_SCHEMA; kind: "HTTPS_ORIGIN"; url: string } | { schemaVersion: typeof LIBRARY_SOURCE_ORIGIN_SCHEMA; kind: "PROJECT_ASSET"; path: string } | { schemaVersion: typeof LIBRARY_SOURCE_ORIGIN_SCHEMA; kind: "USER_SELECTED_FILE"; selectionId: string; fileName: string; byteLength: number; sourceSha256: string }; export interface AcceptedLibrarySourceIR { status: "READY"; kind: LibrarySourceKind; canonicalLocator: string; } export class LibrarySourceOriginValidationError extends Error { readonly code: ErrorCode; readonly path?: string; constructor(code: ErrorCode, message: string, path?: string) { super(`${code}: ${message}`); this.name = "LibrarySourceOriginValidationError"; this.code = code; this.path = path; } } const SHA256 = /^[a-f0-9]{64}$/; const SELECTION_ID = /^file-selection:[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; const FILE_NAME = /^[^\\/\u0000-\u001f\u007f]{1,255}$/; const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/; const MAX_FILE_BYTES = 4 * 1024 * 1024 * 1024; function record(value: unknown, path: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new LibrarySourceOriginValidationError("ASSET_MANIFEST_INVALID", `${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 LibrarySourceOriginValidationError("ASSET_MANIFEST_INVALID", `${path} contains undeclared fields`, path); } function text(value: unknown, path: string, maximum: number): string { if (typeof value !== "string" || value.length === 0 || value.length > maximum) throw new LibrarySourceOriginValidationError("ASSET_MANIFEST_INVALID", `${path} is invalid`, path); return value; } function digest(value: unknown, path: string): string { if (typeof value !== "string" || !SHA256.test(value)) throw new LibrarySourceOriginValidationError("ASSET_MANIFEST_INVALID", `${path} must be a lowercase SHA-256 digest`, path); return value; } function validateUriText(value: string, path: string): void { if (value.includes("\\") || CONTROL_CHARACTER.test(value) || /%(?![0-9a-fA-F]{2})/.test(value)) { throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", `${path} contains unsafe URI characters`, path); } } function validateUriPath(pathname: string, path: string): void { let decoded: string; try { decoded = decodeURIComponent(pathname); } catch { throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", `${path} contains malformed percent encoding`, path); } if (decoded.includes("%") || decoded.includes("\\") || CONTROL_CHARACTER.test(decoded)) { throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", `${path} contains an unsafe path`, path); } } function canonicalOrigin(value: unknown, path: string): string { const textValue = text(value, path, 2_048); validateUriText(textValue, path); let parsed: URL; try { parsed = new URL(textValue); } catch { throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", `${path} is not an absolute URL`, path); } if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.port) throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", `${path} must be a credential-free HTTPS origin`, path); // A policy entry is an origin, not a URL whose path/query/fragment is discarded by URL.origin. // Check the raw suffix too: URL parsing normalizes encoded and literal dot segments before exposing // pathname, which must not turn an origin-smuggling declaration into an apparently trusted origin. const schemeSeparator = textValue.indexOf("://"); const authorityAndSuffix = schemeSeparator < 0 ? textValue : textValue.slice(schemeSeparator + 3); const suffixStart = authorityAndSuffix.search(/[/?#]/); const rawSuffix = suffixStart < 0 ? "" : authorityAndSuffix.slice(suffixStart); validateUriPath(parsed.pathname, path); if (schemeSeparator < 0 || parsed.pathname !== "/" || parsed.search || parsed.hash || rawSuffix !== "" && rawSuffix !== "/") throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", `${path} must not include a path, query, or fragment`, path); return parsed.origin; } function httpsUrl(value: unknown, path: string): string { const textValue = text(value, path, 8_192); validateUriText(textValue, path); let parsed: URL; try { parsed = new URL(textValue); } catch { throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", `${path} is not an absolute URL`, path); } if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.port) throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", `${path} must be a credential-free HTTPS URL`, path); validateUriPath(parsed.pathname, path); return parsed.href; } export function parseLibrarySourcePolicy(value: unknown): LibrarySourcePolicyIR { const policy = record(value, "policy"); exactKeys(policy, ["schemaVersion", "declaredHttpsOrigins"], "policy"); if (policy.schemaVersion !== LIBRARY_SOURCE_ORIGIN_SCHEMA || !Array.isArray(policy.declaredHttpsOrigins) || policy.declaredHttpsOrigins.length === 0 || policy.declaredHttpsOrigins.length > 1_024) throw new LibrarySourceOriginValidationError("PROTOCOL_MISMATCH", "Unsupported or empty library source policy", "policy"); const declaredHttpsOrigins = policy.declaredHttpsOrigins.map((origin, index) => canonicalOrigin(origin, `declaredHttpsOrigins[${index}]`)); if (new Set(declaredHttpsOrigins).size !== declaredHttpsOrigins.length) throw new LibrarySourceOriginValidationError("ASSET_MANIFEST_INVALID", "declared HTTPS origins must be unique", "declaredHttpsOrigins"); return { schemaVersion: LIBRARY_SOURCE_ORIGIN_SCHEMA, declaredHttpsOrigins }; } export function parseLibrarySourceRequest(value: unknown): LibrarySourceRequestIR { const request = record(value, "request"); if (request.schemaVersion !== LIBRARY_SOURCE_ORIGIN_SCHEMA) throw new LibrarySourceOriginValidationError("PROTOCOL_MISMATCH", "Unsupported library source request schema", "schemaVersion"); if (request.kind === "HTTPS_ORIGIN") { exactKeys(request, ["schemaVersion", "kind", "url"], "request"); return { schemaVersion: LIBRARY_SOURCE_ORIGIN_SCHEMA, kind: "HTTPS_ORIGIN", url: httpsUrl(request.url, "url") }; } if (request.kind === "PROJECT_ASSET") { exactKeys(request, ["schemaVersion", "kind", "path"], "request"); let path: string; try { path = normalizeProjectAssetPath(text(request.path, "path", 2_048)); } catch (error) { throw new LibrarySourceOriginValidationError(error instanceof Error && error.message === "ASSET_PATH_INVALID" ? "ASSET_MANIFEST_INVALID" : "IO_EXTERNAL_URI_BLOCKED", "project asset path is outside the project", "path"); } return { schemaVersion: LIBRARY_SOURCE_ORIGIN_SCHEMA, kind: "PROJECT_ASSET", path }; } if (request.kind === "USER_SELECTED_FILE") { exactKeys(request, ["schemaVersion", "kind", "selectionId", "fileName", "byteLength", "sourceSha256"], "request"); if (typeof request.selectionId !== "string" || !SELECTION_ID.test(request.selectionId)) throw new LibrarySourceOriginValidationError("ASSET_MANIFEST_INVALID", "selectionId is invalid", "selectionId"); if (typeof request.fileName !== "string" || !FILE_NAME.test(request.fileName) || request.fileName === "." || request.fileName === "..") throw new LibrarySourceOriginValidationError("ASSET_MANIFEST_INVALID", "fileName is invalid", "fileName"); if (typeof request.byteLength !== "number" || !Number.isSafeInteger(request.byteLength) || request.byteLength <= 0 || request.byteLength > MAX_FILE_BYTES) throw new LibrarySourceOriginValidationError("ASSET_BUDGET_EXCEEDED", "selected file length is outside the budget", "byteLength"); return { schemaVersion: LIBRARY_SOURCE_ORIGIN_SCHEMA, kind: "USER_SELECTED_FILE", selectionId: request.selectionId, fileName: request.fileName, byteLength: request.byteLength, sourceSha256: digest(request.sourceSha256, "sourceSha256") }; } throw new LibrarySourceOriginValidationError("ASSET_MANIFEST_INVALID", "source kind is unsupported", "kind"); } export function acceptLibrarySource(policyValue: unknown, requestValue: unknown): AcceptedLibrarySourceIR { const policy = parseLibrarySourcePolicy(policyValue); const request = parseLibrarySourceRequest(requestValue); if (request.kind === "HTTPS_ORIGIN") { const origin = new URL(request.url).origin; if (!policy.declaredHttpsOrigins.includes(origin)) throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", "HTTPS origin is not declared by the policy", "url"); return { status: "READY", kind: request.kind, canonicalLocator: request.url }; } if (request.kind === "PROJECT_ASSET") return { status: "READY", kind: request.kind, canonicalLocator: `project-assets/${request.path}` }; return { status: "READY", kind: request.kind, canonicalLocator: `user-file/${request.selectionId}/${request.fileName}` }; }