Checkpoint web parity through Chromium input tasks
This commit is contained in:
97
web/protocol/archive-conflicts.ts
Normal file
97
web/protocol/archive-conflicts.ts
Normal 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 };
|
||||
}
|
||||
50
web/protocol/archive-extraction-recovery.ts
Normal file
50
web/protocol/archive-extraction-recovery.ts
Normal 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";
|
||||
258
web/protocol/archive-extraction-transaction.ts
Normal file
258
web/protocol/archive-extraction-transaction.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
160
web/protocol/archive-link-safety.ts
Normal file
160
web/protocol/archive-link-safety.ts
Normal 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 };
|
||||
}
|
||||
135
web/protocol/archive-metadata-first.ts
Normal file
135
web/protocol/archive-metadata-first.ts
Normal 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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
69
web/protocol/device-budget.ts
Normal file
69
web/protocol/device-budget.ts
Normal 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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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));
|
||||
|
||||
63
web/protocol/glb-loss-report.ts
Normal file
63
web/protocol/glb-loss-report.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
134
web/protocol/glb-recovery.ts
Normal file
134
web/protocol/glb-recovery.ts
Normal 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 };
|
||||
}
|
||||
34
web/protocol/ime-composition.ts
Normal file
34
web/protocol/ime-composition.ts
Normal 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;
|
||||
}
|
||||
41
web/protocol/input-modal.ts
Normal file
41
web/protocol/input-modal.ts
Normal 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 };
|
||||
}
|
||||
135
web/protocol/io-format-capability-matrix.ts
Normal file
135
web/protocol/io-format-capability-matrix.ts
Normal 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 };
|
||||
}
|
||||
115
web/protocol/io-format-receipt-binding.ts
Normal file
115
web/protocol/io-format-receipt-binding.ts
Normal 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;
|
||||
}
|
||||
185
web/protocol/io-format-receipt-freshness.ts
Normal file
185
web/protocol/io-format-receipt-freshness.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
73
web/protocol/io-format-recovery.ts
Normal file
73
web/protocol/io-format-recovery.ts
Normal 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 };
|
||||
}
|
||||
167
web/protocol/io-format-runtime-receipt.ts
Normal file
167
web/protocol/io-format-runtime-receipt.ts
Normal 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 };
|
||||
}
|
||||
149
web/protocol/io-format-ui-gate.ts
Normal file
149
web/protocol/io-format-ui-gate.ts
Normal 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(",");
|
||||
}
|
||||
34
web/protocol/keyboard-contract.ts
Normal file
34
web/protocol/keyboard-contract.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
228
web/protocol/library-linked-missing.ts
Normal file
228
web/protocol/library-linked-missing.ts
Normal 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] } })) } };
|
||||
}
|
||||
91
web/protocol/library-linked-mutation.ts
Normal file
91
web/protocol/library-linked-mutation.ts
Normal 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)]);
|
||||
}
|
||||
222
web/protocol/library-linked-reload.ts
Normal file
222
web/protocol/library-linked-reload.ts
Normal 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 })),
|
||||
})) },
|
||||
};
|
||||
}
|
||||
168
web/protocol/library-negative-cases.ts
Normal file
168
web/protocol/library-negative-cases.ts
Normal 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" };
|
||||
}
|
||||
165
web/protocol/library-override-freshness.ts
Normal file
165
web/protocol/library-override-freshness.ts
Normal 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 };
|
||||
}
|
||||
156
web/protocol/library-override-writer.ts
Normal file
156
web/protocol/library-override-writer.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
148
web/protocol/library-source-origin.ts
Normal file
148
web/protocol/library-source-origin.ts
Normal 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
219
web/protocol/obj-import.ts
Normal 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
298
web/protocol/ply-import.ts
Normal 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;
|
||||
}
|
||||
35
web/protocol/pointer-contract.ts
Normal file
35
web/protocol/pointer-contract.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
@@ -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")]);
|
||||
|
||||
42
web/protocol/stl-export.ts
Normal file
42
web/protocol/stl-export.ts
Normal 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
125
web/protocol/stl-import.ts
Normal 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");
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
49
web/protocol/viewport-dpr.ts
Normal file
49
web/protocol/viewport-dpr.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user