Checkpoint web parity through Chromium input tasks
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

This commit is contained in:
mes123456
2026-08-19 10:39:03 -04:00
parent 5a11045ca5
commit 380cbed4ff
634 changed files with 41862 additions and 212 deletions

View File

@@ -0,0 +1,97 @@
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 };
}

View File

@@ -0,0 +1,50 @@
import type { ErrorCode } from "./error";
import {
ArchiveExtractionError,
runArchiveExtractionTransaction as runArchiveExtractionTransactionBase,
type ArchiveExtractionEntryReader,
type ArchiveExtractionReceiptIR,
type ArchiveExtractionStorage,
} from "./archive-extraction-transaction";
export type ArchiveExtractionResourceFaultCode = Extract<ErrorCode, "STORAGE_QUOTA" | "WASM_OUT_OF_MEMORY">;
/** Maps platform-specific allocation failures to the stable archive error contract. */
export function archiveExtractionResourceFaultCode(error: unknown): ArchiveExtractionResourceFaultCode | undefined {
if (typeof error !== "object" || error === null) return undefined;
const candidate = error as { code?: unknown; name?: unknown; message?: unknown };
if (candidate.code === "STORAGE_QUOTA" || candidate.code === "WASM_OUT_OF_MEMORY") return candidate.code;
if (candidate.name === "QuotaExceededError" || candidate.name === "NotEnoughSpaceError") return "STORAGE_QUOTA";
if (candidate.name === "OutOfMemoryError") return "WASM_OUT_OF_MEMORY";
if (candidate.name === "RangeError" && typeof candidate.message === "string" && /out[ -]?of[ -]?memory|oom/i.test(candidate.message)) {
return "WASM_OUT_OF_MEMORY";
}
return undefined;
}
/**
* Runs the existing atomic extraction transaction and normalizes resource failures only after
* the base transaction has removed staging and revalidated the committed identity.
*/
export async function runArchiveExtractionTransaction(
value: unknown,
storage: ArchiveExtractionStorage,
readEntry: ArchiveExtractionEntryReader,
signal: AbortSignal,
): Promise<ArchiveExtractionReceiptIR> {
try {
return await runArchiveExtractionTransactionBase(value, storage, readEntry, signal);
}
catch (error) {
const code = archiveExtractionResourceFaultCode(error);
if (code) throw new ArchiveExtractionError(code, "archive extraction released staging after a resource fault");
throw error;
}
}
export type {
ArchiveExtractionEntryIR,
ArchiveExtractionRequestIR,
ArchiveExtractionReceiptIR,
ArchiveExtractionStorage,
} from "./archive-extraction-transaction";

View File

@@ -0,0 +1,258 @@
import type { ErrorCode } from "./error";
export const ARCHIVE_EXTRACTION_SCHEMA = 1 as const;
export interface ArchiveProjectIdentityIR {
projectId: string;
revision: number;
sha256: string;
}
export interface ArchiveExtractionEntryIR {
path: string;
uncompressedBytes: number;
sha256: string;
}
export interface ArchiveExtractionRequestIR {
schemaVersion: typeof ARCHIVE_EXTRACTION_SCHEMA;
transactionId: string;
archiveId: string;
committed: ArchiveProjectIdentityIR;
candidate: ArchiveProjectIdentityIR;
entries: ArchiveExtractionEntryIR[];
}
export interface ArchiveExtractionStorage {
readCommitted(projectId: string): Promise<ArchiveProjectIdentityIR>;
createStaging(transactionId: string, projectId: string): Promise<void>;
writeStaging(transactionId: string, path: string, bytes: Uint8Array): Promise<void>;
countStagingEntries(transactionId: string): Promise<number>;
discardStaging(transactionId: string): Promise<number>;
commitStaging(
transactionId: string,
expected: ArchiveProjectIdentityIR,
candidate: ArchiveProjectIdentityIR,
): Promise<ArchiveProjectIdentityIR>;
}
export type ArchiveExtractionEntryReader = (
entry: ArchiveExtractionEntryIR,
signal: AbortSignal,
) => Promise<Uint8Array>;
export type ArchiveExtractionReceiptIR =
| {
status: "COMMITTED";
transactionId: string;
committed: ArchiveProjectIdentityIR;
stagingEntriesAfter: 0;
}
| {
status: "CANCELLED";
code: "IO_ARCHIVE_CANCELLED";
transactionId: string;
committedBefore: ArchiveProjectIdentityIR;
committedAfter: ArchiveProjectIdentityIR;
removedStagingEntries: number;
stagingEntriesAfter: 0;
publishedProjects: 0;
};
export class ArchiveExtractionError extends Error {
readonly code: ErrorCode;
readonly path?: string;
constructor(code: ErrorCode, message: string, path?: string) {
super(`${code}: ${message}`);
this.name = "ArchiveExtractionError";
this.code = code;
this.path = path;
}
}
const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const SHA256 = /^[a-f0-9]{64}$/;
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 ArchiveExtractionError("IO_ARCHIVE_UNSAFE", `${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 ArchiveExtractionError("IO_ARCHIVE_UNSAFE", `${path} contains undeclared fields`, path);
}
}
function parseIdentity(value: unknown, path: string): ArchiveProjectIdentityIR {
const input = record(value, path);
exactKeys(input, ["projectId", "revision", "sha256"], path);
if (typeof input.projectId !== "string" || !ID.test(input.projectId)) {
throw new ArchiveExtractionError("IO_ARCHIVE_UNSAFE", `${path}.projectId is invalid`, `${path}.projectId`);
}
if (typeof input.revision !== "number" || !Number.isSafeInteger(input.revision) || input.revision < 0) {
throw new ArchiveExtractionError("IO_ARCHIVE_UNSAFE", `${path}.revision is invalid`, `${path}.revision`);
}
if (typeof input.sha256 !== "string" || !SHA256.test(input.sha256)) {
throw new ArchiveExtractionError("IO_ARCHIVE_UNSAFE", `${path}.sha256 is invalid`, `${path}.sha256`);
}
return { projectId: input.projectId, revision: input.revision, sha256: input.sha256 };
}
function parseArchivePath(value: unknown, path: string): string {
if (typeof value !== "string" || value.length === 0 || value.length > 2_048 || CONTROL_CHARACTER.test(value)) {
throw new ArchiveExtractionError("IO_ARCHIVE_UNSAFE", `${path} is invalid`, path);
}
const normalized = value.normalize("NFC");
if (normalized !== value || value.startsWith("/") || value.startsWith("\\") || value.includes("\\") || DRIVE_PATH.test(value) || URI_SCHEME.test(value)) {
throw new ArchiveExtractionError("IO_ARCHIVE_UNSAFE", `${path} is not canonical`, path);
}
const segments = value.split("/");
if (segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) {
throw new ArchiveExtractionError("IO_ARCHIVE_UNSAFE", `${path} escapes staging`, path);
}
return value;
}
export function parseArchiveExtractionRequest(value: unknown): ArchiveExtractionRequestIR {
const input = record(value, "input");
exactKeys(input, ["schemaVersion", "transactionId", "archiveId", "committed", "candidate", "entries"], "input");
if (input.schemaVersion !== ARCHIVE_EXTRACTION_SCHEMA) {
throw new ArchiveExtractionError("IO_ARCHIVE_UNSAFE", "unsupported archive extraction schema", "input.schemaVersion");
}
if (typeof input.transactionId !== "string" || !ID.test(input.transactionId)) {
throw new ArchiveExtractionError("IO_ARCHIVE_UNSAFE", "transactionId is invalid", "input.transactionId");
}
if (typeof input.archiveId !== "string" || !ID.test(input.archiveId)) {
throw new ArchiveExtractionError("IO_ARCHIVE_UNSAFE", "archiveId is invalid", "input.archiveId");
}
const committed = parseIdentity(input.committed, "input.committed");
const candidate = parseIdentity(input.candidate, "input.candidate");
if (candidate.projectId !== committed.projectId || candidate.revision !== committed.revision + 1) {
throw new ArchiveExtractionError("REVISION_CONFLICT", "candidate must advance the same project by one revision", "input.candidate");
}
if (!Array.isArray(input.entries) || input.entries.length === 0 || input.entries.length > 100_000) {
throw new ArchiveExtractionError("IO_ARCHIVE_UNSAFE", "entries exceeds its bound", "input.entries");
}
const paths = new Set<string>();
const entries = input.entries.map((value, index): ArchiveExtractionEntryIR => {
const path = `input.entries[${index}]`;
const entry = record(value, path);
exactKeys(entry, ["path", "uncompressedBytes", "sha256"], path);
const entryPath = parseArchivePath(entry.path, `${path}.path`);
if (paths.has(entryPath)) throw new ArchiveExtractionError("IO_ARCHIVE_UNSAFE", `duplicate entry ${entryPath}`, `${path}.path`);
paths.add(entryPath);
if (typeof entry.uncompressedBytes !== "number" || !Number.isSafeInteger(entry.uncompressedBytes) || entry.uncompressedBytes < 0 || entry.uncompressedBytes > 2 * 1024 * 1024 * 1024) {
throw new ArchiveExtractionError("IO_ARCHIVE_UNSAFE", `${path}.uncompressedBytes is outside its bound`, `${path}.uncompressedBytes`);
}
if (typeof entry.sha256 !== "string" || !SHA256.test(entry.sha256)) {
throw new ArchiveExtractionError("IO_ARCHIVE_UNSAFE", `${path}.sha256 is invalid`, `${path}.sha256`);
}
return { path: entryPath, uncompressedBytes: entry.uncompressedBytes, sha256: entry.sha256 };
});
const orderedPaths = [...paths].sort();
for (let index = 1; index < orderedPaths.length; index++) {
if (orderedPaths[index].startsWith(`${orderedPaths[index - 1]}/`)) {
throw new ArchiveExtractionError("IO_ARCHIVE_UNSAFE", `file/directory prefix conflict at ${orderedPaths[index]}`, "input.entries");
}
}
return {
schemaVersion: ARCHIVE_EXTRACTION_SCHEMA,
transactionId: input.transactionId,
archiveId: input.archiveId,
committed,
candidate,
entries,
};
}
function sameIdentity(left: ArchiveProjectIdentityIR, right: ArchiveProjectIdentityIR): boolean {
return left.projectId === right.projectId && left.revision === right.revision && left.sha256 === right.sha256;
}
async function digest(bytes: Uint8Array): Promise<string> {
const source = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
const result = await crypto.subtle.digest("SHA-256", source);
return [...new Uint8Array(result)].map((value) => value.toString(16).padStart(2, "0")).join("");
}
function cancellation(error: unknown, signal: AbortSignal): boolean {
return signal.aborted || typeof error === "object" && error !== null && "name" in error && error.name === "AbortError";
}
function cancelled(): never {
throw new DOMException("Archive extraction was cancelled", "AbortError");
}
export async function runArchiveExtractionTransaction(
value: unknown,
storage: ArchiveExtractionStorage,
readEntry: ArchiveExtractionEntryReader,
signal: AbortSignal,
): Promise<ArchiveExtractionReceiptIR> {
const request = parseArchiveExtractionRequest(value);
const committedBefore = await storage.readCommitted(request.committed.projectId);
if (!sameIdentity(committedBefore, request.committed)) {
throw new ArchiveExtractionError("REVISION_CONFLICT", "committed project identity is stale", "input.committed");
}
let stagingCreated = false;
let published = false;
try {
if (signal.aborted) cancelled();
stagingCreated = true;
await storage.createStaging(request.transactionId, request.committed.projectId);
for (const entry of request.entries) {
if (signal.aborted) cancelled();
const bytes = await readEntry(entry, signal);
if (signal.aborted) cancelled();
if (!(bytes instanceof Uint8Array) || bytes.byteLength !== entry.uncompressedBytes || await digest(bytes) !== entry.sha256) {
throw new ArchiveExtractionError("ASSET_SOURCE_HASH_MISMATCH", `payload identity mismatch for ${entry.path}`, entry.path);
}
await storage.writeStaging(request.transactionId, entry.path, bytes);
if (signal.aborted) cancelled();
}
// Cancellation linearizes here. Once the atomic commit starts, it completes as a commit.
if (signal.aborted) cancelled();
const committed = await storage.commitStaging(request.transactionId, committedBefore, request.candidate);
published = true;
if (!sameIdentity(committed, request.candidate)) {
throw new ArchiveExtractionError("STORAGE_TRANSACTION", "storage published an unexpected project identity");
}
if (await storage.countStagingEntries(request.transactionId) !== 0) {
throw new ArchiveExtractionError("STORAGE_TRANSACTION", "committed extraction retained staging entries");
}
return { status: "COMMITTED", transactionId: request.transactionId, committed, stagingEntriesAfter: 0 };
}
catch (error) {
if (published) throw error;
const removedStagingEntries = stagingCreated ? await storage.discardStaging(request.transactionId) : 0;
const stagingEntriesAfter = stagingCreated ? await storage.countStagingEntries(request.transactionId) : 0;
const committedAfter = await storage.readCommitted(request.committed.projectId);
if (stagingEntriesAfter !== 0 || !sameIdentity(committedBefore, committedAfter)) {
throw new ArchiveExtractionError("STORAGE_TRANSACTION", "archive rollback did not preserve the committed project");
}
if (cancellation(error, signal)) {
return {
status: "CANCELLED",
code: "IO_ARCHIVE_CANCELLED",
transactionId: request.transactionId,
committedBefore,
committedAfter,
removedStagingEntries,
stagingEntriesAfter: 0,
publishedProjects: 0,
};
}
throw error;
}
}

View File

@@ -0,0 +1,160 @@
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 };
}

View File

@@ -0,0 +1,135 @@
import type { ErrorCode } from "./error";
export const ARCHIVE_METADATA_FIRST_SCHEMA = 1 as const;
export const ARCHIVE_METADATA_FORMATS = ["ZIP", "TAR"] as const;
export type ArchiveMetadataFormat = typeof ARCHIVE_METADATA_FORMATS[number];
export interface ArchiveMetadataFirstRequestIR {
schemaVersion: typeof ARCHIVE_METADATA_FIRST_SCHEMA;
archiveId: string;
format: ArchiveMetadataFormat;
archiveByteLength: number;
metadataOffset: number;
metadataByteLength: number;
}
export interface ArchiveMetadataReadIR {
kind: "CENTRAL_DIRECTORY" | "MANIFEST";
byteOffset: number;
byteLength: number;
}
export interface ArchiveMetadataFirstPlanIR {
status: "METADATA_ONLY";
schemaVersion: typeof ARCHIVE_METADATA_FIRST_SCHEMA;
archiveId: string;
format: ArchiveMetadataFormat;
firstRead: ArchiveMetadataReadIR;
payloadReads: [];
}
export interface ArchiveReadTraceItemIR {
sequence: number;
kind: "CENTRAL_DIRECTORY" | "MANIFEST" | "PAYLOAD";
byteOffset: number;
byteLength: number;
}
export interface ArchiveReadTraceIR {
schemaVersion: typeof ARCHIVE_METADATA_FIRST_SCHEMA;
archiveId: string;
reads: ArchiveReadTraceItemIR[];
}
export interface ArchiveReadTraceValidationIR {
status: "VALID";
metadataFirst: true;
payloadReadsAfterMetadata: true;
}
export class ArchiveMetadataFirstError extends Error {
readonly code: ErrorCode;
readonly path?: string;
constructor(message: string, path?: string) {
super(`IO_ARCHIVE_UNSAFE: ${message}`);
this.name = "ArchiveMetadataFirstError";
this.code = "IO_ARCHIVE_UNSAFE";
this.path = path;
}
}
const ARCHIVE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const MAX_ARCHIVE_BYTES = Number.MAX_SAFE_INTEGER;
const MAX_METADATA_BYTES = 64 * 1024 * 1024;
function record(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new ArchiveMetadataFirstError(`${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 ArchiveMetadataFirstError(`${path} contains undeclared fields`, path);
}
function integer(value: unknown, path: string, minimum = 0): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > MAX_ARCHIVE_BYTES) throw new ArchiveMetadataFirstError(`${path} must be a safe integer`, path);
return value;
}
function parseRequest(value: unknown): ArchiveMetadataFirstRequestIR {
const input = record(value, "input");
exactKeys(input, ["schemaVersion", "archiveId", "format", "archiveByteLength", "metadataOffset", "metadataByteLength"], "input");
if (input.schemaVersion !== ARCHIVE_METADATA_FIRST_SCHEMA) throw new ArchiveMetadataFirstError("unsupported archive metadata schema", "input.schemaVersion");
if (typeof input.archiveId !== "string" || !ARCHIVE_ID.test(input.archiveId)) throw new ArchiveMetadataFirstError("archiveId is invalid", "input.archiveId");
if (!ARCHIVE_METADATA_FORMATS.includes(input.format as ArchiveMetadataFormat)) throw new ArchiveMetadataFirstError("archive format is unsupported", "input.format");
const archiveByteLength = integer(input.archiveByteLength, "input.archiveByteLength", 1);
const metadataOffset = integer(input.metadataOffset, "input.metadataOffset");
const metadataByteLength = integer(input.metadataByteLength, "input.metadataByteLength", 1);
if (metadataByteLength > MAX_METADATA_BYTES || metadataOffset + metadataByteLength > archiveByteLength) throw new ArchiveMetadataFirstError("metadata range is outside the archive or exceeds its bound", "input.metadataByteLength");
return { schemaVersion: ARCHIVE_METADATA_FIRST_SCHEMA, archiveId: input.archiveId, format: input.format as ArchiveMetadataFormat, archiveByteLength, metadataOffset, metadataByteLength };
}
export function planArchiveMetadataRead(value: unknown): ArchiveMetadataFirstPlanIR {
const request = parseRequest(value);
return {
status: "METADATA_ONLY",
schemaVersion: ARCHIVE_METADATA_FIRST_SCHEMA,
archiveId: request.archiveId,
format: request.format,
firstRead: { kind: request.format === "ZIP" ? "CENTRAL_DIRECTORY" : "MANIFEST", byteOffset: request.metadataOffset, byteLength: request.metadataByteLength },
payloadReads: [],
};
}
function parseTraceItem(value: unknown, index: number): ArchiveReadTraceItemIR {
const path = `reads[${index}]`;
const item = record(value, path);
exactKeys(item, ["sequence", "kind", "byteOffset", "byteLength"], path);
const sequence = integer(item.sequence, `${path}.sequence`);
if (!(["CENTRAL_DIRECTORY", "MANIFEST", "PAYLOAD"] as const).includes(item.kind as ArchiveReadTraceItemIR["kind"])) throw new ArchiveMetadataFirstError(`${path}.kind is unsupported`, `${path}.kind`);
return { sequence, kind: item.kind as ArchiveReadTraceItemIR["kind"], byteOffset: integer(item.byteOffset, `${path}.byteOffset`), byteLength: integer(item.byteLength, `${path}.byteLength`, 1) };
}
function parseTrace(value: unknown): ArchiveReadTraceIR {
const input = record(value, "trace");
exactKeys(input, ["schemaVersion", "archiveId", "reads"], "trace");
if (input.schemaVersion !== ARCHIVE_METADATA_FIRST_SCHEMA) throw new ArchiveMetadataFirstError("unsupported archive trace schema", "trace.schemaVersion");
if (typeof input.archiveId !== "string" || !ARCHIVE_ID.test(input.archiveId)) throw new ArchiveMetadataFirstError("trace archiveId is invalid", "trace.archiveId");
if (!Array.isArray(input.reads) || input.reads.length === 0 || input.reads.length > 10_000) throw new ArchiveMetadataFirstError("trace reads exceeds its bound", "trace.reads");
const reads = input.reads.map(parseTraceItem).sort((left, right) => left.sequence - right.sequence);
if (reads.some((item, index) => item.sequence !== index)) throw new ArchiveMetadataFirstError("trace sequence must be contiguous and unique", "trace.reads");
return { schemaVersion: ARCHIVE_METADATA_FIRST_SCHEMA, archiveId: input.archiveId, reads };
}
export function validateArchiveReadTrace(planValue: unknown, traceValue: unknown): ArchiveReadTraceValidationIR {
const plan = planArchiveMetadataRead(planValue);
const trace = parseTrace(traceValue);
if (trace.archiveId !== plan.archiveId) throw new ArchiveMetadataFirstError("trace archiveId does not match the plan", "trace.archiveId");
const first = trace.reads[0];
if (first.kind !== plan.firstRead.kind || first.byteOffset !== plan.firstRead.byteOffset || first.byteLength !== plan.firstRead.byteLength) throw new ArchiveMetadataFirstError("payload was read before the central directory or manifest", "trace.reads[0]");
if (trace.reads.slice(1).some((item) => item.kind === plan.firstRead.kind || item.kind === (plan.format === "ZIP" ? "MANIFEST" : "CENTRAL_DIRECTORY"))) throw new ArchiveMetadataFirstError("metadata read sequence is duplicated or out of order", "trace.reads");
return { status: "VALID", metadataFirst: true, payloadReadsAfterMetadata: true };
}

View File

@@ -13,6 +13,8 @@ export const ASSET_LIBRARY_BUDGET = {
maxEntryBytes: 2 * 1024 * 1024 * 1024,
maxArchiveBytes: 4 * 1024 * 1024 * 1024,
maxCompressionRatio: 100,
maxArchivePathDepth: 64,
maxArchiveFileNameBytes: 255,
maxExternalUris: 10_000,
} as const;
@@ -51,6 +53,11 @@ const FORMATS = new Set<IOFormat>(["GLB", "GLTF", "OBJ", "PLY", "STL", "USD", "A
function record(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
function text(value: unknown, name: string, maximum = 256): string { if (typeof value !== "string" || value.length === 0 || value.length > maximum) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${name} is invalid`); return value; }
function integer(value: unknown, name: string, minimum: number, maximum: number): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${name} is outside the bounded range`); return value; }
function archiveInteger(value: unknown, name: string, minimum: number, maximum: number): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${name} is outside the bounded range`);
if (value > maximum) throw new AssetLibraryValidationError("ASSET_BUDGET_EXCEEDED", `${name} exceeds the archive entry budget`);
return value;
}
function digest(value: unknown, name: string): string { if (typeof value !== "string" || !SHA256.test(value)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${name} must be a lowercase SHA-256 digest`); return value; }
function projectPath(value: unknown, name: string, code: ErrorCode = "ASSET_MANIFEST_INVALID"): string { try { return normalizeProjectAssetPath(text(value, name, 2048)); } catch { throw new AssetLibraryValidationError(code, `${name} is outside the project`); } }
@@ -131,7 +138,10 @@ export function parseIORequest(value: unknown): IORequestIR {
let totalCompressed = 0; let totalUncompressed = 0; const archivePaths = new Set<string>();
request.archiveEntries = value.archiveEntries.map((entry, index): IOArchiveEntryIR => {
if (!record(entry)) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", `archiveEntries[${index}] is invalid`);
const path = projectPath(entry.path, `archiveEntries[${index}].path`, "IO_ARCHIVE_UNSAFE"); const compressedBytes = integer(entry.compressedBytes, `archiveEntries[${index}].compressedBytes`, 0, ASSET_LIBRARY_BUDGET.maxEntryBytes); const uncompressedBytes = integer(entry.uncompressedBytes, `archiveEntries[${index}].uncompressedBytes`, 0, ASSET_LIBRARY_BUDGET.maxEntryBytes);
const path = projectPath(entry.path, `archiveEntries[${index}].path`, "IO_ARCHIVE_UNSAFE"); const compressedBytes = archiveInteger(entry.compressedBytes, `archiveEntries[${index}].compressedBytes`, 0, ASSET_LIBRARY_BUDGET.maxEntryBytes); const uncompressedBytes = archiveInteger(entry.uncompressedBytes, `archiveEntries[${index}].uncompressedBytes`, 0, ASSET_LIBRARY_BUDGET.maxEntryBytes);
const pathSegments = path.split("/");
const fileNameBytes = new TextEncoder().encode(pathSegments[pathSegments.length - 1]).byteLength;
if (pathSegments.length - 1 > ASSET_LIBRARY_BUDGET.maxArchivePathDepth || fileNameBytes > ASSET_LIBRARY_BUDGET.maxArchiveFileNameBytes) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", `Archive path ${path} exceeds its depth or filename budget`);
if (archivePaths.has(path) || [...archivePaths].some((existing) => existing.startsWith(`${path}/`) || path.startsWith(`${existing}/`))) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", `Archive path ${path} is duplicated or conflicts with a file prefix`);
archivePaths.add(path);
totalCompressed += compressedBytes; totalUncompressed += uncompressedBytes;

View File

@@ -1,23 +1,64 @@
const DRIVE_PATH = /^[A-Za-z]:[\\/]/;
const URI_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:/;
const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/;
function invalidPath(message: "ASSET_PATH_INVALID" | "ASSET_PATH_OUTSIDE_PROJECT"): never {
throw new Error(message);
}
function decodePath(sourcePath: string): string {
let decoded: string;
try {
decoded = decodeURIComponent(sourcePath);
}
catch {
return invalidPath("ASSET_PATH_INVALID");
}
// A canonical path must be safe to normalize again. Residual percent octets could otherwise
// become separators or dot segments in a second decoder.
if (decoded.includes("%")) return invalidPath("ASSET_PATH_OUTSIDE_PROJECT");
try {
encodeURIComponent(decoded);
}
catch {
return invalidPath("ASSET_PATH_INVALID");
}
return decoded.normalize("NFC");
}
export function normalizeProjectAssetPath(sourcePath: string): string {
if (typeof sourcePath !== "string" || sourcePath.length === 0 || sourcePath.length > 2048) {
throw new Error("ASSET_PATH_INVALID");
return invalidPath("ASSET_PATH_INVALID");
}
if (sourcePath.includes("\0") || sourcePath.includes("\\") || sourcePath.includes("%")) {
throw new Error("ASSET_PATH_OUTSIDE_PROJECT");
const blenderRelative = sourcePath.startsWith("//");
if (sourcePath.startsWith("\\") || sourcePath.startsWith("/") && !blenderRelative) {
return invalidPath("ASSET_PATH_OUTSIDE_PROJECT");
}
let relative = sourcePath.startsWith("//") ? sourcePath.slice(2) : sourcePath;
let relative = decodePath(sourcePath);
if (relative.startsWith("//")) {
if (!blenderRelative) return invalidPath("ASSET_PATH_OUTSIDE_PROJECT");
relative = relative.slice(2);
}
relative = relative.replaceAll("\\", "/");
if (relative.startsWith("/") || DRIVE_PATH.test(relative) || URI_SCHEME.test(relative)) {
throw new Error("ASSET_PATH_OUTSIDE_PROJECT");
return invalidPath("ASSET_PATH_OUTSIDE_PROJECT");
}
const segments = relative.split("/");
if (segments.length === 0 || segments.some((segment) =>
segment.length === 0 || segment === "." || segment === ".." || /[\u0000-\u001f\u007f]/.test(segment))) {
throw new Error("ASSET_PATH_OUTSIDE_PROJECT");
const canonicalSegments: string[] = [];
for (const segment of relative.split("/")) {
if (segment.length === 0 || segment === ".") continue;
if (segment === "..") {
if (canonicalSegments.length === 0) return invalidPath("ASSET_PATH_OUTSIDE_PROJECT");
canonicalSegments.pop();
continue;
}
if (CONTROL_CHARACTER.test(segment)) return invalidPath("ASSET_PATH_OUTSIDE_PROJECT");
canonicalSegments.push(segment);
}
relative = segments.join("/");
if (!relative) throw new Error("ASSET_PATH_INVALID");
return relative;
const canonical = canonicalSegments.join("/");
if (!canonical || canonical.length > 2048) return invalidPath("ASSET_PATH_INVALID");
return canonical;
}

View File

@@ -0,0 +1,69 @@
export const DEVICE_BUDGET_SCHEMA_VERSION = 1 as const;
export type DeviceBudgetTier = "CONSERVATIVE" | "BALANCED" | "HIGH";
export interface DeviceBudgetObservation {
schemaVersion: typeof DEVICE_BUDGET_SCHEMA_VERSION;
identitySha256: string;
webgl2: { status: "PASS" | "BLOCKED"; renderer?: string; vendor?: string };
webgpu: { status: "PASS" | "BLOCKED"; device?: string; description?: string; isFallbackAdapter?: boolean };
hardwareConcurrency: number | null;
deviceMemory: number | null;
}
export interface DeviceBudgetLimits {
maxTextureGPUBytes: number;
maxTexturePayloadBytes: number;
maxTextureDimension: number;
maxLights: number;
maxShadowMaps: number;
}
export interface DeviceBudgetSelection {
schemaVersion: typeof DEVICE_BUDGET_SCHEMA_VERSION;
identitySha256: string;
tier: DeviceBudgetTier;
reason: "MISSING_GPU" | "UNTRUSTED_ADAPTER" | "WEBGPU_UNAVAILABLE" | "BALANCED_CAPABILITY" | "HIGH_CAPABILITY";
limits: DeviceBudgetLimits;
}
const mib = 1024 * 1024;
const LIMITS: Readonly<Record<DeviceBudgetTier, DeviceBudgetLimits>> = Object.freeze({
CONSERVATIVE: Object.freeze({ maxTextureGPUBytes: 256 * mib, maxTexturePayloadBytes: 256 * mib, maxTextureDimension: 8192, maxLights: 8, maxShadowMaps: 2 }),
BALANCED: Object.freeze({ maxTextureGPUBytes: 512 * mib, maxTexturePayloadBytes: 512 * mib, maxTextureDimension: 16384, maxLights: 16, maxShadowMaps: 4 }),
HIGH: Object.freeze({ maxTextureGPUBytes: 1024 * mib, maxTexturePayloadBytes: 512 * mib, maxTextureDimension: 16384, maxLights: 64, maxShadowMaps: 8 }),
});
function validHash(value: unknown): value is string {
return typeof value === "string" && /^[a-f0-9]{64}$/u.test(value);
}
function validPositive(value: number | null): value is number {
return value !== null && Number.isSafeInteger(value) && value > 0;
}
export function selectDeviceBudget(observation: DeviceBudgetObservation): DeviceBudgetSelection {
if (!observation || observation.schemaVersion !== DEVICE_BUDGET_SCHEMA_VERSION || !validHash(observation.identitySha256)) {
throw new Error("DEVICE_BUDGET_IDENTITY_INVALID");
}
const conservative = (reason: DeviceBudgetSelection["reason"]): DeviceBudgetSelection => ({
schemaVersion: DEVICE_BUDGET_SCHEMA_VERSION,
identitySha256: observation.identitySha256,
tier: "CONSERVATIVE",
reason,
limits: LIMITS.CONSERVATIVE,
});
if (observation.webgl2.status !== "PASS") return conservative("MISSING_GPU");
const renderer = `${observation.webgl2.renderer ?? ""} ${observation.webgpu.description ?? ""}`.toLowerCase();
if (!renderer || renderer.includes("swiftshader") || renderer.includes("unknown") || observation.webgpu.isFallbackAdapter) return conservative("UNTRUSTED_ADAPTER");
if (observation.webgpu.status !== "PASS") return conservative("WEBGPU_UNAVAILABLE");
if (!validPositive(observation.hardwareConcurrency) || !validPositive(observation.deviceMemory)) return conservative("UNTRUSTED_ADAPTER");
const tier: DeviceBudgetTier = observation.hardwareConcurrency >= 8 && observation.deviceMemory >= 8 ? "HIGH" : "BALANCED";
return { schemaVersion: DEVICE_BUDGET_SCHEMA_VERSION, identitySha256: observation.identitySha256, tier, reason: tier === "HIGH" ? "HIGH_CAPABILITY" : "BALANCED_CAPABILITY", limits: LIMITS[tier] };
}
export function deviceBudgetLimits(tier: DeviceBudgetTier): DeviceBudgetLimits {
const limits = LIMITS[tier];
if (!limits) throw new Error("DEVICE_BUDGET_TIER_INVALID");
return limits;
}

View File

@@ -24,6 +24,7 @@ export const APP_DIAGNOSTIC_MESSAGES = {
STORAGE_START_FAILED: "Storage: unavailable",
PBR_ASSET_INVALID: "PBR asset unavailable",
BLEND_OPEN_FAILED: "Engine: .blend open failed",
IO_FORMAT_UNSUPPORTED: "IO: format route unavailable",
POST_COMMIT_MAINTENANCE_FAILED: "Storage: post-commit maintenance failed",
PROJECT_RECOVERY_FAILED: "Recovery: project could not be restored",
WORKER_RECOVERY_FAILED: "Recovery: Worker restart failed",

View File

@@ -166,6 +166,7 @@ export type ErrorCode =
| "LIBRARY_MUTATION_UNAVAILABLE"
| "IO_FORMAT_UNSUPPORTED"
| "IO_ARCHIVE_UNSAFE"
| "IO_ARCHIVE_CANCELLED"
| "IO_EXTERNAL_URI_BLOCKED"
| "EDITOR_LAYOUT_INVALID"
| "EDITOR_LAYOUT_BUDGET_EXCEEDED"
@@ -177,6 +178,10 @@ export type ErrorCode =
| "SCRIPT_POLICY_DENIED"
| "SCRIPT_SIGNATURE_INVALID"
| "SCRIPT_SANDBOX_UNAVAILABLE"
| "SCRIPT_SANDBOX_CRASHED"
| "SCRIPT_SANDBOX_TIMEOUT"
| "SCRIPT_SANDBOX_CANCELLED"
| "SCRIPT_SANDBOX_LATE_RESULT"
| "SCRIPT_BUDGET_EXCEEDED"
| "PLATFORM_CAPABILITY_UNAVAILABLE"
| "SERVER_JOB_UNAVAILABLE"

View File

@@ -11,6 +11,10 @@ interface GLBAccessor {
componentType: number;
count: number;
type: string;
normalized?: boolean;
min?: number[];
max?: number[];
sparse?: Record<string, unknown>;
}
interface GLBBufferView {
@@ -22,20 +26,62 @@ interface GLBBufferView {
interface GLBPrimitive {
attributes?: Record<string, number>;
indices?: number;
material?: number;
mode?: number;
targets?: Array<Record<string, number>>;
}
interface GLBDocument {
asset?: { version?: string };
buffers?: Array<{ byteLength?: number }>;
asset?: { version?: string; generator?: string };
scene?: number;
scenes?: Array<{ nodes?: number[] }>;
extensionsUsed?: string[];
extensionsRequired?: string[];
buffers?: Array<{ byteLength?: number; uri?: string }>;
bufferViews?: GLBBufferView[];
accessors?: GLBAccessor[];
meshes?: Array<{ name?: string; primitives?: GLBPrimitive[]; extras?: Record<string, unknown> }>;
images?: Array<{ name?: string; mimeType?: string; bufferView?: number; extras?: Record<string, unknown> }>;
skins?: Array<{ joints?: number[]; inverseBindMatrices?: number; extras?: Record<string, unknown> }>;
animations?: Array<{ name?: string; channels?: Array<{ target?: { node?: number; path?: string } }>; extras?: Record<string, unknown> }>;
images?: Array<{ name?: string; mimeType?: string; bufferView?: number; uri?: string; extras?: Record<string, unknown> }>;
samplers?: Array<Record<string, unknown>>;
textures?: Array<{ sampler?: number; source?: number }>;
materials?: Array<{
name?: string;
alphaMode?: string;
doubleSided?: boolean;
pbrMetallicRoughness?: {
baseColorFactor?: number[];
baseColorTexture?: Record<string, unknown>;
metallicFactor?: number;
roughnessFactor?: number;
};
normalTexture?: Record<string, unknown>;
emissiveFactor?: number[];
}>;
nodes?: Array<{
name?: string;
mesh?: number;
skin?: number;
children?: number[];
translation?: number[];
rotation?: number[];
scale?: number[];
}>;
skins?: Array<{ name?: string; joints?: number[]; inverseBindMatrices?: number; skeleton?: number; extras?: Record<string, unknown> }>;
animations?: Array<{
name?: string;
samplers?: Array<{ input?: number; output?: number; interpolation?: string }>;
channels?: Array<{ sampler?: number; target?: { node?: number; path?: string } }>;
extras?: Record<string, unknown>;
}>;
}
export const GLB_IMPORT_BUDGET = {
maxBytes: 512 * 1024,
maxJsonBytes: 256 * 1024,
maxBufferViews: 4096,
maxAccessors: 8192,
} as const;
export interface ImportedGLBImage {
name?: string;
blenderId?: string;
@@ -62,6 +108,73 @@ export interface GLBSemanticComparison {
mismatches: string[];
}
export interface GLBDesktopAccessorSemantics {
componentType: number;
count: number;
type: string;
normalized: boolean;
min: number[] | null;
max: number[] | null;
}
export interface GLBDesktopFixtureSemantics {
asset: { version?: string; generator?: string } | null;
extensionsUsed: string[];
extensionsRequired: string[];
scene: number | null;
nodeNames: Array<string | null>;
nodes: Array<{
name: string | null;
mesh: number | null;
skin: number | null;
children: number[];
translation: number[] | null;
rotation: number[] | null;
scale: number[] | null;
}>;
meshes: Array<{
name: string | null;
primitives: Array<{
attributes: Record<string, GLBDesktopAccessorSemantics>;
indices: GLBDesktopAccessorSemantics | null;
material: number | null;
mode: number;
targets: Array<Record<string, GLBDesktopAccessorSemantics>>;
}>;
}>;
materials: Array<{
name: string | null;
alphaMode: string;
doubleSided: boolean;
pbr: {
baseColorFactor: number[] | null;
baseColorTexture: Record<string, unknown> | null;
metallicFactor: number | null;
roughnessFactor: number | null;
};
normalTexture: Record<string, unknown> | null;
emissiveFactor: number[] | null;
}>;
textures: Array<Record<string, unknown>>;
images: Array<Record<string, unknown>>;
samplers: Array<Record<string, unknown>>;
skins: Array<{
name: string | null;
joints: number[];
inverseBindMatrices: GLBDesktopAccessorSemantics | null;
skeleton: number | null;
}>;
animations: Array<{
name: string | null;
samplers: Array<{
interpolation: string;
input: GLBDesktopAccessorSemantics | null;
output: GLBDesktopAccessorSemantics | null;
}>;
channels: Array<{ sampler: number; target: { node: number; path: string } }>;
}>;
}
function recordId(extras: Record<string, unknown> | undefined): string | undefined {
return typeof extras?.blenderId === "string" ? extras.blenderId : undefined;
}
@@ -72,11 +185,12 @@ function requireIndex(value: unknown, size: number, label: string): number {
}
function jsonChunk(bytes: Uint8Array, length: number): GLBDocument {
if (bytes.byteLength > GLB_IMPORT_BUDGET.maxBytes) throw new Error(`GLB_IMPORT_BUDGET_EXCEEDED: file exceeds ${GLB_IMPORT_BUDGET.maxBytes} bytes`);
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
if (bytes.byteLength < 20 || view.getUint32(0, true) !== GLB_MAGIC || view.getUint32(4, true) !== 2) throw new Error("GLB header is invalid");
if (view.getUint32(8, true) !== bytes.byteLength) throw new Error("GLB length does not match header");
const jsonLength = view.getUint32(12, true);
if (view.getUint32(16, true) !== JSON_CHUNK || jsonLength % 4 !== 0 || 20 + jsonLength > bytes.byteLength) throw new Error("GLB JSON chunk is invalid");
if (jsonLength > GLB_IMPORT_BUDGET.maxJsonBytes || view.getUint32(16, true) !== JSON_CHUNK || jsonLength % 4 !== 0 || 20 + jsonLength > bytes.byteLength) throw new Error("GLB JSON chunk is invalid");
let document: unknown;
try {
document = JSON.parse(new TextDecoder().decode(bytes.subarray(20, 20 + jsonLength)).trim());
@@ -102,11 +216,15 @@ export function importGLBSemantics(glb: ArrayBuffer): ImportedGLBSemantics {
const view = new DataView(glb);
const jsonLength = view.getUint32(12, true);
const document = jsonChunk(bytes, glb.byteLength);
if ((document.extensionsUsed?.length ?? 0) > 0 || (document.extensionsRequired?.length ?? 0) > 0) throw new Error("GLB_EXTENSION_UNSUPPORTED: extensions are outside the bounded importer");
if ((document.buffers ?? []).some((buffer) => buffer.uri !== undefined) || (document.images ?? []).some((image) => image.uri !== undefined)) throw new Error("GLB_EXTERNAL_URI_BLOCKED: external URI resources are not accepted");
const bufferViews = document.bufferViews ?? [];
const accessors = document.accessors ?? [];
if (bufferViews.length > GLB_IMPORT_BUDGET.maxBufferViews || accessors.length > GLB_IMPORT_BUDGET.maxAccessors) throw new Error("GLB_IMPORT_BUDGET_EXCEEDED: accessor or bufferView count exceeds the bounded importer");
for (const [index, bufferView] of bufferViews.entries()) bufferViewBytes(bytes, jsonLength, bufferView, `bufferViews[${index}]`);
const accessorType = (index: number): string => accessors[requireIndex(index, accessors.length, "accessor")]?.type ?? "";
for (const [index, accessor] of accessors.entries()) {
if (accessor.sparse !== undefined) throw new Error(`GLB_SPARSE_ACCESSOR_UNSUPPORTED: accessors[${index}]`);
if (!Number.isSafeInteger(accessor.count) || accessor.count < 0 || (accessor.byteOffset ?? 0) < 0) throw new Error(`accessors[${index}] is invalid`);
if (accessor.bufferView !== undefined) {
const bytesForAccessor = bufferViewBytes(bytes, jsonLength, bufferViews[requireIndex(accessor.bufferView, bufferViews.length, `accessors[${index}]`)], `accessors[${index}]`);
@@ -153,6 +271,173 @@ export function importGLBSemantics(glb: ArrayBuffer): ImportedGLBSemantics {
return { version: 2, meshCount: meshes.length, primitiveCount: meshes.reduce((sum, mesh) => sum + mesh.primitiveCount, 0), meshes, images, skinCount: skins.length, skins, animationCount: animations.length, animationChannelCount: animationPaths.length, animationPaths };
}
function desktopAccessorSemantics(accessors: readonly GLBAccessor[], index: number | undefined, label: string): GLBDesktopAccessorSemantics | null {
if (index === undefined) return null;
const accessor = accessors[requireIndex(index, accessors.length, label)]!;
return {
componentType: accessor.componentType,
count: accessor.count,
type: accessor.type,
normalized: accessor.normalized === true,
min: accessor.min ?? null,
max: accessor.max ?? null,
};
}
/**
* Imports the canonical, bounded semantic surface used by the M12 desktop GLB
* fixtures. This intentionally does not create Blender Main data; that writer
* and its stable-ID persistence gate belong to M12-06C.
*/
export function importGLBDesktopFixtureSemantics(glb: ArrayBuffer): GLBDesktopFixtureSemantics {
importGLBSemantics(glb);
const bytes = new Uint8Array(glb);
const jsonLength = new DataView(glb).getUint32(12, true);
const document = jsonChunk(bytes, glb.byteLength);
const accessors = document.accessors ?? [];
const meshes = document.meshes ?? [];
const materials = document.materials ?? [];
const nodes = document.nodes ?? [];
const textures = document.textures ?? [];
const images = document.images ?? [];
const samplers = document.samplers ?? [];
const skins = document.skins ?? [];
const animations = document.animations ?? [];
if (document.scene !== undefined) requireIndex(document.scene, document.scenes?.length ?? 0, "scene");
for (const [index, node] of nodes.entries()) {
if (node.mesh !== undefined) requireIndex(node.mesh, meshes.length, `nodes[${index}].mesh`);
if (node.skin !== undefined) requireIndex(node.skin, skins.length, `nodes[${index}].skin`);
for (const child of node.children ?? []) requireIndex(child, nodes.length, `nodes[${index}].children`);
}
for (const [index, texture] of textures.entries()) {
if (texture.source !== undefined) requireIndex(texture.source, images.length, `textures[${index}].source`);
if (texture.sampler !== undefined) requireIndex(texture.sampler, samplers.length, `textures[${index}].sampler`);
}
const importedMeshes = meshes.map((mesh, meshIndex) => ({
name: mesh.name ?? null,
primitives: (mesh.primitives ?? []).map((primitive, primitiveIndex) => {
if (primitive.material !== undefined) requireIndex(primitive.material, materials.length, `meshes[${meshIndex}].primitives[${primitiveIndex}].material`);
const attributes: Record<string, GLBDesktopAccessorSemantics> = {};
for (const [name, accessor] of Object.entries(primitive.attributes ?? {}).sort(([left], [right]) => left.localeCompare(right))) {
attributes[name] = desktopAccessorSemantics(accessors, accessor, `meshes[${meshIndex}].primitives[${primitiveIndex}].attributes.${name}`)!;
}
return {
attributes,
indices: desktopAccessorSemantics(accessors, primitive.indices, `meshes[${meshIndex}].primitives[${primitiveIndex}].indices`),
material: primitive.material ?? null,
mode: primitive.mode ?? 4,
targets: (primitive.targets ?? []).map((target, targetIndex) => {
const imported: Record<string, GLBDesktopAccessorSemantics> = {};
for (const [name, accessor] of Object.entries(target).sort(([left], [right]) => left.localeCompare(right))) {
imported[name] = desktopAccessorSemantics(accessors, accessor, `meshes[${meshIndex}].primitives[${primitiveIndex}].targets[${targetIndex}].${name}`)!;
}
return imported;
}),
};
}),
}));
const importedSkins = skins.map((skin, skinIndex) => {
const joints = skin.joints ?? [];
for (const joint of joints) requireIndex(joint, nodes.length, `skins[${skinIndex}].joints`);
if (skin.skeleton !== undefined) requireIndex(skin.skeleton, nodes.length, `skins[${skinIndex}].skeleton`);
return {
name: skin.name ?? null,
joints,
inverseBindMatrices: desktopAccessorSemantics(accessors, skin.inverseBindMatrices, `skins[${skinIndex}].inverseBindMatrices`),
skeleton: skin.skeleton ?? null,
};
});
const importedAnimations = animations.map((animation, animationIndex) => {
const animationSamplers = animation.samplers ?? [];
return {
name: animation.name ?? null,
samplers: animationSamplers.map((sampler, samplerIndex) => ({
interpolation: sampler.interpolation ?? "LINEAR",
input: desktopAccessorSemantics(accessors, sampler.input, `animations[${animationIndex}].samplers[${samplerIndex}].input`),
output: desktopAccessorSemantics(accessors, sampler.output, `animations[${animationIndex}].samplers[${samplerIndex}].output`),
})),
channels: (animation.channels ?? []).map((channel, channelIndex) => {
const sampler = requireIndex(channel.sampler, animationSamplers.length, `animations[${animationIndex}].channels[${channelIndex}].sampler`);
const node = requireIndex(channel.target?.node, nodes.length, `animations[${animationIndex}].channels[${channelIndex}].target.node`);
const path = channel.target?.path;
if (path !== "translation" && path !== "rotation" && path !== "scale" && path !== "weights") throw new Error(`animations[${animationIndex}].channels[${channelIndex}].target.path is invalid`);
return { sampler, target: { node, path } };
}),
};
});
return {
asset: document.asset ?? null,
extensionsUsed: [...(document.extensionsUsed ?? [])].sort(),
extensionsRequired: [...(document.extensionsRequired ?? [])].sort(),
scene: document.scene ?? null,
nodeNames: nodes.map((node) => node.name ?? null),
nodes: nodes.map((node) => ({
name: node.name ?? null,
mesh: node.mesh ?? null,
skin: node.skin ?? null,
children: node.children ?? [],
translation: node.translation ?? null,
rotation: node.rotation ?? null,
scale: node.scale ?? null,
})),
meshes: importedMeshes,
materials: materials.map((material) => {
const pbr = material.pbrMetallicRoughness ?? {};
return {
name: material.name ?? null,
alphaMode: material.alphaMode ?? "OPAQUE",
doubleSided: material.doubleSided === true,
pbr: {
baseColorFactor: pbr.baseColorFactor ?? null,
baseColorTexture: pbr.baseColorTexture ?? null,
metallicFactor: pbr.metallicFactor ?? null,
roughnessFactor: pbr.roughnessFactor ?? null,
},
normalTexture: material.normalTexture ?? null,
emissiveFactor: material.emissiveFactor ?? null,
};
}),
textures: textures.map((texture) => ({ ...texture })),
images: images.map((image) => ({ ...image })),
samplers: samplers.map((sampler) => ({ ...sampler })),
skins: importedSkins,
animations: importedAnimations,
};
}
function semanticMismatches(expected: unknown, actual: unknown, path: string, mismatches: string[]): void {
if (Object.is(expected, actual)) return;
if (Array.isArray(expected) || Array.isArray(actual)) {
if (!Array.isArray(expected) || !Array.isArray(actual)) {
mismatches.push(`${path}: expected ${JSON.stringify(expected)} got ${JSON.stringify(actual)}`);
return;
}
if (expected.length !== actual.length) mismatches.push(`${path}.length: expected ${expected.length} got ${actual.length}`);
for (let index = 0; index < Math.min(expected.length, actual.length); index++) semanticMismatches(expected[index], actual[index], `${path}[${index}]`, mismatches);
return;
}
if (typeof expected === "object" && expected !== null && typeof actual === "object" && actual !== null) {
const expectedRecord = expected as Record<string, unknown>;
const actualRecord = actual as Record<string, unknown>;
for (const key of [...new Set([...Object.keys(expectedRecord), ...Object.keys(actualRecord)])].sort()) {
semanticMismatches(expectedRecord[key], actualRecord[key], `${path}.${key}`, mismatches);
}
return;
}
mismatches.push(`${path}: expected ${JSON.stringify(expected)} got ${JSON.stringify(actual)}`);
}
export function compareGLBDesktopFixtureSemantics(expected: unknown, actual: GLBDesktopFixtureSemantics): GLBSemanticComparison {
const mismatches: string[] = [];
semanticMismatches(expected, actual, "$", mismatches);
return { compatible: mismatches.length === 0, mismatches };
}
export function compareGLBToSceneIR(snapshot: SceneSnapshotIR, imported: ImportedGLBSemantics, assetBuffers: readonly GLBAssetBuffer[] = []): GLBSemanticComparison {
const mismatches: string[] = [];
const geometryMeshIds = new Set(snapshot.meshes.filter((mesh) => mesh.geometryStatus !== "summary-only").map((mesh) => mesh.id));

View File

@@ -0,0 +1,63 @@
import type { GLBExportReport } from "./glb-export";
import type { SceneSnapshotIR } from "./scene-ir";
export const GLB_LOSS_REPORT_SCHEMA_VERSION = 1 as const;
export interface GLBLossReport {
schemaVersion: typeof GLB_LOSS_REPORT_SCHEMA_VERSION;
operation: "GLB_EXPORT_LOSS_REPORT";
sceneId: string;
sourceRevision: number;
canExport: boolean;
errorCount: number;
warningCount: number;
losses: Array<{
code: string;
severity: "warning" | "error";
message: string;
id: string | null;
}>;
surface: {
nodeCount: number;
meshCount: number;
materialCount: number;
imageCount: number;
animationCount: number;
nonMeshCount: number;
};
}
/** Convert the exporter result into a stable, machine-consumable loss report. */
export function createGLBLossReport(snapshot: SceneSnapshotIR, report: GLBExportReport): GLBLossReport {
const losses = report.warnings
.map((warning) => ({
code: warning.code,
severity: warning.severity,
message: warning.message,
id: warning.id ?? null,
}))
.sort((left, right) =>
left.code.localeCompare(right.code) ||
left.severity.localeCompare(right.severity) ||
(left.id ?? "").localeCompare(right.id ?? "") ||
left.message.localeCompare(right.message),
);
return {
schemaVersion: GLB_LOSS_REPORT_SCHEMA_VERSION,
operation: "GLB_EXPORT_LOSS_REPORT",
sceneId: snapshot.sceneId,
sourceRevision: snapshot.revision,
canExport: report.canExport,
errorCount: losses.filter((loss) => loss.severity === "error").length,
warningCount: losses.filter((loss) => loss.severity === "warning").length,
losses,
surface: {
nodeCount: snapshot.nodes.length,
meshCount: snapshot.meshes.length,
materialCount: snapshot.materials.length,
imageCount: snapshot.images.length,
animationCount: snapshot.animations.length,
nonMeshCount: snapshot.nonMeshData?.length ?? 0,
},
};
}

View File

@@ -0,0 +1,134 @@
export const GLB_RECOVERY_SCHEMA_VERSION = 1 as const;
export type GLBRecoveryOperation = "IMPORT" | "EXPORT";
export type GLBRecoveryStatus = "RUNNING" | "CANCELLED" | "COMMITTED" | "RECOVERED" | "BLOCKED";
export type GLBRecoveryErrorCode =
| "GLB_OPERATION_CANCELLED"
| "GLB_WORKER_RESTARTED"
| "GLB_OPFS_QUOTA"
| "GLB_RECOVERY_INVALID";
export interface GLBRecoveryReceipt {
schemaVersion: typeof GLB_RECOVERY_SCHEMA_VERSION;
operationId: string;
operation: GLBRecoveryOperation;
status: GLBRecoveryStatus;
workerGeneration: number;
baseRevision: number;
candidateRevision: number;
inputBytes: number;
inputSha256: string;
outputBytes: number;
outputSha256: string | null;
temporaryBytes: number;
liveRequests: number;
committed: boolean;
errorCode?: GLBRecoveryErrorCode;
}
const SHA256 = /^[a-f0-9]{64}$/;
const OPERATION_ID = /^[A-Za-z0-9_-]{1,96}$/;
function assertBase(receipt: GLBRecoveryReceipt): void {
if (receipt.schemaVersion !== GLB_RECOVERY_SCHEMA_VERSION || !OPERATION_ID.test(receipt.operationId) ||
(receipt.operation !== "IMPORT" && receipt.operation !== "EXPORT") || !Number.isSafeInteger(receipt.workerGeneration) || receipt.workerGeneration < 1 ||
!Number.isSafeInteger(receipt.baseRevision) || receipt.baseRevision < 0 || !Number.isSafeInteger(receipt.candidateRevision) || receipt.candidateRevision < receipt.baseRevision ||
!Number.isSafeInteger(receipt.inputBytes) || receipt.inputBytes <= 0 || !SHA256.test(receipt.inputSha256) ||
!Number.isSafeInteger(receipt.outputBytes) || receipt.outputBytes < 0 || (receipt.outputSha256 !== null && !SHA256.test(receipt.outputSha256)) ||
!Number.isSafeInteger(receipt.temporaryBytes) || receipt.temporaryBytes < 0 || !Number.isSafeInteger(receipt.liveRequests) || receipt.liveRequests < 0 ||
typeof receipt.committed !== "boolean") {
throw new Error("GLB_RECOVERY_INVALID: receipt fields are malformed");
}
}
function clone(receipt: GLBRecoveryReceipt): GLBRecoveryReceipt {
assertBase(receipt);
return { ...receipt };
}
export function beginGLBRecoveryOperation(input: {
operationId: string;
operation: GLBRecoveryOperation;
workerGeneration: number;
baseRevision: number;
inputBytes: number;
inputSha256: string;
}): GLBRecoveryReceipt {
const receipt: GLBRecoveryReceipt = {
schemaVersion: GLB_RECOVERY_SCHEMA_VERSION,
operationId: input.operationId,
operation: input.operation,
status: "RUNNING",
workerGeneration: input.workerGeneration,
baseRevision: input.baseRevision,
candidateRevision: input.baseRevision + 1,
inputBytes: input.inputBytes,
inputSha256: input.inputSha256,
outputBytes: 0,
outputSha256: null,
temporaryBytes: input.inputBytes,
liveRequests: 1,
committed: false,
};
assertBase(receipt);
return receipt;
}
export function commitGLBRecoveryOperation(receipt: GLBRecoveryReceipt, output: { bytes: number; sha256: string }): GLBRecoveryReceipt {
const next = clone(receipt);
if (next.status !== "RUNNING" || !Number.isSafeInteger(output.bytes) || output.bytes <= 0 || !SHA256.test(output.sha256)) {
throw new Error("GLB_RECOVERY_INVALID: operation cannot commit");
}
next.status = "COMMITTED";
next.outputBytes = output.bytes;
next.outputSha256 = output.sha256;
next.temporaryBytes = 0;
next.liveRequests = 0;
next.committed = true;
return next;
}
export function cancelGLBRecoveryOperation(receipt: GLBRecoveryReceipt): GLBRecoveryReceipt {
const next = clone(receipt);
if (next.status !== "RUNNING") throw new Error("GLB_RECOVERY_INVALID: operation is not running");
next.status = "CANCELLED";
next.errorCode = "GLB_OPERATION_CANCELLED";
next.temporaryBytes = 0;
next.liveRequests = 0;
next.committed = false;
return next;
}
export function blockGLBRecoveryForQuota(receipt: GLBRecoveryReceipt): GLBRecoveryReceipt {
const next = clone(receipt);
if (next.status !== "RUNNING") throw new Error("GLB_RECOVERY_INVALID: operation is not running");
next.status = "BLOCKED";
next.errorCode = "GLB_OPFS_QUOTA";
next.temporaryBytes = 0;
next.liveRequests = 0;
next.committed = false;
return next;
}
export function recoverGLBRecoveryOperation(receipt: GLBRecoveryReceipt, workerGeneration: number): GLBRecoveryReceipt {
const next = clone(receipt);
if (next.status !== "COMMITTED" || !Number.isSafeInteger(workerGeneration) || workerGeneration <= next.workerGeneration) {
throw new Error("GLB_RECOVERY_INVALID: only a committed operation can recover");
}
next.status = "RECOVERED";
next.workerGeneration = workerGeneration;
next.errorCode = "GLB_WORKER_RESTARTED";
return next;
}
export function parseGLBRecoveryReceipt(value: unknown): GLBRecoveryReceipt {
if (!value || typeof value !== "object") throw new Error("GLB_RECOVERY_INVALID: receipt is not an object");
const receipt = value as GLBRecoveryReceipt;
assertBase(receipt);
if (!["RUNNING", "CANCELLED", "COMMITTED", "RECOVERED", "BLOCKED"].includes(receipt.status)) throw new Error("GLB_RECOVERY_INVALID: status");
if (receipt.status === "CANCELLED" && receipt.errorCode !== "GLB_OPERATION_CANCELLED") throw new Error("GLB_RECOVERY_INVALID: cancellation code");
if (receipt.status === "BLOCKED" && receipt.errorCode !== "GLB_OPFS_QUOTA") throw new Error("GLB_RECOVERY_INVALID: quota code");
if (receipt.status === "COMMITTED" && (!receipt.committed || receipt.outputBytes <= 0 || !receipt.outputSha256)) throw new Error("GLB_RECOVERY_INVALID: committed receipt");
if (receipt.status === "RECOVERED" && (!receipt.committed || receipt.errorCode !== "GLB_WORKER_RESTARTED")) throw new Error("GLB_RECOVERY_INVALID: recovered receipt");
return { ...receipt };
}

View File

@@ -0,0 +1,34 @@
export const IME_COMPOSITION_SCHEMA_VERSION = 1 as const;
export interface IMECompositionState {
schemaVersion: typeof IME_COMPOSITION_SCHEMA_VERSION;
composing: boolean;
revision: number;
pendingText: string;
lastEvent: "IDLE" | "START" | "UPDATE" | "END";
}
export type IMECompositionEvent =
| { type: "compositionstart"; data?: string }
| { type: "compositionupdate"; data?: string }
| { type: "compositionend"; data?: string };
export function createIMECompositionState(): IMECompositionState {
return { schemaVersion: IME_COMPOSITION_SCHEMA_VERSION, composing: false, revision: 0, pendingText: "", lastEvent: "IDLE" };
}
export function reduceIMEComposition(state: IMECompositionState, event: IMECompositionEvent): IMECompositionState {
if (!state || state.schemaVersion !== IME_COMPOSITION_SCHEMA_VERSION) throw new Error("IME_STATE_INVALID");
const text = typeof event.data === "string" ? event.data : "";
if (event.type === "compositionstart") return { schemaVersion: 1, composing: true, revision: state.revision + 1, pendingText: text, lastEvent: "START" };
if (event.type === "compositionupdate") {
if (!state.composing) return state;
return { schemaVersion: 1, composing: true, revision: state.revision + 1, pendingText: text, lastEvent: "UPDATE" };
}
return { schemaVersion: 1, composing: false, revision: state.revision + 1, pendingText: text, lastEvent: "END" };
}
export function shouldBlockOperatorShortcuts(state: IMECompositionState, eventIsComposing = false): boolean {
if (!state || state.schemaVersion !== IME_COMPOSITION_SCHEMA_VERSION) throw new Error("IME_STATE_INVALID");
return state.composing || eventIsComposing;
}

View File

@@ -0,0 +1,41 @@
export const INPUT_MODAL_SCHEMA_VERSION = 1 as const;
export type InputModalKind = "NONE" | "TOUCH_NAVIGATION" | "PEN_STROKE";
export interface InputModalState {
schemaVersion: typeof INPUT_MODAL_SCHEMA_VERSION;
kind: InputModalKind;
activePointerIds: number[];
cancelled: boolean;
navigationRevision: number;
mainCommitCount: number;
}
export function createInputModalState(): InputModalState {
return { schemaVersion: 1, kind: "NONE", activePointerIds: [], cancelled: false, navigationRevision: 0, mainCommitCount: 0 };
}
export function beginTouch(state: InputModalState, pointerId: number): InputModalState {
if (!Number.isSafeInteger(pointerId) || pointerId < 0) throw new Error("POINTER_ID_INVALID");
const ids = state.activePointerIds.includes(pointerId) ? state.activePointerIds : [...state.activePointerIds, pointerId].sort((a, b) => a - b);
return { ...state, kind: "TOUCH_NAVIGATION", activePointerIds: ids, cancelled: false, navigationRevision: ids.length >= 2 && state.activePointerIds.length < 2 ? state.navigationRevision + 1 : state.navigationRevision };
}
export function cancelInputModal(state: InputModalState): InputModalState {
return { ...state, kind: "NONE", activePointerIds: [], cancelled: true };
}
export function endTouch(state: InputModalState, pointerId: number): InputModalState {
const ids = state.activePointerIds.filter((id) => id !== pointerId);
return { ...state, kind: ids.length > 0 ? "TOUCH_NAVIGATION" : "NONE", activePointerIds: ids };
}
export function beginPenStroke(state: InputModalState, pointerId: number): InputModalState {
if (!Number.isSafeInteger(pointerId) || pointerId < 0) throw new Error("POINTER_ID_INVALID");
return { ...state, kind: "PEN_STROKE", activePointerIds: [pointerId], cancelled: false };
}
export function commitPenStroke(state: InputModalState, pointerId: number): InputModalState {
if (state.kind !== "PEN_STROKE" || !state.activePointerIds.includes(pointerId) || state.cancelled) return state;
return { ...state, kind: "NONE", activePointerIds: [], mainCommitCount: state.mainCommitCount + 1 };
}

View File

@@ -0,0 +1,135 @@
import type { ErrorCode } from "./error";
export const IO_FORMAT_CAPABILITY_MATRIX_SCHEMA = 1 as const;
export const IO_FORMAT_MATRIX_FORMATS = ["GLTF", "GLB", "OBJ", "STL", "PLY", "USD", "ALEMBIC"] as const;
export const IO_FORMAT_MATRIX_OPERATIONS = ["IMPORT", "EXPORT"] as const;
export type IOFormatMatrixFormat = typeof IO_FORMAT_MATRIX_FORMATS[number];
export type IOFormatMatrixOperation = typeof IO_FORMAT_MATRIX_OPERATIONS[number];
export type IOFormatMatrixFeatureStatus = "SUPPORTED" | "PARTIAL" | "UNVERIFIED";
export type IOFormatMatrixRouteStatus = "READY" | "BLOCKED";
export type IOFormatMatrixExecution = "LOCAL" | "SERVER" | "NONE";
export interface IOFormatMatrixRouteIR {
status: IOFormatMatrixRouteStatus;
execution: IOFormatMatrixExecution;
code: Extract<ErrorCode, "IO_FORMAT_UNSUPPORTED" | "SERVER_JOB_UNAVAILABLE"> | null;
}
export interface IOFormatMatrixFeatureIR {
status: IOFormatMatrixFeatureStatus;
evidence: string;
}
export interface IOFormatMatrixOperationIR {
local: IOFormatMatrixRouteIR;
server: IOFormatMatrixRouteIR;
geometry: IOFormatMatrixFeatureIR;
material: IOFormatMatrixFeatureIR;
animation: IOFormatMatrixFeatureIR;
}
export interface IOFormatMatrixEntryIR {
format: IOFormatMatrixFormat;
runtimeImportStatus: "AVAILABLE" | "OPERATOR_UNREGISTERED";
runtimeExportStatus: "AVAILABLE" | "OPERATOR_UNREGISTERED";
operations: Record<IOFormatMatrixOperation, IOFormatMatrixOperationIR>;
}
export interface IOFormatCapabilityMatrixIR {
schemaVersion: typeof IO_FORMAT_CAPABILITY_MATRIX_SCHEMA;
task: "M12-05B";
runtimeInventorySha256: string;
formats: IOFormatMatrixEntryIR[];
}
export class IOFormatCapabilityMatrixError extends Error {
readonly code: ErrorCode;
constructor(code: ErrorCode, message: string) {
super(`${code}: ${message}`);
this.name = "IOFormatCapabilityMatrixError";
this.code = code;
}
}
const SHA256 = /^[a-f0-9]{64}$/;
const FEATURE_STATUSES = ["SUPPORTED", "PARTIAL", "UNVERIFIED"] as const;
const ROUTE_STATUSES = ["READY", "BLOCKED"] as const;
const EXECUTIONS = ["LOCAL", "SERVER", "NONE"] as const;
const RUNTIME_STATUSES = ["AVAILABLE", "OPERATOR_UNREGISTERED"] as const;
function record(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new IOFormatCapabilityMatrixError("ASSET_MANIFEST_INVALID", `${path} must be an object`);
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 IOFormatCapabilityMatrixError("ASSET_MANIFEST_INVALID", `${path} contains undeclared fields`);
}
function text(value: unknown, path: string, maximum = 512): string {
if (typeof value !== "string" || value.length === 0 || value.length > maximum) throw new IOFormatCapabilityMatrixError("ASSET_MANIFEST_INVALID", `${path} is invalid`);
return value;
}
function parseRoute(value: unknown, path: string): IOFormatMatrixRouteIR {
const input = record(value, path);
exactKeys(input, ["status", "execution", "code"], path);
if (!ROUTE_STATUSES.includes(input.status as IOFormatMatrixRouteStatus) || !EXECUTIONS.includes(input.execution as IOFormatMatrixExecution)) throw new IOFormatCapabilityMatrixError("ASSET_MANIFEST_INVALID", `${path} route status is invalid`);
const status = input.status as IOFormatMatrixRouteStatus;
const execution = input.execution as IOFormatMatrixExecution;
if (status === "READY" && ((execution !== "LOCAL" && execution !== "SERVER") || input.code !== null)) throw new IOFormatCapabilityMatrixError("IO_FORMAT_UNSUPPORTED", `${path} ready route is not bound to an executor`);
if (status === "BLOCKED" && (execution !== "NONE" || !["IO_FORMAT_UNSUPPORTED", "SERVER_JOB_UNAVAILABLE"].includes(input.code as string))) throw new IOFormatCapabilityMatrixError("IO_FORMAT_UNSUPPORTED", `${path} blocked route is not fail-closed`);
return { status, execution, code: input.code as IOFormatMatrixRouteIR["code"] };
}
function parseFeature(value: unknown, path: string): IOFormatMatrixFeatureIR {
const input = record(value, path);
exactKeys(input, ["status", "evidence"], path);
if (!FEATURE_STATUSES.includes(input.status as IOFormatMatrixFeatureStatus)) throw new IOFormatCapabilityMatrixError("ASSET_MANIFEST_INVALID", `${path}.status is invalid`);
return { status: input.status as IOFormatMatrixFeatureStatus, evidence: text(input.evidence, `${path}.evidence`) };
}
function parseOperation(value: unknown, path: string): IOFormatMatrixOperationIR {
const input = record(value, path);
exactKeys(input, ["local", "server", "geometry", "material", "animation"], path);
const local = parseRoute(input.local, `${path}.local`);
const server = parseRoute(input.server, `${path}.server`);
const geometry = parseFeature(input.geometry, `${path}.geometry`);
const material = parseFeature(input.material, `${path}.material`);
const animation = parseFeature(input.animation, `${path}.animation`);
if (local.status === "READY" && [geometry, material, animation].some((feature) => feature.status === "UNVERIFIED")) throw new IOFormatCapabilityMatrixError("ASSET_MANIFEST_INVALID", `${path} ready local route has unverified feature support`);
return { local, server, geometry, material, animation };
}
function parseEntry(value: unknown, index: number): IOFormatMatrixEntryIR {
const path = `formats[${index}]`;
const input = record(value, path);
exactKeys(input, ["format", "runtimeImportStatus", "runtimeExportStatus", "operations"], path);
if (!IO_FORMAT_MATRIX_FORMATS.includes(input.format as IOFormatMatrixFormat)) throw new IOFormatCapabilityMatrixError("ASSET_MANIFEST_INVALID", `${path}.format is invalid`);
if (!RUNTIME_STATUSES.includes(input.runtimeImportStatus as IOFormatMatrixEntryIR["runtimeImportStatus"]) || !RUNTIME_STATUSES.includes(input.runtimeExportStatus as IOFormatMatrixEntryIR["runtimeExportStatus"])) throw new IOFormatCapabilityMatrixError("ASSET_MANIFEST_INVALID", `${path} runtime status is invalid`);
const operations = record(input.operations, `${path}.operations`);
exactKeys(operations, IO_FORMAT_MATRIX_OPERATIONS, `${path}.operations`);
return {
format: input.format as IOFormatMatrixFormat,
runtimeImportStatus: input.runtimeImportStatus as IOFormatMatrixEntryIR["runtimeImportStatus"],
runtimeExportStatus: input.runtimeExportStatus as IOFormatMatrixEntryIR["runtimeExportStatus"],
operations: {
IMPORT: parseOperation(operations.IMPORT, `${path}.operations.IMPORT`),
EXPORT: parseOperation(operations.EXPORT, `${path}.operations.EXPORT`),
},
};
}
export function parseIOFormatCapabilityMatrix(value: unknown): IOFormatCapabilityMatrixIR {
const input = record(value, "input");
exactKeys(input, ["schemaVersion", "task", "runtimeInventorySha256", "formats"], "input");
if (input.schemaVersion !== IO_FORMAT_CAPABILITY_MATRIX_SCHEMA || input.task !== "M12-05B" || typeof input.runtimeInventorySha256 !== "string" || !SHA256.test(input.runtimeInventorySha256)) throw new IOFormatCapabilityMatrixError("ASSET_MANIFEST_INVALID", "matrix header is invalid");
if (!Array.isArray(input.formats) || input.formats.length !== IO_FORMAT_MATRIX_FORMATS.length) throw new IOFormatCapabilityMatrixError("ASSET_MANIFEST_INVALID", "matrix must contain each inventoried format exactly once");
const formats = input.formats.map(parseEntry);
const seen = new Set(formats.map((entry) => entry.format));
if (seen.size !== IO_FORMAT_MATRIX_FORMATS.length || IO_FORMAT_MATRIX_FORMATS.some((format) => !seen.has(format))) throw new IOFormatCapabilityMatrixError("ASSET_MANIFEST_INVALID", "matrix format identities are incomplete or duplicated");
return { schemaVersion: IO_FORMAT_CAPABILITY_MATRIX_SCHEMA, task: "M12-05B", runtimeInventorySha256: input.runtimeInventorySha256, formats };
}

View File

@@ -0,0 +1,115 @@
import type { IOFormatRuntimeIdentityIR, IOFormatRuntimeReceiptIR, IOFormatRuntimeReceiptSetIR } from "./io-format-runtime-receipt";
export const IO_FORMAT_RECEIPT_BINDING_SCHEMA = 1 as const;
export const IO_FORMAT_RECEIPT_BINDING_TASK = "M12-05E" as const;
export interface IOFormatReceiptBindingIR {
sourceSha256: string;
settingsSha256: string;
runtimeSha256: string;
}
export interface IOFormatBoundRuntimeReceiptIR extends IOFormatRuntimeReceiptIR, IOFormatReceiptBindingIR {}
export interface IOFormatBoundRuntimeReceiptSetIR {
schemaVersion: typeof IO_FORMAT_RECEIPT_BINDING_SCHEMA;
task: typeof IO_FORMAT_RECEIPT_BINDING_TASK;
parentReceiptSetSha256: string;
inventorySha256: string;
runtime: IOFormatRuntimeIdentityIR;
receipts: IOFormatBoundRuntimeReceiptIR[];
}
export class IOFormatReceiptBindingError extends Error {
readonly code = "IO_FORMAT_UNSUPPORTED" as const;
constructor(message: string) {
super(`IO_FORMAT_UNSUPPORTED: ${message}`);
this.name = "IOFormatReceiptBindingError";
}
}
const SHA256 = /^[a-f0-9]{64}$/;
const FORMAT_ORDER = ["GLTF", "GLB", "OBJ", "STL", "PLY", "USD", "ALEMBIC"] as const;
function assertSha(value: unknown, path: string): string {
if (typeof value !== "string" || !SHA256.test(value)) throw new IOFormatReceiptBindingError(`${path} is not SHA-256`);
return value;
}
function record(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new IOFormatReceiptBindingError(`${path} must be an object`);
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 IOFormatReceiptBindingError(`${path} contains undeclared fields`);
}
function parseReceipt(value: unknown, index: number): IOFormatBoundRuntimeReceiptIR {
const path = `receipts[${index}]`;
const input = record(value, path);
exactKeys(input, ["format", "family", "operation", "operator", "registered", "rnaIdentifier", "buildOption", "buildOptionEnabled", "runtimeStatus", "variants", "extensions", "sourceSha256", "settingsSha256", "runtimeSha256"], path);
if (typeof input.format !== "string" || !FORMAT_ORDER.includes(input.format as typeof FORMAT_ORDER[number])) throw new IOFormatReceiptBindingError(`${path}.format is invalid`);
if (input.operation !== "IMPORT" && input.operation !== "EXPORT") throw new IOFormatReceiptBindingError(`${path}.operation is invalid`);
if (typeof input.family !== "string" || !input.family || typeof input.operator !== "string" || !input.operator) throw new IOFormatReceiptBindingError(`${path} identity is invalid`);
if (typeof input.registered !== "boolean" || (input.rnaIdentifier !== null && typeof input.rnaIdentifier !== "string") || (input.buildOption !== null && typeof input.buildOption !== "string") || (input.buildOptionEnabled !== null && typeof input.buildOptionEnabled !== "boolean")) throw new IOFormatReceiptBindingError(`${path} runtime fields are invalid`);
if (input.runtimeStatus !== "AVAILABLE" && input.runtimeStatus !== "OPERATOR_UNREGISTERED") throw new IOFormatReceiptBindingError(`${path}.runtimeStatus is invalid`);
if (!Array.isArray(input.variants) || !Array.isArray(input.extensions) || input.variants.length === 0 || input.extensions.length === 0 || input.variants.some((item) => typeof item !== "string") || input.extensions.some((item) => typeof item !== "string")) throw new IOFormatReceiptBindingError(`${path} variants/extensions are invalid`);
return {
format: input.format as IOFormatBoundRuntimeReceiptIR["format"],
family: input.family,
operation: input.operation as IOFormatBoundRuntimeReceiptIR["operation"],
operator: input.operator,
registered: input.registered,
rnaIdentifier: input.rnaIdentifier as string | null,
buildOption: input.buildOption as string | null,
buildOptionEnabled: input.buildOptionEnabled as boolean | null,
runtimeStatus: input.runtimeStatus as IOFormatBoundRuntimeReceiptIR["runtimeStatus"],
variants: [...input.variants as string[]],
extensions: [...input.extensions as string[]],
sourceSha256: assertSha(input.sourceSha256, `${path}.sourceSha256`),
settingsSha256: assertSha(input.settingsSha256, `${path}.settingsSha256`),
runtimeSha256: assertSha(input.runtimeSha256, `${path}.runtimeSha256`),
};
}
function parseRuntime(value: unknown): IOFormatRuntimeIdentityIR {
const input = record(value, "runtime");
if (!Array.isArray(input.versionTuple) || input.versionTuple.length !== 3 || input.versionTuple.some((item) => !Number.isSafeInteger(item))) throw new IOFormatReceiptBindingError("runtime.versionTuple is invalid");
if (typeof input.binarySha256 !== "string" || !SHA256.test(input.binarySha256)) throw new IOFormatReceiptBindingError("runtime.binarySha256 is invalid");
if (typeof input.blenderVersion !== "string" || typeof input.buildHash !== "string" || typeof input.buildBranch !== "string" || typeof input.buildPlatform !== "string" || typeof input.buildType !== "string" || typeof input.buildDate !== "string" || typeof input.buildTime !== "string" || !Number.isSafeInteger(input.buildCommitTimestamp) || typeof input.buildOptions !== "object" || input.buildOptions === null || Array.isArray(input.buildOptions)) throw new IOFormatReceiptBindingError("runtime identity is invalid");
return input as unknown as IOFormatRuntimeIdentityIR;
}
export function canonicalReceiptSource(receipt: IOFormatRuntimeReceiptIR): Record<string, unknown> {
return { format: receipt.format, family: receipt.family, operation: receipt.operation, operator: receipt.operator, registered: receipt.registered, rnaIdentifier: receipt.rnaIdentifier };
}
export function canonicalReceiptSettings(receipt: IOFormatRuntimeReceiptIR): Record<string, unknown> {
return { buildOption: receipt.buildOption, buildOptionEnabled: receipt.buildOptionEnabled, variants: receipt.variants, extensions: receipt.extensions };
}
export function canonicalRuntimeIdentity(runtime: IOFormatRuntimeIdentityIR): IOFormatRuntimeIdentityIR {
return runtime;
}
export function validateIOFormatBoundReceiptSet(value: unknown, expectedParentReceiptSetSha256: string, expectedInventorySha256: string): IOFormatBoundRuntimeReceiptSetIR {
const input = record(value, "input");
exactKeys(input, ["schemaVersion", "task", "parentReceiptSetSha256", "inventorySha256", "runtime", "receipts"], "input");
if (input.schemaVersion !== IO_FORMAT_RECEIPT_BINDING_SCHEMA || input.task !== IO_FORMAT_RECEIPT_BINDING_TASK) throw new IOFormatReceiptBindingError("receipt binding header is invalid");
if (!SHA256.test(expectedParentReceiptSetSha256) || !SHA256.test(expectedInventorySha256) || input.parentReceiptSetSha256 !== expectedParentReceiptSetSha256 || input.inventorySha256 !== expectedInventorySha256) throw new IOFormatReceiptBindingError("receipt binding parent identity drifted");
if (!Array.isArray(input.receipts) || input.receipts.length !== FORMAT_ORDER.length * 2) throw new IOFormatReceiptBindingError("bound receipt count is invalid");
const receipts = input.receipts.map(parseReceipt);
const identities = receipts.map((receipt) => `${receipt.format}:${receipt.operation}`);
if (new Set(identities).size !== identities.length || FORMAT_ORDER.some((format) => !["IMPORT", "EXPORT"].every((operation) => identities.includes(`${format}:${operation}`)))) throw new IOFormatReceiptBindingError("bound receipt identities are incomplete or duplicated");
return { schemaVersion: IO_FORMAT_RECEIPT_BINDING_SCHEMA, task: IO_FORMAT_RECEIPT_BINDING_TASK, parentReceiptSetSha256: input.parentReceiptSetSha256, inventorySha256: input.inventorySha256, runtime: parseRuntime(input.runtime), receipts };
}
export function resolveBoundReceipt(receiptSet: IOFormatBoundRuntimeReceiptSetIR, format: IOFormatBoundRuntimeReceiptIR["format"], operation: IOFormatBoundRuntimeReceiptIR["operation"]): IOFormatBoundRuntimeReceiptIR {
const receipt = receiptSet.receipts.find((candidate) => candidate.format === format && candidate.operation === operation);
if (!receipt || !SHA256.test(receipt.sourceSha256) || !SHA256.test(receipt.settingsSha256) || !SHA256.test(receipt.runtimeSha256)) throw new IOFormatReceiptBindingError("bound runtime receipt is unavailable");
return receipt;
}

View File

@@ -0,0 +1,185 @@
import {
validateIOFormatBoundReceiptSet,
type IOFormatBoundRuntimeReceiptSetIR,
} from "./io-format-receipt-binding";
import {
type IOFormatRuntimeIdentityIR,
type IOFormatRuntimeRouteQuery,
} from "./io-format-runtime-receipt";
export const IO_FORMAT_RECEIPT_FRESHNESS_SCHEMA = 1 as const;
export const IO_FORMAT_RECEIPT_FRESHNESS_TASK = "M12-05F" as const;
export interface IOFormatReceiptFreshnessEnvelopeIR {
schemaVersion: typeof IO_FORMAT_RECEIPT_FRESHNESS_SCHEMA;
task: typeof IO_FORMAT_RECEIPT_FRESHNESS_TASK;
parentBindingSha256: string;
boundReceiptSetSha256: string;
runtimeSha256: string;
bound: IOFormatBoundRuntimeReceiptSetIR;
}
export interface IOFormatReceiptFreshnessExpectedIR {
parentBindingSha256: string;
parentReceiptSetSha256: string;
inventorySha256: string;
boundReceiptSetSha256: string;
runtimeSha256: string;
runtime: IOFormatRuntimeIdentityIR;
receiptIdentities: Array<Pick<IOFormatBoundRuntimeReceiptSetIR["receipts"][number], "format" | "family" | "operation" | "operator" | "registered" | "rnaIdentifier" | "buildOption" | "buildOptionEnabled" | "runtimeStatus" | "variants" | "extensions" | "sourceSha256" | "settingsSha256" | "runtimeSha256">>;
}
export type IOFormatReceiptFreshnessFailure =
| "RECEIPT_INVALID"
| "RECEIPT_FORGED"
| "RECEIPT_STALE"
| "RECEIPT_CROSS_VERSION";
export class IOFormatReceiptFreshnessError extends Error {
readonly code = "IO_FORMAT_UNSUPPORTED" as const;
readonly reason: IOFormatReceiptFreshnessFailure;
constructor(reason: IOFormatReceiptFreshnessFailure, message: string) {
super(`IO_FORMAT_UNSUPPORTED: ${reason}: ${message}`);
this.name = "IOFormatReceiptFreshnessError";
this.reason = reason;
}
}
const SHA256 = /^[a-f0-9]{64}$/;
function record(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new IOFormatReceiptFreshnessError("RECEIPT_INVALID", `${path} must be an object`);
}
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 IOFormatReceiptFreshnessError("RECEIPT_FORGED", `${path} contains undeclared fields`);
}
}
function sha(value: unknown, path: string): string {
if (typeof value !== "string" || !SHA256.test(value)) {
throw new IOFormatReceiptFreshnessError("RECEIPT_INVALID", `${path} is not SHA-256`);
}
return value;
}
function stableValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(stableValue);
if (typeof value === "object" && value !== null) {
return Object.fromEntries(Object.keys(value as Record<string, unknown>).sort().map((key) => [key, stableValue((value as Record<string, unknown>)[key])]));
}
return value;
}
function stableJSON(value: unknown): string {
return JSON.stringify(stableValue(value));
}
function sameRuntime(left: IOFormatRuntimeIdentityIR, right: IOFormatRuntimeIdentityIR): boolean {
return stableJSON(left) === stableJSON(right);
}
export function parseIOFormatReceiptFreshness(value: unknown): IOFormatReceiptFreshnessEnvelopeIR {
const input = record(value, "input");
exactKeys(input, ["schemaVersion", "task", "parentBindingSha256", "boundReceiptSetSha256", "runtimeSha256", "bound"], "input");
if (input.schemaVersion !== IO_FORMAT_RECEIPT_FRESHNESS_SCHEMA || input.task !== IO_FORMAT_RECEIPT_FRESHNESS_TASK) {
throw new IOFormatReceiptFreshnessError("RECEIPT_INVALID", "freshness envelope header is invalid");
}
const boundInput = record(input.bound, "bound");
const parentReceiptSetSha256 = sha(boundInput.parentReceiptSetSha256, "bound.parentReceiptSetSha256");
const inventorySha256 = sha(boundInput.inventorySha256, "bound.inventorySha256");
let bound: IOFormatBoundRuntimeReceiptSetIR;
try {
bound = validateIOFormatBoundReceiptSet(input.bound, parentReceiptSetSha256, inventorySha256);
}
catch (error) {
if (error instanceof IOFormatReceiptFreshnessError) throw error;
throw new IOFormatReceiptFreshnessError("RECEIPT_FORGED", error instanceof Error ? error.message : "bound receipt set is invalid");
}
return {
schemaVersion: IO_FORMAT_RECEIPT_FRESHNESS_SCHEMA,
task: IO_FORMAT_RECEIPT_FRESHNESS_TASK,
parentBindingSha256: sha(input.parentBindingSha256, "input.parentBindingSha256"),
boundReceiptSetSha256: sha(input.boundReceiptSetSha256, "input.boundReceiptSetSha256"),
runtimeSha256: sha(input.runtimeSha256, "input.runtimeSha256"),
bound,
};
}
function expectedHashes(expected: IOFormatReceiptFreshnessExpectedIR): void {
sha(expected.parentBindingSha256, "expected.parentBindingSha256");
sha(expected.parentReceiptSetSha256, "expected.parentReceiptSetSha256");
sha(expected.inventorySha256, "expected.inventorySha256");
sha(expected.boundReceiptSetSha256, "expected.boundReceiptSetSha256");
sha(expected.runtimeSha256, "expected.runtimeSha256");
if (!Array.isArray(expected.receiptIdentities) || expected.receiptIdentities.length !== 14) {
throw new IOFormatReceiptFreshnessError("RECEIPT_INVALID", "expected receipt identity set is incomplete");
}
}
export function validateIOFormatReceiptFreshness(value: unknown, expected: IOFormatReceiptFreshnessExpectedIR): IOFormatReceiptFreshnessEnvelopeIR {
expectedHashes(expected);
const parsed = parseIOFormatReceiptFreshness(value);
if (parsed.parentBindingSha256 !== expected.parentBindingSha256 || parsed.bound.parentReceiptSetSha256 !== expected.parentReceiptSetSha256 || parsed.bound.inventorySha256 !== expected.inventorySha256) {
throw new IOFormatReceiptFreshnessError("RECEIPT_STALE", "receipt parent or inventory identity is stale");
}
if (parsed.boundReceiptSetSha256 !== expected.boundReceiptSetSha256) {
throw new IOFormatReceiptFreshnessError("RECEIPT_FORGED", "receipt-set content identity does not match the trusted build");
}
if (parsed.runtimeSha256 !== expected.runtimeSha256 || !sameRuntime(parsed.bound.runtime, expected.runtime)) {
throw new IOFormatReceiptFreshnessError("RECEIPT_CROSS_VERSION", "runtime identity does not match the trusted build");
}
for (const receipt of parsed.bound.receipts) {
if (receipt.runtimeSha256 !== expected.runtimeSha256) {
throw new IOFormatReceiptFreshnessError("RECEIPT_CROSS_VERSION", `${receipt.format}:${receipt.operation} runtime identity is stale`);
}
const expectedReceipt = expected.receiptIdentities.find((candidate) => candidate.format === receipt.format && candidate.operation === receipt.operation);
if (!expectedReceipt || stableJSON(receipt) !== stableJSON(expectedReceipt)) {
throw new IOFormatReceiptFreshnessError("RECEIPT_FORGED", `${receipt.format}:${receipt.operation} receipt content is not trusted`);
}
}
return parsed;
}
async function sha256Text(value: string): Promise<string> {
const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
/** Recomputes the canonical receipt-set and runtime digests for an independent checker. */
export async function verifyIOFormatReceiptFreshness(value: unknown, expected: IOFormatReceiptFreshnessExpectedIR): Promise<IOFormatReceiptFreshnessEnvelopeIR> {
const parsed = validateIOFormatReceiptFreshness(value, expected);
if (await sha256Text(stableJSON(parsed.bound)) !== parsed.boundReceiptSetSha256) {
throw new IOFormatReceiptFreshnessError("RECEIPT_FORGED", "receipt-set canonical digest does not match its contents");
}
if (await sha256Text(stableJSON(parsed.bound.runtime)) !== parsed.runtimeSha256) {
throw new IOFormatReceiptFreshnessError("RECEIPT_FORGED", "runtime canonical digest does not match its contents");
}
return parsed;
}
export type IOFormatFreshRuntimeRouteResult =
| { status: "READY"; format: IOFormatRuntimeRouteQuery["format"]; operation: IOFormatRuntimeRouteQuery["operation"]; operator: string; receipt: IOFormatReceiptFreshnessEnvelopeIR["bound"]["receipts"][number]; freshness: "VERIFIED" }
| { status: "BLOCKED"; code: "IO_FORMAT_UNSUPPORTED"; reason: IOFormatReceiptFreshnessFailure | "UNAVAILABLE"; format: IOFormatRuntimeRouteQuery["format"]; operation: IOFormatRuntimeRouteQuery["operation"] };
export function resolveFreshIOFormatRuntimeRoute(value: unknown, expected: IOFormatReceiptFreshnessExpectedIR, query: IOFormatRuntimeRouteQuery): IOFormatFreshRuntimeRouteResult {
try {
const parsed = validateIOFormatReceiptFreshness(value, expected);
const receipt = parsed.bound.receipts.find((candidate) => candidate.format === query.format && candidate.operation === query.operation);
if (!receipt || receipt.runtimeStatus !== "AVAILABLE" || receipt.registered !== true || receipt.rnaIdentifier === null || receipt.buildOptionEnabled === false) {
return { status: "BLOCKED", code: "IO_FORMAT_UNSUPPORTED", reason: "UNAVAILABLE", format: query.format, operation: query.operation };
}
return { status: "READY", format: query.format, operation: query.operation, operator: receipt.operator, receipt, freshness: "VERIFIED" };
}
catch (error) {
const reason = error instanceof IOFormatReceiptFreshnessError ? error.reason : "RECEIPT_INVALID";
return { status: "BLOCKED", code: "IO_FORMAT_UNSUPPORTED", reason, format: query.format, operation: query.operation };
}
}

View File

@@ -0,0 +1,73 @@
export const IO_FORMAT_RECOVERY_SCHEMA_VERSION = 1 as const;
export type IOFormat = "OBJ" | "STL" | "PLY";
export type IOFormatRecoveryStatus = "RUNNING" | "CANCELLED" | "BLOCKED" | "COMMITTED" | "RECOVERED";
export type IOFormatRecoveryErrorCode = "IO_FORMAT_OPERATION_CANCELLED" | "IO_FORMAT_OOM" | "IO_FORMAT_WORKER_RESTARTED" | "IO_FORMAT_RECOVERY_INVALID";
export interface IOFormatRecoveryReceipt {
schemaVersion: typeof IO_FORMAT_RECOVERY_SCHEMA_VERSION;
operationId: string;
format: IOFormat;
operation: "IMPORT" | "EXPORT";
status: IOFormatRecoveryStatus;
workerGeneration: number;
baseRevision: number;
candidateRevision: number;
inputBytes: number;
inputSha256: string;
outputBytes: number;
outputSha256: string | null;
temporaryBytes: number;
liveRequests: number;
publishedResults: number;
committed: boolean;
errorCode?: IOFormatRecoveryErrorCode;
}
const HASH = /^[a-f0-9]{64}$/;
const ID = /^[A-Za-z0-9_-]{1,96}$/;
function validate(receipt: IOFormatRecoveryReceipt): void {
if (receipt.schemaVersion !== 1 || !ID.test(receipt.operationId) || !["OBJ", "STL", "PLY"].includes(receipt.format) || !["IMPORT", "EXPORT"].includes(receipt.operation) || !["RUNNING", "CANCELLED", "BLOCKED", "COMMITTED", "RECOVERED"].includes(receipt.status) || !Number.isSafeInteger(receipt.workerGeneration) || receipt.workerGeneration < 1 || !Number.isSafeInteger(receipt.baseRevision) || receipt.baseRevision < 0 || !Number.isSafeInteger(receipt.candidateRevision) || receipt.candidateRevision < receipt.baseRevision || !Number.isSafeInteger(receipt.inputBytes) || receipt.inputBytes <= 0 || !HASH.test(receipt.inputSha256) || !Number.isSafeInteger(receipt.outputBytes) || receipt.outputBytes < 0 || (receipt.outputSha256 !== null && !HASH.test(receipt.outputSha256)) || !Number.isSafeInteger(receipt.temporaryBytes) || receipt.temporaryBytes < 0 || !Number.isSafeInteger(receipt.liveRequests) || receipt.liveRequests < 0 || !Number.isSafeInteger(receipt.publishedResults) || receipt.publishedResults < 0 || typeof receipt.committed !== "boolean") {
throw new Error("IO_FORMAT_RECOVERY_INVALID: receipt fields are malformed");
}
}
function clone(receipt: IOFormatRecoveryReceipt): IOFormatRecoveryReceipt { validate(receipt); return { ...receipt }; }
export function beginIOFormatRecoveryOperation(input: { operationId: string; format: IOFormat; operation: "IMPORT" | "EXPORT"; workerGeneration: number; baseRevision: number; inputBytes: number; inputSha256: string }): IOFormatRecoveryReceipt {
const receipt: IOFormatRecoveryReceipt = { schemaVersion: 1, operationId: input.operationId, format: input.format, operation: input.operation, status: "RUNNING", workerGeneration: input.workerGeneration, baseRevision: input.baseRevision, candidateRevision: input.baseRevision + 1, inputBytes: input.inputBytes, inputSha256: input.inputSha256, outputBytes: 0, outputSha256: null, temporaryBytes: input.inputBytes, liveRequests: 1, publishedResults: 0, committed: false };
validate(receipt); return receipt;
}
export function commitIOFormatRecoveryOperation(receipt: IOFormatRecoveryReceipt, output: { bytes: number; sha256: string }): IOFormatRecoveryReceipt {
const next = clone(receipt);
if (next.status !== "RUNNING" || !Number.isSafeInteger(output.bytes) || output.bytes <= 0 || !HASH.test(output.sha256)) throw new Error("IO_FORMAT_RECOVERY_INVALID: operation cannot commit");
next.status = "COMMITTED"; next.outputBytes = output.bytes; next.outputSha256 = output.sha256; next.temporaryBytes = 0; next.liveRequests = 0; next.publishedResults = 1; next.committed = true; validate(next); return next;
}
export function cancelIOFormatRecoveryOperation(receipt: IOFormatRecoveryReceipt): IOFormatRecoveryReceipt {
const next = clone(receipt);
if (next.status !== "RUNNING") throw new Error("IO_FORMAT_RECOVERY_INVALID: operation is not running");
next.status = "CANCELLED"; next.errorCode = "IO_FORMAT_OPERATION_CANCELLED"; next.temporaryBytes = 0; next.liveRequests = 0; next.publishedResults = 0; next.committed = false; validate(next); return next;
}
export function blockIOFormatRecoveryOperation(receipt: IOFormatRecoveryReceipt): IOFormatRecoveryReceipt {
const next = clone(receipt);
if (next.status !== "RUNNING") throw new Error("IO_FORMAT_RECOVERY_INVALID: operation is not running");
next.status = "BLOCKED"; next.errorCode = "IO_FORMAT_OOM"; next.temporaryBytes = 0; next.liveRequests = 0; next.publishedResults = 0; next.committed = false; validate(next); return next;
}
export function recoverIOFormatRecoveryOperation(receipt: IOFormatRecoveryReceipt, workerGeneration: number): IOFormatRecoveryReceipt {
const next = clone(receipt);
if (next.status !== "COMMITTED" || !Number.isSafeInteger(workerGeneration) || workerGeneration <= next.workerGeneration) throw new Error("IO_FORMAT_RECOVERY_INVALID: only a committed operation can recover");
next.status = "RECOVERED"; next.workerGeneration = workerGeneration; next.errorCode = "IO_FORMAT_WORKER_RESTARTED"; validate(next); return next;
}
export function parseIOFormatRecoveryReceipt(value: unknown): IOFormatRecoveryReceipt {
if (!value || typeof value !== "object") throw new Error("IO_FORMAT_RECOVERY_INVALID: receipt is not an object");
const receipt = value as IOFormatRecoveryReceipt; validate(receipt);
if (receipt.status === "CANCELLED" && receipt.errorCode !== "IO_FORMAT_OPERATION_CANCELLED") throw new Error("IO_FORMAT_RECOVERY_INVALID: cancellation code");
if (receipt.status === "BLOCKED" && receipt.errorCode !== "IO_FORMAT_OOM") throw new Error("IO_FORMAT_RECOVERY_INVALID: oom code");
if ((receipt.status === "COMMITTED" || receipt.status === "RECOVERED") && (!receipt.committed || receipt.outputBytes <= 0 || !receipt.outputSha256 || receipt.publishedResults !== 1)) throw new Error("IO_FORMAT_RECOVERY_INVALID: committed receipt");
return { ...receipt };
}

View File

@@ -0,0 +1,167 @@
import type { IOFormatMatrixFormat, IOFormatMatrixOperation } from "./io-format-capability-matrix";
export const IO_FORMAT_RUNTIME_RECEIPT_SCHEMA = 1 as const;
export const IO_FORMAT_RUNTIME_RECEIPT_TASK = "M12-05D" as const;
export const IO_FORMAT_RUNTIME_FORMATS = ["GLTF", "GLB", "OBJ", "STL", "PLY", "USD", "ALEMBIC"] as const;
export type IOFormatRuntimeFormat = typeof IO_FORMAT_RUNTIME_FORMATS[number];
export type IOFormatRuntimeOperation = IOFormatMatrixOperation;
export type IOFormatRuntimeStatus = "AVAILABLE" | "OPERATOR_UNREGISTERED";
export interface IOFormatRuntimeIdentityIR {
blenderVersion: string;
versionTuple: [number, number, number];
buildHash: string;
buildBranch: string;
buildPlatform: string;
buildType: string;
buildDate: string;
buildTime: string;
buildCommitTimestamp: number;
binarySha256: string;
buildOptions: Record<string, boolean>;
}
export interface IOFormatRuntimeReceiptIR {
format: IOFormatRuntimeFormat;
family: string;
operation: IOFormatRuntimeOperation;
operator: string;
registered: boolean;
rnaIdentifier: string | null;
buildOption: string | null;
buildOptionEnabled: boolean | null;
runtimeStatus: IOFormatRuntimeStatus;
variants: string[];
extensions: string[];
}
export interface IOFormatRuntimeReceiptSetIR {
schemaVersion: typeof IO_FORMAT_RUNTIME_RECEIPT_SCHEMA;
task: typeof IO_FORMAT_RUNTIME_RECEIPT_TASK;
inventorySha256: string;
runtime: IOFormatRuntimeIdentityIR;
receipts: IOFormatRuntimeReceiptIR[];
}
export interface IOFormatRuntimeRouteQuery {
format: IOFormatRuntimeFormat;
operation: IOFormatRuntimeOperation;
}
export type IOFormatRuntimeRouteResult =
| { status: "READY"; format: IOFormatRuntimeFormat; operation: IOFormatRuntimeOperation; operator: string; receipt: IOFormatRuntimeReceiptIR }
| { status: "BLOCKED"; code: "IO_FORMAT_UNSUPPORTED"; format: IOFormatRuntimeFormat; operation: IOFormatRuntimeOperation };
export class IOFormatRuntimeReceiptError extends Error {
readonly code = "IO_FORMAT_UNSUPPORTED" as const;
constructor(message: string) {
super(`IO_FORMAT_UNSUPPORTED: ${message}`);
this.name = "IOFormatRuntimeReceiptError";
}
}
const SHA256 = /^[a-f0-9]{64}$/;
const VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:\s+.*)?$/;
const IDENTIFIER = /^[A-Za-z0-9_.:-]+$/;
function record(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new IOFormatRuntimeReceiptError(`${path} must be an object`);
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 IOFormatRuntimeReceiptError(`${path} contains undeclared fields`);
}
function nonEmpty(value: unknown, path: string, maximum = 256): string {
if (typeof value !== "string" || value.length === 0 || value.length > maximum) throw new IOFormatRuntimeReceiptError(`${path} is invalid`);
return value;
}
function sha(value: unknown, path: string): string {
if (typeof value !== "string" || !SHA256.test(value)) throw new IOFormatRuntimeReceiptError(`${path} is not SHA-256`);
return value;
}
function parseRuntime(value: unknown): IOFormatRuntimeIdentityIR {
const input = record(value, "runtime");
exactKeys(input, ["blenderVersion", "versionTuple", "buildHash", "buildBranch", "buildPlatform", "buildType", "buildDate", "buildTime", "buildCommitTimestamp", "binarySha256", "buildOptions"], "runtime");
if (typeof input.blenderVersion !== "string" || !VERSION.test(input.blenderVersion)) throw new IOFormatRuntimeReceiptError("runtime.blenderVersion is invalid");
if (!Array.isArray(input.versionTuple) || input.versionTuple.length !== 3 || input.versionTuple.some((part) => !Number.isSafeInteger(part) || (part as number) < 0)) throw new IOFormatRuntimeReceiptError("runtime.versionTuple is invalid");
const buildOptions = record(input.buildOptions, "runtime.buildOptions");
const parsedOptions: Record<string, boolean> = {};
for (const key of Object.keys(buildOptions).sort()) {
if (!IDENTIFIER.test(key) || typeof buildOptions[key] !== "boolean") throw new IOFormatRuntimeReceiptError("runtime.buildOptions is invalid");
parsedOptions[key] = buildOptions[key] as boolean;
}
if (!Number.isSafeInteger(input.buildCommitTimestamp) || (input.buildCommitTimestamp as number) < 0) throw new IOFormatRuntimeReceiptError("runtime.buildCommitTimestamp is invalid");
return {
blenderVersion: input.blenderVersion,
versionTuple: [...input.versionTuple] as [number, number, number],
buildHash: nonEmpty(input.buildHash, "runtime.buildHash"),
buildBranch: nonEmpty(input.buildBranch, "runtime.buildBranch"),
buildPlatform: nonEmpty(input.buildPlatform, "runtime.buildPlatform"),
buildType: nonEmpty(input.buildType, "runtime.buildType"),
buildDate: nonEmpty(input.buildDate, "runtime.buildDate"),
buildTime: nonEmpty(input.buildTime, "runtime.buildTime"),
buildCommitTimestamp: input.buildCommitTimestamp as number,
binarySha256: sha(input.binarySha256, "runtime.binarySha256"),
buildOptions: parsedOptions,
};
}
function parseReceipt(value: unknown, index: number): IOFormatRuntimeReceiptIR {
const path = `receipts[${index}]`;
const input = record(value, path);
exactKeys(input, ["format", "family", "operation", "operator", "registered", "rnaIdentifier", "buildOption", "buildOptionEnabled", "runtimeStatus", "variants", "extensions"], path);
if (!IO_FORMAT_RUNTIME_FORMATS.includes(input.format as IOFormatRuntimeFormat) || !["IMPORT", "EXPORT"].includes(input.operation as string)) throw new IOFormatRuntimeReceiptError(`${path} identity is invalid`);
if (typeof input.registered !== "boolean" || !["AVAILABLE", "OPERATOR_UNREGISTERED"].includes(input.runtimeStatus as string)) throw new IOFormatRuntimeReceiptError(`${path} registration status is invalid`);
if (input.rnaIdentifier !== null && (typeof input.rnaIdentifier !== "string" || !IDENTIFIER.test(input.rnaIdentifier))) throw new IOFormatRuntimeReceiptError(`${path}.rnaIdentifier is invalid`);
if (input.buildOption !== null && (typeof input.buildOption !== "string" || !IDENTIFIER.test(input.buildOption))) throw new IOFormatRuntimeReceiptError(`${path}.buildOption is invalid`);
if (input.buildOptionEnabled !== null && typeof input.buildOptionEnabled !== "boolean") throw new IOFormatRuntimeReceiptError(`${path}.buildOptionEnabled is invalid`);
if (!Array.isArray(input.variants) || input.variants.length === 0 || input.variants.some((variant) => typeof variant !== "string" || !IDENTIFIER.test(variant))) throw new IOFormatRuntimeReceiptError(`${path}.variants are invalid`);
if (!Array.isArray(input.extensions) || input.extensions.length === 0 || input.extensions.some((extension) => typeof extension !== "string" || !/^\.[a-z0-9]+$/.test(extension))) throw new IOFormatRuntimeReceiptError(`${path}.extensions are invalid`);
const available = input.runtimeStatus === "AVAILABLE";
if (available !== input.registered || (available && input.rnaIdentifier === null) || (!available && input.rnaIdentifier !== null) || (!available && input.buildOptionEnabled !== null)) throw new IOFormatRuntimeReceiptError(`${path} has inconsistent runtime receipt state`);
return {
format: input.format as IOFormatRuntimeFormat,
family: nonEmpty(input.family, `${path}.family`),
operation: input.operation as IOFormatRuntimeOperation,
operator: nonEmpty(input.operator, `${path}.operator`),
registered: input.registered as boolean,
rnaIdentifier: input.rnaIdentifier as string | null,
buildOption: input.buildOption as string | null,
buildOptionEnabled: input.buildOptionEnabled as boolean | null,
runtimeStatus: input.runtimeStatus as IOFormatRuntimeStatus,
variants: [...input.variants as string[]],
extensions: [...input.extensions as string[]],
};
}
export function parseIOFormatRuntimeReceiptSet(value: unknown): IOFormatRuntimeReceiptSetIR {
const input = record(value, "input");
exactKeys(input, ["schemaVersion", "task", "inventorySha256", "runtime", "receipts"], "input");
if (input.schemaVersion !== IO_FORMAT_RUNTIME_RECEIPT_SCHEMA || input.task !== IO_FORMAT_RUNTIME_RECEIPT_TASK) throw new IOFormatRuntimeReceiptError("receipt set header is invalid");
const receipts = Array.isArray(input.receipts) ? input.receipts.map(parseReceipt) : (() => { throw new IOFormatRuntimeReceiptError("input.receipts must be an array"); })();
if (typeof input.inventorySha256 !== "string" || !SHA256.test(input.inventorySha256)) throw new IOFormatRuntimeReceiptError("input.inventorySha256 is invalid");
if (receipts.length !== IO_FORMAT_RUNTIME_FORMATS.length * 2) throw new IOFormatRuntimeReceiptError("receipt set must contain one import and export receipt per format");
const identities = receipts.map((receipt) => `${receipt.format}:${receipt.operation}`);
if (new Set(identities).size !== identities.length || IO_FORMAT_RUNTIME_FORMATS.some((format) => !["IMPORT", "EXPORT"].every((operation) => identities.includes(`${format}:${operation}`)))) throw new IOFormatRuntimeReceiptError("receipt identities are incomplete or duplicated");
return { schemaVersion: IO_FORMAT_RUNTIME_RECEIPT_SCHEMA, task: IO_FORMAT_RUNTIME_RECEIPT_TASK, inventorySha256: input.inventorySha256, runtime: parseRuntime(input.runtime), receipts };
}
export function validateIOFormatRuntimeReceiptSet(value: unknown, expectedInventorySha256: string): IOFormatRuntimeReceiptSetIR {
if (!SHA256.test(expectedInventorySha256)) throw new IOFormatRuntimeReceiptError("expected inventory SHA-256 is invalid");
const parsed = parseIOFormatRuntimeReceiptSet(value);
if (parsed.inventorySha256 !== expectedInventorySha256) throw new IOFormatRuntimeReceiptError("runtime receipt inventory identity does not match");
return parsed;
}
export function resolveIOFormatRuntimeRoute(receiptSet: IOFormatRuntimeReceiptSetIR, query: IOFormatRuntimeRouteQuery): IOFormatRuntimeRouteResult {
const receipt = receiptSet.receipts.find((candidate) => candidate.format === query.format && candidate.operation === query.operation);
if (!receipt || receipt.runtimeStatus !== "AVAILABLE" || receipt.registered !== true || receipt.rnaIdentifier === null || receipt.buildOptionEnabled === false) return { status: "BLOCKED", code: "IO_FORMAT_UNSUPPORTED", format: query.format, operation: query.operation };
return { status: "READY", format: query.format, operation: query.operation, operator: receipt.operator, receipt };
}

View File

@@ -0,0 +1,149 @@
import type {
IOFormatCapabilityMatrixIR,
IOFormatMatrixExecution,
IOFormatMatrixFormat,
IOFormatMatrixOperation,
} from "./io-format-capability-matrix";
export const IO_FORMAT_UI_GATE_SCHEMA = 1 as const;
export const IO_FORMAT_UI_TASK = "M12-05C" as const;
export const IO_FORMAT_PROJECT_ACCEPT = ".blend,application/octet-stream" as const;
export interface IOFormatUIRouteIR {
format: IOFormatMatrixFormat;
operation: IOFormatMatrixOperation;
execution: IOFormatMatrixExecution;
extensions: string[];
}
export interface IOFormatUIRegistryIR {
schemaVersion: typeof IO_FORMAT_UI_GATE_SCHEMA;
task: typeof IO_FORMAT_UI_TASK;
parentMatrixSha256: string;
projectFileAccept: typeof IO_FORMAT_PROJECT_ACCEPT;
importRoutes: IOFormatUIRouteIR[];
exportRoutes: IOFormatUIRouteIR[];
}
export interface IOFormatUICommandRef {
format: IOFormatMatrixFormat;
operation: IOFormatMatrixOperation;
execution: IOFormatMatrixExecution;
}
export class IOFormatUIGateError extends Error {
readonly code = "IO_FORMAT_UNSUPPORTED" as const;
constructor(message: string) {
super(`IO_FORMAT_UNSUPPORTED: ${message}`);
this.name = "IOFormatUIGateError";
}
}
const SHA256 = /^[a-f0-9]{64}$/;
const FORMAT_EXTENSIONS: Record<IOFormatMatrixFormat, readonly string[]> = {
GLTF: [".gltf"],
GLB: [".glb"],
OBJ: [".obj"],
STL: [".stl"],
PLY: [".ply"],
USD: [".usd", ".usda", ".usdc"],
ALEMBIC: [".abc"],
};
function routeFor(
matrix: IOFormatCapabilityMatrixIR,
operation: IOFormatMatrixOperation,
execution: IOFormatMatrixExecution,
): IOFormatUIRouteIR[] {
return matrix.formats
.filter((entry) => {
const route = entry.operations[operation][execution.toLowerCase() as "local" | "server"];
const runtimeStatus = operation === "IMPORT" ? entry.runtimeImportStatus : entry.runtimeExportStatus;
return runtimeStatus === "AVAILABLE" && route.status === "READY" && route.execution === execution;
})
.map((entry) => ({
format: entry.format,
operation,
execution,
extensions: [...FORMAT_EXTENSIONS[entry.format]],
}));
}
export function buildIOFormatUIRegistry(matrix: IOFormatCapabilityMatrixIR, parentMatrixSha256: string): IOFormatUIRegistryIR {
if (!SHA256.test(parentMatrixSha256)) throw new IOFormatUIGateError("parent matrix SHA-256 is invalid");
return {
schemaVersion: IO_FORMAT_UI_GATE_SCHEMA,
task: IO_FORMAT_UI_TASK,
parentMatrixSha256,
projectFileAccept: IO_FORMAT_PROJECT_ACCEPT,
importRoutes: routeFor(matrix, "IMPORT", "LOCAL"),
exportRoutes: routeFor(matrix, "EXPORT", "LOCAL"),
};
}
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 IOFormatUIGateError(`${path} contains undeclared fields`);
}
function record(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new IOFormatUIGateError(`${path} must be an object`);
return value as Record<string, unknown>;
}
function parseRoute(value: unknown, path: string, operation: IOFormatMatrixOperation): IOFormatUIRouteIR {
const input = record(value, path);
exactKeys(input, ["format", "operation", "execution", "extensions"], path);
if (typeof input.format !== "string" || !(input.format in FORMAT_EXTENSIONS) || input.operation !== operation || input.execution !== "LOCAL") throw new IOFormatUIGateError(`${path} route identity is invalid`);
if (!Array.isArray(input.extensions) || input.extensions.length !== FORMAT_EXTENSIONS[input.format as IOFormatMatrixFormat].length || input.extensions.some((extension, index) => extension !== FORMAT_EXTENSIONS[input.format as IOFormatMatrixFormat][index])) throw new IOFormatUIGateError(`${path}.extensions are invalid`);
return { format: input.format as IOFormatMatrixFormat, operation, execution: "LOCAL", extensions: [...input.extensions as string[]] };
}
export function parseIOFormatUIRegistry(value: unknown): IOFormatUIRegistryIR {
const input = record(value, "input");
exactKeys(input, ["schemaVersion", "task", "parentMatrixSha256", "projectFileAccept", "importRoutes", "exportRoutes"], "input");
if (input.schemaVersion !== IO_FORMAT_UI_GATE_SCHEMA || input.task !== IO_FORMAT_UI_TASK || typeof input.parentMatrixSha256 !== "string" || !SHA256.test(input.parentMatrixSha256) || input.projectFileAccept !== IO_FORMAT_PROJECT_ACCEPT) throw new IOFormatUIGateError("registry header is invalid");
if (!Array.isArray(input.importRoutes) || !Array.isArray(input.exportRoutes)) throw new IOFormatUIGateError("registry routes are invalid");
const importRoutes = input.importRoutes.map((route, index) => parseRoute(route, `importRoutes[${index}]`, "IMPORT"));
const exportRoutes = input.exportRoutes.map((route, index) => parseRoute(route, `exportRoutes[${index}]`, "EXPORT"));
const identities = [...importRoutes, ...exportRoutes].map((route) => `${route.operation}:${route.execution}:${route.format}`);
if (new Set(identities).size !== identities.length) throw new IOFormatUIGateError("registry contains duplicate route identities");
return { schemaVersion: IO_FORMAT_UI_GATE_SCHEMA, task: IO_FORMAT_UI_TASK, parentMatrixSha256: input.parentMatrixSha256, projectFileAccept: IO_FORMAT_PROJECT_ACCEPT, importRoutes, exportRoutes };
}
function routeIdentity(route: IOFormatUIRouteIR): string {
return `${route.operation}:${route.execution}:${route.format}:${route.extensions.join("|")}`;
}
export function validateIOFormatUIRegistry(value: unknown, matrix: IOFormatCapabilityMatrixIR, parentMatrixSha256: string): IOFormatUIRegistryIR {
const parsed = parseIOFormatUIRegistry(value);
const expected = buildIOFormatUIRegistry(matrix, parentMatrixSha256);
const actualRoutes = [...parsed.importRoutes, ...parsed.exportRoutes].map(routeIdentity);
const expectedRoutes = [...expected.importRoutes, ...expected.exportRoutes].map(routeIdentity);
if (parsed.parentMatrixSha256 !== parentMatrixSha256 || actualRoutes.length !== expectedRoutes.length || actualRoutes.some((route, index) => route !== expectedRoutes[index])) throw new IOFormatUIGateError("registry route is not declared by the capability matrix");
return parsed;
}
function routeMatches(registry: IOFormatUIRegistryIR, command: IOFormatUICommandRef): boolean {
const routes = command.operation === "IMPORT" ? registry.importRoutes : registry.exportRoutes;
return routes.some((route) => route.format === command.format && route.execution === command.execution);
}
export function filterIOFormatOperatorCommands<T extends { ioFormat?: IOFormatUICommandRef }>(commands: readonly T[], registry: IOFormatUIRegistryIR): T[] {
return commands.filter((command) => !command.ioFormat || routeMatches(registry, command.ioFormat));
}
export function gateIOFormatFileSelection(fileName: string, registry: IOFormatUIRegistryIR): { status: "READY"; kind: "BLEND" } | { status: "BLOCKED"; code: "IO_FORMAT_UNSUPPORTED"; extension: string } {
const extension = fileName.trim().toLowerCase().match(/\.[a-z0-9]+$/)?.[0] ?? "";
if (extension === ".blend") return { status: "READY", kind: "BLEND" };
const route = registry.importRoutes.find((candidate) => candidate.extensions.includes(extension));
if (route) return { status: "BLOCKED", code: "IO_FORMAT_UNSUPPORTED", extension };
return { status: "BLOCKED", code: "IO_FORMAT_UNSUPPORTED", extension };
}
export function ioFormatUIAccept(registry: IOFormatUIRegistryIR): string {
const importExtensions = registry.importRoutes.flatMap((route) => route.extensions);
return [registry.projectFileAccept, ...importExtensions].join(",");
}

View File

@@ -0,0 +1,34 @@
export const KEYBOARD_CONTRACT_SCHEMA_VERSION = 1 as const;
export interface KeyboardObservation {
schemaVersion: typeof KEYBOARD_CONTRACT_SCHEMA_VERSION;
key: string;
code: string;
location: 0 | 1 | 2 | 3;
shiftKey: boolean;
ctrlKey: boolean;
altKey: boolean;
metaKey: boolean;
repeat: boolean;
isComposing: boolean;
deadKey: boolean;
}
export function observeKeyboardEvent(event: { key?: string; code?: string; location?: number; shiftKey?: boolean; ctrlKey?: boolean; altKey?: boolean; metaKey?: boolean; repeat?: boolean; isComposing?: boolean }): KeyboardObservation {
if (typeof event.key !== "string" || event.key.length === 0 || event.key.length > 128) throw new Error("KEY_IDENTITY_INVALID");
if (typeof event.code !== "string" || event.code.length === 0 || event.code.length > 64) throw new Error("KEY_CODE_INVALID");
if (!Number.isInteger(event.location) || event.location! < 0 || event.location! > 3) throw new Error("KEY_LOCATION_INVALID");
return {
schemaVersion: KEYBOARD_CONTRACT_SCHEMA_VERSION,
key: event.key,
code: event.code,
location: event.location as 0 | 1 | 2 | 3,
shiftKey: Boolean(event.shiftKey),
ctrlKey: Boolean(event.ctrlKey),
altKey: Boolean(event.altKey),
metaKey: Boolean(event.metaKey),
repeat: Boolean(event.repeat),
isComposing: Boolean(event.isComposing),
deadKey: event.key === "Dead",
};
}

View File

@@ -0,0 +1,228 @@
import type { ErrorCode } from "./error";
export const LIBRARY_LINKED_MISSING_SCHEMA = 1 as const;
export const LINKED_MISSING_OPERATION = "MARK_MISSING" as const;
export interface MissingLibraryPlaceholderIR {
kind: "MISSING_LIBRARY";
sourceLibraryId: string;
dataBlockIds: string[];
}
export interface LinkedLibraryReferenceIR {
sourceLibraryId: string;
sourceLocator: string;
sourceSha256: string;
sourceGeneration: number;
sourceRevision: number;
dataBlockIds: string[];
status: "AVAILABLE" | "MISSING";
placeholder: MissingLibraryPlaceholderIR | null;
}
export interface LinkedMissingStateIR {
schemaVersion: typeof LIBRARY_LINKED_MISSING_SCHEMA;
references: LinkedLibraryReferenceIR[];
}
export interface LinkedMissingRequestIR {
schemaVersion: typeof LIBRARY_LINKED_MISSING_SCHEMA;
operation: typeof LINKED_MISSING_OPERATION;
sourceLibraryId: string;
sourceLocator: string;
sourceSha256: string;
expectedGeneration: number;
expectedRevision: number;
}
export interface LinkedMissingDecisionIR {
status: "MARKED" | "STALE";
code: ErrorCode | null;
sourceLibraryId: string;
state: LinkedMissingStateIR;
}
export class LinkedMissingValidationError extends Error {
readonly code: ErrorCode;
readonly path?: string;
constructor(code: ErrorCode, message: string, path?: string) {
super(`${code}: ${message}`);
this.name = "LinkedMissingValidationError";
this.code = code;
this.path = path;
}
}
const LIBRARY_ID = /^library:[a-f0-9]{64}$/;
const SHA256 = /^[a-f0-9]{64}$/;
const SOURCE_LOCATOR = /^[^\u0000\r\n]{1,4096}$/;
const DATA_BLOCK_ID = /^[A-Za-z0-9][A-Za-z0-9:._/ -]{0,255}$/;
const MAX_REFERENCES = 10_000;
const MAX_DATA_BLOCKS = 256;
function record(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${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 LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path} contains undeclared fields`, path);
}
}
function integer(value: unknown, path: string): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path} must be a safe integer >= 0`, path);
}
return value;
}
function libraryId(value: unknown, path: string): string {
if (typeof value !== "string" || !LIBRARY_ID.test(value)) {
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path} must be a library identity`, path);
}
return value;
}
function digest(value: unknown, path: string): string {
if (typeof value !== "string" || !SHA256.test(value)) {
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path} must be a lowercase SHA-256 digest`, path);
}
return value;
}
function locator(value: unknown, path: string): string {
if (typeof value !== "string" || !SOURCE_LOCATOR.test(value)) {
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path} is outside the source locator budget`, path);
}
return value;
}
function dataBlockIds(value: unknown, path: string): string[] {
if (!Array.isArray(value) || value.length === 0 || value.length > MAX_DATA_BLOCKS ||
value.some((item) => typeof item !== "string" || !DATA_BLOCK_ID.test(item))) {
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path} is outside its bounded ID list`, path);
}
const result = [...value] as string[];
if (new Set(result).size !== result.length) {
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path} contains duplicate IDs`, path);
}
return result;
}
function parsePlaceholder(value: unknown, path: string): MissingLibraryPlaceholderIR | null {
if (value === null) return null;
const placeholder = record(value, path);
exactKeys(placeholder, ["kind", "sourceLibraryId", "dataBlockIds"], path);
if (placeholder.kind !== "MISSING_LIBRARY") {
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path}.kind is invalid`, `${path}.kind`);
}
return {
kind: "MISSING_LIBRARY",
sourceLibraryId: libraryId(placeholder.sourceLibraryId, `${path}.sourceLibraryId`),
dataBlockIds: dataBlockIds(placeholder.dataBlockIds, `${path}.dataBlockIds`),
};
}
export function parseLinkedLibraryReference(value: unknown, path = "reference"): LinkedLibraryReferenceIR {
const reference = record(value, path);
exactKeys(reference, ["sourceLibraryId", "sourceLocator", "sourceSha256", "sourceGeneration", "sourceRevision", "dataBlockIds", "status", "placeholder"], path);
const sourceLibraryId = libraryId(reference.sourceLibraryId, `${path}.sourceLibraryId`);
const sourceLocator = locator(reference.sourceLocator, `${path}.sourceLocator`);
const sourceSha256 = digest(reference.sourceSha256, `${path}.sourceSha256`);
const sourceGeneration = integer(reference.sourceGeneration, `${path}.sourceGeneration`);
const sourceRevision = integer(reference.sourceRevision, `${path}.sourceRevision`);
const ids = dataBlockIds(reference.dataBlockIds, `${path}.dataBlockIds`);
if (reference.status !== "AVAILABLE" && reference.status !== "MISSING") {
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path}.status is invalid`, `${path}.status`);
}
const placeholder = parsePlaceholder(reference.placeholder, `${path}.placeholder`);
if (reference.status === "AVAILABLE" && placeholder !== null) {
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path} available reference cannot have a placeholder`, path);
}
if (reference.status === "MISSING" && (placeholder === null || placeholder.sourceLibraryId !== sourceLibraryId ||
placeholder.dataBlockIds.length !== ids.length || placeholder.dataBlockIds.some((id, index) => id !== ids[index]))) {
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path} missing placeholder must preserve the source IDs`, path);
}
return { sourceLibraryId, sourceLocator, sourceSha256, sourceGeneration, sourceRevision, dataBlockIds: ids, status: reference.status, placeholder };
}
export function parseLinkedMissingState(value: unknown): LinkedMissingStateIR {
const state = record(value, "state");
exactKeys(state, ["schemaVersion", "references"], "state");
if (state.schemaVersion !== LIBRARY_LINKED_MISSING_SCHEMA) {
throw new LinkedMissingValidationError("PROTOCOL_MISMATCH", "Unsupported linked missing-library state schema", "schemaVersion");
}
if (!Array.isArray(state.references) || state.references.length > MAX_REFERENCES) {
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", "state.references exceeds its bounded range", "references");
}
const references = state.references.map((item, index) => parseLinkedLibraryReference(item, `state.references[${index}]`));
const identities = references.map((item) => `${item.sourceLibraryId}:${item.sourceGeneration}`);
if (new Set(identities).size !== identities.length) {
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", "state contains duplicate source generations", "references");
}
return { schemaVersion: LIBRARY_LINKED_MISSING_SCHEMA, references };
}
export function parseLinkedMissingRequest(value: unknown): LinkedMissingRequestIR {
const request = record(value, "request");
exactKeys(request, ["schemaVersion", "operation", "sourceLibraryId", "sourceLocator", "sourceSha256", "expectedGeneration", "expectedRevision"], "request");
if (request.schemaVersion !== LIBRARY_LINKED_MISSING_SCHEMA) {
throw new LinkedMissingValidationError("PROTOCOL_MISMATCH", "Unsupported linked missing-library request schema", "schemaVersion");
}
if (request.operation !== LINKED_MISSING_OPERATION) {
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", "missing-library operation is invalid", "operation");
}
return {
schemaVersion: LIBRARY_LINKED_MISSING_SCHEMA,
operation: LINKED_MISSING_OPERATION,
sourceLibraryId: libraryId(request.sourceLibraryId, "request.sourceLibraryId"),
sourceLocator: locator(request.sourceLocator, "request.sourceLocator"),
sourceSha256: digest(request.sourceSha256, "request.sourceSha256"),
expectedGeneration: integer(request.expectedGeneration, "request.expectedGeneration"),
expectedRevision: integer(request.expectedRevision, "request.expectedRevision"),
};
}
function cloneState(state: LinkedMissingStateIR): LinkedMissingStateIR {
return {
schemaVersion: LIBRARY_LINKED_MISSING_SCHEMA,
references: state.references.map((reference) => ({
...reference,
dataBlockIds: [...reference.dataBlockIds],
placeholder: reference.placeholder === null ? null : {
...reference.placeholder,
dataBlockIds: [...reference.placeholder.dataBlockIds],
},
})),
};
}
export function markLinkedLibraryMissing(stateValue: unknown, requestValue: unknown): LinkedMissingDecisionIR {
const state = parseLinkedMissingState(stateValue);
const request = parseLinkedMissingRequest(requestValue);
const index = state.references.findIndex((reference) =>
reference.sourceLibraryId === request.sourceLibraryId && reference.sourceGeneration === request.expectedGeneration,
);
if (index === -1 || state.references[index].sourceRevision !== request.expectedRevision) {
return { status: "STALE", code: "REVISION_CONFLICT", sourceLibraryId: request.sourceLibraryId, state: cloneState(state) };
}
const current = state.references[index];
if (current.sourceLocator !== request.sourceLocator || current.sourceSha256 !== request.sourceSha256) {
return { status: "STALE", code: "ASSET_SOURCE_HASH_MISMATCH", sourceLibraryId: request.sourceLibraryId, state: cloneState(state) };
}
const missing: LinkedLibraryReferenceIR = {
...current,
status: "MISSING",
placeholder: { kind: "MISSING_LIBRARY", sourceLibraryId: current.sourceLibraryId, dataBlockIds: [...current.dataBlockIds] },
dataBlockIds: [...current.dataBlockIds],
};
const references = state.references.map((reference, itemIndex) => itemIndex === index ? missing : reference);
return { status: "MARKED", code: null, sourceLibraryId: request.sourceLibraryId, state: { schemaVersion: 1, references: references.map((reference) => ({ ...reference, dataBlockIds: [...reference.dataBlockIds], placeholder: reference.placeholder === null ? null : { ...reference.placeholder, dataBlockIds: [...reference.placeholder.dataBlockIds] } })) } };
}

View File

@@ -0,0 +1,91 @@
import { blockedGate, capabilityIssue, type CapabilityGateResult } from "./capability-gates";
import type { ErrorCode } from "./error";
export const LIBRARY_LINKED_MUTATION_SCHEMA = 1 as const;
export const LINKED_DATA_WRITER_OPERATIONS = [
"OBJECT_TRANSFORM",
"MESH_GEOMETRY",
"MESH_MATERIAL_SLOT",
"MATERIAL_PROPERTIES",
"MATERIAL_IMAGE_NODE",
"IMAGE_PACKED_DATA",
] as const;
export type LinkedDataWriterOperation = typeof LINKED_DATA_WRITER_OPERATIONS[number];
export interface LinkedDataMutationIR {
schemaVersion: typeof LIBRARY_LINKED_MUTATION_SCHEMA;
operation: LinkedDataWriterOperation;
dataBlockId: string;
baseRevision: number;
owner: "SOURCE_LIBRARY";
linkedLibrary: true;
readOnly: true;
}
export class LinkedDataMutationError extends Error {
readonly code: ErrorCode;
readonly path?: string;
constructor(code: ErrorCode, message: string, path?: string) {
super(message);
this.name = "LinkedDataMutationError";
this.code = code;
this.path = path;
}
}
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function exactKeys(value: Record<string, unknown>): void {
const expected = ["schemaVersion", "operation", "dataBlockId", "baseRevision", "owner", "linkedLibrary", "readOnly"];
const actual = Object.keys(value).sort();
if (actual.length !== expected.length || actual.some((key, index) => key !== expected.slice().sort()[index])) {
throw new LinkedDataMutationError("TASK_VALIDATION_FAILED", "linked data mutation contains unsupported fields");
}
}
export function parseLinkedDataMutation(value: unknown): LinkedDataMutationIR {
if (!record(value) || value.schemaVersion !== LIBRARY_LINKED_MUTATION_SCHEMA) {
throw new LinkedDataMutationError("PROTOCOL_MISMATCH", "Unsupported linked data mutation schema");
}
exactKeys(value);
if (!LINKED_DATA_WRITER_OPERATIONS.includes(value.operation as LinkedDataWriterOperation)) {
throw new LinkedDataMutationError("TASK_VALIDATION_FAILED", "linked data mutation operation is unsupported", "operation");
}
if (typeof value.dataBlockId !== "string" || value.dataBlockId.length === 0 || value.dataBlockId.length > 256) {
throw new LinkedDataMutationError("TASK_VALIDATION_FAILED", "dataBlockId is invalid", "dataBlockId");
}
if (typeof value.baseRevision !== "number" || !Number.isSafeInteger(value.baseRevision) || value.baseRevision < 0) {
throw new LinkedDataMutationError("TASK_VALIDATION_FAILED", "baseRevision is invalid", "baseRevision");
}
if (value.owner !== "SOURCE_LIBRARY" || value.linkedLibrary !== true || value.readOnly !== true) {
throw new LinkedDataMutationError("LINKED_DATA_MUTATION_BLOCKED", "linked data must remain SOURCE_LIBRARY/readOnly", "ownership");
}
return {
schemaVersion: LIBRARY_LINKED_MUTATION_SCHEMA,
operation: value.operation as LinkedDataWriterOperation,
dataBlockId: value.dataBlockId,
baseRevision: value.baseRevision,
owner: "SOURCE_LIBRARY",
linkedLibrary: true,
readOnly: true,
};
}
export function gateLinkedDataMutation(value: unknown, currentRevision: number): CapabilityGateResult {
let request: LinkedDataMutationIR;
try {
request = parseLinkedDataMutation(value);
}
catch (error) {
const code = error instanceof LinkedDataMutationError ? error.code : "TASK_VALIDATION_FAILED";
const message = error instanceof Error ? error.message : "linked data mutation is invalid";
return blockedGate("N-023", "LINKED_DATA_WRITER", [capabilityIssue(code, message, error instanceof LinkedDataMutationError ? error.path : undefined, false)]);
}
if (!Number.isSafeInteger(currentRevision) || currentRevision < 0 || request.baseRevision !== currentRevision) {
return blockedGate("N-023", `LINKED_${request.operation}`, [capabilityIssue("REVISION_CONFLICT", "linked data mutation revision is stale", "baseRevision", false)]);
}
return blockedGate("N-023", `LINKED_${request.operation}`, [capabilityIssue("LINKED_DATA_MUTATION_BLOCKED", "linked-library data is read-only", "readOnly", false)]);
}

View File

@@ -0,0 +1,222 @@
import type { ErrorCode } from "./error";
export const LIBRARY_LINKED_RELOAD_SCHEMA = 1 as const;
export const LINKED_RELOAD_OPERATION = "RELOAD" as const;
export interface LinkedSnapshotDataBlockIR {
dataBlockId: string;
owner: "SOURCE_LIBRARY";
readOnly: true;
}
export interface LinkedSnapshotIR {
sourceLibraryId: string;
sourceGeneration: number;
sourceRevision: number;
dependencyClosureSha256: string;
graphSha256: string;
dataBlocks: LinkedSnapshotDataBlockIR[];
}
export interface LinkedReloadStateIR {
schemaVersion: typeof LIBRARY_LINKED_RELOAD_SCHEMA;
snapshots: LinkedSnapshotIR[];
}
export interface LinkedReloadRequestIR {
schemaVersion: typeof LIBRARY_LINKED_RELOAD_SCHEMA;
operation: typeof LINKED_RELOAD_OPERATION;
sourceLibraryId: string;
expectedGeneration: number;
expectedRevision: number;
replacement: LinkedSnapshotIR;
}
export interface LinkedReloadDecisionIR {
status: "REPLACED" | "STALE";
code: ErrorCode | null;
sourceLibraryId: string;
replacedGeneration: number;
replacementGeneration: number;
state: LinkedReloadStateIR;
}
export class LinkedReloadValidationError extends Error {
readonly code: ErrorCode;
readonly path?: string;
constructor(code: ErrorCode, message: string, path?: string) {
super(`${code}: ${message}`);
this.name = "LinkedReloadValidationError";
this.code = code;
this.path = path;
}
}
const LIBRARY_ID = /^library:[a-f0-9]{64}$/;
const SHA256 = /^[a-f0-9]{64}$/;
const DATA_BLOCK_ID = /^[A-Za-z0-9][A-Za-z0-9:._/ -]{0,255}$/;
const MAX_SNAPSHOTS = 10_000;
const MAX_DATA_BLOCKS = 256;
function record(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new LinkedReloadValidationError("TASK_VALIDATION_FAILED", `${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 keys = Object.keys(value).sort();
const allowed = [...expected].sort();
if (keys.length !== allowed.length || keys.some((key, index) => key !== allowed[index])) {
throw new LinkedReloadValidationError("TASK_VALIDATION_FAILED", `${path} contains undeclared fields`, path);
}
}
function integer(value: unknown, path: string): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
throw new LinkedReloadValidationError("TASK_VALIDATION_FAILED", `${path} must be a safe integer >= 0`, path);
}
return value;
}
function libraryId(value: unknown, path: string): string {
if (typeof value !== "string" || !LIBRARY_ID.test(value)) {
throw new LinkedReloadValidationError("TASK_VALIDATION_FAILED", `${path} must be a library identity`, path);
}
return value;
}
function digest(value: unknown, path: string): string {
if (typeof value !== "string" || !SHA256.test(value)) {
throw new LinkedReloadValidationError("TASK_VALIDATION_FAILED", `${path} must be a lowercase SHA-256 digest`, path);
}
return value;
}
function dataBlock(value: unknown, path: string): LinkedSnapshotDataBlockIR {
const item = record(value, path);
exactKeys(item, ["dataBlockId", "owner", "readOnly"], path);
if (typeof item.dataBlockId !== "string" || !DATA_BLOCK_ID.test(item.dataBlockId)) {
throw new LinkedReloadValidationError("TASK_VALIDATION_FAILED", `${path}.dataBlockId is invalid`, `${path}.dataBlockId`);
}
if (item.owner !== "SOURCE_LIBRARY" || item.readOnly !== true) {
throw new LinkedReloadValidationError("LINKED_DATA_MUTATION_BLOCKED", `${path} must remain source-library/read-only`, path);
}
return { dataBlockId: item.dataBlockId, owner: "SOURCE_LIBRARY", readOnly: true };
}
export function parseLinkedSnapshot(value: unknown, path = "snapshot"): LinkedSnapshotIR {
const snapshot = record(value, path);
exactKeys(snapshot, ["sourceLibraryId", "sourceGeneration", "sourceRevision", "dependencyClosureSha256", "graphSha256", "dataBlocks"], path);
if (!Array.isArray(snapshot.dataBlocks) || snapshot.dataBlocks.length === 0 || snapshot.dataBlocks.length > MAX_DATA_BLOCKS) {
throw new LinkedReloadValidationError("TASK_VALIDATION_FAILED", `${path}.dataBlocks is outside its bounded range`, `${path}.dataBlocks`);
}
const dataBlocks = snapshot.dataBlocks.map((item, index) => dataBlock(item, `${path}.dataBlocks[${index}]`));
if (new Set(dataBlocks.map((item) => item.dataBlockId)).size !== dataBlocks.length) {
throw new LinkedReloadValidationError("TASK_VALIDATION_FAILED", `${path}.dataBlocks contains duplicate IDs`, `${path}.dataBlocks`);
}
return {
sourceLibraryId: libraryId(snapshot.sourceLibraryId, `${path}.sourceLibraryId`),
sourceGeneration: integer(snapshot.sourceGeneration, `${path}.sourceGeneration`),
sourceRevision: integer(snapshot.sourceRevision, `${path}.sourceRevision`),
dependencyClosureSha256: digest(snapshot.dependencyClosureSha256, `${path}.dependencyClosureSha256`),
graphSha256: digest(snapshot.graphSha256, `${path}.graphSha256`),
dataBlocks,
};
}
export function parseLinkedReloadState(value: unknown): LinkedReloadStateIR {
const state = record(value, "state");
exactKeys(state, ["schemaVersion", "snapshots"], "state");
if (state.schemaVersion !== LIBRARY_LINKED_RELOAD_SCHEMA) {
throw new LinkedReloadValidationError("PROTOCOL_MISMATCH", "Unsupported linked reload state schema", "schemaVersion");
}
if (!Array.isArray(state.snapshots) || state.snapshots.length > MAX_SNAPSHOTS) {
throw new LinkedReloadValidationError("TASK_VALIDATION_FAILED", "state.snapshots exceeds its bounded range", "snapshots");
}
const snapshots = state.snapshots.map((item, index) => parseLinkedSnapshot(item, `state.snapshots[${index}]`));
const identities = snapshots.map((item) => `${item.sourceLibraryId}:${item.sourceGeneration}`);
if (new Set(identities).size !== identities.length) {
throw new LinkedReloadValidationError("TASK_VALIDATION_FAILED", "state contains duplicate library generations", "snapshots");
}
return { schemaVersion: LIBRARY_LINKED_RELOAD_SCHEMA, snapshots };
}
export function parseLinkedReloadRequest(value: unknown): LinkedReloadRequestIR {
const request = record(value, "request");
exactKeys(request, ["schemaVersion", "operation", "sourceLibraryId", "expectedGeneration", "expectedRevision", "replacement"], "request");
if (request.schemaVersion !== LIBRARY_LINKED_RELOAD_SCHEMA) {
throw new LinkedReloadValidationError("PROTOCOL_MISMATCH", "Unsupported linked reload request schema", "schemaVersion");
}
if (request.operation !== LINKED_RELOAD_OPERATION) {
throw new LinkedReloadValidationError("TASK_VALIDATION_FAILED", "linked reload operation is invalid", "operation");
}
const replacement = parseLinkedSnapshot(request.replacement, "request.replacement");
const sourceLibraryId = libraryId(request.sourceLibraryId, "request.sourceLibraryId");
if (replacement.sourceLibraryId !== sourceLibraryId) {
throw new LinkedReloadValidationError("TASK_VALIDATION_FAILED", "replacement must retain the requested source library", "replacement.sourceLibraryId");
}
return {
schemaVersion: LIBRARY_LINKED_RELOAD_SCHEMA,
operation: LINKED_RELOAD_OPERATION,
sourceLibraryId,
expectedGeneration: integer(request.expectedGeneration, "request.expectedGeneration"),
expectedRevision: integer(request.expectedRevision, "request.expectedRevision"),
replacement,
};
}
function cloneState(state: LinkedReloadStateIR): LinkedReloadStateIR {
return {
schemaVersion: LIBRARY_LINKED_RELOAD_SCHEMA,
snapshots: state.snapshots.map((snapshot) => ({
...snapshot,
dataBlocks: snapshot.dataBlocks.map((dataBlock) => ({ ...dataBlock })),
})),
};
}
export function reloadMatchingLinkedSnapshot(stateValue: unknown, requestValue: unknown): LinkedReloadDecisionIR {
const state = parseLinkedReloadState(stateValue);
const request = parseLinkedReloadRequest(requestValue);
const matchingIndex = state.snapshots.findIndex((snapshot) =>
snapshot.sourceLibraryId === request.sourceLibraryId && snapshot.sourceGeneration === request.expectedGeneration,
);
if (matchingIndex === -1 || state.snapshots[matchingIndex].sourceRevision !== request.expectedRevision) {
return {
status: "STALE",
code: "REVISION_CONFLICT",
sourceLibraryId: request.sourceLibraryId,
replacedGeneration: request.expectedGeneration,
replacementGeneration: request.replacement.sourceGeneration,
state: cloneState(state),
};
}
const current = state.snapshots[matchingIndex];
if (request.replacement.sourceLibraryId !== request.sourceLibraryId ||
request.replacement.sourceGeneration !== request.expectedGeneration + 1 ||
request.replacement.sourceRevision <= current.sourceRevision) {
return {
status: "STALE",
code: "REVISION_CONFLICT",
sourceLibraryId: request.sourceLibraryId,
replacedGeneration: request.expectedGeneration,
replacementGeneration: request.replacement.sourceGeneration,
state: cloneState(state),
};
}
const snapshots = state.snapshots.map((snapshot, index) => index === matchingIndex ? request.replacement : snapshot);
return {
status: "REPLACED",
code: null,
sourceLibraryId: request.sourceLibraryId,
replacedGeneration: current.sourceGeneration,
replacementGeneration: request.replacement.sourceGeneration,
state: { schemaVersion: LIBRARY_LINKED_RELOAD_SCHEMA, snapshots: snapshots.map((snapshot) => ({
...snapshot,
dataBlocks: snapshot.dataBlocks.map((dataBlock) => ({ ...dataBlock })),
})) },
};
}

View File

@@ -0,0 +1,168 @@
import type { ErrorCode } from "./error";
export const LIBRARY_NEGATIVE_CASE_SCHEMA = 1 as const;
export interface LibraryNegativeLibraryIR {
libraryId: string;
dependencyIds: string[];
}
export interface LibraryNegativeDataBlockIR {
dataBlockId: string;
sourceLibraryId: string;
}
export interface LibraryNegativeCrossReferenceIR {
fromLibraryId: string;
toLibraryId: string;
}
export interface LibraryNegativeReloadIR {
sourceLibraryId: string;
generation: number;
}
export interface LibraryNegativeInputIR {
schemaVersion: typeof LIBRARY_NEGATIVE_CASE_SCHEMA;
libraries: LibraryNegativeLibraryIR[];
dataBlocks: LibraryNegativeDataBlockIR[];
crossReferences: LibraryNegativeCrossReferenceIR[];
reloads: LibraryNegativeReloadIR[];
}
export interface LibraryNegativeValidationIR {
status: "VALID";
}
export class LibraryNegativeValidationError extends Error {
readonly code: ErrorCode;
readonly path?: string;
constructor(code: ErrorCode, message: string, path?: string) {
super(`${code}: ${message}`);
this.name = "LibraryNegativeValidationError";
this.code = code;
this.path = path;
}
}
const LIBRARY_ID = /^library:[a-f0-9]{64}$/;
const DATA_BLOCK_ID = /^[A-Za-z0-9][A-Za-z0-9:._/ -]{0,255}$/;
const MAX_ENTRIES = 10_000;
function record(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", `${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 LibraryNegativeValidationError("TASK_VALIDATION_FAILED", `${path} contains undeclared fields`, path);
}
function libraryId(value: unknown, path: string): string {
if (typeof value !== "string" || !LIBRARY_ID.test(value)) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", `${path} must be a library identity`, path);
return value;
}
function dataBlockId(value: unknown, path: string): string {
if (typeof value !== "string" || !DATA_BLOCK_ID.test(value)) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", `${path} is invalid`, path);
return value;
}
function integer(value: unknown, path: string): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", `${path} must be a safe integer >= 0`, path);
return value;
}
function parseLibrary(value: unknown, path: string): LibraryNegativeLibraryIR {
const item = record(value, path);
exactKeys(item, ["libraryId", "dependencyIds"], path);
if (!Array.isArray(item.dependencyIds) || item.dependencyIds.length > MAX_ENTRIES) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", `${path}.dependencyIds exceeds its bound`, path);
return { libraryId: libraryId(item.libraryId, `${path}.libraryId`), dependencyIds: item.dependencyIds.map((dependency, index) => libraryId(dependency, `${path}.dependencyIds[${index}]`)) };
}
function parseDataBlock(value: unknown, path: string): LibraryNegativeDataBlockIR {
const item = record(value, path);
exactKeys(item, ["dataBlockId", "sourceLibraryId"], path);
return { dataBlockId: dataBlockId(item.dataBlockId, `${path}.dataBlockId`), sourceLibraryId: libraryId(item.sourceLibraryId, `${path}.sourceLibraryId`) };
}
function parseCrossReference(value: unknown, path: string): LibraryNegativeCrossReferenceIR {
const item = record(value, path);
exactKeys(item, ["fromLibraryId", "toLibraryId"], path);
return { fromLibraryId: libraryId(item.fromLibraryId, `${path}.fromLibraryId`), toLibraryId: libraryId(item.toLibraryId, `${path}.toLibraryId`) };
}
function parseReload(value: unknown, path: string): LibraryNegativeReloadIR {
const item = record(value, path);
exactKeys(item, ["sourceLibraryId", "generation"], path);
return { sourceLibraryId: libraryId(item.sourceLibraryId, `${path}.sourceLibraryId`), generation: integer(item.generation, `${path}.generation`) };
}
export function parseLibraryNegativeInput(value: unknown): LibraryNegativeInputIR {
const input = record(value, "input");
exactKeys(input, ["schemaVersion", "libraries", "dataBlocks", "crossReferences", "reloads"], "input");
if (input.schemaVersion !== LIBRARY_NEGATIVE_CASE_SCHEMA) throw new LibraryNegativeValidationError("PROTOCOL_MISMATCH", "Unsupported library negative-case schema", "schemaVersion");
const libraries = input.libraries;
const dataBlocks = input.dataBlocks;
const crossReferences = input.crossReferences;
const reloads = input.reloads;
if (!Array.isArray(libraries) || libraries.length > MAX_ENTRIES) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", "input.libraries exceeds its bound", "input.libraries");
if (!Array.isArray(dataBlocks) || dataBlocks.length > MAX_ENTRIES) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", "input.dataBlocks exceeds its bound", "input.dataBlocks");
if (!Array.isArray(crossReferences) || crossReferences.length > MAX_ENTRIES) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", "input.crossReferences exceeds its bound", "input.crossReferences");
if (!Array.isArray(reloads) || reloads.length > MAX_ENTRIES) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", "input.reloads exceeds its bound", "input.reloads");
return {
schemaVersion: LIBRARY_NEGATIVE_CASE_SCHEMA,
libraries: libraries.map((item, index) => parseLibrary(item, `libraries[${index}]`)),
dataBlocks: dataBlocks.map((item, index) => parseDataBlock(item, `dataBlocks[${index}]`)),
crossReferences: crossReferences.map((item, index) => parseCrossReference(item, `crossReferences[${index}]`)),
reloads: reloads.map((item, index) => parseReload(item, `reloads[${index}]`)),
};
}
function assertNoCycle(nodes: Set<string>, edges: Map<string, string[]>, label: string): void {
const active = new Set<string>();
const complete = new Set<string>();
const visit = (node: string): void => {
if (active.has(node)) throw new LibraryNegativeValidationError("LIBRARY_DEPENDENCY_CYCLE", `${label} contains a cycle at ${node}`, label);
if (complete.has(node)) return;
active.add(node);
for (const dependency of edges.get(node) ?? []) {
if (!nodes.has(dependency)) throw new LibraryNegativeValidationError("ASSET_MANIFEST_INVALID", `${label} references a missing library ${dependency}`, label);
visit(dependency);
}
active.delete(node);
complete.add(node);
};
for (const node of nodes) visit(node);
}
export function validateLibraryNegativeInput(value: unknown): LibraryNegativeValidationIR {
const input = parseLibraryNegativeInput(value);
const libraries = new Set(input.libraries.map((item) => item.libraryId));
if (libraries.size !== input.libraries.length) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", "duplicate library ID", "libraries");
const libraryEdges = new Map(input.libraries.map((item) => [item.libraryId, item.dependencyIds]));
assertNoCycle(libraries, libraryEdges, "library dependencies");
const crossEdges = new Map<string, string[]>();
for (const item of input.crossReferences) {
if (!libraries.has(item.fromLibraryId) || !libraries.has(item.toLibraryId)) throw new LibraryNegativeValidationError("ASSET_MANIFEST_INVALID", "cross-library reference names a missing library", "crossReferences");
crossEdges.set(item.fromLibraryId, [...(crossEdges.get(item.fromLibraryId) ?? []), item.toLibraryId]);
}
assertNoCycle(libraries, crossEdges, "cross-library references");
const dataBlocks = new Set<string>();
for (const item of input.dataBlocks) {
if (!libraries.has(item.sourceLibraryId)) throw new LibraryNegativeValidationError("ASSET_SOURCE_HASH_MISMATCH", "data-block source library is missing", "dataBlocks");
if (dataBlocks.has(item.dataBlockId)) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", `duplicate data-block ID ${item.dataBlockId}`, "dataBlocks");
dataBlocks.add(item.dataBlockId);
}
const reloads = new Set<string>();
for (const item of input.reloads) {
if (!libraries.has(item.sourceLibraryId)) throw new LibraryNegativeValidationError("ASSET_SOURCE_HASH_MISMATCH", "reload source library is missing", "reloads");
const identity = `${item.sourceLibraryId}:${item.generation}`;
if (reloads.has(identity)) throw new LibraryNegativeValidationError("REVISION_CONFLICT", `duplicate reload ${identity}`, "reloads");
reloads.add(identity);
}
return { status: "VALID" };
}

View File

@@ -0,0 +1,165 @@
import type { ErrorCode } from "./error";
export const LIBRARY_OVERRIDE_FRESHNESS_SCHEMA = 1 as const;
export const LIBRARY_OVERRIDE_COMMIT_OPERATION = "COMMIT_OVERRIDE" as const;
export interface OverrideFreshnessStateIR {
schemaVersion: typeof LIBRARY_OVERRIDE_FRESHNESS_SCHEMA;
sourceLibraryId: string;
sourceGeneration: number;
sourceRevision: number;
dependencyClosureSha256: string;
invalidationToken: string;
localDataBlockId: string;
referenceSourceDataBlockId: string;
hierarchyRootDataBlockId: string;
owner: "LOCAL_OVERRIDE";
readOnly: false;
referenceReadOnly: true;
}
export interface OverrideFreshnessRequestIR {
schemaVersion: typeof LIBRARY_OVERRIDE_FRESHNESS_SCHEMA;
operation: typeof LIBRARY_OVERRIDE_COMMIT_OPERATION;
sourceLibraryId: string;
sourceGeneration: number;
sourceRevision: number;
dependencyClosureSha256: string;
invalidationToken: string;
baseRevision: number;
localDataBlockId: string;
referenceSourceDataBlockId: string;
hierarchyRootDataBlockId: string;
owner: "LOCAL_OVERRIDE";
readOnly: false;
referenceReadOnly: true;
}
export interface OverrideFreshnessDecisionIR {
status: "READY" | "BLOCKED";
code: ErrorCode | null;
}
export class OverrideFreshnessValidationError extends Error {
readonly code: ErrorCode;
readonly path?: string;
constructor(code: ErrorCode, message: string, path?: string) {
super(`${code}: ${message}`);
this.name = "OverrideFreshnessValidationError";
this.code = code;
this.path = path;
}
}
const LIBRARY_ID = /^library:[a-f0-9]{64}$/;
const SHA256 = /^[a-f0-9]{64}$/;
const TOKEN = /^override-token:[a-f0-9]{64}$/;
const DATA_BLOCK_ID = /^[A-Za-z0-9][A-Za-z0-9:._/ -]{0,255}$/;
function record(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new OverrideFreshnessValidationError("TASK_VALIDATION_FAILED", `${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 OverrideFreshnessValidationError("TASK_VALIDATION_FAILED", `${path} contains undeclared fields`, path);
}
function integer(value: unknown, path: string): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new OverrideFreshnessValidationError("TASK_VALIDATION_FAILED", `${path} must be a safe integer >= 0`, path);
return value;
}
function id(value: unknown, path: string): string {
if (typeof value !== "string" || !DATA_BLOCK_ID.test(value)) throw new OverrideFreshnessValidationError("TASK_VALIDATION_FAILED", `${path} is invalid`, path);
return value;
}
function digest(value: unknown, path: string): string {
if (typeof value !== "string" || !SHA256.test(value)) throw new OverrideFreshnessValidationError("TASK_VALIDATION_FAILED", `${path} must be a SHA-256 digest`, path);
return value;
}
function library(value: unknown, path: string): string {
if (typeof value !== "string" || !LIBRARY_ID.test(value)) throw new OverrideFreshnessValidationError("TASK_VALIDATION_FAILED", `${path} must be a library identity`, path);
return value;
}
function token(value: unknown, path: string): string {
if (typeof value !== "string" || !TOKEN.test(value)) throw new OverrideFreshnessValidationError("TASK_VALIDATION_FAILED", `${path} must be an invalidation token`, path);
return value;
}
function ownership(value: Record<string, unknown>, path: string): void {
if (value.owner !== "LOCAL_OVERRIDE" || value.readOnly !== false || value.referenceReadOnly !== true) throw new OverrideFreshnessValidationError("LINKED_DATA_MUTATION_BLOCKED", `${path} ownership semantics are invalid`, path);
}
const COMMON_KEYS = ["schemaVersion", "sourceLibraryId", "sourceGeneration", "sourceRevision", "dependencyClosureSha256", "invalidationToken", "localDataBlockId", "referenceSourceDataBlockId", "hierarchyRootDataBlockId", "owner", "readOnly", "referenceReadOnly"] as const;
export function parseOverrideFreshnessState(value: unknown): OverrideFreshnessStateIR {
const state = record(value, "state");
exactKeys(state, COMMON_KEYS, "state");
if (state.schemaVersion !== LIBRARY_OVERRIDE_FRESHNESS_SCHEMA) throw new OverrideFreshnessValidationError("PROTOCOL_MISMATCH", "Unsupported override freshness state schema", "schemaVersion");
ownership(state, "state");
const sourceLibraryId = library(state.sourceLibraryId, "sourceLibraryId");
return {
schemaVersion: LIBRARY_OVERRIDE_FRESHNESS_SCHEMA,
sourceLibraryId,
sourceGeneration: integer(state.sourceGeneration, "sourceGeneration"),
sourceRevision: integer(state.sourceRevision, "sourceRevision"),
dependencyClosureSha256: digest(state.dependencyClosureSha256, "dependencyClosureSha256"),
invalidationToken: token(state.invalidationToken, "invalidationToken"),
localDataBlockId: id(state.localDataBlockId, "localDataBlockId"),
referenceSourceDataBlockId: id(state.referenceSourceDataBlockId, "referenceSourceDataBlockId"),
hierarchyRootDataBlockId: id(state.hierarchyRootDataBlockId, "hierarchyRootDataBlockId"),
owner: "LOCAL_OVERRIDE",
readOnly: false,
referenceReadOnly: true,
};
}
export function parseOverrideFreshnessRequest(value: unknown): OverrideFreshnessRequestIR {
const request = record(value, "request");
exactKeys(request, [...COMMON_KEYS, "operation", "baseRevision"], "request");
if (request.schemaVersion !== LIBRARY_OVERRIDE_FRESHNESS_SCHEMA) throw new OverrideFreshnessValidationError("PROTOCOL_MISMATCH", "Unsupported override freshness request schema", "schemaVersion");
if (request.operation !== LIBRARY_OVERRIDE_COMMIT_OPERATION) throw new OverrideFreshnessValidationError("TASK_VALIDATION_FAILED", "override commit operation is invalid", "operation");
ownership(request, "request");
return {
schemaVersion: LIBRARY_OVERRIDE_FRESHNESS_SCHEMA,
operation: LIBRARY_OVERRIDE_COMMIT_OPERATION,
sourceLibraryId: library(request.sourceLibraryId, "sourceLibraryId"),
sourceGeneration: integer(request.sourceGeneration, "sourceGeneration"),
sourceRevision: integer(request.sourceRevision, "sourceRevision"),
dependencyClosureSha256: digest(request.dependencyClosureSha256, "dependencyClosureSha256"),
invalidationToken: token(request.invalidationToken, "invalidationToken"),
baseRevision: integer(request.baseRevision, "baseRevision"),
localDataBlockId: id(request.localDataBlockId, "localDataBlockId"),
referenceSourceDataBlockId: id(request.referenceSourceDataBlockId, "referenceSourceDataBlockId"),
hierarchyRootDataBlockId: id(request.hierarchyRootDataBlockId, "hierarchyRootDataBlockId"),
owner: "LOCAL_OVERRIDE",
readOnly: false,
referenceReadOnly: true,
};
}
export function gateOverrideFreshness(stateValue: unknown, requestValue: unknown): OverrideFreshnessDecisionIR {
let state: OverrideFreshnessStateIR;
let request: OverrideFreshnessRequestIR;
try {
state = parseOverrideFreshnessState(stateValue);
request = parseOverrideFreshnessRequest(requestValue);
}
catch (error) {
return { status: "BLOCKED", code: error instanceof OverrideFreshnessValidationError ? error.code : "TASK_VALIDATION_FAILED" };
}
if (request.baseRevision !== state.sourceRevision || request.sourceLibraryId !== state.sourceLibraryId || request.sourceGeneration !== state.sourceGeneration || request.sourceRevision !== state.sourceRevision || request.dependencyClosureSha256 !== state.dependencyClosureSha256 || request.invalidationToken !== state.invalidationToken) {
return { status: "BLOCKED", code: "REVISION_CONFLICT" };
}
if (request.localDataBlockId !== state.localDataBlockId || request.referenceSourceDataBlockId !== state.referenceSourceDataBlockId || request.hierarchyRootDataBlockId !== state.hierarchyRootDataBlockId) {
return { status: "BLOCKED", code: "ASSET_SOURCE_HASH_MISMATCH" };
}
return { status: "READY", code: null };
}

View File

@@ -0,0 +1,156 @@
import type { ErrorCode } from "./error";
export const LIBRARY_OVERRIDE_WRITER_SCHEMA = 1 as const;
export const LIBRARY_OVERRIDE_WRITER_OPERATION = "SET_M12_OVERRIDE_VALUE" as const;
export const LIBRARY_OVERRIDE_PROPERTY_PATH = '["m12_override_value"]' as const;
export interface OverrideWriterStateIR {
schemaVersion: typeof LIBRARY_OVERRIDE_WRITER_SCHEMA;
revision: number;
localDataBlockId: string;
referenceSourceDataBlockId: string;
hierarchyRootDataBlockId: string;
owner: "LOCAL_OVERRIDE";
readOnly: false;
referenceReadOnly: true;
propertyPath: typeof LIBRARY_OVERRIDE_PROPERTY_PATH;
value: number;
}
export interface OverrideWriterRequestIR {
schemaVersion: typeof LIBRARY_OVERRIDE_WRITER_SCHEMA;
operation: typeof LIBRARY_OVERRIDE_WRITER_OPERATION;
baseRevision: number;
localDataBlockId: string;
referenceSourceDataBlockId: string;
hierarchyRootDataBlockId: string;
owner: "LOCAL_OVERRIDE";
readOnly: false;
referenceReadOnly: true;
propertyPath: typeof LIBRARY_OVERRIDE_PROPERTY_PATH;
value: number;
}
export interface OverrideWriterDecisionIR {
status: "APPLIED" | "BLOCKED";
code: ErrorCode | null;
state: OverrideWriterStateIR;
}
export class OverrideWriterValidationError extends Error {
readonly code: ErrorCode;
readonly path?: string;
constructor(code: ErrorCode, message: string, path?: string) {
super(`${code}: ${message}`);
this.name = "OverrideWriterValidationError";
this.code = code;
this.path = path;
}
}
const DATA_BLOCK_ID = /^[A-Za-z0-9][A-Za-z0-9:._/ -]{0,255}$/;
const MAX_VALUE = 1_000_000;
function record(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new OverrideWriterValidationError("TASK_VALIDATION_FAILED", `${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 OverrideWriterValidationError("TASK_VALIDATION_FAILED", `${path} contains undeclared fields`, path);
}
}
function integer(value: unknown, path: string): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
throw new OverrideWriterValidationError("TASK_VALIDATION_FAILED", `${path} must be a safe integer >= 0`, path);
}
return value;
}
function dataBlockId(value: unknown, path: string): string {
if (typeof value !== "string" || !DATA_BLOCK_ID.test(value)) {
throw new OverrideWriterValidationError("TASK_VALIDATION_FAILED", `${path} is invalid`, path);
}
return value;
}
function valueNumber(value: unknown, path: string): number {
if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) > MAX_VALUE) {
throw new OverrideWriterValidationError("TASK_VALIDATION_FAILED", `${path} is outside the bounded float range`, path);
}
return value;
}
export function parseOverrideWriterState(value: unknown): OverrideWriterStateIR {
const state = record(value, "state");
exactKeys(state, ["schemaVersion", "revision", "localDataBlockId", "referenceSourceDataBlockId", "hierarchyRootDataBlockId", "owner", "readOnly", "referenceReadOnly", "propertyPath", "value"], "state");
if (state.schemaVersion !== LIBRARY_OVERRIDE_WRITER_SCHEMA) throw new OverrideWriterValidationError("PROTOCOL_MISMATCH", "Unsupported override writer state schema", "schemaVersion");
if (state.owner !== "LOCAL_OVERRIDE" || state.readOnly !== false || state.referenceReadOnly !== true) throw new OverrideWriterValidationError("TASK_VALIDATION_FAILED", "state ownership semantics are invalid", "owner");
if (state.propertyPath !== LIBRARY_OVERRIDE_PROPERTY_PATH) throw new OverrideWriterValidationError("EDITOR_WRITER_UNAVAILABLE", "state property path is not the verified writer path", "propertyPath");
return {
schemaVersion: LIBRARY_OVERRIDE_WRITER_SCHEMA,
revision: integer(state.revision, "revision"),
localDataBlockId: dataBlockId(state.localDataBlockId, "localDataBlockId"),
referenceSourceDataBlockId: dataBlockId(state.referenceSourceDataBlockId, "referenceSourceDataBlockId"),
hierarchyRootDataBlockId: dataBlockId(state.hierarchyRootDataBlockId, "hierarchyRootDataBlockId"),
owner: "LOCAL_OVERRIDE",
readOnly: false,
referenceReadOnly: true,
propertyPath: LIBRARY_OVERRIDE_PROPERTY_PATH,
value: valueNumber(state.value, "value"),
};
}
export function parseOverrideWriterRequest(value: unknown): OverrideWriterRequestIR {
const request = record(value, "request");
exactKeys(request, ["schemaVersion", "operation", "baseRevision", "localDataBlockId", "referenceSourceDataBlockId", "hierarchyRootDataBlockId", "owner", "readOnly", "referenceReadOnly", "propertyPath", "value"], "request");
if (request.schemaVersion !== LIBRARY_OVERRIDE_WRITER_SCHEMA) throw new OverrideWriterValidationError("PROTOCOL_MISMATCH", "Unsupported override writer request schema", "schemaVersion");
if (request.operation !== LIBRARY_OVERRIDE_WRITER_OPERATION) throw new OverrideWriterValidationError("TASK_VALIDATION_FAILED", "override writer operation is invalid", "operation");
if (request.owner !== "LOCAL_OVERRIDE" || request.readOnly !== false || request.referenceReadOnly !== true) throw new OverrideWriterValidationError("LINKED_DATA_MUTATION_BLOCKED", "override writer must retain local override ownership", "owner");
if (request.propertyPath !== LIBRARY_OVERRIDE_PROPERTY_PATH) throw new OverrideWriterValidationError("EDITOR_WRITER_UNAVAILABLE", "only the verified override property is writable", "propertyPath");
return {
schemaVersion: LIBRARY_OVERRIDE_WRITER_SCHEMA,
operation: LIBRARY_OVERRIDE_WRITER_OPERATION,
baseRevision: integer(request.baseRevision, "baseRevision"),
localDataBlockId: dataBlockId(request.localDataBlockId, "localDataBlockId"),
referenceSourceDataBlockId: dataBlockId(request.referenceSourceDataBlockId, "referenceSourceDataBlockId"),
hierarchyRootDataBlockId: dataBlockId(request.hierarchyRootDataBlockId, "hierarchyRootDataBlockId"),
owner: "LOCAL_OVERRIDE",
readOnly: false,
referenceReadOnly: true,
propertyPath: LIBRARY_OVERRIDE_PROPERTY_PATH,
value: valueNumber(request.value, "value"),
};
}
function blocked(state: OverrideWriterStateIR, code: ErrorCode): OverrideWriterDecisionIR {
return { status: "BLOCKED", code, state: { ...state } };
}
export function applyOverrideWriter(stateValue: unknown, requestValue: unknown): OverrideWriterDecisionIR {
const state = parseOverrideWriterState(stateValue);
let request: OverrideWriterRequestIR;
try {
request = parseOverrideWriterRequest(requestValue);
}
catch (error) {
const code = error instanceof OverrideWriterValidationError ? error.code : "TASK_VALIDATION_FAILED";
return blocked(state, code);
}
if (request.baseRevision !== state.revision) return blocked(state, "REVISION_CONFLICT");
if (request.localDataBlockId !== state.localDataBlockId || request.referenceSourceDataBlockId !== state.referenceSourceDataBlockId || request.hierarchyRootDataBlockId !== state.hierarchyRootDataBlockId) {
return blocked(state, "ASSET_SOURCE_HASH_MISMATCH");
}
return {
status: "APPLIED",
code: null,
state: { ...state, revision: state.revision + 1, value: request.value },
};
}

View File

@@ -0,0 +1,148 @@
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<string, unknown> {
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<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 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}` };
}

219
web/protocol/obj-import.ts Normal file
View File

@@ -0,0 +1,219 @@
export const OBJ_IMPORT_SCHEMA_VERSION = 1 as const;
export const OBJ_IMPORT_BUDGET = {
maxObjBytes: 512 * 1024,
maxMtlBytes: 128 * 1024,
maxLines: 16_384,
maxPositions: 65_536,
maxTexcoords: 65_536,
maxNormals: 65_536,
maxFaces: 65_536,
} as const;
export interface OBJFaceVertex {
position: number;
texcoord: number | null;
normal: number | null;
}
export interface OBJFace {
object: string | null;
groups: string[];
material: string | null;
vertices: OBJFaceVertex[];
}
export interface OBJMaterial {
name: string;
mapKd: string | null;
}
export interface OBJSemantics {
schemaVersion: typeof OBJ_IMPORT_SCHEMA_VERSION;
materialLibraries: string[];
objects: string[];
groups: string[];
positions: number[][];
texcoords: number[][];
normals: number[][];
faces: OBJFace[];
materials: OBJMaterial[];
}
export type OBJLossCode = "OBJ_TEXTURE_ORIGIN_UNRESOLVED";
export interface OBJLossWarning {
code: OBJLossCode;
severity: "warning";
message: string;
path: string;
}
export interface OBJLossReport {
schemaVersion: typeof OBJ_IMPORT_SCHEMA_VERSION;
operation: "OBJ_EXPORT_LOSS_REPORT";
canRoundTrip: boolean;
warningCount: number;
warnings: OBJLossWarning[];
}
function parseNumber(value: string, label: string): number {
const parsed = Number(value);
if (!Number.isFinite(parsed)) throw new Error(`OBJ_NUMBER_INVALID: ${label}`);
return parsed === 0 ? 0 : parsed;
}
function decode(bytes: ArrayBuffer, limit: number, label: string): string {
if (bytes.byteLength > limit) throw new Error(`OBJ_IMPORT_BUDGET_EXCEEDED: ${label}`);
try {
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
}
catch {
throw new Error(`OBJ_TEXT_INVALID: ${label}`);
}
}
function resolveIndex(raw: string, count: number, label: string): number {
const value = Number(raw);
if (!Number.isSafeInteger(value) || value === 0) throw new Error(`OBJ_INDEX_INVALID: ${label}`);
const resolved = value < 0 ? count + value + 1 : value;
if (resolved < 1 || resolved > count) throw new Error(`OBJ_INDEX_OUT_OF_RANGE: ${label}`);
return resolved;
}
function parseMaterialText(mtlText: string): OBJMaterial[] {
const materials: OBJMaterial[] = [];
let current: OBJMaterial | null = null;
for (const rawLine of mtlText.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const parts = line.split(/\s+/);
if (parts[0] === "newmtl") {
if (parts.length < 2) throw new Error("OBJ_MTL_INVALID: newmtl name is missing");
current = { name: parts.slice(1).join(" "), mapKd: null };
materials.push(current);
}
else if (parts[0] === "map_Kd" && current) {
if (parts.length < 2) throw new Error("OBJ_MTL_INVALID: map_Kd path is missing");
current.mapKd = parts.slice(1).join(" ");
}
}
return materials;
}
export function importOBJ(obj: ArrayBuffer, mtl?: ArrayBuffer): OBJSemantics {
const objText = decode(obj, OBJ_IMPORT_BUDGET.maxObjBytes, "OBJ");
const mtlText = mtl ? decode(mtl, OBJ_IMPORT_BUDGET.maxMtlBytes, "MTL") : "";
const positions: number[][] = [];
const texcoords: number[][] = [];
const normals: number[][] = [];
const faces: OBJFace[] = [];
const materialLibraries: string[] = [];
const objects: string[] = [];
const groups: string[] = [];
let currentObject: string | null = null;
let currentGroups: string[] = [];
let currentMaterial: string | null = null;
const lines = objText.split(/\r?\n/);
if (lines.length > OBJ_IMPORT_BUDGET.maxLines) throw new Error("OBJ_IMPORT_BUDGET_EXCEEDED: line count");
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const parts = line.split(/\s+/);
const kind = parts[0];
if (kind === "v") {
if (parts.length < 4 || positions.length >= OBJ_IMPORT_BUDGET.maxPositions) throw new Error("OBJ_IMPORT_BUDGET_EXCEEDED: positions");
positions.push([parseNumber(parts[1], "v.x"), parseNumber(parts[2], "v.y"), parseNumber(parts[3], "v.z")]);
}
else if (kind === "vt") {
if (parts.length < 3 || texcoords.length >= OBJ_IMPORT_BUDGET.maxTexcoords) throw new Error("OBJ_IMPORT_BUDGET_EXCEEDED: texcoords");
texcoords.push([parseNumber(parts[1], "vt.u"), parseNumber(parts[2], "vt.v")]);
}
else if (kind === "vn") {
if (parts.length < 4 || normals.length >= OBJ_IMPORT_BUDGET.maxNormals) throw new Error("OBJ_IMPORT_BUDGET_EXCEEDED: normals");
normals.push([parseNumber(parts[1], "vn.x"), parseNumber(parts[2], "vn.y"), parseNumber(parts[3], "vn.z")]);
}
else if (kind === "mtllib") materialLibraries.push(parts.slice(1).join(" "));
else if (kind === "o") {
currentObject = parts.slice(1).join(" ") || null;
if (currentObject && !objects.includes(currentObject)) objects.push(currentObject);
}
else if (kind === "g") {
currentGroups = parts.slice(1);
for (const group of currentGroups) if (group && !groups.includes(group)) groups.push(group);
const meshGroup = currentGroups.find((group) => group.endsWith("_Mesh"));
if (meshGroup) {
currentObject = meshGroup;
if (!objects.includes(meshGroup)) objects.push(meshGroup);
}
}
else if (kind === "usemtl") currentMaterial = parts.slice(1).join(" ") || null;
else if (kind === "f") {
if (parts.length < 4) throw new Error("OBJ_FACE_ARITY_INVALID: face requires at least three vertices");
if (faces.length >= OBJ_IMPORT_BUDGET.maxFaces) throw new Error("OBJ_IMPORT_BUDGET_EXCEEDED: faces");
const vertices = parts.slice(1).map((token, index) => {
const indices = token.split("/");
if (indices.length < 1 || indices.length > 3 || !indices[0] || (indices.length === 2 && !indices[1])) throw new Error(`OBJ_FACE_VERTEX_INVALID: face vertex ${index}`);
return {
position: resolveIndex(indices[0], positions.length, "face.position"),
texcoord: indices.length > 1 && indices[1] ? resolveIndex(indices[1], texcoords.length, "face.texcoord") : null,
normal: indices.length > 2 && indices[2] ? resolveIndex(indices[2], normals.length, "face.normal") : null,
};
});
faces.push({ object: currentObject, groups: [...currentGroups], material: currentMaterial, vertices });
}
}
if (faces.length === 0) throw new Error("OBJ_EMPTY: no faces were found");
return {
schemaVersion: OBJ_IMPORT_SCHEMA_VERSION,
materialLibraries,
objects,
groups,
positions,
texcoords,
normals,
faces,
materials: parseMaterialText(mtlText),
};
}
function formatNumber(value: number): string {
if (!Number.isFinite(value)) throw new Error("OBJ_NUMBER_INVALID: cannot serialize non-finite value");
return String(Object.is(value, -0) ? 0 : Number(value.toFixed(7)));
}
export function serializeOBJ(document: OBJSemantics): { obj: string; mtl: string } {
if (document.schemaVersion !== OBJ_IMPORT_SCHEMA_VERSION || document.faces.length === 0) throw new Error("OBJ_SERIALIZE_INVALID: semantic document");
const lines = ["# Web Blender OBJ export", "# schema 1"];
if (document.materials.length > 0) lines.push("mtllib " + (document.materialLibraries[0] ?? "materials.mtl"));
for (const object of document.objects) lines.push(`o ${object}`);
for (const position of document.positions) lines.push(`v ${position.map(formatNumber).join(" ")}`);
for (const texcoord of document.texcoords) lines.push(`vt ${texcoord.map(formatNumber).join(" ")}`);
for (const normal of document.normals) lines.push(`vn ${normal.map(formatNumber).join(" ")}`);
let object = "";
let groups = "";
let material = "";
for (const face of document.faces) {
if (face.object && face.object !== object) { lines.push(`o ${face.object}`); object = face.object; }
const nextGroups = face.groups.join(" ");
if (nextGroups !== groups) { if (nextGroups) lines.push(`g ${nextGroups}`); groups = nextGroups; }
const nextMaterial = face.material ?? "";
if (nextMaterial !== material) { if (nextMaterial) lines.push(`usemtl ${nextMaterial}`); material = nextMaterial; }
lines.push(`f ${face.vertices.map((vertex) => `${vertex.position}/${vertex.texcoord ?? ""}/${vertex.normal ?? ""}`).join(" ")}`);
}
const mtlLines = ["# Web Blender MTL export", "# schema 1"];
for (const value of document.materials) {
mtlLines.push(`newmtl ${value.name}`);
if (value.mapKd) mtlLines.push(`map_Kd ${value.mapKd}`);
}
return { obj: lines.join("\n") + "\n", mtl: mtlLines.join("\n") + "\n" };
}
export function createOBJLossReport(document: OBJSemantics, textureAssets: readonly string[] = []): OBJLossReport {
const assets = new Set(textureAssets);
const warnings = document.materials
.filter((material) => material.mapKd && !assets.has(material.mapKd))
.map((material) => ({ code: "OBJ_TEXTURE_ORIGIN_UNRESOLVED" as const, severity: "warning" as const, message: `OBJ texture ${material.mapKd} is not bound to a supplied asset`, path: material.mapKd! }))
.sort((left, right) => left.path.localeCompare(right.path));
return { schemaVersion: OBJ_IMPORT_SCHEMA_VERSION, operation: "OBJ_EXPORT_LOSS_REPORT", canRoundTrip: true, warningCount: warnings.length, warnings };
}

298
web/protocol/ply-import.ts Normal file
View File

@@ -0,0 +1,298 @@
export const PLY_IMPORT_SCHEMA_VERSION = 1 as const;
export const PLY_IMPORT_BUDGET = {
maxBytes: 512 * 1024,
maxHeaderBytes: 64 * 1024,
maxElements: 16,
maxVertices: 65_536,
maxFaces: 65_536,
maxListLength: 256,
maxCustomProperties: 64,
} as const;
export type PLYFormat = "ascii" | "binary_little_endian";
export interface PLYVertex {
position: [number, number, number];
normal: [number, number, number] | null;
color: [number, number, number, number] | null;
customProperties: Record<string, number>;
}
export interface PLYFace {
indices: number[];
customProperties: Record<string, number>;
}
export type PLYLossCode =
| "PLY_UNKNOWN_ELEMENT"
| "PLY_UNKNOWN_PROPERTY"
| "PLY_NORMAL_PROPERTY_INCOMPLETE"
| "PLY_COLOR_PROPERTY_INCOMPLETE";
export interface PLYLossWarning {
code: PLYLossCode;
severity: "warning";
element: string;
property: string | null;
message: string;
}
export interface PLYLossReport {
schemaVersion: typeof PLY_IMPORT_SCHEMA_VERSION;
operation: "PLY_IMPORT_LOSS_REPORT";
canImport: boolean;
warningCount: number;
warnings: PLYLossWarning[];
}
export interface PLYImportResult {
schemaVersion: typeof PLY_IMPORT_SCHEMA_VERSION;
format: PLYFormat;
vertices: PLYVertex[];
faces: PLYFace[];
warnings: PLYLossWarning[];
}
type ScalarType = "int8" | "uint8" | "int16" | "uint16" | "int32" | "uint32" | "float32" | "float64";
interface ScalarProperty { kind: "scalar"; name: string; type: ScalarType; }
interface ListProperty { kind: "list"; name: string; countType: ScalarType; valueType: ScalarType; }
type Property = ScalarProperty | ListProperty;
interface Element { name: string; count: number; properties: Property[]; }
const SCALAR_TYPES: Record<string, ScalarType> = {
char: "int8", int8: "int8", uchar: "uint8", uint8: "uint8", short: "int16", int16: "int16",
ushort: "uint16", uint16: "uint16", int: "int32", int32: "int32", uint: "uint32", uint32: "uint32",
float: "float32", float32: "float32", double: "float64", float64: "float64",
};
function fail(code: string): never { throw new Error(code); }
function finite(value: number, label: string): number {
if (!Number.isFinite(value)) fail(`PLY_NUMBER_INVALID: ${label}`);
return Object.is(value, -0) ? 0 : value;
}
function decodeHeader(bytes: Uint8Array): { format: PLYFormat; elements: Element[]; offset: number } {
const limit = Math.min(bytes.byteLength, PLY_IMPORT_BUDGET.maxHeaderBytes);
let end = -1;
let terminatorLength = 0;
for (let index = 0; index + 10 <= limit; index++) {
if (bytes[index] === 101 && bytes[index + 1] === 110 && bytes[index + 2] === 100 && bytes[index + 3] === 95 && bytes[index + 4] === 104 && bytes[index + 5] === 101 && bytes[index + 6] === 97 && bytes[index + 7] === 100 && bytes[index + 8] === 101 && bytes[index + 9] === 114) {
if (bytes[index + 10] === 10) { end = index; terminatorLength = 11; break; }
if (bytes[index + 10] === 13 && bytes[index + 11] === 10) { end = index; terminatorLength = 12; break; }
}
}
if (end < 0) fail("PLY_HEADER_INVALID");
let header: string;
try { header = new TextDecoder("ascii", { fatal: true }).decode(bytes.subarray(0, end)); }
catch { fail("PLY_HEADER_INVALID"); }
const lines = header.split(/\r?\n/);
if (lines[0] !== "ply") fail("PLY_MAGIC_INVALID");
let format: PLYFormat | null = null;
const elements: Element[] = [];
let current: Element | null = null;
for (const rawLine of lines.slice(1)) {
const line = rawLine.trim();
if (!line || line.startsWith("comment") || line.startsWith("obj_info")) continue;
const parts = line.split(/\s+/);
if (parts[0] === "format") {
if (parts[1] === "ascii") format = "ascii";
else if (parts[1] === "binary_little_endian") format = "binary_little_endian";
else fail("PLY_FORMAT_UNSUPPORTED");
}
else if (parts[0] === "element") {
if (parts.length !== 3 || !Number.isSafeInteger(Number(parts[2])) || Number(parts[2]) < 0) fail("PLY_ELEMENT_INVALID");
if (elements.length >= PLY_IMPORT_BUDGET.maxElements) fail("PLY_IMPORT_BUDGET_EXCEEDED: elements");
const count = Number(parts[2]);
if (count > PLY_IMPORT_BUDGET.maxVertices) fail(`PLY_IMPORT_BUDGET_EXCEEDED: ${parts[1]}`);
current = { name: parts[1], count, properties: [] };
elements.push(current);
}
else if (parts[0] === "property") {
if (!current) fail("PLY_PROPERTY_WITHOUT_ELEMENT");
if (parts[1] === "list") {
if (parts.length !== 5) fail("PLY_PROPERTY_INVALID");
const countType = SCALAR_TYPES[parts[2]];
const valueType = SCALAR_TYPES[parts[3]];
if (!countType || !valueType) fail("PLY_PROPERTY_TYPE_UNSUPPORTED");
current.properties.push({ kind: "list", name: parts[4], countType, valueType });
}
else {
if (parts.length !== 3) fail("PLY_PROPERTY_INVALID");
const type = SCALAR_TYPES[parts[1]];
if (!type) fail("PLY_PROPERTY_TYPE_UNSUPPORTED");
current.properties.push({ kind: "scalar", name: parts[2], type });
}
}
else if (parts[0] !== "end_header") fail("PLY_HEADER_INVALID");
}
if (!format) fail("PLY_FORMAT_MISSING");
return { format, elements, offset: end + terminatorLength };
}
function readScalar(view: DataView, offset: number, type: ScalarType): { value: number; next: number } {
const size = type === "int8" || type === "uint8" ? 1 : type === "int16" || type === "uint16" ? 2 : 4;
if (offset + size > view.byteLength) fail("PLY_DATA_TRUNCATED");
let value: number;
if (type === "int8") value = view.getInt8(offset);
else if (type === "uint8") value = view.getUint8(offset);
else if (type === "int16") value = view.getInt16(offset, true);
else if (type === "uint16") value = view.getUint16(offset, true);
else if (type === "int32") value = view.getInt32(offset, true);
else if (type === "uint32") value = view.getUint32(offset, true);
else if (type === "float32") value = view.getFloat32(offset, true);
else value = view.getFloat64(offset, true);
return { value: finite(value, "binary"), next: offset + size };
}
function parseAsciiRecords(bytes: Uint8Array, offset: number, elements: Element[]): Map<string, Array<Record<string, number | number[]>>> {
let text: string;
try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(offset)); }
catch { fail("PLY_ASCII_INVALID"); }
const lines = text.split(/\r?\n/);
let cursor = 0;
const records = new Map<string, Array<Record<string, number | number[]>>>();
for (const element of elements) {
const values: Array<Record<string, number | number[]>> = [];
for (let row = 0; row < element.count; row++) {
while (cursor < lines.length && !lines[cursor].trim()) cursor++;
if (cursor >= lines.length) fail("PLY_DATA_TRUNCATED");
const tokens = lines[cursor++].trim().split(/\s+/);
let tokenIndex = 0;
const record: Record<string, number | number[]> = {};
for (const property of element.properties) {
if (property.kind === "scalar") {
if (tokenIndex >= tokens.length) fail("PLY_DATA_TRUNCATED");
record[property.name] = finite(Number(tokens[tokenIndex++]), `${element.name}.${property.name}`);
}
else {
if (tokenIndex >= tokens.length) fail("PLY_DATA_TRUNCATED");
const length = Number(tokens[tokenIndex++]);
if (!Number.isSafeInteger(length) || length < 0 || length > PLY_IMPORT_BUDGET.maxListLength) fail("PLY_LIST_INVALID");
const list: number[] = [];
for (let index = 0; index < length; index++) {
if (tokenIndex >= tokens.length) fail("PLY_DATA_TRUNCATED");
list.push(finite(Number(tokens[tokenIndex++]), `${element.name}.${property.name}`));
}
record[property.name] = list;
}
}
if (tokenIndex !== tokens.length) fail("PLY_DATA_EXTRA_TOKENS");
values.push(record);
}
records.set(element.name, values);
}
return records;
}
function parseBinaryRecords(bytes: Uint8Array, offset: number, elements: Element[]): Map<string, Array<Record<string, number | number[]>>> {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
let cursor = offset;
const records = new Map<string, Array<Record<string, number | number[]>>>();
for (const element of elements) {
const values: Array<Record<string, number | number[]>> = [];
for (let row = 0; row < element.count; row++) {
const record: Record<string, number | number[]> = {};
for (const property of element.properties) {
if (property.kind === "scalar") {
const result = readScalar(view, cursor, property.type); record[property.name] = result.value; cursor = result.next;
}
else {
const count = readScalar(view, cursor, property.countType); cursor = count.next;
if (!Number.isSafeInteger(count.value) || count.value < 0 || count.value > PLY_IMPORT_BUDGET.maxListLength) fail("PLY_LIST_INVALID");
const list: number[] = [];
for (let index = 0; index < count.value; index++) { const result = readScalar(view, cursor, property.valueType); list.push(result.value); cursor = result.next; }
record[property.name] = list;
}
}
values.push(record);
}
records.set(element.name, values);
}
return records;
}
function warning(code: PLYLossCode, element: string, property: string | null, message: string): PLYLossWarning {
return { code, severity: "warning", element, property, message };
}
function mapDocument(elements: Element[], records: Map<string, Array<Record<string, number | number[]>>>): PLYImportResult {
const warnings: PLYLossWarning[] = [];
const vertexElement = elements.find((element) => element.name === "vertex");
if (!vertexElement) fail("PLY_VERTEX_ELEMENT_MISSING");
const vertexRecords = records.get("vertex") ?? [];
const vertexProperties = new Set(vertexElement.properties.filter((property): property is ScalarProperty => property.kind === "scalar").map((property) => property.name));
for (const name of ["x", "y", "z"]) if (!vertexProperties.has(name)) fail("PLY_VERTEX_POSITION_MISSING");
const hasNormals = ["nx", "ny", "nz"].every((name) => vertexProperties.has(name));
if (!hasNormals && ["nx", "ny", "nz"].some((name) => vertexProperties.has(name))) warnings.push(warning("PLY_NORMAL_PROPERTY_INCOMPLETE", "vertex", null, "vertex normal requires nx, ny and nz"));
const hasColor = ["red", "green", "blue"].every((name) => vertexProperties.has(name));
if (!hasColor && ["red", "green", "blue", "alpha"].some((name) => vertexProperties.has(name))) warnings.push(warning("PLY_COLOR_PROPERTY_INCOMPLETE", "vertex", null, "vertex color requires red, green and blue"));
const customNames = vertexElement.properties.filter((property): property is ScalarProperty => property.kind === "scalar" && !["x", "y", "z", "nx", "ny", "nz", "red", "green", "blue", "alpha"].includes(property.name)).map((property) => property.name);
if (customNames.length > PLY_IMPORT_BUDGET.maxCustomProperties) fail("PLY_IMPORT_BUDGET_EXCEEDED: custom properties");
for (const property of vertexElement.properties) if (property.kind === "list") warnings.push(warning("PLY_UNKNOWN_PROPERTY", "vertex", property.name, `vertex list property ${property.name} is not mapped`));
const vertices = vertexRecords.map((record) => ({
position: [record.x, record.y, record.z].map((value) => finite(value as number, "vertex position")) as [number, number, number],
normal: hasNormals ? [record.nx, record.ny, record.nz].map((value) => finite(value as number, "vertex normal")) as [number, number, number] : null,
color: hasColor ? ["red", "green", "blue", "alpha"].map((name) => Math.max(0, Math.min(255, Number(record[name] ?? (name === "alpha" ? 255 : 0)))) / 255) as [number, number, number, number] : null,
customProperties: Object.fromEntries(customNames.map((name) => [name, finite(record[name] as number, `vertex.${name}`)])),
}));
const faceElement = elements.find((element) => element.name === "face");
const faces: PLYFace[] = [];
if (faceElement) {
const indexProperty = faceElement.properties.find((property): property is ListProperty => property.kind === "list" && (property.name === "vertex_indices" || property.name === "vertex_index"));
if (!indexProperty) fail("PLY_FACE_INDEX_MISSING");
const faceCustomNames = faceElement.properties.filter((property): property is ScalarProperty => property.kind === "scalar").map((property) => property.name);
for (const property of faceElement.properties) if (property.kind === "list" && property !== indexProperty) warnings.push(warning("PLY_UNKNOWN_PROPERTY", "face", property.name, `face list property ${property.name} is not mapped`));
for (const record of records.get("face") ?? []) {
const values = record[indexProperty.name];
if (!Array.isArray(values) || values.length < 3) fail("PLY_FACE_ARITY_INVALID");
const indices = values.map((value) => { if (!Number.isSafeInteger(value) || value < 0 || value >= vertices.length) fail("PLY_FACE_INDEX_OUT_OF_RANGE"); return value; });
faces.push({ indices, customProperties: Object.fromEntries(faceCustomNames.map((name) => [name, finite(record[name] as number, `face.${name}`)])) });
}
}
for (const element of elements) if (element.name !== "vertex" && element.name !== "face") warnings.push(warning("PLY_UNKNOWN_ELEMENT", element.name, null, `element ${element.name} is not mapped`));
return { schemaVersion: PLY_IMPORT_SCHEMA_VERSION, format: "ascii", vertices, faces, warnings };
}
export function importPLY(bytes: ArrayBuffer, options?: { format?: PLYFormat }): PLYImportResult {
if (bytes.byteLength > PLY_IMPORT_BUDGET.maxBytes) fail("PLY_IMPORT_BUDGET_EXCEEDED: bytes");
const payload = new Uint8Array(bytes);
const header = decodeHeader(payload);
if (options?.format && options.format !== header.format) fail("PLY_FORMAT_MISMATCH");
const records = header.format === "ascii" ? parseAsciiRecords(payload, header.offset, header.elements) : parseBinaryRecords(payload, header.offset, header.elements);
const result = mapDocument(header.elements, records);
result.format = header.format;
return result;
}
export function createPLYLossReport(document: PLYImportResult): PLYLossReport {
const warnings = [...document.warnings].sort((left, right) => left.code.localeCompare(right.code) || left.element.localeCompare(right.element) || (left.property ?? "").localeCompare(right.property ?? ""));
return { schemaVersion: PLY_IMPORT_SCHEMA_VERSION, operation: "PLY_IMPORT_LOSS_REPORT", canImport: true, warningCount: warnings.length, warnings };
}
function formatNumber(value: number): string { return Number.isInteger(value) ? String(value) : String(Number(value.toPrecision(9))); }
export function serializePLYAscii(document: PLYImportResult): ArrayBuffer {
if (document.schemaVersion !== PLY_IMPORT_SCHEMA_VERSION || document.vertices.length > PLY_IMPORT_BUDGET.maxVertices || document.faces.length > PLY_IMPORT_BUDGET.maxFaces) fail("PLY_EXPORT_DOCUMENT_INVALID");
const customNames = [...new Set(document.vertices.flatMap((vertex) => Object.keys(vertex.customProperties)))].sort();
const faceCustomNames = [...new Set(document.faces.flatMap((face) => Object.keys(face.customProperties)))].sort();
const lines = ["ply", "format ascii 1.0", "comment Web Blender PLY schema 1", `element vertex ${document.vertices.length}`, "property float x", "property float y", "property float z"];
if (document.vertices.some((vertex) => vertex.normal)) lines.push("property float nx", "property float ny", "property float nz");
if (document.vertices.some((vertex) => vertex.color)) lines.push("property uchar red", "property uchar green", "property uchar blue", "property uchar alpha");
for (const name of customNames) lines.push(`property float ${name}`);
lines.push(`element face ${document.faces.length}`, "property list uchar uint vertex_indices");
for (const name of faceCustomNames) lines.push(`property float ${name}`);
lines.push("end_header");
for (const vertex of document.vertices) {
const values = vertex.position.map(formatNumber);
if (document.vertices.some((item) => item.normal)) values.push(...(vertex.normal ?? [0, 0, 0]).map(formatNumber));
if (document.vertices.some((item) => item.color)) values.push(...(vertex.color ?? [0, 0, 0, 1]).map((value) => String(Math.max(0, Math.min(255, Math.round(value * 255))))));
values.push(...customNames.map((name) => formatNumber(vertex.customProperties[name] ?? 0)));
lines.push(values.join(" "));
}
for (const face of document.faces) lines.push(`${face.indices.length} ${face.indices.join(" ")} ${faceCustomNames.map((name) => formatNumber(face.customProperties[name] ?? 0)).join(" ")}`.trim());
const output = new TextEncoder().encode(lines.join("\n") + "\n");
if (output.byteLength > PLY_IMPORT_BUDGET.maxBytes) fail("PLY_IMPORT_BUDGET_EXCEEDED: output bytes");
return output.buffer;
}

View File

@@ -0,0 +1,35 @@
export const POINTER_CONTRACT_SCHEMA_VERSION = 1 as const;
export type PointerKind = "mouse" | "touch" | "pen";
export interface PointerObservation {
schemaVersion: typeof POINTER_CONTRACT_SCHEMA_VERSION;
pointerType: PointerKind;
pointerId: number;
pressure: number;
tiltX: number;
tiltY: number;
button: number;
buttons: number;
cancelled: boolean;
}
function bounded(value: number, min: number, max: number, fallback: number): number {
return Number.isFinite(value) ? Math.max(min, Math.min(max, value)) : fallback;
}
export function observePointerEvent(event: { pointerType?: string; pointerId?: number; pressure?: number; tiltX?: number; tiltY?: number; button?: number; buttons?: number; type?: string }): PointerObservation {
const pointerType = event.pointerType === "touch" || event.pointerType === "pen" || event.pointerType === "mouse" ? event.pointerType : null;
if (!pointerType) throw new Error("POINTER_TYPE_UNSUPPORTED");
if (!Number.isSafeInteger(event.pointerId) || event.pointerId! < 0) throw new Error("POINTER_ID_INVALID");
return {
schemaVersion: POINTER_CONTRACT_SCHEMA_VERSION,
pointerType,
pointerId: event.pointerId!,
pressure: bounded(event.pressure ?? (pointerType === "mouse" ? 0 : 0.5), 0, 1, 0),
tiltX: bounded(event.tiltX ?? 0, -90, 90, 0),
tiltY: bounded(event.tiltY ?? 0, -90, 90, 0),
button: Number.isInteger(event.button) ? event.button! : -1,
buttons: Number.isInteger(event.buttons) && event.buttons! >= 0 ? event.buttons! : 0,
cancelled: event.type === "pointercancel",
};
}

View File

@@ -6,7 +6,14 @@ export const SCRIPTING_PLATFORM_SCHEMA = 1 as const;
export const SCRIPT_SOURCE_SCHEMA = 1 as const;
export const SCRIPT_EXECUTION_AUDIT_SCHEMA = 1 as const;
export const SCRIPT_EXECUTION_AUDIT_LOG_SCHEMA = 1 as const;
export const SCRIPT_TRUST_POLICY_SCHEMA = 1 as const;
export const SCRIPT_SANDBOX_SCOPE_SCHEMA = 1 as const;
export const SCRIPTING_BUDGET = { maxScripts: 1_024, maxPermissions: 64, maxDependencies: 128, maxCpuMs: 60_000, maxMemoryBytes: 512 * 1024 * 1024, maxWallMs: 300_000, maxSourceBytes: 1024 * 1024, maxSourceLines: 65_536, maxAuditEntries: 65_536 } as const;
export const SCRIPT_TRUST_POLICY_BUDGET = { maxKeys: 1_024, maxClockSkewMs: 300_000 } as const;
export const SCRIPT_SANDBOX_BUDGET = { maxCpuMs: 60_000, maxWallMs: 300_000, maxMemoryBytes: 512 * 1024 * 1024, maxMessageBytes: 1 * 1024 * 1024, maxOutputBytes: 16 * 1024 * 1024 } as const;
export const SCRIPT_HOST_CALL_SCHEMA = 1 as const;
export const SCRIPT_HOST_CALLS = ["READ_MAIN", "READ_ASSET", "WRITE_MAIN", "WRITE_ASSET", "SUBMIT_SERVER_JOB"] as const;
export const SCRIPT_SANDBOX_JOB_SCHEMA = 1 as const;
export const SCRIPT_PERMISSIONS = ["READ_MAIN", "WRITE_MAIN", "READ_ASSET", "WRITE_ASSET", "SUBMIT_SERVER_JOB"] as const;
export type ScriptPermission = typeof SCRIPT_PERMISSIONS[number];
@@ -15,12 +22,14 @@ export interface ScriptManifestIR {
id: string;
name: string;
entryPath: string;
sourceByteLength: number;
sourceSha256: string;
publisher: string;
signature: string;
keyId: string;
permissions: ScriptPermission[];
dependencies: ScriptDependencyIR[];
module: false;
cpuMs: number;
memoryBytes: number;
wallMs: number;
@@ -30,6 +39,94 @@ export interface ScriptManifestIR {
addonInstall: false;
}
export interface ScriptingManifestIR { schemaVersion: typeof SCRIPTING_PLATFORM_SCHEMA; scripts: ScriptManifestIR[] }
export interface ScriptTrustKeyIR {
keyId: string;
publisher: string;
algorithm: "ED25519";
publicKey: string;
status: "ACTIVE" | "REVOKED";
notBefore: string;
notAfter: string;
revokedAt?: string;
replaces?: string;
}
export interface ScriptTrustPolicyIR {
schemaVersion: typeof SCRIPT_TRUST_POLICY_SCHEMA;
issuer: string;
issuedAt: string;
expiresAt: string;
maxClockSkewMs: number;
keys: ScriptTrustKeyIR[];
}
export interface ScriptSignerResolutionIR {
status: "ELIGIBLE" | "BLOCKED";
keyId: string;
publisher: string;
trust: "ACTIVE" | "REVOKED" | "NOT_FOUND" | "PUBLISHER_MISMATCH" | "POLICY_NOT_YET_VALID" | "POLICY_EXPIRED" | "KEY_NOT_YET_VALID" | "KEY_EXPIRED";
cryptographicVerification: "REQUIRED";
}
export interface ScriptSignatureVerificationIR {
status: "VERIFIED" | "BLOCKED";
code: "SCRIPT_SIGNATURE_VERIFIED" | "SCRIPT_SIGNATURE_INVALID" | "SCRIPT_POLICY_DENIED";
keyId: string;
sourceSha256: string;
inputSha256: string;
}
export interface ScriptPermissionResolutionIR {
status: "ALLOWED" | "BLOCKED";
code: "SCRIPT_PERMISSIONS_ALLOWED" | "SCRIPT_POLICY_DENIED";
scriptId: string;
declared: ScriptPermission[];
requested: ScriptPermission[];
granted: ScriptPermission[];
}
export interface ScriptSandboxScopeIR {
schemaVersion: typeof SCRIPT_SANDBOX_SCOPE_SCHEMA;
dom: false;
hostWorker: false;
opfs: false;
indexedDB: false;
network: false;
}
export interface ScriptSandboxBudgetIR {
schemaVersion: typeof SCRIPT_SANDBOX_SCOPE_SCHEMA;
cpuMs: number;
wallMs: number;
memoryBytes: number;
maxMessageBytes: number;
maxOutputBytes: number;
}
export type ScriptHostCallName = typeof SCRIPT_HOST_CALLS[number];
export type ScriptHostCallParameters =
| { revision: number }
| { path: string; expectedSha256: string }
| { revision: number; operation: string; payload: Record<string, unknown> }
| { path: string; byteLength: number; sha256: string }
| { inputBlendSha256: string; settingsSha256: string };
export interface ScriptHostCallIR {
schemaVersion: typeof SCRIPT_HOST_CALL_SCHEMA;
requestId: string;
scriptId: string;
call: ScriptHostCallName;
permission: ScriptPermission;
parameters: ScriptHostCallParameters;
execution: "DISABLED";
}
export interface ScriptSandboxJobIR {
schemaVersion: typeof SCRIPT_SANDBOX_JOB_SCHEMA;
jobId: string;
workerGeneration: number;
baseRevision: number;
mainRevisionBefore: number;
mainRevisionAfter: number;
status: "CRASHED" | "TIMED_OUT" | "CANCELLED";
errorCode: "SCRIPT_SANDBOX_CRASHED" | "SCRIPT_SANDBOX_TIMEOUT" | "SCRIPT_SANDBOX_CANCELLED";
temporaryBytes: 0;
publishedResults: 0;
lateResults: 0;
committed: false;
execution: "DISABLED";
}
export interface ScriptSourceIR {
id: string;
name: string;
@@ -111,11 +208,128 @@ function canonicalManifest(manifest: ScriptingManifestIR): ScriptingManifestIR {
return {
schemaVersion: manifest.schemaVersion,
scripts: manifest.scripts
.map((script) => ({ ...script, permissions: [...script.permissions].sort(), dependencies: script.dependencies.map((dependency) => ({ ...dependency })).sort((a, b) => a.id.localeCompare(b.id)) }))
.sort((a, b) => a.id.localeCompare(b.id)),
.map((script) => ({ ...script, permissions: [...script.permissions].sort(), dependencies: script.dependencies.map((dependency) => ({ ...dependency })).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0) }))
.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
};
}
export function canonicalizeScriptingManifest(value: unknown): ScriptingManifestIR {
return canonicalManifest(parseScriptingManifest(value));
}
export function serializeScriptingManifest(value: unknown): string {
return stableJSON(canonicalizeScriptingManifest(value));
}
export function serializeScriptSignatureInput(value: unknown, scriptId: string): string {
const parsed = canonicalizeScriptingManifest(value);
const script = parsed.scripts.find((item) => item.id === scriptId);
if (!script) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Unknown script ${scriptId}`);
return stableJSON({ schemaVersion: SCRIPTING_PLATFORM_SCHEMA, script: { ...script, signature: "" } });
}
export function parseScriptTrustPolicy(value: unknown): ScriptTrustPolicyIR {
if (!record(value) || value.schemaVersion !== SCRIPT_TRUST_POLICY_SCHEMA || !Array.isArray(value.keys)) throw new ScriptingPlatformValidationError("PROTOCOL_MISMATCH", "Unsupported script trust policy schema");
const issuer = text(value.issuer, "trustPolicy.issuer", 256);
const issuedAt = isoDate(value.issuedAt, "trustPolicy.issuedAt");
const expiresAt = isoDate(value.expiresAt, "trustPolicy.expiresAt");
if (expiresAt <= issuedAt) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", "trustPolicy.expiresAt must be after issuedAt");
const maxClockSkewMs = integer(value.maxClockSkewMs, "trustPolicy.maxClockSkewMs", 0, SCRIPT_TRUST_POLICY_BUDGET.maxClockSkewMs);
if (value.keys.length > SCRIPT_TRUST_POLICY_BUDGET.maxKeys) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", "Trust policy key count exceeds the budget");
const keyIds = new Set<string>();
const keys = value.keys.map((item, index): ScriptTrustKeyIR => {
const name = `trustPolicy.keys[${index}]`;
if (!record(item)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name} is invalid`);
const keyId = text(item.keyId, `${name}.keyId`, 128);
if (keyIds.has(keyId)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name}.keyId is duplicated`);
keyIds.add(keyId);
if (item.algorithm !== "ED25519" || typeof item.publicKey !== "string" || !/^[a-f0-9]{64}$/.test(item.publicKey)) throw new ScriptingPlatformValidationError("SCRIPT_SIGNATURE_INVALID", `${name} has an unsupported public key`);
const publisher = text(item.publisher, `${name}.publisher`, 256);
const notBefore = isoDate(item.notBefore, `${name}.notBefore`);
const notAfter = isoDate(item.notAfter, `${name}.notAfter`);
if (notAfter <= notBefore) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name} validity window is invalid`);
if (item.status !== "ACTIVE" && item.status !== "REVOKED") throw new ScriptingPlatformValidationError("SCRIPT_POLICY_DENIED", `${name}.status is invalid`);
const revokedAt = item.revokedAt === undefined ? undefined : isoDate(item.revokedAt, `${name}.revokedAt`);
if (item.status === "REVOKED" ? revokedAt === undefined : revokedAt !== undefined) throw new ScriptingPlatformValidationError("SCRIPT_POLICY_DENIED", `${name}.revokedAt does not match status`);
if (revokedAt !== undefined && (revokedAt < notBefore || revokedAt > notAfter)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name}.revokedAt is outside the key validity window`);
const replaces = item.replaces === undefined ? undefined : text(item.replaces, `${name}.replaces`, 128);
return { keyId, publisher, algorithm: "ED25519", publicKey: item.publicKey, status: item.status, notBefore, notAfter, ...(revokedAt === undefined ? {} : { revokedAt }), ...(replaces === undefined ? {} : { replaces }) };
});
const byId = new Map(keys.map((key) => [key.keyId, key]));
const active = new Set<string>(); const complete = new Set<string>();
const visit = (keyId: string): void => {
if (active.has(keyId)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Trust key rotation cycle includes ${keyId}`);
if (complete.has(keyId)) return;
const key = byId.get(keyId); if (!key) return;
active.add(keyId);
if (key.replaces !== undefined) {
const predecessor = byId.get(key.replaces);
if (!predecessor) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${keyId} replaces missing key ${key.replaces}`);
if (predecessor.publisher !== key.publisher) throw new ScriptingPlatformValidationError("SCRIPT_POLICY_DENIED", `${keyId} crosses publisher rotation boundary`);
visit(predecessor.keyId);
}
active.delete(keyId); complete.add(keyId);
};
keys.forEach((key) => visit(key.keyId));
return { schemaVersion: SCRIPT_TRUST_POLICY_SCHEMA, issuer, issuedAt, expiresAt, maxClockSkewMs, keys };
}
export function canonicalizeScriptTrustPolicy(value: unknown): ScriptTrustPolicyIR {
const parsed = parseScriptTrustPolicy(value);
return { ...parsed, keys: [...parsed.keys].sort((a, b) => a.keyId < b.keyId ? -1 : a.keyId > b.keyId ? 1 : 0) };
}
export function serializeScriptTrustPolicy(value: unknown): string {
return stableJSON(canonicalizeScriptTrustPolicy(value));
}
export function resolveScriptSigner(manifest: unknown, scriptId: string, policy: unknown, at: string): ScriptSignerResolutionIR {
const parsedManifest = parseScriptingManifest(manifest);
const script = parsedManifest.scripts.find((item) => item.id === scriptId);
if (!script) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Unknown script ${scriptId}`);
const parsedPolicy = parseScriptTrustPolicy(policy);
const key = parsedPolicy.keys.find((item) => item.keyId === script.keyId);
const blocked = (trust: ScriptSignerResolutionIR["trust"]): ScriptSignerResolutionIR => ({ status: "BLOCKED", keyId: script.keyId, publisher: script.publisher, trust, cryptographicVerification: "REQUIRED" });
if (!key) return blocked("NOT_FOUND");
const requestedAt = isoDate(at, "signer.at");
const policyStart = new Date(parsedPolicy.issuedAt).getTime() - parsedPolicy.maxClockSkewMs;
const policyEnd = new Date(parsedPolicy.expiresAt).getTime() + parsedPolicy.maxClockSkewMs;
const requestedTime = new Date(requestedAt).getTime();
if (requestedTime < policyStart) return blocked("POLICY_NOT_YET_VALID");
if (requestedTime > policyEnd) return blocked("POLICY_EXPIRED");
if (key.publisher !== script.publisher) return blocked("PUBLISHER_MISMATCH");
if (key.status === "REVOKED") return blocked("REVOKED");
if (requestedAt < key.notBefore) return blocked("KEY_NOT_YET_VALID");
if (requestedAt > key.notAfter) return blocked("KEY_EXPIRED");
return { status: "ELIGIBLE", keyId: key.keyId, publisher: key.publisher, trust: "ACTIVE", cryptographicVerification: "REQUIRED" };
}
function hexBytes(value: string): Uint8Array {
const bytes = new Uint8Array(value.length / 2);
for (let index = 0; index < bytes.length; index += 1) bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
return bytes;
}
export async function verifyScriptManifestSignature(manifest: unknown, scriptId: string, policy: unknown, at: string): Promise<ScriptSignatureVerificationIR> {
const parsedManifest = parseScriptingManifest(manifest);
const script = parsedManifest.scripts.find((item) => item.id === scriptId);
if (!script) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Unknown script ${scriptId}`);
const resolution = resolveScriptSigner(parsedManifest, scriptId, policy, at);
const input = serializeScriptSignatureInput(parsedManifest, scriptId);
const inputSha256 = await sha256(input);
if (resolution.status !== "ELIGIBLE") return { status: "BLOCKED", code: "SCRIPT_POLICY_DENIED", keyId: script.keyId, sourceSha256: script.sourceSha256, inputSha256 };
const signer = parseScriptTrustPolicy(policy).keys.find((key) => key.keyId === script.keyId);
if (!signer) return { status: "BLOCKED", code: "SCRIPT_SIGNATURE_INVALID", keyId: script.keyId, sourceSha256: script.sourceSha256, inputSha256 };
try {
const key = await crypto.subtle.importKey("raw", hexBytes(signer.publicKey) as unknown as BufferSource, { name: "Ed25519" }, false, ["verify"]);
const valid = await crypto.subtle.verify("Ed25519", key, hexBytes(script.signature) as unknown as BufferSource, new TextEncoder().encode(input) as unknown as BufferSource);
return { status: valid ? "VERIFIED" : "BLOCKED", code: valid ? "SCRIPT_SIGNATURE_VERIFIED" : "SCRIPT_SIGNATURE_INVALID", keyId: script.keyId, sourceSha256: script.sourceSha256, inputSha256 };
}
catch {
return { status: "BLOCKED", code: "SCRIPT_SIGNATURE_INVALID", keyId: script.keyId, sourceSha256: script.sourceSha256, inputSha256 };
}
}
export async function createScriptExecutionAudit(
manifest: unknown,
scriptId: string,
@@ -129,7 +343,7 @@ export async function createScriptExecutionAudit(
const requestedAt = isoDate(options.requestedAt ?? new Date().toISOString(), "requestedAt");
const approvedKey = approvedKeyIds.has(script.keyId);
const reason = approvedKey ? "SCRIPT_SANDBOX_UNAVAILABLE" : "SCRIPT_SIGNATURE_INVALID";
const manifestSha256 = await sha256(stableJSON(canonicalManifest(parsed)));
const manifestSha256 = await sha256(serializeScriptingManifest(parsed));
const request = canonicalAuditRequest({ requestId, requestedAt, scriptId, sourceSha256: script.sourceSha256, manifestSha256, permissions: [...script.permissions], budget: { cpuMs: script.cpuMs, memoryBytes: script.memoryBytes, wallMs: script.wallMs }, approvedKey, decision: "DENY", reason });
const requestSha256 = await sha256(stableJSON(request));
return Object.freeze({
@@ -227,16 +441,20 @@ export async function verifyScriptSource(source: ScriptSourceIR): Promise<boolea
export function parseScriptingManifest(value: unknown): ScriptingManifestIR {
if (!record(value) || value.schemaVersion !== SCRIPTING_PLATFORM_SCHEMA || !Array.isArray(value.scripts)) throw new ScriptingPlatformValidationError("PROTOCOL_MISMATCH", "Unsupported scripting manifest schema");
if (value.scripts.length > SCRIPTING_BUDGET.maxScripts) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", "Script count exceeds the budget");
const ids = new Set<string>();
const ids = new Set<string>(); let totalSourceBytes = 0;
const scripts = value.scripts.map((item, index): ScriptManifestIR => {
const name = `scripts[${index}]`; if (!record(item) || !Array.isArray(item.permissions) || !Array.isArray(item.dependencies)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name} is invalid`);
const id = text(item.id, `${name}.id`); if (ids.has(id)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Duplicate script ${id}`); ids.add(id);
if (item.permissions.length > SCRIPTING_BUDGET.maxPermissions || item.permissions.some((permission) => !SCRIPT_PERMISSIONS.includes(permission as ScriptPermission)) || new Set(item.permissions).size !== item.permissions.length) throw new ScriptingPlatformValidationError("SCRIPT_POLICY_DENIED", `${name}.permissions are invalid or exceed the allowlist`);
if (item.dependencies.length > SCRIPTING_BUDGET.maxDependencies) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", `${name}.dependencies exceed the budget`);
const dependencies = item.dependencies.map((dependency, dependencyIndex): ScriptDependencyIR => { const dependencyName = `${name}.dependencies[${dependencyIndex}]`; if (!record(dependency)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${dependencyName} is invalid`); return { id: text(dependency.id, `${dependencyName}.id`), sourceSha256: digest(dependency.sourceSha256, `${dependencyName}.sourceSha256`), sourcePath: path(dependency.sourcePath, `${dependencyName}.sourcePath`) }; });
if (item.network !== false || item.autorun !== false || item.driverExpressions !== false || item.addonInstall !== false) throw new ScriptingPlatformValidationError(item.driverExpressions === true ? "DRIVER_EXECUTION_BLOCKED" : item.addonInstall === true ? "ADDON_INSTALL_BLOCKED" : "SCRIPT_POLICY_DENIED", `${name} requests a denied execution policy`);
const dependencyIds = new Set<string>();
const dependencies = item.dependencies.map((dependency, dependencyIndex): ScriptDependencyIR => { const dependencyName = `${name}.dependencies[${dependencyIndex}]`; if (!record(dependency)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${dependencyName} is invalid`); const dependencyId = text(dependency.id, `${dependencyName}.id`); if (dependencyIds.has(dependencyId)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${dependencyName}.id is duplicated`); dependencyIds.add(dependencyId); return { id: dependencyId, sourceSha256: digest(dependency.sourceSha256, `${dependencyName}.sourceSha256`), sourcePath: path(dependency.sourcePath, `${dependencyName}.sourcePath`) }; });
if (item.module !== false || item.network !== false || item.autorun !== false || item.driverExpressions !== false || item.addonInstall !== false) throw new ScriptingPlatformValidationError(item.driverExpressions === true ? "DRIVER_EXECUTION_BLOCKED" : item.addonInstall === true ? "ADDON_INSTALL_BLOCKED" : "SCRIPT_POLICY_DENIED", `${name} requests a denied execution policy`);
if (typeof item.signature !== "string" || !HEX_SIGNATURE.test(item.signature)) throw new ScriptingPlatformValidationError("SCRIPT_SIGNATURE_INVALID", `${name}.signature is invalid`);
return { id, name: text(item.name, `${name}.name`), entryPath: path(item.entryPath, `${name}.entryPath`), sourceSha256: digest(item.sourceSha256, `${name}.sourceSha256`), publisher: text(item.publisher, `${name}.publisher`), signature: item.signature, keyId: text(item.keyId, `${name}.keyId`, 128), permissions: [...item.permissions] as ScriptPermission[], dependencies, cpuMs: integer(item.cpuMs, `${name}.cpuMs`, 1, SCRIPTING_BUDGET.maxCpuMs), memoryBytes: integer(item.memoryBytes, `${name}.memoryBytes`, 1, SCRIPTING_BUDGET.maxMemoryBytes), wallMs: integer(item.wallMs, `${name}.wallMs`, 1, SCRIPTING_BUDGET.maxWallMs), network: false, autorun: false, driverExpressions: false, addonInstall: false };
const sourceByteLength = integer(item.sourceByteLength, `${name}.sourceByteLength`, 0, SCRIPTING_BUDGET.maxSourceBytes);
totalSourceBytes += sourceByteLength;
if (!Number.isSafeInteger(totalSourceBytes) || totalSourceBytes > SCRIPTING_BUDGET.maxSourceBytes) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", "Manifest source bytes exceed the total budget");
return { id, name: text(item.name, `${name}.name`), entryPath: path(item.entryPath, `${name}.entryPath`), sourceByteLength, sourceSha256: digest(item.sourceSha256, `${name}.sourceSha256`), publisher: text(item.publisher, `${name}.publisher`), signature: item.signature, keyId: text(item.keyId, `${name}.keyId`, 128), permissions: [...item.permissions] as ScriptPermission[], dependencies, module: false, cpuMs: integer(item.cpuMs, `${name}.cpuMs`, 1, SCRIPTING_BUDGET.maxCpuMs), memoryBytes: integer(item.memoryBytes, `${name}.memoryBytes`, 1, SCRIPTING_BUDGET.maxMemoryBytes), wallMs: integer(item.wallMs, `${name}.wallMs`, 1, SCRIPTING_BUDGET.maxWallMs), network: false, autorun: false, driverExpressions: false, addonInstall: false };
});
const scriptIds = new Set(scripts.map((script) => script.id));
const active = new Set<string>(); const complete = new Set<string>(); const visit = (id: string): void => { if (active.has(id)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Script dependency cycle includes ${id}`); if (complete.has(id)) return; const script = scripts.find((item) => item.id === id); if (!script) return; active.add(id); for (const dependency of script.dependencies) { if (!scriptIds.has(dependency.id)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${id} references missing script ${dependency.id}`); visit(dependency.id); } active.delete(id); complete.add(id); }; scripts.forEach((script) => visit(script.id));
@@ -249,6 +467,81 @@ export function gateScriptExecution(manifest: unknown, scriptId: string, approve
return blockedGate("N-025", `SCRIPT_${script.id}`, [capabilityIssue("SCRIPT_SANDBOX_UNAVAILABLE", "Local Python/Native execution requires an isolated sandbox")]);
}
export function resolveScriptPermissions(manifest: unknown, scriptId: string, requested: unknown = []): ScriptPermissionResolutionIR {
const parsed = parseScriptingManifest(manifest);
const script = parsed.scripts.find((item) => item.id === scriptId);
if (!script) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Unknown script ${scriptId}`);
const declared = [...script.permissions].sort();
const requestedList = Array.isArray(requested) ? requested : [];
const requestedValid = requestedList.every((permission): permission is ScriptPermission => typeof permission === "string" && SCRIPT_PERMISSIONS.includes(permission as ScriptPermission));
const requestedUnique = new Set(requestedList).size === requestedList.length;
const requestedCanonical = [...requestedList].filter((permission): permission is ScriptPermission => typeof permission === "string" && SCRIPT_PERMISSIONS.includes(permission as ScriptPermission)).sort();
const allowed = requestedValid && requestedUnique && requestedCanonical.every((permission) => declared.includes(permission));
return {
status: allowed ? "ALLOWED" : "BLOCKED",
code: allowed ? "SCRIPT_PERMISSIONS_ALLOWED" : "SCRIPT_POLICY_DENIED",
scriptId,
declared,
requested: requestedCanonical,
granted: allowed ? requestedCanonical : [],
};
}
export function parseScriptSandboxScope(value: unknown): ScriptSandboxScopeIR {
if (!record(value) || value.schemaVersion !== SCRIPT_SANDBOX_SCOPE_SCHEMA) throw new ScriptingPlatformValidationError("PROTOCOL_MISMATCH", "Unsupported script sandbox scope schema");
const denied = ["dom", "hostWorker", "opfs", "indexedDB", "network"] as const;
if (denied.some((name) => value[name] !== false)) throw new ScriptingPlatformValidationError("SCRIPT_POLICY_DENIED", "Script sandbox scope must deny browser and host capabilities");
return { schemaVersion: SCRIPT_SANDBOX_SCOPE_SCHEMA, dom: false, hostWorker: false, opfs: false, indexedDB: false, network: false };
}
export function parseScriptSandboxBudget(value: unknown): ScriptSandboxBudgetIR {
if (!record(value) || value.schemaVersion !== SCRIPT_SANDBOX_SCOPE_SCHEMA) throw new ScriptingPlatformValidationError("PROTOCOL_MISMATCH", "Unsupported script sandbox budget schema");
return {
schemaVersion: SCRIPT_SANDBOX_SCOPE_SCHEMA,
cpuMs: integer(value.cpuMs, "sandbox.cpuMs", 1, SCRIPT_SANDBOX_BUDGET.maxCpuMs),
wallMs: integer(value.wallMs, "sandbox.wallMs", 1, SCRIPT_SANDBOX_BUDGET.maxWallMs),
memoryBytes: integer(value.memoryBytes, "sandbox.memoryBytes", 1, SCRIPT_SANDBOX_BUDGET.maxMemoryBytes),
maxMessageBytes: integer(value.maxMessageBytes, "sandbox.maxMessageBytes", 1, SCRIPT_SANDBOX_BUDGET.maxMessageBytes),
maxOutputBytes: integer(value.maxOutputBytes, "sandbox.maxOutputBytes", 1, SCRIPT_SANDBOX_BUDGET.maxOutputBytes),
};
}
export function parseScriptHostCall(value: unknown, declaredPermissions: ReadonlySet<ScriptPermission>): ScriptHostCallIR {
if (!record(value) || value.schemaVersion !== SCRIPT_HOST_CALL_SCHEMA) throw new ScriptingPlatformValidationError("PROTOCOL_MISMATCH", "Unsupported script host call schema");
const requestId = auditRequestId(value.requestId, "hostCall.requestId");
const scriptId = text(value.scriptId, "hostCall.scriptId");
if (!SCRIPT_HOST_CALLS.includes(value.call as ScriptHostCallName)) throw new ScriptingPlatformValidationError("SCRIPT_POLICY_DENIED", "Host call is not allowlisted");
const call = value.call as ScriptHostCallName;
if (value.permission !== call || !declaredPermissions.has(call)) throw new ScriptingPlatformValidationError("SCRIPT_POLICY_DENIED", "Host call permission is not declared");
if (!record(value.parameters)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", "Host call parameters must be a structured object");
const parameters = value.parameters;
const keys = Object.keys(parameters).sort();
const exact = (expected: string[]): void => { if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", "Host call parameters contain unknown fields"); };
let normalized: ScriptHostCallParameters;
if (call === "READ_MAIN") { exact(["revision"]); normalized = { revision: integer(parameters.revision, "hostCall.parameters.revision", 0, Number.MAX_SAFE_INTEGER) }; }
else if (call === "READ_ASSET") { exact(["expectedSha256", "path"]); normalized = { path: path(parameters.path, "hostCall.parameters.path"), expectedSha256: digest(parameters.expectedSha256, "hostCall.parameters.expectedSha256") }; }
else if (call === "WRITE_MAIN") { exact(["operation", "payload", "revision"]); if (!record(parameters.payload)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", "hostCall.parameters.payload must be an object"); normalized = { revision: integer(parameters.revision, "hostCall.parameters.revision", 0, Number.MAX_SAFE_INTEGER), operation: text(parameters.operation, "hostCall.parameters.operation", 128), payload: { ...parameters.payload } }; }
else if (call === "WRITE_ASSET") { exact(["byteLength", "path", "sha256"]); normalized = { path: path(parameters.path, "hostCall.parameters.path"), byteLength: integer(parameters.byteLength, "hostCall.parameters.byteLength", 0, SCRIPT_SANDBOX_BUDGET.maxOutputBytes), sha256: digest(parameters.sha256, "hostCall.parameters.sha256") }; }
else { exact(["inputBlendSha256", "settingsSha256"]); normalized = { inputBlendSha256: digest(parameters.inputBlendSha256, "hostCall.parameters.inputBlendSha256"), settingsSha256: digest(parameters.settingsSha256, "hostCall.parameters.settingsSha256") }; }
return { schemaVersion: SCRIPT_HOST_CALL_SCHEMA, requestId, scriptId, call, permission: call, parameters: normalized, execution: "DISABLED" };
}
export function terminateScriptSandboxJob(value: unknown, reason: "CRASH" | "TIMEOUT" | "CANCEL"): ScriptSandboxJobIR {
if (!record(value) || value.schemaVersion !== SCRIPT_SANDBOX_JOB_SCHEMA) throw new ScriptingPlatformValidationError("PROTOCOL_MISMATCH", "Unsupported script sandbox job schema");
const jobId = auditRequestId(value.jobId, "sandbox.jobId");
const workerGeneration = integer(value.workerGeneration, "sandbox.workerGeneration", 1, Number.MAX_SAFE_INTEGER);
const baseRevision = integer(value.baseRevision, "sandbox.baseRevision", 0, Number.MAX_SAFE_INTEGER);
const mainRevisionBefore = integer(value.mainRevisionBefore, "sandbox.mainRevisionBefore", 0, Number.MAX_SAFE_INTEGER);
if (baseRevision !== mainRevisionBefore || value.status !== "RUNNING") throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", "Sandbox termination must start from the current running Main revision");
const errorCode = reason === "CRASH" ? "SCRIPT_SANDBOX_CRASHED" : reason === "TIMEOUT" ? "SCRIPT_SANDBOX_TIMEOUT" : "SCRIPT_SANDBOX_CANCELLED";
return { schemaVersion: SCRIPT_SANDBOX_JOB_SCHEMA, jobId, workerGeneration, baseRevision, mainRevisionBefore, mainRevisionAfter: mainRevisionBefore, status: reason === "CRASH" ? "CRASHED" : reason === "TIMEOUT" ? "TIMED_OUT" : "CANCELLED", errorCode, temporaryBytes: 0, publishedResults: 0, lateResults: 0, committed: false, execution: "DISABLED" };
}
export function rejectLateScriptSandboxResult(value: unknown): never {
if (!record(value) || value.schemaVersion !== SCRIPT_SANDBOX_JOB_SCHEMA || !["CRASHED", "TIMED_OUT", "CANCELLED"].includes(value.status as string)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", "Late sandbox result does not reference a terminated job");
throw new ScriptingPlatformValidationError("SCRIPT_SANDBOX_LATE_RESULT", "Sandbox result arrived after job termination");
}
export function gateServerScriptJob(value: unknown, manifest: unknown, inputBlendSha256: string): CapabilityGateResult {
const parsed = parseScriptingManifest(manifest); if (!record(value)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", "Server script job is invalid"); const script = parsed.scripts.find((item) => item.id === value.scriptId); if (!script || script.sourceSha256 !== value.sourceSha256 || !SHA256.test(inputBlendSha256)) throw new ScriptingPlatformValidationError("ASSET_SOURCE_HASH_MISMATCH", "Server script job source hash is invalid");
return blockedGate("N-025", `SERVER_SCRIPT_${script.id}`, [capabilityIssue("SERVER_JOB_UNAVAILABLE", "Server Blender job endpoint is not configured")]);

View File

@@ -0,0 +1,42 @@
import type { STLImportResult } from "./stl-import";
export const STL_EXPORT_SCHEMA_VERSION = 1 as const;
export interface STLLossReport {
schemaVersion: typeof STL_EXPORT_SCHEMA_VERSION;
operation: "STL_EXPORT_LOSS_REPORT";
canRoundTrip: boolean;
warningCount: number;
warnings: Array<{ code: "STL_MATERIAL_UNSUPPORTED"; severity: "warning"; message: string }>;
}
function writeFloat(view: DataView, offset: number, value: number): void {
if (!Number.isFinite(value)) throw new Error("STL_EXPORT_NUMBER_INVALID");
view.setFloat32(offset, value, true);
}
export function exportBinarySTL(document: STLImportResult): ArrayBuffer {
if (document.schemaVersion !== 1 || document.triangleCount !== document.vertices.length || document.triangleCount !== document.normals.length) throw new Error("STL_EXPORT_DOCUMENT_INVALID");
const output = new ArrayBuffer(84 + document.triangleCount * 50);
const bytes = new Uint8Array(output);
bytes.set(new TextEncoder().encode("Web Blender STL schema 1").subarray(0, 80));
const view = new DataView(output);
view.setUint32(80, document.triangleCount, true);
for (let triangle = 0; triangle < document.triangleCount; triangle++) {
const offset = 84 + triangle * 50;
for (let axis = 0; axis < 3; axis++) writeFloat(view, offset + axis * 4, document.normals[triangle][axis]);
for (let vertex = 0; vertex < 3; vertex++) for (let axis = 0; axis < 3; axis++) writeFloat(view, offset + 12 + vertex * 12 + axis * 4, document.vertices[triangle][vertex][axis]);
view.setUint16(offset + 48, 0, true);
}
return output;
}
export function createSTLLossReport(sourceMaterialCount: number): STLLossReport {
if (!Number.isSafeInteger(sourceMaterialCount) || sourceMaterialCount < 0) throw new Error("STL_EXPORT_MATERIAL_COUNT_INVALID");
const warnings = sourceMaterialCount > 0 ? [{
code: "STL_MATERIAL_UNSUPPORTED" as const,
severity: "warning" as const,
message: `STL has no material slots; ${sourceMaterialCount} source material assignments are omitted`,
}] : [];
return { schemaVersion: STL_EXPORT_SCHEMA_VERSION, operation: "STL_EXPORT_LOSS_REPORT", canRoundTrip: true, warningCount: warnings.length, warnings };
}

125
web/protocol/stl-import.ts Normal file
View File

@@ -0,0 +1,125 @@
export const STL_IMPORT_SCHEMA_VERSION = 1 as const;
export type STLVariant = "STL_BINARY" | "STL_ASCII";
export const STL_IMPORT_BUDGET = {
maxBytes: 512 * 1024,
maxTriangles: 65_536,
maxUnitScale: 1_000_000,
} as const;
export interface STLImportResult {
schemaVersion: typeof STL_IMPORT_SCHEMA_VERSION;
variant: STLVariant;
unitScale: number;
declaredTriangleCount: number;
triangleCount: number;
removedDegenerateTriangles: number;
normals: number[][];
vertices: number[][][];
bounds: { min: number[]; max: number[] };
}
function unitScale(value: number): number {
if (!Number.isFinite(value) || value <= 0 || value > STL_IMPORT_BUDGET.maxUnitScale) throw new Error("STL_UNIT_SCALE_INVALID");
return value;
}
function finite(values: number[], label: string): number[] {
if (values.some((value) => !Number.isFinite(value))) throw new Error(`STL_NUMBER_INVALID: ${label}`);
return values.map((value) => value === 0 ? 0 : value);
}
function degenerate(vertices: number[][]): boolean {
const left = vertices[1].map((value, index) => value - vertices[0][index]);
const right = vertices[2].map((value, index) => value - vertices[0][index]);
const cross = [left[1] * right[2] - left[2] * right[1], left[2] * right[0] - left[0] * right[2], left[0] * right[1] - left[1] * right[0]];
return cross[0] * cross[0] + cross[1] * cross[1] + cross[2] * cross[2] <= 1e-20;
}
function finish(variant: STLVariant, scale: number, declaredTriangleCount: number, normals: number[][], rawVertices: number[][][]): STLImportResult {
const keptNormals: number[][] = [];
const vertices: number[][][] = [];
let removedDegenerateTriangles = 0;
for (let index = 0; index < rawVertices.length; index++) {
if (degenerate(rawVertices[index])) {
removedDegenerateTriangles++;
continue;
}
keptNormals.push(normals[index]);
vertices.push(rawVertices[index].map((vertex) => vertex.map((value) => value * scale)));
}
const flat = vertices.flat();
const bounds = flat.length > 0 ? {
min: [0, 1, 2].map((axis) => Math.min(...flat.map((vertex) => vertex[axis]))),
max: [0, 1, 2].map((axis) => Math.max(...flat.map((vertex) => vertex[axis]))),
} : { min: [0, 0, 0], max: [0, 0, 0] };
return {
schemaVersion: STL_IMPORT_SCHEMA_VERSION,
variant,
unitScale: scale,
declaredTriangleCount,
triangleCount: vertices.length,
removedDegenerateTriangles,
normals: keptNormals,
vertices,
bounds,
};
}
function parseBinary(bytes: ArrayBuffer, scale: number): STLImportResult {
if (bytes.byteLength < 84) throw new Error("STL_BINARY_TRUNCATED");
const view = new DataView(bytes);
const count = view.getUint32(80, true);
if (count > STL_IMPORT_BUDGET.maxTriangles) throw new Error("STL_IMPORT_BUDGET_EXCEEDED: triangles");
const expectedBytes = 84 + count * 50;
if (bytes.byteLength < expectedBytes) throw new Error("STL_BINARY_TRUNCATED");
if (bytes.byteLength > expectedBytes) throw new Error("STL_TRAILING_BYTES");
const normals: number[][] = [];
const vertices: number[][][] = [];
for (let triangle = 0; triangle < count; triangle++) {
const offset = 84 + triangle * 50;
normals.push(finite([view.getFloat32(offset, true), view.getFloat32(offset + 4, true), view.getFloat32(offset + 8, true)], `normal ${triangle}`));
const triangleVertices = [];
for (let vertex = 0; vertex < 3; vertex++) {
const vertexOffset = offset + 12 + vertex * 12;
triangleVertices.push(finite([view.getFloat32(vertexOffset, true), view.getFloat32(vertexOffset + 4, true), view.getFloat32(vertexOffset + 8, true)], `vertex ${triangle}/${vertex}`));
}
vertices.push(triangleVertices);
}
return finish("STL_BINARY", scale, count, normals, vertices);
}
function parseAscii(bytes: ArrayBuffer, scale: number): STLImportResult {
let source: string;
try { source = new TextDecoder("utf-8", { fatal: true }).decode(bytes); }
catch { throw new Error("STL_ASCII_INVALID"); }
const end = source.search(/^endsolid.*$/m);
if (!/^solid(?:\s|$)/.test(source) || end < 0) throw new Error("STL_ASCII_INVALID");
const endLine = source.indexOf("\n", end);
const trailing = source.slice(endLine < 0 ? source.length : endLine + 1);
if (trailing.trim()) throw new Error("STL_TRAILING_BYTES");
const facetPattern = /facet\s+normal\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+outer\s+loop\s+vertex\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+vertex\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+vertex\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+endloop\s+endfacet/g;
const normals: number[][] = [];
const vertices: number[][][] = [];
let match: RegExpExecArray | null;
while ((match = facetPattern.exec(source.slice(0, end)))) {
normals.push(finite(match.slice(1, 4).map(Number), `normal ${normals.length}`));
vertices.push([
finite(match.slice(4, 7).map(Number), `vertex ${vertices.length}/0`),
finite(match.slice(7, 10).map(Number), `vertex ${vertices.length}/1`),
finite(match.slice(10, 13).map(Number), `vertex ${vertices.length}/2`),
]);
if (vertices.length > STL_IMPORT_BUDGET.maxTriangles) throw new Error("STL_IMPORT_BUDGET_EXCEEDED: triangles");
}
if (vertices.length === 0) throw new Error("STL_ASCII_INVALID");
return finish("STL_ASCII", scale, vertices.length, normals, vertices);
}
export function importSTL(bytes: ArrayBuffer, options: { variant: STLVariant; unitScale: number }): STLImportResult {
if (bytes.byteLength > STL_IMPORT_BUDGET.maxBytes) throw new Error("STL_IMPORT_BUDGET_EXCEEDED: bytes");
const scale = unitScale(options.unitScale);
if (options.variant === "STL_BINARY") return parseBinary(bytes, scale);
if (options.variant === "STL_ASCII") return parseAscii(bytes, scale);
throw new Error("STL_VARIANT_REQUIRED");
}

View File

@@ -257,7 +257,7 @@ export interface StorageRequest {
| { type: "saveSnapshot"; projectId: string; revision: number; buffer: ArrayBuffer; maxCount?: number; maxBytes?: number }
| { type: "listSnapshots"; projectId: string }
| { type: "readSnapshot"; projectId: string; revision: number }
| { type: "putAsset"; projectId: string; data: ArrayBuffer; mimeType: string; sourcePath?: string }
| { type: "putAsset"; projectId: string; data: ArrayBuffer; mimeType: string; sourcePath?: string; faultAt?: "quota" }
| { type: "readAsset"; projectId: string; sha256: string }
| { type: "listAssets"; projectId: string }
| { type: "commitTexturePaintTile"; commit: TexturePaintTileCommitIR }

View File

@@ -0,0 +1,49 @@
export const VIEWPORT_DPR_SCHEMA_VERSION = 1 as const;
export const VIEWPORT_MAX_DPR = 2 as const;
export interface ViewportPixelMetrics {
schemaVersion: typeof VIEWPORT_DPR_SCHEMA_VERSION;
cssWidth: number;
cssHeight: number;
pixelRatio: number;
backingWidth: number;
backingHeight: number;
}
export interface ViewportNDC {
x: number;
y: number;
}
function finitePositive(value: number): boolean {
return Number.isFinite(value) && value > 0;
}
export function resolveViewportPixelRatio(devicePixelRatio: number | undefined, maximum = VIEWPORT_MAX_DPR): number {
if (!finitePositive(maximum)) throw new Error("VIEWPORT_DPR_INVALID");
const observed = finitePositive(devicePixelRatio ?? 1) ? devicePixelRatio! : 1;
return Math.min(observed, maximum);
}
export function resolveViewportPixelMetrics(cssWidth: number, cssHeight: number, devicePixelRatio: number | undefined, maximum = VIEWPORT_MAX_DPR): ViewportPixelMetrics {
if (!finitePositive(cssWidth) || !finitePositive(cssHeight)) throw new Error("VIEWPORT_SIZE_INVALID");
const pixelRatio = resolveViewportPixelRatio(devicePixelRatio, maximum);
return {
schemaVersion: VIEWPORT_DPR_SCHEMA_VERSION,
cssWidth,
cssHeight,
pixelRatio,
backingWidth: Math.max(1, Math.floor(cssWidth * pixelRatio)),
backingHeight: Math.max(1, Math.floor(cssHeight * pixelRatio)),
};
}
export function viewportNDC(clientX: number, clientY: number, bounds: { left: number; top: number; width: number; height: number }): ViewportNDC {
if (![clientX, clientY, bounds.left, bounds.top, bounds.width, bounds.height].every(Number.isFinite) || bounds.width <= 0 || bounds.height <= 0) {
throw new Error("VIEWPORT_BOUNDS_INVALID");
}
return {
x: ((clientX - bounds.left) / bounds.width) * 2 - 1,
y: -((clientY - bounds.top) / bounds.height) * 2 + 1,
};
}