Checkpoint web parity through Chromium input tasks
Some checks are pending
M6 deployable RC / quick (push) Waiting to run
M6 deployable RC / chromium (push) Blocked by required conditions
M6 deployable RC / release (push) Blocked by required conditions

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

View File

@@ -50,6 +50,8 @@ import {
} from "../volume/nanovdb-viewport";
import { composePaintColorPatch, composePaintWeightPatch } from "../../../protocol/paint";
import { createDefaultWebWorkspaceState, reduceUICommand, type EditorType, type UICommand, type WorkspaceId } from "../../../protocol/ui-schema";
import { createIMECompositionState, reduceIMEComposition, shouldBlockOperatorShortcuts, type IMECompositionEvent } from "../../../protocol/ime-composition";
import { observeKeyboardEvent, type KeyboardObservation } from "../../../protocol/keyboard-contract";
import { createInitialUserActionStates, reduceUserActionStates, type UserActionIdentity, type UserActionKind } from "../../../protocol/user-action-state";
import { acquireProjectAction, createProjectActionMutexState, releaseProjectAction, type ProjectActionConflict, type ProjectActionIdentity } from "../../../protocol/project-action-mutex";
import { DEFAULT_FILE_READ_YIELD_BYTES, FileByteReadError, readFileBytes, type FileReadPhase } from "../../../protocol/file-byte-reader";
@@ -58,8 +60,17 @@ import { acceptHistoryTransaction, acceptMainSave, acceptMainTransaction, create
import type { WorkerFault } from "../../../protocol/worker-fault";
import { normalizeRecentProjects, RECENT_PROJECTS_SCHEMA_VERSION, type RecentProjectBackend, type RecentProjectIssue, type RecentProjectRecord } from "../../../protocol/recent-projects";
import { appendAppDiagnostic, createAppDiagnosticEntry, createAppDiagnosticReport, type AppDiagnosticArea, type AppDiagnosticCode, type AppDiagnosticContextValue, type AppDiagnosticEntry } from "../../../protocol/diagnostic-report";
import ioFormatUIRegistryJSON from "../capabilities/io-format-ui-registry.json";
import { filterIOFormatOperatorCommands, gateIOFormatFileSelection, ioFormatUIAccept, parseIOFormatUIRegistry, type IOFormatUICommandRef } from "../../../protocol/io-format-ui-gate";
import ioFormatRuntimeReceiptFreshnessJSON from "../capabilities/io-format-runtime-receipts-freshness.json";
import ioFormatRuntimeReceiptFreshnessExpectedJSON from "../capabilities/io-format-runtime-receipts-freshness-expected.json";
import { parseIOFormatReceiptFreshness, resolveFreshIOFormatRuntimeRoute, type IOFormatReceiptFreshnessExpectedIR } from "../../../protocol/io-format-receipt-freshness";
import "./app-shell.css";
const IO_FORMAT_UI_REGISTRY = parseIOFormatUIRegistry(ioFormatUIRegistryJSON);
const IO_FORMAT_RUNTIME_RECEIPTS = parseIOFormatReceiptFreshness(ioFormatRuntimeReceiptFreshnessJSON);
const IO_FORMAT_RUNTIME_RECEIPT_EXPECTED = ioFormatRuntimeReceiptFreshnessExpectedJSON as unknown as IOFormatReceiptFreshnessExpectedIR;
function recentProjectIssueMessage(code: RecentProjectIssue["code"]): string {
if (code === "MISSING") return "项目内容缺失";
if (code === "HASH_MISMATCH") return "项目内容校验失败";
@@ -948,6 +959,7 @@ interface OperatorCommand {
id: string;
label: string;
keywords: string;
ioFormat?: IOFormatUICommandRef;
execute: () => void;
}
@@ -1047,6 +1059,8 @@ export function App() {
const [wasmStatus, setWasmStatus] = useState("WASM ABI: starting");
const [storageStatus, setStorageStatus] = useState("Storage: starting");
const [manifestStatus, setManifestStatus] = useState("Manifest: checking");
const [imeComposition, setIMEComposition] = useState(createIMECompositionState);
const [keyboardObservation, setKeyboardObservation] = useState<KeyboardObservation | null>(null);
const [snapshot, setSnapshot] = useState<SceneSnapshotIR | null>(null);
const [selectedObjectIds, setSelectedObjectIds] = useState<Set<string>>(() => new Set());
const [meshSelection, setMeshSelection] = useState<MeshEditSelection>({ meshId: null, mode: "FACE", indices: new Set() });
@@ -1070,6 +1084,7 @@ export function App() {
const [recentProjectIssues, setRecentProjectIssues] = useState<RecentProjectIssue[]>([]);
const [storageBudget, setStorageBudget] = useState<StorageBudgetResult | null>(null);
const [diagnostics, setDiagnostics] = useState<AppDiagnosticEntry[]>([]);
const imeCompositionRef = useRef(imeComposition);
const webClientRef = useRef<WebEngineClient | null>(null);
const storageClientRef = useRef<StorageClient | null>(null);
const autosaveRef = useRef<AutosaveScheduler | null>(null);
@@ -1142,6 +1157,22 @@ export function App() {
dispatchUI({ type: "toggleMenu", menu: menu ?? undefined });
window.requestAnimationFrame(() => { if (menu) menuTriggerRefs.current[menu]?.focus(); });
};
useEffect(() => {
const handleComposition = (event: CompositionEvent): void => {
const type = event.type as IMECompositionEvent["type"];
const next = reduceIMEComposition(imeCompositionRef.current, { type, data: event.data });
imeCompositionRef.current = next;
setIMEComposition(next);
};
window.addEventListener("compositionstart", handleComposition);
window.addEventListener("compositionupdate", handleComposition);
window.addEventListener("compositionend", handleComposition);
return () => {
window.removeEventListener("compositionstart", handleComposition);
window.removeEventListener("compositionupdate", handleComposition);
window.removeEventListener("compositionend", handleComposition);
};
}, []);
const nextUserActionIdentity = <Kind extends UserActionKind>(kind: Kind): UserActionIdentity & { kind: Kind } => (
{ kind, actionId: `${kind}:${++userActionSequenceRef.current}` }
);
@@ -1676,6 +1707,12 @@ export function App() {
const onKeyDown = (event: KeyboardEvent): void => {
const target = event.target as HTMLElement | null;
const interactiveTarget = target?.closest("button, a, input, textarea, select, option, [contenteditable='true'], [role='button'], [role='menuitem'], [role='tab']");
try { setKeyboardObservation(observeKeyboardEvent(event)); }
catch { setKeyboardObservation(null); }
if (shouldBlockOperatorShortcuts(imeCompositionRef.current, event.isComposing)) {
if (!interactiveTarget) event.preventDefault();
return;
}
if (event.key === "F3") {
if (interactiveTarget && target?.matches("input, textarea, select, [contenteditable='true']")) return;
event.preventDefault();
@@ -2312,6 +2349,12 @@ export function App() {
};
const reportGLBExport = async (): Promise<void> => {
const identity = beginUserAction("EXPORT");
const runtimeRoute = resolveFreshIOFormatRuntimeRoute(IO_FORMAT_RUNTIME_RECEIPTS, IO_FORMAT_RUNTIME_RECEIPT_EXPECTED, { format: "GLB", operation: "EXPORT" });
if (runtimeRoute.status !== "READY") {
failUserAction(identity, "EXPORT_BLOCKED");
setEngineStatus(recordDiagnostic("EXPORT", "IO_FORMAT_UNSUPPORTED", runtimeRoute, { format: "GLB", operation: "EXPORT" }));
return;
}
if (!snapshot) {
failUserAction(identity, "EXPORT_PROJECT_UNAVAILABLE");
setEngineStatus(recordDiagnostic("EXPORT", "GLB_PROJECT_UNAVAILABLE", { code: "EXPORT_PROJECT_UNAVAILABLE", message: "No SceneIR snapshot is open" }));
@@ -2384,7 +2427,7 @@ export function App() {
if (workerFaultTestMode === "storage") storageClientRef.current?.crashForTest();
else webClientRef.current?.crashForTest();
};
const operatorCommands: OperatorCommand[] = [
const operatorCommands = filterIOFormatOperatorCommands<OperatorCommand>([
...(["Layout", "Modeling", "Animation"] as WorkspaceId[]).filter((id) => id !== workspace).map((id) => ({ id: `workspace.${id}`, label: `Switch to ${id}`, keywords: "workspace", execute: () => dispatchUI({ type: "switchWorkspace", workspaceId: id }) })),
{ id: "mode.toggle", label: uiState.context.mode === "Object" ? "Enter Edit Mode" : "Exit Edit Mode", keywords: "mode tab", execute: () => dispatchUI({ type: "setMode", mode: uiState.context.mode === "Object" ? "Edit" : "Object" }) },
...(snapshot && uiState.context.mode === "Object" ? [{ id: "object.add-cube", label: "Add Cube", keywords: "object primitive mesh", execute: () => { void applyEditCommand({ type: "createPrimitive", primitive: "CUBE", location: [0, 0, 0] }); } }] : []),
@@ -2394,11 +2437,12 @@ export function App() {
{ id: "edit.redo", label: "Redo", keywords: "history", execute: () => { void applyEditCommand({ type: "redo" }); } },
{ id: "file.save", label: "Save Project", keywords: "file blend", execute: () => { void saveBlend(); } },
{ id: "file.close", label: "Close Project", keywords: "file", execute: closeProject },
{ id: "file.export-glb", label: "Export GLB", keywords: "file export glb", ioFormat: { format: "GLB", operation: "EXPORT", execution: "LOCAL" } as const, execute: () => { void reportGLBExport(); } },
] : []),
];
], IO_FORMAT_UI_REGISTRY).filter((command) => !command.ioFormat || resolveFreshIOFormatRuntimeRoute(IO_FORMAT_RUNTIME_RECEIPTS, IO_FORMAT_RUNTIME_RECEIPT_EXPECTED, { format: command.ioFormat.format, operation: command.ioFormat.operation }).status === "READY");
return (
<main className="blender-app" data-workspace={workspace} data-ui-revision={uiState.context.revision}
<main className="blender-app" data-workspace={workspace} data-ime-composing={imeComposition.composing ? "true" : "false"} data-ime-revision={imeComposition.revision} data-ime-last-event={imeComposition.lastEvent} data-key-key={keyboardObservation?.key} data-key-code={keyboardObservation?.code} data-key-location={keyboardObservation?.location} data-key-modifiers={`${keyboardObservation?.shiftKey ? "S" : ""}${keyboardObservation?.ctrlKey ? "C" : ""}${keyboardObservation?.altKey ? "A" : ""}${keyboardObservation?.metaKey ? "M" : ""}`} data-key-dead={keyboardObservation?.deadKey ? "true" : "false"} data-ui-revision={uiState.context.revision}
data-user-action-import-status={userActions.IMPORT.status} data-user-action-import-id={userActions.IMPORT.identity?.actionId} data-user-action-import-error={userActions.IMPORT.errorCode ?? undefined}
data-user-action-open-status={userActions.OPEN.status} data-user-action-open-id={userActions.OPEN.identity?.actionId} data-user-action-open-error={userActions.OPEN.errorCode ?? undefined}
data-user-action-save-status={userActions.SAVE.status} data-user-action-save-id={userActions.SAVE.identity?.actionId} data-user-action-save-error={userActions.SAVE.errorCode ?? undefined}
@@ -2437,7 +2481,7 @@ export function App() {
{(["Layout", "Modeling", "Animation"] as WorkspaceId[]).map((item) => <button key={item} className={item === workspace ? "workspace-tab active" : "workspace-tab"} type="button" aria-current={item === workspace ? "page" : undefined} onClick={() => dispatchUI({ type: "switchWorkspace", workspaceId: item })}>{item}</button>)}
</nav>
<div className="topbar-actions"><select aria-label="最近项目" data-testid="recent-projects" defaultValue="" onChange={(event) => { const projectId = event.target.value; const displayName = event.target.selectedOptions[0]?.textContent ?? projectId; if (!projectId) return; void recoverCachedProject(projectId, displayName); event.currentTarget.value = ""; }}><option value=""> ({recentProjects.length})</option>{recentProjects.map((project) => <option key={project.projectId} value={project.projectId}>{project.displayName}</option>)}</select><button type="button" aria-label="打开 .blend" onClick={() => fileInputRef.current?.click()}></button><button type="button" aria-label="关闭项目" disabled={!snapshot} onClick={closeProject}></button><button type="button" aria-label="恢复项目" onClick={() => void recoverCachedProject()}></button><button type="button" aria-label="保存项目" onClick={() => void saveBlend()}></button><button type="button" aria-label="导出 GLB" onClick={() => void reportGLBExport()}>GLB</button><button type="button" aria-label="导出诊断报告" data-testid="export-diagnostics" disabled={diagnostics.length === 0} onClick={exportDiagnosticReport}></button><button type="button" aria-label="撤销" onClick={() => void applyEditCommand({ type: "undo" })}></button><button type="button" aria-label="重做" onClick={() => void applyEditCommand({ type: "redo" })}></button><button ref={operatorSearchTriggerRef} type="button" aria-label="操作搜索" aria-haspopup="dialog" onClick={() => dispatchUI({ type: "toggleOperatorSearch", open: true })}>F3</button>{workerFaultTestMode ? <button type="button" data-testid="inject-worker-crash" aria-label="注入 Worker 崩溃" onClick={injectWorkerCrash}>Fault</button> : null}</div>
<input ref={fileInputRef} className="file-input-hidden" type="file" accept=".blend,application/octet-stream" aria-label="打开 .blend 文件" data-testid="blend-file-input" onChange={(event) => { const file = event.target.files?.[0]; if (file) void openBlendFile(file); event.target.value = ""; }} />
<input ref={fileInputRef} className="file-input-hidden" type="file" accept={ioFormatUIAccept(IO_FORMAT_UI_REGISTRY)} aria-label="打开 .blend 文件" data-testid="blend-file-input" data-io-format-import-routes={IO_FORMAT_UI_REGISTRY.importRoutes.map((route) => route.format).join(",")} onChange={(event) => { const file = event.target.files?.[0]; if (file) { const gate = gateIOFormatFileSelection(file.name, IO_FORMAT_UI_REGISTRY); if (gate.status === "BLOCKED") setEngineStatus(recordDiagnostic("ENGINE", "IO_FORMAT_UNSUPPORTED", gate, { fileName: file.name })); else void openBlendFile(file); } event.target.value = ""; }} />
</header>
{workerFault ? <div className="worker-fault-banner" role="alert" data-testid="worker-fault-banner"><span>{workerFault.source === "engine" ? "Engine Worker" : "Storage Worker"} stopped; current project list and scene are retained.</span><button type="button" data-testid="restart-and-recover" onClick={() => void restartWorkersAndRecover()} disabled={workerRecoveryStatus === "RUNNING"}>{workerRecoveryStatus === "RUNNING" ? "恢复中..." : "重启并恢复"}</button>{workerRecoveryStatus === "FAILED" ? <span data-testid="worker-recovery-error">Recovery failed; retry is safe.</span> : null}</div> : null}
{recentProjectIssues.length > 0 ? <div className="recent-project-repair-banner" role="alert" data-testid="recent-project-repair-banner"><span> {recentProjectIssues.length} </span><div className="recent-project-repair-list">{recentProjectIssues.map((issue) => <div key={issue.project.projectId} className="recent-project-repair-item" data-project-id={issue.project.projectId} data-issue-code={issue.code}><span>{issue.project.displayName}: {recentProjectIssueMessage(issue.code)}</span><button type="button" data-testid={`remove-recent-project-${issue.project.projectId}`} aria-label={`移除失效项目引用 ${issue.project.displayName}`} onClick={() => void removeInvalidRecentProject(issue.project.projectId)}></button></div>)}</div></div> : null}

View File

@@ -0,0 +1,326 @@
{
"parentBindingSha256": "7f765bf16b62466f579b3de00751a0e012fef1c788f229c8e9675a57caa18aad",
"parentReceiptSetSha256": "f2c9a77e2cfad0ef3574b804fc06fb22972c07f55c72b0e6db2e359c4d95f57b",
"inventorySha256": "0d660b0fd8b647ebbf4e91afebd5006bd100973ab8e2507a5bfe759f477b33b4",
"boundReceiptSetSha256": "2015d719a2046f76dda3daf7c8ae7a3fc5183a499489ef06a0c33f166c6ea0b9",
"runtimeSha256": "729b46fdfea424feb258ee42ad0f3360aa433abaf31831eeb8e4f6203fa298b6",
"runtime": {
"binarySha256": "d4483926610484ef9c2ad9241aae1469f934d955ebe791f1920e263e0ba85b82",
"blenderVersion": "5.2.0 LTS",
"buildBranch": "unknown",
"buildCommitTimestamp": 0,
"buildDate": "2026-07-24",
"buildHash": "unknown",
"buildOptions": {
"alembic": false,
"io_ply": true,
"io_stl": true,
"io_wavefront_obj": true,
"usd": false
},
"buildPlatform": "Linux",
"buildTime": "08:03:47",
"buildType": "Release",
"versionTuple": [
5,
2,
0
]
},
"receiptIdentities": [
{
"format": "GLTF",
"family": "GLTF",
"operation": "IMPORT",
"operator": "import_scene.gltf",
"registered": true,
"rnaIdentifier": "IMPORT_SCENE_OT_gltf",
"buildOption": null,
"buildOptionEnabled": true,
"runtimeStatus": "AVAILABLE",
"variants": [
"GLTF_SEPARATE"
],
"extensions": [
".gltf"
],
"sourceSha256": "843c7eb040189ccd7fcccfb697c4ecfd35d405add50008967b7ca5a89e4dcde7",
"settingsSha256": "01a5fbe96ab7af4bf38c35a3723a7619233299086e400ff5064dbd91e9d586e8",
"runtimeSha256": "729b46fdfea424feb258ee42ad0f3360aa433abaf31831eeb8e4f6203fa298b6"
},
{
"format": "GLTF",
"family": "GLTF",
"operation": "EXPORT",
"operator": "export_scene.gltf",
"registered": true,
"rnaIdentifier": "EXPORT_SCENE_OT_gltf",
"buildOption": null,
"buildOptionEnabled": true,
"runtimeStatus": "AVAILABLE",
"variants": [
"GLTF_SEPARATE"
],
"extensions": [
".gltf"
],
"sourceSha256": "a1c2dea067898071d6d4f0fc4f83e81df920bc572a9250ed01229d618ef15a37",
"settingsSha256": "df7db92e5ea9a4d52a81dc8092f48ca9dfda80594e500b05f3787352891962f9",
"runtimeSha256": "729b46fdfea424feb258ee42ad0f3360aa433abaf31831eeb8e4f6203fa298b6"
},
{
"format": "GLB",
"family": "GLTF",
"operation": "IMPORT",
"operator": "import_scene.gltf",
"registered": true,
"rnaIdentifier": "IMPORT_SCENE_OT_gltf",
"buildOption": null,
"buildOptionEnabled": true,
"runtimeStatus": "AVAILABLE",
"variants": [
"GLB"
],
"extensions": [
".glb"
],
"sourceSha256": "d78e336432dc578727992b8ca53368e3ac49ffdafeb1c16847fe48c54e35a079",
"settingsSha256": "b909a16f4103dfad49997c597c80ad3e71a932989f8a4594eb4f019223bd56e6",
"runtimeSha256": "729b46fdfea424feb258ee42ad0f3360aa433abaf31831eeb8e4f6203fa298b6"
},
{
"format": "GLB",
"family": "GLTF",
"operation": "EXPORT",
"operator": "export_scene.gltf",
"registered": true,
"rnaIdentifier": "EXPORT_SCENE_OT_gltf",
"buildOption": null,
"buildOptionEnabled": true,
"runtimeStatus": "AVAILABLE",
"variants": [
"GLB"
],
"extensions": [
".glb"
],
"sourceSha256": "0c2820eb9d7b82a1cd1cb208f75f263a527c58576952d4bd934cf0f7eb29ee3e",
"settingsSha256": "3b375dcb889e594e61da9d8e912037d8fa0220ea876e7465e6c37da4f5b21cee",
"runtimeSha256": "729b46fdfea424feb258ee42ad0f3360aa433abaf31831eeb8e4f6203fa298b6"
},
{
"format": "OBJ",
"family": "OBJ",
"operation": "IMPORT",
"operator": "wm.obj_import",
"registered": true,
"rnaIdentifier": "WM_OT_obj_import",
"buildOption": "io_wavefront_obj",
"buildOptionEnabled": true,
"runtimeStatus": "AVAILABLE",
"variants": [
"OBJ"
],
"extensions": [
".obj"
],
"sourceSha256": "42f9e3b35f753e43100aaea618ed306f1bf062fb7bb44af841a2e2d599449fbd",
"settingsSha256": "4e879a3018ffcf529c28fb54e09efe5f3829b501a2b001da86f9a9b4b303bccb",
"runtimeSha256": "729b46fdfea424feb258ee42ad0f3360aa433abaf31831eeb8e4f6203fa298b6"
},
{
"format": "OBJ",
"family": "OBJ",
"operation": "EXPORT",
"operator": "wm.obj_export",
"registered": true,
"rnaIdentifier": "WM_OT_obj_export",
"buildOption": "io_wavefront_obj",
"buildOptionEnabled": true,
"runtimeStatus": "AVAILABLE",
"variants": [
"OBJ"
],
"extensions": [
".obj"
],
"sourceSha256": "b4328f82639bdfefb016aec15629135ebd080e0a5696759dd670ab85168b7c60",
"settingsSha256": "f7660d5742890d2f05e8da803f5d8616233570a4f06960b85a2142efd9396d2f",
"runtimeSha256": "729b46fdfea424feb258ee42ad0f3360aa433abaf31831eeb8e4f6203fa298b6"
},
{
"format": "STL",
"family": "STL",
"operation": "IMPORT",
"operator": "wm.stl_import",
"registered": true,
"rnaIdentifier": "WM_OT_stl_import",
"buildOption": "io_stl",
"buildOptionEnabled": true,
"runtimeStatus": "AVAILABLE",
"variants": [
"STL_BINARY",
"STL_ASCII"
],
"extensions": [
".stl"
],
"sourceSha256": "f6bb7a058c246914f5f077665aab1f3d45c60b176f917b15a54522d12164c703",
"settingsSha256": "b01bd0b819aa72cf1de817b3e9bca2df03b9ceac41f639f738176973fea9accb",
"runtimeSha256": "729b46fdfea424feb258ee42ad0f3360aa433abaf31831eeb8e4f6203fa298b6"
},
{
"format": "STL",
"family": "STL",
"operation": "EXPORT",
"operator": "wm.stl_export",
"registered": true,
"rnaIdentifier": "WM_OT_stl_export",
"buildOption": "io_stl",
"buildOptionEnabled": true,
"runtimeStatus": "AVAILABLE",
"variants": [
"STL_BINARY",
"STL_ASCII"
],
"extensions": [
".stl"
],
"sourceSha256": "8be11ddd9bd68dcddc676529d30abcc236289f25ece32e137fd79bcd5ff3286d",
"settingsSha256": "5f1797ab8291fb3d84a8c1a892aa692a120a7db4ef84c9d3a9b1e78d5a6d92b7",
"runtimeSha256": "729b46fdfea424feb258ee42ad0f3360aa433abaf31831eeb8e4f6203fa298b6"
},
{
"format": "PLY",
"family": "PLY",
"operation": "IMPORT",
"operator": "wm.ply_import",
"registered": true,
"rnaIdentifier": "WM_OT_ply_import",
"buildOption": "io_ply",
"buildOptionEnabled": true,
"runtimeStatus": "AVAILABLE",
"variants": [
"PLY"
],
"extensions": [
".ply"
],
"sourceSha256": "2c8483351e293c5e0450f55902c24e3346bf95d53243ebf194fccc1efc1caa80",
"settingsSha256": "390cbc8a4843d88bd567a7f7d51b9e0d5853992bfc7a66c4a2fd335ed33d25a3",
"runtimeSha256": "729b46fdfea424feb258ee42ad0f3360aa433abaf31831eeb8e4f6203fa298b6"
},
{
"format": "PLY",
"family": "PLY",
"operation": "EXPORT",
"operator": "wm.ply_export",
"registered": true,
"rnaIdentifier": "WM_OT_ply_export",
"buildOption": "io_ply",
"buildOptionEnabled": true,
"runtimeStatus": "AVAILABLE",
"variants": [
"PLY"
],
"extensions": [
".ply"
],
"sourceSha256": "2f6cf92d5a813083b206b9c38a4442f82685b1c5740f313b90a0a7b60604a2b5",
"settingsSha256": "cd4bf21d72086001a02377cdc5c7f22856cbd1e04b261e37484260f3fc2ea6ae",
"runtimeSha256": "729b46fdfea424feb258ee42ad0f3360aa433abaf31831eeb8e4f6203fa298b6"
},
{
"format": "USD",
"family": "USD",
"operation": "IMPORT",
"operator": "wm.usd_import",
"registered": false,
"rnaIdentifier": null,
"buildOption": "usd",
"buildOptionEnabled": null,
"runtimeStatus": "OPERATOR_UNREGISTERED",
"variants": [
"USD",
"USDA",
"USDC",
"USDZ"
],
"extensions": [
".usd",
".usda",
".usdc",
".usdz"
],
"sourceSha256": "a8f79c9243ffa9ba0ba8e3e948c198fdffa727bac578269d8ec041f56cad1c5d",
"settingsSha256": "5a396eb68ddd5943df9834996b1e6b993ad7e66b019308aa318517980b518390",
"runtimeSha256": "729b46fdfea424feb258ee42ad0f3360aa433abaf31831eeb8e4f6203fa298b6"
},
{
"format": "USD",
"family": "USD",
"operation": "EXPORT",
"operator": "wm.usd_export",
"registered": false,
"rnaIdentifier": null,
"buildOption": "usd",
"buildOptionEnabled": null,
"runtimeStatus": "OPERATOR_UNREGISTERED",
"variants": [
"USD",
"USDA",
"USDC",
"USDZ"
],
"extensions": [
".usd",
".usda",
".usdc",
".usdz"
],
"sourceSha256": "cf6a9737773c27faf82cd8b3d4df84a9b671e1c873cd5041f12d70926ec62abf",
"settingsSha256": "5a396eb68ddd5943df9834996b1e6b993ad7e66b019308aa318517980b518390",
"runtimeSha256": "729b46fdfea424feb258ee42ad0f3360aa433abaf31831eeb8e4f6203fa298b6"
},
{
"format": "ALEMBIC",
"family": "ALEMBIC",
"operation": "IMPORT",
"operator": "wm.alembic_import",
"registered": false,
"rnaIdentifier": null,
"buildOption": "alembic",
"buildOptionEnabled": null,
"runtimeStatus": "OPERATOR_UNREGISTERED",
"variants": [
"ALEMBIC"
],
"extensions": [
".abc"
],
"sourceSha256": "7471c261ab8520df718399e69f6f8d73be11fb76f1717eac9a407964fc2f7ee3",
"settingsSha256": "fa2669dd3c464f8312581faf665690709dc503fdfb5662642a6e635bd2e91e55",
"runtimeSha256": "729b46fdfea424feb258ee42ad0f3360aa433abaf31831eeb8e4f6203fa298b6"
},
{
"format": "ALEMBIC",
"family": "ALEMBIC",
"operation": "EXPORT",
"operator": "wm.alembic_export",
"registered": false,
"rnaIdentifier": null,
"buildOption": "alembic",
"buildOptionEnabled": null,
"runtimeStatus": "OPERATOR_UNREGISTERED",
"variants": [
"ALEMBIC"
],
"extensions": [
".abc"
],
"sourceSha256": "8d983f6f8c5bb722d2b12c47c56115ae4bdff0a173a9fb9f4d93f083bea8e513",
"settingsSha256": "fa2669dd3c464f8312581faf665690709dc503fdfb5662642a6e635bd2e91e55",
"runtimeSha256": "729b46fdfea424feb258ee42ad0f3360aa433abaf31831eeb8e4f6203fa298b6"
}
]
}

View File

@@ -0,0 +1,332 @@
{
"schemaVersion": 1,
"task": "M12-05F",
"parentBindingSha256": "7f765bf16b62466f579b3de00751a0e012fef1c788f229c8e9675a57caa18aad",
"boundReceiptSetSha256": "2015d719a2046f76dda3daf7c8ae7a3fc5183a499489ef06a0c33f166c6ea0b9",
"runtimeSha256": "729b46fdfea424feb258ee42ad0f3360aa433abaf31831eeb8e4f6203fa298b6",
"bound": {
"schemaVersion": 1,
"task": "M12-05E",
"parentReceiptSetSha256": "f2c9a77e2cfad0ef3574b804fc06fb22972c07f55c72b0e6db2e359c4d95f57b",
"inventorySha256": "0d660b0fd8b647ebbf4e91afebd5006bd100973ab8e2507a5bfe759f477b33b4",
"runtime": {
"binarySha256": "d4483926610484ef9c2ad9241aae1469f934d955ebe791f1920e263e0ba85b82",
"blenderVersion": "5.2.0 LTS",
"buildBranch": "unknown",
"buildCommitTimestamp": 0,
"buildDate": "2026-07-24",
"buildHash": "unknown",
"buildOptions": {
"alembic": false,
"io_ply": true,
"io_stl": true,
"io_wavefront_obj": true,
"usd": false
},
"buildPlatform": "Linux",
"buildTime": "08:03:47",
"buildType": "Release",
"versionTuple": [
5,
2,
0
]
},
"receipts": [
{
"format": "GLTF",
"family": "GLTF",
"operation": "IMPORT",
"operator": "import_scene.gltf",
"registered": true,
"rnaIdentifier": "IMPORT_SCENE_OT_gltf",
"buildOption": null,
"buildOptionEnabled": true,
"runtimeStatus": "AVAILABLE",
"variants": [
"GLTF_SEPARATE"
],
"extensions": [
".gltf"
],
"sourceSha256": "843c7eb040189ccd7fcccfb697c4ecfd35d405add50008967b7ca5a89e4dcde7",
"settingsSha256": "01a5fbe96ab7af4bf38c35a3723a7619233299086e400ff5064dbd91e9d586e8",
"runtimeSha256": "729b46fdfea424feb258ee42ad0f3360aa433abaf31831eeb8e4f6203fa298b6"
},
{
"format": "GLTF",
"family": "GLTF",
"operation": "EXPORT",
"operator": "export_scene.gltf",
"registered": true,
"rnaIdentifier": "EXPORT_SCENE_OT_gltf",
"buildOption": null,
"buildOptionEnabled": true,
"runtimeStatus": "AVAILABLE",
"variants": [
"GLTF_SEPARATE"
],
"extensions": [
".gltf"
],
"sourceSha256": "a1c2dea067898071d6d4f0fc4f83e81df920bc572a9250ed01229d618ef15a37",
"settingsSha256": "df7db92e5ea9a4d52a81dc8092f48ca9dfda80594e500b05f3787352891962f9",
"runtimeSha256": "729b46fdfea424feb258ee42ad0f3360aa433abaf31831eeb8e4f6203fa298b6"
},
{
"format": "GLB",
"family": "GLTF",
"operation": "IMPORT",
"operator": "import_scene.gltf",
"registered": true,
"rnaIdentifier": "IMPORT_SCENE_OT_gltf",
"buildOption": null,
"buildOptionEnabled": true,
"runtimeStatus": "AVAILABLE",
"variants": [
"GLB"
],
"extensions": [
".glb"
],
"sourceSha256": "d78e336432dc578727992b8ca53368e3ac49ffdafeb1c16847fe48c54e35a079",
"settingsSha256": "b909a16f4103dfad49997c597c80ad3e71a932989f8a4594eb4f019223bd56e6",
"runtimeSha256": "729b46fdfea424feb258ee42ad0f3360aa433abaf31831eeb8e4f6203fa298b6"
},
{
"format": "GLB",
"family": "GLTF",
"operation": "EXPORT",
"operator": "export_scene.gltf",
"registered": true,
"rnaIdentifier": "EXPORT_SCENE_OT_gltf",
"buildOption": null,
"buildOptionEnabled": true,
"runtimeStatus": "AVAILABLE",
"variants": [
"GLB"
],
"extensions": [
".glb"
],
"sourceSha256": "0c2820eb9d7b82a1cd1cb208f75f263a527c58576952d4bd934cf0f7eb29ee3e",
"settingsSha256": "3b375dcb889e594e61da9d8e912037d8fa0220ea876e7465e6c37da4f5b21cee",
"runtimeSha256": "729b46fdfea424feb258ee42ad0f3360aa433abaf31831eeb8e4f6203fa298b6"
},
{
"format": "OBJ",
"family": "OBJ",
"operation": "IMPORT",
"operator": "wm.obj_import",
"registered": true,
"rnaIdentifier": "WM_OT_obj_import",
"buildOption": "io_wavefront_obj",
"buildOptionEnabled": true,
"runtimeStatus": "AVAILABLE",
"variants": [
"OBJ"
],
"extensions": [
".obj"
],
"sourceSha256": "42f9e3b35f753e43100aaea618ed306f1bf062fb7bb44af841a2e2d599449fbd",
"settingsSha256": "4e879a3018ffcf529c28fb54e09efe5f3829b501a2b001da86f9a9b4b303bccb",
"runtimeSha256": "729b46fdfea424feb258ee42ad0f3360aa433abaf31831eeb8e4f6203fa298b6"
},
{
"format": "OBJ",
"family": "OBJ",
"operation": "EXPORT",
"operator": "wm.obj_export",
"registered": true,
"rnaIdentifier": "WM_OT_obj_export",
"buildOption": "io_wavefront_obj",
"buildOptionEnabled": true,
"runtimeStatus": "AVAILABLE",
"variants": [
"OBJ"
],
"extensions": [
".obj"
],
"sourceSha256": "b4328f82639bdfefb016aec15629135ebd080e0a5696759dd670ab85168b7c60",
"settingsSha256": "f7660d5742890d2f05e8da803f5d8616233570a4f06960b85a2142efd9396d2f",
"runtimeSha256": "729b46fdfea424feb258ee42ad0f3360aa433abaf31831eeb8e4f6203fa298b6"
},
{
"format": "STL",
"family": "STL",
"operation": "IMPORT",
"operator": "wm.stl_import",
"registered": true,
"rnaIdentifier": "WM_OT_stl_import",
"buildOption": "io_stl",
"buildOptionEnabled": true,
"runtimeStatus": "AVAILABLE",
"variants": [
"STL_BINARY",
"STL_ASCII"
],
"extensions": [
".stl"
],
"sourceSha256": "f6bb7a058c246914f5f077665aab1f3d45c60b176f917b15a54522d12164c703",
"settingsSha256": "b01bd0b819aa72cf1de817b3e9bca2df03b9ceac41f639f738176973fea9accb",
"runtimeSha256": "729b46fdfea424feb258ee42ad0f3360aa433abaf31831eeb8e4f6203fa298b6"
},
{
"format": "STL",
"family": "STL",
"operation": "EXPORT",
"operator": "wm.stl_export",
"registered": true,
"rnaIdentifier": "WM_OT_stl_export",
"buildOption": "io_stl",
"buildOptionEnabled": true,
"runtimeStatus": "AVAILABLE",
"variants": [
"STL_BINARY",
"STL_ASCII"
],
"extensions": [
".stl"
],
"sourceSha256": "8be11ddd9bd68dcddc676529d30abcc236289f25ece32e137fd79bcd5ff3286d",
"settingsSha256": "5f1797ab8291fb3d84a8c1a892aa692a120a7db4ef84c9d3a9b1e78d5a6d92b7",
"runtimeSha256": "729b46fdfea424feb258ee42ad0f3360aa433abaf31831eeb8e4f6203fa298b6"
},
{
"format": "PLY",
"family": "PLY",
"operation": "IMPORT",
"operator": "wm.ply_import",
"registered": true,
"rnaIdentifier": "WM_OT_ply_import",
"buildOption": "io_ply",
"buildOptionEnabled": true,
"runtimeStatus": "AVAILABLE",
"variants": [
"PLY"
],
"extensions": [
".ply"
],
"sourceSha256": "2c8483351e293c5e0450f55902c24e3346bf95d53243ebf194fccc1efc1caa80",
"settingsSha256": "390cbc8a4843d88bd567a7f7d51b9e0d5853992bfc7a66c4a2fd335ed33d25a3",
"runtimeSha256": "729b46fdfea424feb258ee42ad0f3360aa433abaf31831eeb8e4f6203fa298b6"
},
{
"format": "PLY",
"family": "PLY",
"operation": "EXPORT",
"operator": "wm.ply_export",
"registered": true,
"rnaIdentifier": "WM_OT_ply_export",
"buildOption": "io_ply",
"buildOptionEnabled": true,
"runtimeStatus": "AVAILABLE",
"variants": [
"PLY"
],
"extensions": [
".ply"
],
"sourceSha256": "2f6cf92d5a813083b206b9c38a4442f82685b1c5740f313b90a0a7b60604a2b5",
"settingsSha256": "cd4bf21d72086001a02377cdc5c7f22856cbd1e04b261e37484260f3fc2ea6ae",
"runtimeSha256": "729b46fdfea424feb258ee42ad0f3360aa433abaf31831eeb8e4f6203fa298b6"
},
{
"format": "USD",
"family": "USD",
"operation": "IMPORT",
"operator": "wm.usd_import",
"registered": false,
"rnaIdentifier": null,
"buildOption": "usd",
"buildOptionEnabled": null,
"runtimeStatus": "OPERATOR_UNREGISTERED",
"variants": [
"USD",
"USDA",
"USDC",
"USDZ"
],
"extensions": [
".usd",
".usda",
".usdc",
".usdz"
],
"sourceSha256": "a8f79c9243ffa9ba0ba8e3e948c198fdffa727bac578269d8ec041f56cad1c5d",
"settingsSha256": "5a396eb68ddd5943df9834996b1e6b993ad7e66b019308aa318517980b518390",
"runtimeSha256": "729b46fdfea424feb258ee42ad0f3360aa433abaf31831eeb8e4f6203fa298b6"
},
{
"format": "USD",
"family": "USD",
"operation": "EXPORT",
"operator": "wm.usd_export",
"registered": false,
"rnaIdentifier": null,
"buildOption": "usd",
"buildOptionEnabled": null,
"runtimeStatus": "OPERATOR_UNREGISTERED",
"variants": [
"USD",
"USDA",
"USDC",
"USDZ"
],
"extensions": [
".usd",
".usda",
".usdc",
".usdz"
],
"sourceSha256": "cf6a9737773c27faf82cd8b3d4df84a9b671e1c873cd5041f12d70926ec62abf",
"settingsSha256": "5a396eb68ddd5943df9834996b1e6b993ad7e66b019308aa318517980b518390",
"runtimeSha256": "729b46fdfea424feb258ee42ad0f3360aa433abaf31831eeb8e4f6203fa298b6"
},
{
"format": "ALEMBIC",
"family": "ALEMBIC",
"operation": "IMPORT",
"operator": "wm.alembic_import",
"registered": false,
"rnaIdentifier": null,
"buildOption": "alembic",
"buildOptionEnabled": null,
"runtimeStatus": "OPERATOR_UNREGISTERED",
"variants": [
"ALEMBIC"
],
"extensions": [
".abc"
],
"sourceSha256": "7471c261ab8520df718399e69f6f8d73be11fb76f1717eac9a407964fc2f7ee3",
"settingsSha256": "fa2669dd3c464f8312581faf665690709dc503fdfb5662642a6e635bd2e91e55",
"runtimeSha256": "729b46fdfea424feb258ee42ad0f3360aa433abaf31831eeb8e4f6203fa298b6"
},
{
"format": "ALEMBIC",
"family": "ALEMBIC",
"operation": "EXPORT",
"operator": "wm.alembic_export",
"registered": false,
"rnaIdentifier": null,
"buildOption": "alembic",
"buildOptionEnabled": null,
"runtimeStatus": "OPERATOR_UNREGISTERED",
"variants": [
"ALEMBIC"
],
"extensions": [
".abc"
],
"sourceSha256": "8d983f6f8c5bb722d2b12c47c56115ae4bdff0a173a9fb9f4d93f083bea8e513",
"settingsSha256": "fa2669dd3c464f8312581faf665690709dc503fdfb5662642a6e635bd2e91e55",
"runtimeSha256": "729b46fdfea424feb258ee42ad0f3360aa433abaf31831eeb8e4f6203fa298b6"
}
]
}
}

View File

@@ -0,0 +1,282 @@
{
"schemaVersion": 1,
"task": "M12-05D",
"inventorySha256": "0d660b0fd8b647ebbf4e91afebd5006bd100973ab8e2507a5bfe759f477b33b4",
"runtime": {
"binarySha256": "d4483926610484ef9c2ad9241aae1469f934d955ebe791f1920e263e0ba85b82",
"blenderVersion": "5.2.0 LTS",
"buildBranch": "unknown",
"buildCommitTimestamp": 0,
"buildDate": "2026-07-24",
"buildHash": "unknown",
"buildOptions": {
"alembic": false,
"io_ply": true,
"io_stl": true,
"io_wavefront_obj": true,
"usd": false
},
"buildPlatform": "Linux",
"buildTime": "08:03:47",
"buildType": "Release",
"versionTuple": [
5,
2,
0
]
},
"receipts": [
{
"format": "GLTF",
"family": "GLTF",
"operation": "IMPORT",
"operator": "import_scene.gltf",
"registered": true,
"rnaIdentifier": "IMPORT_SCENE_OT_gltf",
"buildOption": null,
"buildOptionEnabled": true,
"runtimeStatus": "AVAILABLE",
"variants": [
"GLTF_SEPARATE"
],
"extensions": [
".gltf"
]
},
{
"format": "GLTF",
"family": "GLTF",
"operation": "EXPORT",
"operator": "export_scene.gltf",
"registered": true,
"rnaIdentifier": "EXPORT_SCENE_OT_gltf",
"buildOption": null,
"buildOptionEnabled": true,
"runtimeStatus": "AVAILABLE",
"variants": [
"GLTF_SEPARATE"
],
"extensions": [
".gltf"
]
},
{
"format": "GLB",
"family": "GLTF",
"operation": "IMPORT",
"operator": "import_scene.gltf",
"registered": true,
"rnaIdentifier": "IMPORT_SCENE_OT_gltf",
"buildOption": null,
"buildOptionEnabled": true,
"runtimeStatus": "AVAILABLE",
"variants": [
"GLB"
],
"extensions": [
".glb"
]
},
{
"format": "GLB",
"family": "GLTF",
"operation": "EXPORT",
"operator": "export_scene.gltf",
"registered": true,
"rnaIdentifier": "EXPORT_SCENE_OT_gltf",
"buildOption": null,
"buildOptionEnabled": true,
"runtimeStatus": "AVAILABLE",
"variants": [
"GLB"
],
"extensions": [
".glb"
]
},
{
"format": "OBJ",
"family": "OBJ",
"operation": "IMPORT",
"operator": "wm.obj_import",
"registered": true,
"rnaIdentifier": "WM_OT_obj_import",
"buildOption": "io_wavefront_obj",
"buildOptionEnabled": true,
"runtimeStatus": "AVAILABLE",
"variants": [
"OBJ"
],
"extensions": [
".obj"
]
},
{
"format": "OBJ",
"family": "OBJ",
"operation": "EXPORT",
"operator": "wm.obj_export",
"registered": true,
"rnaIdentifier": "WM_OT_obj_export",
"buildOption": "io_wavefront_obj",
"buildOptionEnabled": true,
"runtimeStatus": "AVAILABLE",
"variants": [
"OBJ"
],
"extensions": [
".obj"
]
},
{
"format": "STL",
"family": "STL",
"operation": "IMPORT",
"operator": "wm.stl_import",
"registered": true,
"rnaIdentifier": "WM_OT_stl_import",
"buildOption": "io_stl",
"buildOptionEnabled": true,
"runtimeStatus": "AVAILABLE",
"variants": [
"STL_BINARY",
"STL_ASCII"
],
"extensions": [
".stl"
]
},
{
"format": "STL",
"family": "STL",
"operation": "EXPORT",
"operator": "wm.stl_export",
"registered": true,
"rnaIdentifier": "WM_OT_stl_export",
"buildOption": "io_stl",
"buildOptionEnabled": true,
"runtimeStatus": "AVAILABLE",
"variants": [
"STL_BINARY",
"STL_ASCII"
],
"extensions": [
".stl"
]
},
{
"format": "PLY",
"family": "PLY",
"operation": "IMPORT",
"operator": "wm.ply_import",
"registered": true,
"rnaIdentifier": "WM_OT_ply_import",
"buildOption": "io_ply",
"buildOptionEnabled": true,
"runtimeStatus": "AVAILABLE",
"variants": [
"PLY"
],
"extensions": [
".ply"
]
},
{
"format": "PLY",
"family": "PLY",
"operation": "EXPORT",
"operator": "wm.ply_export",
"registered": true,
"rnaIdentifier": "WM_OT_ply_export",
"buildOption": "io_ply",
"buildOptionEnabled": true,
"runtimeStatus": "AVAILABLE",
"variants": [
"PLY"
],
"extensions": [
".ply"
]
},
{
"format": "USD",
"family": "USD",
"operation": "IMPORT",
"operator": "wm.usd_import",
"registered": false,
"rnaIdentifier": null,
"buildOption": "usd",
"buildOptionEnabled": null,
"runtimeStatus": "OPERATOR_UNREGISTERED",
"variants": [
"USD",
"USDA",
"USDC",
"USDZ"
],
"extensions": [
".usd",
".usda",
".usdc",
".usdz"
]
},
{
"format": "USD",
"family": "USD",
"operation": "EXPORT",
"operator": "wm.usd_export",
"registered": false,
"rnaIdentifier": null,
"buildOption": "usd",
"buildOptionEnabled": null,
"runtimeStatus": "OPERATOR_UNREGISTERED",
"variants": [
"USD",
"USDA",
"USDC",
"USDZ"
],
"extensions": [
".usd",
".usda",
".usdc",
".usdz"
]
},
{
"format": "ALEMBIC",
"family": "ALEMBIC",
"operation": "IMPORT",
"operator": "wm.alembic_import",
"registered": false,
"rnaIdentifier": null,
"buildOption": "alembic",
"buildOptionEnabled": null,
"runtimeStatus": "OPERATOR_UNREGISTERED",
"variants": [
"ALEMBIC"
],
"extensions": [
".abc"
]
},
{
"format": "ALEMBIC",
"family": "ALEMBIC",
"operation": "EXPORT",
"operator": "wm.alembic_export",
"registered": false,
"rnaIdentifier": null,
"buildOption": "alembic",
"buildOptionEnabled": null,
"runtimeStatus": "OPERATOR_UNREGISTERED",
"variants": [
"ALEMBIC"
],
"extensions": [
".abc"
]
}
]
}

View File

@@ -0,0 +1,17 @@
{
"schemaVersion": 1,
"task": "M12-05C",
"parentMatrixSha256": "139c9d764736da176b32414ecda840c07eb0ba5f3864da2dc08ce04bae161366",
"projectFileAccept": ".blend,application/octet-stream",
"importRoutes": [],
"exportRoutes": [
{
"format": "GLB",
"operation": "EXPORT",
"execution": "LOCAL",
"extensions": [
".glb"
]
}
]
}

View File

@@ -0,0 +1 @@
export { gateLinkedDataMutation } from "../../protocol/library-linked-mutation";

View File

@@ -0,0 +1 @@
export { applyOverrideWriter } from "../../protocol/library-override-writer";

View File

@@ -135,8 +135,8 @@ export class StorageClient {
return this.request({ type: "readSnapshot", projectId, revision }) as Promise<StorageSnapshotReadResult>;
}
putAsset(projectId: string, data: ArrayBuffer, mimeType: string, sourcePath?: string): Promise<StorageAssetPutResult> {
return this.request({ type: "putAsset", projectId, data, mimeType, sourcePath }, [data]) as Promise<StorageAssetPutResult>;
putAsset(projectId: string, data: ArrayBuffer, mimeType: string, sourcePath?: string, faultAt?: "quota"): Promise<StorageAssetPutResult> {
return this.request({ type: "putAsset", projectId, data, mimeType, sourcePath, faultAt }, [data]) as Promise<StorageAssetPutResult>;
}
readAsset(projectId: string, sha256: string): Promise<StorageAssetReadResult> {

View File

@@ -0,0 +1,7 @@
export {
GLB_RECOVERY_SCHEMA_VERSION,
beginGLBRecoveryOperation,
blockGLBRecoveryForQuota,
parseGLBRecoveryReceipt,
recoverGLBRecoveryOperation,
} from "../../../protocol/glb-recovery";

View File

@@ -0,0 +1,9 @@
export {
IO_FORMAT_RECOVERY_SCHEMA_VERSION,
beginIOFormatRecoveryOperation,
blockIOFormatRecoveryOperation,
cancelIOFormatRecoveryOperation,
commitIOFormatRecoveryOperation,
parseIOFormatRecoveryReceipt,
recoverIOFormatRecoveryOperation,
} from "../../../protocol/io-format-recovery";

View File

@@ -0,0 +1,27 @@
export const SCRIPT_SANDBOX_DISPOSE_SCHEMA = 1 as const;
export interface ScriptSandboxDisposeReceipt {
schemaVersion: typeof SCRIPT_SANDBOX_DISPOSE_SCHEMA;
disposeCount: number;
idempotent: boolean;
resources: {
messagePorts: 0;
timers: 0;
abortControllers: 0;
transferableBuffers: 0;
pendingRequests: 0;
cacheReferences: 0;
};
lateTimerMessages: 0;
}
export function createScriptSandboxDisposeReceipt(disposeCount: number): ScriptSandboxDisposeReceipt {
if (!Number.isSafeInteger(disposeCount) || disposeCount < 1) throw new Error("SCRIPT_SANDBOX_DISPOSE_INVALID: dispose count must be positive");
return {
schemaVersion: SCRIPT_SANDBOX_DISPOSE_SCHEMA,
disposeCount,
idempotent: disposeCount > 1,
resources: { messagePorts: 0, timers: 0, abortControllers: 0, transferableBuffers: 0, pendingRequests: 0, cacheReferences: 0 },
lateTimerMessages: 0,
};
}

View File

@@ -0,0 +1,56 @@
export const SCRIPT_SANDBOX_RECOVERY_SCHEMA = 1 as const;
export interface ScriptSandboxRecoveryAuditEntry {
sequence: 1 | 2;
requestId: string;
previousEntrySha256: string | null;
entrySha256: string;
sourceSha256: string;
manifestSha256: string;
}
export interface ScriptSandboxRecoveryReceipt {
schemaVersion: typeof SCRIPT_SANDBOX_RECOVERY_SCHEMA;
operation: "SCRIPT_SANDBOX_RECOVERY";
previousGeneration: number;
nextGeneration: number;
mainRevisionBefore: number;
mainRevisionAfter: number;
sourceSha256: string;
manifestSha256: string;
audit: {
entries: 2;
first: ScriptSandboxRecoveryAuditEntry;
second: ScriptSandboxRecoveryAuditEntry;
};
recovered: true;
execution: "DISABLED";
}
const SHA256 = /^[a-f0-9]{64}$/;
const REQUEST_ID = /^[-A-Za-z0-9:_./]{1,256}$/;
function assertDigest(value: string, name: string): void {
if (!SHA256.test(value)) throw new Error(`SCRIPT_SANDBOX_RECOVERY_INVALID: ${name} must be a SHA-256 digest`);
}
function assertEntry(entry: ScriptSandboxRecoveryAuditEntry, expectedSequence: 1 | 2, sourceSha256: string, manifestSha256: string): void {
if (entry.sequence !== expectedSequence || !REQUEST_ID.test(entry.requestId)) throw new Error("SCRIPT_SANDBOX_RECOVERY_INVALID: audit sequence or request id is invalid");
if (expectedSequence === 1 ? entry.previousEntrySha256 !== null : !entry.previousEntrySha256) throw new Error("SCRIPT_SANDBOX_RECOVERY_INVALID: audit previous hash is invalid");
assertDigest(entry.entrySha256, `audit[${expectedSequence}].entrySha256`);
if (entry.previousEntrySha256 !== null) assertDigest(entry.previousEntrySha256, `audit[${expectedSequence}].previousEntrySha256`);
if (entry.sourceSha256 !== sourceSha256 || entry.manifestSha256 !== manifestSha256) throw new Error("SCRIPT_SANDBOX_RECOVERY_INVALID: audit source or manifest hash drifted");
}
export function createScriptSandboxRecoveryReceipt(input: Omit<ScriptSandboxRecoveryReceipt, "schemaVersion" | "operation" | "recovered" | "execution">): ScriptSandboxRecoveryReceipt {
if (!Number.isSafeInteger(input.previousGeneration) || !Number.isSafeInteger(input.nextGeneration) || input.nextGeneration !== input.previousGeneration + 1) throw new Error("SCRIPT_SANDBOX_RECOVERY_INVALID: generation must advance once");
if (!Number.isSafeInteger(input.mainRevisionBefore) || input.mainRevisionBefore < 0 || input.mainRevisionAfter !== input.mainRevisionBefore) throw new Error("SCRIPT_SANDBOX_RECOVERY_INVALID: Main revision changed during recovery");
assertDigest(input.sourceSha256, "sourceSha256");
assertDigest(input.manifestSha256, "manifestSha256");
if (input.audit.entries !== 2) throw new Error("SCRIPT_SANDBOX_RECOVERY_INVALID: audit entry count is invalid");
assertEntry(input.audit.first, 1, input.sourceSha256, input.manifestSha256);
assertEntry(input.audit.second, 2, input.sourceSha256, input.manifestSha256);
if (input.audit.second.previousEntrySha256 !== input.audit.first.entrySha256) throw new Error("SCRIPT_SANDBOX_RECOVERY_INVALID: audit hash chain is not continuous");
if (input.audit.first.requestId === input.audit.second.requestId) throw new Error("SCRIPT_SANDBOX_RECOVERY_INVALID: audit request id is replayed");
return { schemaVersion: SCRIPT_SANDBOX_RECOVERY_SCHEMA, operation: "SCRIPT_SANDBOX_RECOVERY", ...input, recovered: true, execution: "DISABLED" };
}

View File

@@ -7,6 +7,8 @@ import { cloneMeshGeometryBuffers } from "../../../protocol/mesh-geometry-delta"
import { nonMeshChunkTransferables } from "../../../protocol/nonmesh-binary";
import type { OffscreenViewportRequest, OffscreenViewportResponse } from "./offscreen-viewport-protocol";
import { PBR_PROFILE, PBR_SHADOW_PROFILE, PBR_TONE_MAPPING } from "./pbr";
import { resolveViewportPixelMetrics, viewportNDC } from "../../../protocol/viewport-dpr";
import { observePointerEvent } from "../../../protocol/pointer-contract";
import type { NonMeshElementKind } from "./nonmesh";
import type { GreasePencilPointPreview, GreasePencilPointRef } from "./grease-pencil";
import type { CurveGizmoFrameIR, CurveGizmoHandleIR } from "../../../protocol/nonmesh-interaction";
@@ -118,7 +120,7 @@ export class OffscreenViewportRenderer implements ViewportBackend {
canvas: offscreen,
width: Math.max(1, canvas.clientWidth),
height: Math.max(1, canvas.clientHeight),
pixelRatio: Math.min(window.devicePixelRatio || 1, 2),
pixelRatio: resolveViewportPixelMetrics(1, 1, window.devicePixelRatio).pixelRatio,
};
this.worker.postMessage(request, [offscreen]);
this.resizeObserver = new ResizeObserver(() => this.resize());
@@ -237,15 +239,21 @@ export class OffscreenViewportRenderer implements ViewportBackend {
}
private resize(): void {
const metrics = resolveViewportPixelMetrics(Math.max(1, this.canvas.clientWidth), Math.max(1, this.canvas.clientHeight), window.devicePixelRatio);
this.canvas.dataset.viewportCssSize = `${metrics.cssWidth}x${metrics.cssHeight}`;
this.canvas.dataset.viewportBackingSize = `${metrics.backingWidth}x${metrics.backingHeight}`;
this.canvas.dataset.viewportPixelRatio = String(metrics.pixelRatio);
this.worker.postMessage({
type: "resize",
width: Math.max(1, this.canvas.clientWidth),
height: Math.max(1, this.canvas.clientHeight),
pixelRatio: Math.min(window.devicePixelRatio || 1, 2),
width: metrics.cssWidth,
height: metrics.cssHeight,
pixelRatio: metrics.pixelRatio,
} satisfies OffscreenViewportRequest);
}
private pointerDown = (event: PointerEvent): void => {
try { this.canvas.dataset.lastPointer = JSON.stringify(observePointerEvent(event)); }
catch { this.canvas.dataset.lastPointer = "BLOCKED"; }
this.pointer = { id: event.pointerId, x: event.clientX, y: event.clientY, moved: false };
try {
this.canvas.setPointerCapture(event.pointerId);
@@ -266,12 +274,13 @@ export class OffscreenViewportRenderer implements ViewportBackend {
};
private pointerUp = (event: PointerEvent): void => {
try { this.canvas.dataset.lastPointer = JSON.stringify(observePointerEvent(event)); }
catch { this.canvas.dataset.lastPointer = "BLOCKED"; }
if (!this.pointer || this.pointer.id !== event.pointerId) return;
if (!this.pointer.moved) {
const bounds = this.canvas.getBoundingClientRect();
const x = ((event.clientX - bounds.left) / Math.max(1, bounds.width)) * 2 - 1;
const y = -((event.clientY - bounds.top) / Math.max(1, bounds.height)) * 2 + 1;
this.worker.postMessage({ type: "pick", x, y, additive: event.shiftKey || event.ctrlKey || event.metaKey, baseSelectionRevision: this.greasePencilSelectionRevision } satisfies OffscreenViewportRequest);
const ndc = viewportNDC(event.clientX, event.clientY, bounds);
this.worker.postMessage({ type: "pick", x: ndc.x, y: ndc.y, additive: event.shiftKey || event.ctrlKey || event.metaKey, baseSelectionRevision: this.greasePencilSelectionRevision } satisfies OffscreenViewportRequest);
}
this.pointer = null;
};

View File

@@ -69,6 +69,8 @@ import {
import { VIEWPORT_DEFAULT_ORBIT, VIEWPORT_ORBIT_MAX_DISTANCE, VIEWPORT_ORBIT_MIN_DISTANCE, VIEWPORT_ORBIT_ROTATE_SENSITIVITY, VIEWPORT_ORBIT_ZOOM_SENSITIVITY, orbitPosition, orbitStateFromPosition } from "../../../protocol/viewport-camera";
import { validatePaintDepthVisibilityRequest, type PaintDepthVisibilityRequestIR, type PaintDepthVisibilityResultIR } from "../../../protocol/paint-depth-visibility";
import { samplePaintDepthVisibilityGPU } from "./paint-depth-visibility";
import { resolveViewportPixelMetrics, viewportNDC } from "../../../protocol/viewport-dpr";
import { observePointerEvent } from "../../../protocol/pointer-contract";
export function collectMeshInstanceGroups(snapshot: SceneSnapshotIR, minimumSize = 2): Map<string, string[]> {
const groups = new Map<string, string[]>();
@@ -137,7 +139,7 @@ export class ViewportRenderer {
this.raycaster.params.Points.threshold = 0.14;
this.renderer = new WebGLRenderer({ canvas, antialias: true, alpha: false, preserveDrawingBuffer: true });
configurePBRRenderer(this.renderer);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
this.renderer.setPixelRatio(resolveViewportPixelMetrics(1, 1, window.devicePixelRatio).pixelRatio);
this.renderer.setClearColor(new Color("#25272b"));
this.canvas.dataset.rendererBackend = "webgl-pbr";
this.canvas.dataset.pbrProfile = PBR_PROFILE;
@@ -170,6 +172,8 @@ export class ViewportRenderer {
this.resizeObserver = new ResizeObserver(() => this.resize());
this.resizeObserver.observe(canvas);
this.canvas.addEventListener("click", this.handleClick);
this.canvas.addEventListener("pointerdown", this.handlePointerObservation);
this.canvas.addEventListener("pointercancel", this.handlePointerObservation);
this.canvas.addEventListener("webglcontextlost", this.handleContextLost);
this.canvas.addEventListener("webglcontextrestored", this.handleContextRestored);
this.resize();
@@ -793,10 +797,14 @@ export class ViewportRenderer {
private resize(): void {
const width = Math.max(1, this.canvas.clientWidth);
const height = Math.max(1, this.canvas.clientHeight);
const metrics = resolveViewportPixelMetrics(width, height, window.devicePixelRatio);
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.controls.rotateSpeed = VIEWPORT_ORBIT_ROTATE_SENSITIVITY * height / (2 * Math.PI);
this.renderer.setSize(width, height, false);
this.canvas.dataset.viewportCssSize = `${metrics.cssWidth}x${metrics.cssHeight}`;
this.canvas.dataset.viewportBackingSize = `${metrics.backingWidth}x${metrics.backingHeight}`;
this.canvas.dataset.viewportPixelRatio = String(metrics.pixelRatio);
this.publishCameraState();
}
@@ -810,12 +818,14 @@ export class ViewportRenderer {
}
private handleClick = (event: MouseEvent): void => {
if ("pointerType" in event) {
try { this.canvas.dataset.lastPointer = JSON.stringify(observePointerEvent(event as MouseEvent & { pointerType?: string; pointerId?: number; pressure?: number; tiltX?: number; tiltY?: number; buttons?: number; type?: string })); }
catch { this.canvas.dataset.lastPointer = "BLOCKED"; }
}
const bounds = this.canvas.getBoundingClientRect();
if (bounds.width <= 0 || bounds.height <= 0) return;
this.pointer.set(
((event.clientX - bounds.left) / bounds.width) * 2 - 1,
-((event.clientY - bounds.top) / bounds.height) * 2 + 1,
);
const ndc = viewportNDC(event.clientX, event.clientY, bounds);
this.pointer.set(ndc.x, ndc.y);
this.raycaster.setFromCamera(this.pointer, this.camera);
const hits = this.raycaster.intersectObjects(this.importedRoot.children, true);
const greasePencilHit = this.editMode
@@ -886,6 +896,11 @@ export class ViewportRenderer {
if (typeof objectId === "string") this.onSelect?.(objectId, additive);
};
private handlePointerObservation = (event: PointerEvent): void => {
try { this.canvas.dataset.lastPointer = JSON.stringify(observePointerEvent(event)); }
catch { this.canvas.dataset.lastPointer = "BLOCKED"; }
};
private renderLoop = (): void => {
if (this.disposed) return;
if (!this.contextLost) {
@@ -912,7 +927,7 @@ export class ViewportRenderer {
this.contextLost = false;
this.canvas.dataset.deviceStatus = "restoring";
configurePBRRenderer(this.renderer);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
this.renderer.setPixelRatio(resolveViewportPixelMetrics(1, 1, window.devicePixelRatio).pixelRatio);
this.renderer.setClearColor(new Color("#25272b"));
this.resize();
this.volumeRenderCache.clear();
@@ -949,6 +964,8 @@ export class ViewportRenderer {
window.cancelAnimationFrame(this.animationFrame);
this.resizeObserver.disconnect();
this.canvas.removeEventListener("click", this.handleClick);
this.canvas.removeEventListener("pointerdown", this.handlePointerObservation);
this.canvas.removeEventListener("pointercancel", this.handlePointerObservation);
this.canvas.removeEventListener("webglcontextlost", this.handleContextLost);
this.canvas.removeEventListener("webglcontextrestored", this.handleContextRestored);
this.controls.dispose();

View File

@@ -0,0 +1,47 @@
import { compareGLBDesktopFixtureSemantics, importGLBDesktopFixtureSemantics } from "../../../protocol/glb-import";
interface FixtureInput {
id: string;
bytes: ArrayBuffer;
sourceSha256: string;
expected: unknown;
}
const scope = self as unknown as {
onmessage: ((event: MessageEvent<{ fixtures: FixtureInput[] }>) => void) | null;
postMessage(message: unknown): void;
};
async function sha256(bytes: ArrayBuffer): Promise<string> {
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
return Array.from(digest, (value) => value.toString(16).padStart(2, "0")).join("");
}
scope.onmessage = async (event) => {
try {
const results = [];
for (const fixture of event.data.fixtures) {
const sourceSha256 = await sha256(fixture.bytes);
if (sourceSha256 !== fixture.sourceSha256) throw new Error(`${fixture.id} source SHA-256 mismatch`);
const imported = importGLBDesktopFixtureSemantics(fixture.bytes);
const comparison = compareGLBDesktopFixtureSemantics(fixture.expected, imported);
results.push({
id: fixture.id,
sourceSha256,
compatible: comparison.compatible,
mismatches: comparison.mismatches,
topology: imported.meshes.reduce((sum, mesh) => sum + mesh.primitives.length, 0),
attributes: [...new Set(imported.meshes.flatMap((mesh) => mesh.primitives.flatMap((primitive) => Object.keys(primitive.attributes))))].sort(),
materials: imported.materials.length,
nodes: imported.nodes.length,
animations: imported.animations.length,
});
}
scope.postMessage({ ok: true, results });
}
catch (error) {
scope.postMessage({ ok: false, error: error instanceof Error ? error.message : "GLB desktop import failed" });
}
};
export {};

View File

@@ -0,0 +1,33 @@
import { exportGLB } from "../../../protocol/glb-export";
import { createGLBLossReport } from "../../../protocol/glb-loss-report";
import type { GLBAssetBuffer } from "../../../protocol/glb-export";
import type { MeshGeometryBuffer } from "../../../protocol/web-engine";
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
import type { SceneSnapshotIR } from "../../../protocol/scene-ir";
interface Request {
snapshot: SceneSnapshotIR;
geometryBuffers: MeshGeometryBuffer[];
assetBuffers: GLBAssetBuffer[];
nonMeshGeometryBuffers: NonMeshGeometryChunk[];
}
const scope = self as unknown as {
onmessage: ((event: MessageEvent<Request>) => void) | null;
postMessage(message: unknown, transfer?: Transferable[]): void;
};
scope.onmessage = (event) => {
try {
const request = event.data;
const exported = exportGLB(request.snapshot, request.geometryBuffers, request.assetBuffers, request.nonMeshGeometryBuffers);
const report = createGLBLossReport(request.snapshot, exported.report);
const output = exported.glb ?? null;
scope.postMessage({ ok: true, report, output }, output ? [output] : []);
}
catch (error) {
scope.postMessage({ ok: false, error: error instanceof Error ? error.message : "GLB loss report failed" });
}
};
export {};

View File

@@ -0,0 +1,26 @@
import { importGLBSemantics } from "../../../protocol/glb-import";
const scope = self as unknown as {
onmessage: ((event: MessageEvent<{ cases: Array<{ id: string; bytes: ArrayBuffer }> }>) => void) | null;
postMessage(message: unknown): void;
};
scope.onmessage = (event) => {
try {
const results = event.data.cases.map((candidate) => {
try {
importGLBSemantics(candidate.bytes);
return { id: candidate.id, code: "ACCEPTED" };
}
catch (error) {
return { id: candidate.id, code: error instanceof Error ? error.message.split(":", 1)[0] : "UNKNOWN" };
}
});
scope.postMessage({ ok: true, results });
}
catch (error) {
scope.postMessage({ ok: false, error: error instanceof Error ? error.message : "GLB negative worker failed" });
}
};
export {};

View File

@@ -0,0 +1,106 @@
import { exportGLB, type GLBAssetBuffer } from "../../../protocol/glb-export";
import { importGLBSemantics } from "../../../protocol/glb-import";
import {
beginGLBRecoveryOperation,
cancelGLBRecoveryOperation,
commitGLBRecoveryOperation,
type GLBRecoveryOperation,
type GLBRecoveryReceipt,
} from "../../../protocol/glb-recovery";
import type { MeshGeometryBuffer } from "../../../protocol/web-engine";
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
import type { SceneSnapshotIR } from "../../../protocol/scene-ir";
interface RunCommand {
type: "run";
requestId: string;
operation: GLBRecoveryOperation;
bytes: ArrayBuffer;
snapshot?: SceneSnapshotIR;
geometryBuffers?: MeshGeometryBuffer[];
assetBuffers?: GLBAssetBuffer[];
nonMeshGeometryBuffers?: NonMeshGeometryChunk[];
baseRevision?: number;
workerGeneration?: number;
}
interface CancelCommand { type: "cancel"; targetRequestId: string; }
const cancelled = new Set<string>();
const running = new Map<string, GLBRecoveryReceipt>();
const scope = self as unknown as {
onmessage: ((event: MessageEvent<RunCommand | CancelCommand>) => void) | null;
postMessage(message: unknown, transfer?: Transferable[]): void;
};
async function sha256Hex(value: ArrayBuffer | string): Promise<string> {
const bytes = typeof value === "string" ? new TextEncoder().encode(value) : new Uint8Array(value);
const digest = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
function yieldControl(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 2));
}
async function checkCancelled(request: RunCommand, receipt: GLBRecoveryReceipt): Promise<GLBRecoveryReceipt | undefined> {
await yieldControl();
if (!cancelled.has(request.requestId)) return undefined;
running.delete(request.requestId);
const final = cancelGLBRecoveryOperation(receipt);
scope.postMessage({ requestId: request.requestId, ok: true, receipt: final, result: null });
return final;
}
async function run(request: RunCommand): Promise<void> {
const inputSha256 = await sha256Hex(request.bytes);
let receipt = beginGLBRecoveryOperation({
operationId: request.requestId.replace(/^glb-/, "").slice(0, 96),
operation: request.operation,
workerGeneration: request.workerGeneration ?? 1,
baseRevision: request.baseRevision ?? 0,
inputBytes: request.bytes.byteLength,
inputSha256,
});
running.set(request.requestId, receipt);
try {
for (let index = 0; index < 4; index++) {
const cancelledReceipt = await checkCancelled(request, receipt);
if (cancelledReceipt) return;
}
let output: ArrayBuffer;
let outputBytes: number;
if (request.operation === "IMPORT") {
const semantics = importGLBSemantics(request.bytes);
output = new TextEncoder().encode(JSON.stringify(semantics)).buffer;
outputBytes = output.byteLength;
}
else {
if (!request.snapshot) throw new Error("GLB_RECOVERY_INVALID: export snapshot missing");
const exported = exportGLB(request.snapshot, request.geometryBuffers ?? [], request.assetBuffers ?? [], request.nonMeshGeometryBuffers ?? []);
if (!exported.glb) throw new Error("GLB_EXPORT_BLOCKED: export produced no output");
output = exported.glb;
outputBytes = output.byteLength;
}
const cancelledReceipt = await checkCancelled(request, receipt);
if (cancelledReceipt) return;
const outputSha256 = await sha256Hex(output);
receipt = commitGLBRecoveryOperation(receipt, { bytes: outputBytes, sha256: outputSha256 });
running.delete(request.requestId);
cancelled.delete(request.requestId);
scope.postMessage({ requestId: request.requestId, ok: true, receipt, result: { output, outputSha256 } }, [output]);
}
catch (error) {
running.delete(request.requestId);
cancelled.delete(request.requestId);
scope.postMessage({ requestId: request.requestId, ok: false, error: error instanceof Error ? error.message : "GLB recovery operation failed" });
}
}
scope.onmessage = (event: MessageEvent<RunCommand | CancelCommand>) => {
if (event.data.type === "cancel") {
cancelled.add(event.data.targetRequestId);
return;
}
void run(event.data);
};

View File

@@ -0,0 +1,62 @@
import { importOBJ, serializeOBJ } from "../../../protocol/obj-import";
import { exportBinarySTL } from "../../../protocol/stl-export";
import { importSTL } from "../../../protocol/stl-import";
import { importPLY, serializePLYAscii } from "../../../protocol/ply-import";
import { beginIOFormatRecoveryOperation, blockIOFormatRecoveryOperation, cancelIOFormatRecoveryOperation, commitIOFormatRecoveryOperation, type IOFormat, type IOFormatRecoveryReceipt } from "../../../protocol/io-format-recovery";
interface RunCommand { type: "run"; requestId: string; format: IOFormat; operation: "IMPORT" | "EXPORT"; bytes: ArrayBuffer; workerGeneration?: number; baseRevision?: number; }
interface CancelCommand { type: "cancel"; targetRequestId: string; }
const cancelled = new Set<string>();
const running = new Map<string, IOFormatRecoveryReceipt>();
const scope = self as unknown as { onmessage: ((event: MessageEvent<RunCommand | CancelCommand>) => void) | null; postMessage(message: unknown, transfer?: Transferable[]): void };
async function sha256Hex(bytes: ArrayBuffer): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("");
}
const pause = () => new Promise<void>((resolve) => setTimeout(resolve, 2));
async function checkCancel(request: RunCommand, receipt: IOFormatRecoveryReceipt): Promise<boolean> {
await pause();
if (!cancelled.has(request.requestId)) return false;
running.delete(request.requestId);
scope.postMessage({ requestId: request.requestId, ok: true, receipt: cancelIOFormatRecoveryOperation(receipt), result: null });
return true;
}
function outputFor(request: RunCommand): ArrayBuffer {
if (request.format === "OBJ") {
const document = importOBJ(request.bytes);
const serialized = serializeOBJ(document);
return new TextEncoder().encode(serialized.obj + serialized.mtl).buffer;
}
if (request.format === "STL") return exportBinarySTL(importSTL(request.bytes, { variant: "STL_BINARY", unitScale: 1 }));
return serializePLYAscii(importPLY(request.bytes, { format: "ascii" }));
}
async function run(request: RunCommand): Promise<void> {
const inputSha256 = await sha256Hex(request.bytes);
let receipt = beginIOFormatRecoveryOperation({ operationId: request.requestId.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 96), format: request.format, operation: request.operation, workerGeneration: request.workerGeneration ?? 1, baseRevision: request.baseRevision ?? 0, inputBytes: request.bytes.byteLength, inputSha256 });
running.set(request.requestId, receipt);
try {
for (let index = 0; index < 4; index++) if (await checkCancel(request, receipt)) return;
if (request.bytes.byteLength > 512 * 1024) {
running.delete(request.requestId); scope.postMessage({ requestId: request.requestId, ok: true, receipt: blockIOFormatRecoveryOperation(receipt), result: null }); return;
}
const output = outputFor(request);
if (await checkCancel(request, receipt)) return;
const outputSha256 = await sha256Hex(output);
receipt = commitIOFormatRecoveryOperation(receipt, { bytes: output.byteLength, sha256: outputSha256 });
running.delete(request.requestId); cancelled.delete(request.requestId);
scope.postMessage({ requestId: request.requestId, ok: true, receipt, result: { output, outputSha256 } }, [output]);
}
catch (error) {
running.delete(request.requestId); cancelled.delete(request.requestId);
if (error instanceof Error && /BUDGET_EXCEEDED|TRUNCATED|OUT_OF_RANGE/.test(error.message)) scope.postMessage({ requestId: request.requestId, ok: true, receipt: blockIOFormatRecoveryOperation(receipt), result: null });
else scope.postMessage({ requestId: request.requestId, ok: false, error: error instanceof Error ? error.message : "IO format recovery operation failed" });
}
}
scope.onmessage = (event) => { if (event.data.type === "cancel") cancelled.add(event.data.targetRequestId); else void run(event.data); };
export {};

View File

@@ -0,0 +1,26 @@
import { createOBJLossReport, importOBJ, serializeOBJ } from "../../../protocol/obj-import";
interface Request {
obj: ArrayBuffer;
mtl?: ArrayBuffer;
textureAssets?: string[];
}
const scope = self as unknown as {
onmessage: ((event: MessageEvent<Request>) => void) | null;
postMessage(message: unknown): void;
};
scope.onmessage = (event) => {
try {
const imported = importOBJ(event.data.obj, event.data.mtl);
const lossReport = createOBJLossReport(imported, event.data.textureAssets ?? []);
const serialized = serializeOBJ(imported);
scope.postMessage({ ok: true, imported, lossReport, obj: serialized.obj, mtl: serialized.mtl });
}
catch (error) {
scope.postMessage({ ok: false, error: error instanceof Error ? error.message : "OBJ round-trip failed" });
}
};
export {};

View File

@@ -0,0 +1,22 @@
import { createPLYLossReport, importPLY, serializePLYAscii } from "../../../protocol/ply-import";
interface Request { bytes: ArrayBuffer; format?: "ascii" | "binary_little_endian"; }
const scope = self as unknown as {
onmessage: ((event: MessageEvent<Request>) => void) | null;
postMessage(message: unknown, transfer?: Transferable[]): void;
};
scope.onmessage = (event) => {
try {
const imported = importPLY(event.data.bytes, { format: event.data.format });
const lossReport = createPLYLossReport(imported);
const output = serializePLYAscii(imported);
scope.postMessage({ ok: true, imported, lossReport, output }, [output]);
}
catch (error) {
scope.postMessage({ ok: false, error: error instanceof Error ? error.message : "PLY round-trip failed" });
}
};
export {};

View File

@@ -0,0 +1,25 @@
import { parseScriptHostCall, SCRIPT_PERMISSIONS } from "../../../protocol/scripting-platform";
const digest = "a".repeat(64);
const permissions = new Set(SCRIPT_PERMISSIONS);
const call = (name: string, parameters: Record<string, unknown>) => ({ schemaVersion: 1, requestId: `host:${name.toLowerCase()}`, scriptId: "clean", call: name, permission: name, parameters });
self.onmessage = () => {
const accepted = [
parseScriptHostCall(call("READ_MAIN", { revision: 3 }), permissions),
parseScriptHostCall(call("READ_ASSET", { path: "//assets/model.bin", expectedSha256: digest }), permissions),
parseScriptHostCall(call("WRITE_MAIN", { revision: 3, operation: "object.transform", payload: { objectId: "obj:1", x: 1 } }), permissions),
parseScriptHostCall(call("WRITE_ASSET", { path: "assets/out.bin", byteLength: 4, sha256: digest }), permissions),
parseScriptHostCall(call("SUBMIT_SERVER_JOB", { inputBlendSha256: digest, settingsSha256: digest }), permissions),
];
const blocked: Record<string, string> = {};
for (const [name, input] of [
["unknown", call("EXECUTE", {})],
["permission", { ...call("READ_MAIN", { revision: 3 }), permission: "WRITE_MAIN" }],
["fields", call("READ_MAIN", { revision: 3, extra: true })],
["path", call("READ_ASSET", { path: "../escape", expectedSha256: digest })],
] as const) {
try { parseScriptHostCall(input, permissions); blocked[name] = "ACCEPTED"; }
catch (error) { blocked[name] = error instanceof Error ? error.message.split(":", 1)[0] : String(error); }
}
self.postMessage({ accepted: accepted.map((item) => ({ call: item.call, permission: item.permission, execution: item.execution, parameters: item.parameters })), blocked });
};

View File

@@ -0,0 +1,17 @@
import { parseScriptingManifest, resolveScriptPermissions } from "../../../protocol/scripting-platform";
const script = (permissions: string[]) => ({ id: "clean", name: "clean", entryPath: "scripts/clean.py", sourceByteLength: 128, sourceSha256: "a".repeat(64), publisher: "Team", signature: "b".repeat(128), keyId: "key:new", permissions, dependencies: [], module: false, cpuMs: 1000, memoryBytes: 1024 * 1024, wallMs: 5000, network: false, autorun: false, driverExpressions: false, addonInstall: false });
const manifest = (permissions: string[]) => ({ schemaVersion: 1, scripts: [script(permissions)] });
const decision = (permissions: string[], requested: unknown = []) => resolveScriptPermissions(manifest(permissions), "clean", requested);
self.onmessage = () => {
let unknownDeclaration = "ACCEPTED";
try { parseScriptingManifest(manifest(["EXECUTE"])); } catch (error) { unknownDeclaration = error instanceof Error ? error.message.split(":", 1)[0] : String(error); }
self.postMessage({
defaultGrant: decision(["READ_MAIN"]),
declaredGrant: decision(["WRITE_ASSET", "READ_MAIN"], ["READ_MAIN"]),
escalation: decision(["READ_MAIN"], ["WRITE_MAIN"]),
unknownRequest: decision(["READ_MAIN"], ["EXECUTE"]),
duplicateRequest: decision(["READ_MAIN"], ["READ_MAIN", "READ_MAIN"]),
unknownDeclaration,
});
};

View File

@@ -0,0 +1,12 @@
import { parseScriptSandboxBudget, SCRIPT_SANDBOX_BUDGET } from "../../../protocol/scripting-platform";
const budget = { schemaVersion: 1, cpuMs: 1000, wallMs: 5000, memoryBytes: 1024 * 1024, maxMessageBytes: 4096, maxOutputBytes: 8192 } as const;
self.onmessage = () => {
const accepted = parseScriptSandboxBudget(budget);
const blocked: Record<string, string> = {};
for (const [field, limit] of Object.entries({ cpuMs: SCRIPT_SANDBOX_BUDGET.maxCpuMs, wallMs: SCRIPT_SANDBOX_BUDGET.maxWallMs, memoryBytes: SCRIPT_SANDBOX_BUDGET.maxMemoryBytes, maxMessageBytes: SCRIPT_SANDBOX_BUDGET.maxMessageBytes, maxOutputBytes: SCRIPT_SANDBOX_BUDGET.maxOutputBytes })) {
try { parseScriptSandboxBudget({ ...budget, [field]: limit + 1 }); blocked[field] = "ACCEPTED"; }
catch (error) { blocked[field] = error instanceof Error ? error.message.split(":", 1)[0] : String(error); }
}
self.postMessage({ accepted, blocked, execution: "DISABLED" });
};

View File

@@ -0,0 +1,17 @@
import { rejectLateScriptSandboxResult, terminateScriptSandboxJob } from "../../../protocol/scripting-platform";
interface SandboxRequest { mode: "RECEIPT" | "RUN" }
const running = { schemaVersion: 1, jobId: "sandbox:cancel", workerGeneration: 5, baseRevision: 11, mainRevisionBefore: 11, status: "RUNNING" as const };
self.onmessage = (event: MessageEvent<SandboxRequest>) => {
if (event.data?.mode === "RECEIPT") {
const cancelled = terminateScriptSandboxJob(running, "CANCEL");
let lateResult = "ACCEPTED";
try { rejectLateScriptSandboxResult(cancelled); } catch (error) { lateResult = error instanceof Error ? error.message.split(":", 1)[0] : String(error); }
self.postMessage({ type: "receipt", cancelled, lateResult });
return;
}
if (event.data?.mode === "RUN") {
setTimeout(() => self.postMessage({ type: "late-result", jobId: running.jobId, workerGeneration: running.workerGeneration, cacheKey: "sandbox-cache:cancel", payload: { revision: running.mainRevisionBefore + 1 } }), 50);
}
};

View File

@@ -0,0 +1,52 @@
import { createScriptSandboxDisposeReceipt } from "../testing/script-sandbox-dispose";
let ports: { primary: MessagePort; peer: MessagePort } | null = null;
let timer: number | undefined;
let controller: AbortController | null = null;
let buffer: ArrayBuffer | null = null;
let pendingRequests = 0;
let cacheReferences = 0;
let disposeCount = 0;
let disposed = false;
const activeResources = () => ({
messagePorts: ports === null ? 0 : 2,
timers: timer === undefined ? 0 : 1,
abortControllers: controller === null ? 0 : 1,
transferableBuffers: buffer === null ? 0 : 1,
pendingRequests,
cacheReferences,
});
self.onmessage = (event: MessageEvent<{ type: "init" | "dispose" }>) => {
if (event.data?.type === "init") {
if (disposed || ports !== null) return;
const channel = new MessageChannel();
ports = { primary: channel.port1, peer: channel.port2 };
ports.primary.start();
ports.peer.start();
controller = new AbortController();
buffer = new ArrayBuffer(64);
pendingRequests = 1;
cacheReferences = 1;
timer = setTimeout(() => self.postMessage({ type: "late-timer" }), 50);
self.postMessage({ type: "ready", resources: activeResources() });
return;
}
if (event.data?.type === "dispose") {
disposeCount += 1;
if (!disposed) {
if (timer !== undefined) { clearTimeout(timer); timer = undefined; }
controller?.abort();
controller = null;
ports?.primary.close();
ports?.peer.close();
ports = null;
buffer = null;
pendingRequests = 0;
cacheReferences = 0;
disposed = true;
}
self.postMessage({ type: "disposed", receipt: createScriptSandboxDisposeReceipt(disposeCount), resources: activeResources() });
}
};

View File

@@ -0,0 +1,24 @@
interface SandboxRequest { mode: "RECEIPTS" | "CRASH" | "TIMEOUT" }
import { rejectLateScriptSandboxResult, terminateScriptSandboxJob } from "../../../protocol/scripting-platform";
const running = { schemaVersion: 1, jobId: "sandbox:1", workerGeneration: 4, baseRevision: 9, mainRevisionBefore: 9, status: "RUNNING" as const };
self.onmessage = (event: MessageEvent<SandboxRequest>) => {
if (event.data?.mode === "RECEIPTS") {
const crash = terminateScriptSandboxJob(running, "CRASH");
const timeout = terminateScriptSandboxJob(running, "TIMEOUT");
const cancel = terminateScriptSandboxJob(running, "CANCEL");
let lateResult = "ACCEPTED";
try { rejectLateScriptSandboxResult(cancel); } catch (error) { lateResult = error instanceof Error ? error.message.split(":", 1)[0] : String(error); }
self.postMessage({ crash, timeout, cancel, lateResult });
return;
}
if (event.data?.mode === "CRASH") {
// The host must convert this Worker error into a terminated job receipt.
throw new Error("sandbox crash fixture");
}
if (event.data?.mode === "TIMEOUT") {
// A real sandbox would be stopped by its wall-time supervisor before this publishes.
setTimeout(() => self.postMessage({ type: "late-result", jobId: "sandbox:timeout" }), 50);
}
};

View File

@@ -0,0 +1,39 @@
import { appendScriptExecutionAudit, createScriptExecutionAudit, parseScriptExecutionAuditLog, parseScriptingManifest } from "../../../protocol/scripting-platform";
import { createScriptSandboxRecoveryReceipt } from "../testing/script-sandbox-recovery";
const sourceSha256 = "a".repeat(64);
const signature = "b".repeat(128);
const manifest = {
schemaVersion: 1,
scripts: [{ id: "script:recovery", name: "Recovery", entryPath: "scripts/recovery.py", sourceByteLength: 128, sourceSha256, publisher: "Team", signature, keyId: "key:trusted", permissions: ["READ_MAIN"], dependencies: [], module: false, cpuMs: 1000, memoryBytes: 1024 * 1024, wallMs: 5000, network: false, autorun: false, driverExpressions: false, addonInstall: false }],
};
self.onmessage = async (event: MessageEvent<{ type: "recover" }>) => {
if (event.data?.type !== "recover") return;
const parsed = parseScriptingManifest(manifest);
const firstAudit = await createScriptExecutionAudit(parsed, "script:recovery", new Set(["key:trusted"]), { requestId: "sandbox-recovery:g4", requestedAt: "2026-08-19T00:00:00.000Z" });
const firstLog = await appendScriptExecutionAudit({ schemaVersion: 1, entries: [] }, firstAudit);
const secondAudit = await createScriptExecutionAudit(parsed, "script:recovery", new Set(["key:trusted"]), { requestId: "sandbox-recovery:g5", requestedAt: "2026-08-19T00:00:01.000Z" });
const auditLog = await appendScriptExecutionAudit(firstLog, secondAudit);
const checked = await parseScriptExecutionAuditLog(auditLog);
const first = checked.entries[0];
const second = checked.entries[1];
const receipt = createScriptSandboxRecoveryReceipt({
previousGeneration: 4,
nextGeneration: 5,
mainRevisionBefore: 11,
mainRevisionAfter: 11,
sourceSha256,
manifestSha256: first.audit.manifestSha256,
audit: {
entries: 2,
first: { sequence: 1, requestId: first.audit.requestId, previousEntrySha256: first.previousEntrySha256, entrySha256: first.entrySha256, sourceSha256: first.audit.sourceSha256, manifestSha256: first.audit.manifestSha256 },
second: { sequence: 2, requestId: second.audit.requestId, previousEntrySha256: second.previousEntrySha256, entrySha256: second.entrySha256, sourceSha256: second.audit.sourceSha256, manifestSha256: second.audit.manifestSha256 },
},
});
let replayError = "ACCEPTED";
try { await appendScriptExecutionAudit(auditLog, secondAudit); } catch (error) { replayError = error instanceof Error ? error.message.split(":", 1)[0] : String(error); }
let tamperError = "ACCEPTED";
try { await parseScriptExecutionAuditLog({ ...auditLog, entries: auditLog.entries.map((entry, index) => index === 0 ? { ...entry, audit: { ...entry.audit, sourceSha256: "c".repeat(64) } } : entry) }); } catch (error) { tamperError = error instanceof Error ? error.message.split(":", 1)[0] : String(error); }
self.postMessage({ receipt, auditLog, replayError, tamperError, manifestSha256: first.audit.manifestSha256, scriptSourceSha256: first.audit.sourceSha256 });
};

View File

@@ -0,0 +1,12 @@
import { parseScriptSandboxScope } from "../../../protocol/scripting-platform";
const deniedScope = { schemaVersion: 1, dom: false, hostWorker: false, opfs: false, indexedDB: false, network: false } as const;
self.onmessage = () => {
const accepted = parseScriptSandboxScope(deniedScope);
const blocked: Record<string, string> = {};
for (const capability of ["dom", "hostWorker", "opfs", "indexedDB", "network"] as const) {
try { parseScriptSandboxScope({ ...deniedScope, [capability]: true }); blocked[capability] = "ACCEPTED"; }
catch (error) { blocked[capability] = error instanceof Error ? error.message.split(":", 1)[0] : String(error); }
}
self.postMessage({ accepted, blocked, execution: "DISABLED" });
};

View File

@@ -0,0 +1,17 @@
import { verifyScriptManifestSignature } from "../../../protocol/scripting-platform";
const publicKey = "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8";
const signature = "fc396c6c68e6f6eb38a18c147becfaec1621a167f6db0a0d76874209accf3cb80dfa1fac1528ebc1bc6b090801a3ad397cae18e6ddb41740766678711c0a8804";
const script = (id = "clean", overrides: Record<string, unknown> = {}) => ({ id, name: id, entryPath: `scripts/${id}.py`, sourceByteLength: 128, sourceSha256: "a".repeat(64), publisher: "Team", signature, keyId: "key:new", permissions: ["READ_MAIN"], dependencies: [], module: false, cpuMs: 1000, memoryBytes: 1024 * 1024, wallMs: 5000, network: false, autorun: false, driverExpressions: false, addonInstall: false, ...overrides });
const manifest = (scripts = [script()]) => ({ schemaVersion: 1, scripts });
const key = (overrides: Record<string, unknown> = {}) => ({ keyId: "key:new", publisher: "Team", algorithm: "ED25519", publicKey, status: "ACTIVE", notBefore: "2026-01-01T00:00:00.000Z", notAfter: "2027-01-01T00:00:00.000Z", ...overrides });
const policy = (overrides: Record<string, unknown> = {}) => ({ schemaVersion: 1, issuer: "web-trust", issuedAt: "2026-01-01T00:00:00.000Z", expiresAt: "2027-01-01T00:00:00.000Z", maxClockSkewMs: 300000, keys: [key()], ...overrides });
self.onmessage = async () => {
const at = "2026-08-18T12:00:00.000Z";
const missing = await verifyScriptManifestSignature(manifest(), "clean", policy({ keys: [] }), at);
const expired = await verifyScriptManifestSignature(manifest(), "clean", policy({ keys: [key({ notAfter: "2026-06-01T00:00:00.000Z" })] }), at);
const notYetValid = await verifyScriptManifestSignature(manifest(), "clean", policy({ keys: [key({ notBefore: "2026-09-01T00:00:00.000Z" })] }), at);
const publisherMismatch = await verifyScriptManifestSignature(manifest(), "clean", policy({ keys: [key({ publisher: "Other" })] }), at);
const swapped = await verifyScriptManifestSignature(manifest([script("other")]), "other", policy(), at);
self.postMessage({ missing, expired, notYetValid, publisherMismatch, swapped });
};

View File

@@ -0,0 +1,15 @@
import { verifyScriptManifestSignature } from "../../../protocol/scripting-platform";
const publicKey = "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8";
const signature = "fc396c6c68e6f6eb38a18c147becfaec1621a167f6db0a0d76874209accf3cb80dfa1fac1528ebc1bc6b090801a3ad397cae18e6ddb41740766678711c0a8804";
const script = (overrides: Record<string, unknown> = {}) => ({ id: "clean", name: "clean", entryPath: "scripts/clean.py", sourceByteLength: 128, sourceSha256: "a".repeat(64), publisher: "Team", signature, keyId: "key:new", permissions: ["READ_MAIN"], dependencies: [], module: false, cpuMs: 1000, memoryBytes: 1024 * 1024, wallMs: 5000, network: false, autorun: false, driverExpressions: false, addonInstall: false, ...overrides });
const policy = (overrides: Record<string, unknown> = {}) => ({ schemaVersion: 1, issuer: "web-trust", issuedAt: "2026-01-01T00:00:00.000Z", expiresAt: "2027-01-01T00:00:00.000Z", maxClockSkewMs: 300000, keys: [{ keyId: "key:new", publisher: "Team", algorithm: "ED25519", publicKey, status: "ACTIVE", notBefore: "2026-01-01T00:00:00.000Z", notAfter: "2027-01-01T00:00:00.000Z" }], ...overrides });
const manifest = { schemaVersion: 1, scripts: [script()] };
self.onmessage = async () => {
const valid = await verifyScriptManifestSignature(manifest, "clean", policy(), "2026-08-18T12:00:00.000Z");
const sourceChanged = await verifyScriptManifestSignature({ schemaVersion: 1, scripts: [script({ sourceSha256: "d".repeat(64) })] }, "clean", policy(), "2026-08-18T12:00:00.000Z");
const signatureChanged = await verifyScriptManifestSignature({ schemaVersion: 1, scripts: [script({ signature: `${signature.slice(0, -1)}${signature.endsWith("0") ? "1" : "0"}` })] }, "clean", policy(), "2026-08-18T12:00:00.000Z");
const revoked = await verifyScriptManifestSignature(manifest, "clean", policy({ keys: [{ ...policy().keys[0], status: "REVOKED", revokedAt: "2026-06-01T00:00:00.000Z" }] }), "2026-08-18T12:00:00.000Z");
self.postMessage({ valid, sourceChanged, signatureChanged, revoked });
};

View File

@@ -0,0 +1,19 @@
import { parseScriptTrustPolicy, resolveScriptSigner, serializeScriptTrustPolicy } from "../../../protocol/scripting-platform";
const digest = "a".repeat(64);
const signature = "b".repeat(128);
const script = (id = "clean", overrides: Record<string, unknown> = {}) => ({ id, name: id, entryPath: `scripts/${id}.py`, sourceByteLength: 128, sourceSha256: digest, publisher: "Team", signature, keyId: "key:new", permissions: ["READ_MAIN"], dependencies: [], module: false, cpuMs: 1000, memoryBytes: 1024 * 1024, wallMs: 5000, network: false, autorun: false, driverExpressions: false, addonInstall: false, ...overrides });
const key = (keyId: string, overrides: Record<string, unknown> = {}) => ({ keyId, publisher: "Team", algorithm: "ED25519", publicKey: "c".repeat(64), status: "ACTIVE", notBefore: "2026-01-01T00:00:00.000Z", notAfter: "2027-01-01T00:00:00.000Z", ...overrides });
const policy = (keys = [key("key:new")], overrides: Record<string, unknown> = {}) => ({ schemaVersion: 1, issuer: "web-trust", issuedAt: "2026-01-01T00:00:00.000Z", expiresAt: "2027-01-01T00:00:00.000Z", maxClockSkewMs: 300000, keys, ...overrides });
const manifest = { schemaVersion: 1, scripts: [script()] };
function errorCode(value: unknown): string {
try { parseScriptTrustPolicy(value); return "ACCEPTED"; }
catch (error) { return error instanceof Error ? error.message : String(error); }
}
self.onmessage = () => {
const rotated = policy([key("key:new", { replaces: "key:old" }), key("key:old", { status: "REVOKED", revokedAt: "2026-06-01T00:00:00.000Z" })]);
const parsed = parseScriptTrustPolicy(rotated);
self.postMessage({ eligible: resolveScriptSigner(manifest, "clean", parsed, "2026-08-18T12:00:00.000Z"), revoked: resolveScriptSigner(manifest, "clean", policy([key("key:new", { status: "REVOKED", revokedAt: "2026-06-01T00:00:00.000Z" })]), "2026-08-18T12:00:00.000Z").trust, crossPublisher: errorCode(policy([key("key:new", { replaces: "key:old" }), key("key:old", { publisher: "Other" })])), policyExpired: resolveScriptSigner(manifest, "clean", policy([key("key:new")], { expiresAt: "2026-06-01T00:00:00.000Z" }), "2026-08-18T12:00:00.000Z").trust, rotationSerialized: serializeScriptTrustPolicy(parsed).length });
};

View File

@@ -0,0 +1,52 @@
import { parseScriptingManifest, SCRIPTING_BUDGET } from "../../../protocol/scripting-platform";
const digest = "a".repeat(64);
const signature = "b".repeat(128);
const script = (id: string, overrides: Record<string, unknown> = {}) => ({
id,
name: id,
entryPath: `scripts/${id}.py`,
sourceByteLength: 128,
sourceSha256: digest,
publisher: "local",
signature,
keyId: "key:local",
permissions: ["READ_MAIN"],
dependencies: [],
module: false,
cpuMs: 1000,
memoryBytes: 1024 * 1024,
wallMs: 5000,
network: false,
autorun: false,
driverExpressions: false,
addonInstall: false,
...overrides,
});
const manifest = (scripts = [script("clean")]) => ({ schemaVersion: 1, scripts });
function denied(value: unknown): string {
try {
parseScriptingManifest(value);
return "ACCEPTED";
}
catch (error) {
return error instanceof Error ? error.message : String(error);
}
}
self.onmessage = () => {
const valid = parseScriptingManifest(manifest([
script("base", { entryPath: "//scripts/../scripts/base.py" }),
script("clean", { dependencies: [{ id: "base", sourceSha256: digest, sourcePath: "//deps/base.py" }] }),
]));
self.postMessage({
valid: [valid.scripts.length, valid.scripts[0].entryPath, valid.scripts[1].dependencies[0].sourcePath, valid.scripts.reduce((total, item) => total + item.sourceByteLength, 0)],
count: denied(manifest(Array.from({ length: SCRIPTING_BUDGET.maxScripts + 1 }, (_, index) => script(`script-${index}`)))),
totalBytes: denied(manifest([script("large", { sourceByteLength: SCRIPTING_BUDGET.maxSourceBytes }), script("overflow", { sourceByteLength: 1 })])),
module: denied(manifest([script("module", { module: true })])),
path: denied(manifest([script("escape", { entryPath: "../escape.py" })])),
dependency: denied(manifest([script("duplicate", { dependencies: [{ id: "base", sourceSha256: digest, sourcePath: "deps/a.py" }, { id: "base", sourceSha256: digest, sourcePath: "deps/b.py" }] }), script("base")])),
permission: denied(manifest([script("unknown", { permissions: ["EXECUTE"] })])),
});
};

View File

@@ -0,0 +1,46 @@
import { canonicalizeScriptingManifest, serializeScriptingManifest } from "../../../protocol/scripting-platform";
const digest = "a".repeat(64);
const signature = "b".repeat(128);
const script = (id: string, overrides: Record<string, unknown> = {}) => ({
id,
name: id,
entryPath: `scripts/${id}.py`,
sourceByteLength: 128,
sourceSha256: digest,
publisher: "local",
signature,
keyId: "key:local",
permissions: ["READ_MAIN"],
dependencies: [],
module: false,
cpuMs: 1000,
memoryBytes: 1024 * 1024,
wallMs: 5000,
network: false,
autorun: false,
driverExpressions: false,
addonInstall: false,
...overrides,
});
self.onmessage = () => {
const first = {
schemaVersion: 1,
scripts: [
script("zeta", { permissions: ["WRITE_ASSET", "READ_MAIN"], dependencies: [{ id: "alpha", sourceSha256: digest, sourcePath: "deps/alpha.py" }, { id: "beta", sourceSha256: digest, sourcePath: "deps/beta.py" }] }),
script("alpha", { permissions: ["SUBMIT_SERVER_JOB", "READ_ASSET"] }),
script("beta"),
],
};
const second = {
schemaVersion: 1,
scripts: [
{ ...first.scripts[1], permissions: [...first.scripts[1].permissions].reverse(), ignored: "removed" },
{ ...first.scripts[0], permissions: [...first.scripts[0].permissions].reverse(), dependencies: [...first.scripts[0].dependencies].reverse() },
first.scripts[2],
],
};
const canonical = canonicalizeScriptingManifest(second);
self.postMessage({ equal: serializeScriptingManifest(first) === serializeScriptingManifest(second), firstId: canonical.scripts[0].id, firstPermission: canonical.scripts[0].permissions[0], dependencyOrder: canonical.scripts[2].dependencies.map((dependency) => dependency.id), unknownDropped: !("ignored" in canonical.scripts[0]) });
};

View File

@@ -1,7 +1,7 @@
import { appendScriptExecutionAudit, createScriptExecutionAudit, gateScriptExecution, gateServerScriptJob, parseScriptExecutionAuditLog, parseScriptingManifest, platformCapabilities, SCRIPTING_BUDGET } from "../../../protocol/scripting-platform";
const sha = "a".repeat(64); const signature = "b".repeat(128);
const script = { id: "script:clean", name: "Clean", entryPath: "scripts/clean.py", sourceSha256: sha, publisher: "Team", signature, keyId: "key:trusted", permissions: ["READ_MAIN"], dependencies: [], cpuMs: 1000, memoryBytes: 1024 * 1024, wallMs: 5000, network: false, autorun: false, driverExpressions: false, addonInstall: false };
const script = { id: "script:clean", name: "Clean", entryPath: "scripts/clean.py", sourceByteLength: 128, sourceSha256: sha, publisher: "Team", signature, keyId: "key:trusted", permissions: ["READ_MAIN"], dependencies: [], module: false, cpuMs: 1000, memoryBytes: 1024 * 1024, wallMs: 5000, network: false, autorun: false, driverExpressions: false, addonInstall: false };
const base = { schemaVersion: 1, scripts: [script] };
self.onmessage = async () => {

View File

@@ -0,0 +1,24 @@
import { importSTL, type STLVariant } from "../../../protocol/stl-import";
interface Request {
cases: Array<{ id: string; bytes: ArrayBuffer; variant: STLVariant; unitScale: number }>;
}
const scope = self as unknown as {
onmessage: ((event: MessageEvent<Request>) => void) | null;
postMessage(message: unknown): void;
};
scope.onmessage = (event) => {
const results = event.data.cases.map((candidate) => {
try {
return { id: candidate.id, status: "ACCEPTED", result: importSTL(candidate.bytes, { variant: candidate.variant, unitScale: candidate.unitScale }) };
}
catch (error) {
return { id: candidate.id, status: "BLOCKED", code: error instanceof Error ? error.message.split(":", 1)[0] : "STL_IMPORT_FAILED" };
}
});
scope.postMessage({ ok: true, results });
};
export {};

View File

@@ -0,0 +1,23 @@
import { exportBinarySTL, createSTLLossReport } from "../../../protocol/stl-export";
import { importSTL } from "../../../protocol/stl-import";
interface Request { bytes: ArrayBuffer; unitScale: number; sourceMaterialCount: number; }
const scope = self as unknown as {
onmessage: ((event: MessageEvent<Request>) => void) | null;
postMessage(message: unknown, transfer?: Transferable[]): void;
};
scope.onmessage = (event) => {
try {
const imported = importSTL(event.data.bytes, { variant: "STL_BINARY", unitScale: event.data.unitScale });
const output = exportBinarySTL(imported);
const lossReport = createSTLLossReport(event.data.sourceMaterialCount);
scope.postMessage({ ok: true, imported, output, lossReport }, [output]);
}
catch (error) {
scope.postMessage({ ok: false, error: error instanceof Error ? error.message : "STL round-trip failed" });
}
};
export {};

View File

@@ -751,7 +751,8 @@ async function readAssetRow(projectId: string, sha256: string): Promise<AssetRow
return row;
}
async function putAsset(projectId: string, data: ArrayBuffer, mimeType: string, sourcePath?: string): Promise<StorageAssetPutResult> {
async function putAsset(projectId: string, data: ArrayBuffer, mimeType: string, sourcePath?: string, faultAt?: "quota"): Promise<StorageAssetPutResult> {
if (faultAt === "quota") throw new Error("QuotaExceededError: injected OPFS quota exhaustion");
projectLayout(projectId);
if (data.byteLength === 0) throw new Error("Asset data is empty");
if (!/^[A-Za-z0-9.+-]+\/[A-Za-z0-9.+-]+$/.test(mimeType)) throw new Error("Invalid asset MIME type");
@@ -1498,7 +1499,7 @@ async function handleRequest(event: MessageEvent<StorageRequest>): Promise<void>
else if (command.type === "saveSnapshot") result = await withProjectTransaction(command.projectId, () => saveSnapshot(command.projectId, command.revision, command.buffer, command.maxCount, command.maxBytes));
else if (command.type === "listSnapshots") result = await listSnapshots(command.projectId);
else if (command.type === "readSnapshot") result = await readSnapshot(command.projectId, command.revision);
else if (command.type === "putAsset") result = await putAsset(command.projectId, command.data, command.mimeType, command.sourcePath);
else if (command.type === "putAsset") result = await putAsset(command.projectId, command.data, command.mimeType, command.sourcePath, command.faultAt);
else if (command.type === "readAsset") result = await readAsset(command.projectId, command.sha256);
else if (command.type === "listAssets") result = await listAssets(command.projectId);
else if (command.type === "commitTexturePaintTile") result = await withProjectTransaction(command.commit.target.projectId, () => commitTexturePaintTile(command.commit));

View File

@@ -144,12 +144,76 @@
"test:library-operation-inventory": "node ../tools/web/check-library-operation-inventory.mjs",
"test:library-operation-identity": "node --test tests/unit/library-operation-identity.test.mjs",
"test:library-append-desktop": "node ../tools/web/check-library-append-fixture.mjs",
"test:library-main-append": "playwright test --config playwright.config.ts --workers=1 tests/e2e/library-append-main.spec.ts",
"test:library-append-wasm": "node --test tests/unit/library-append-wasm.test.mjs",
"test:library-append-chromium": "playwright test --config playwright.config.ts --workers=1 tests/e2e/library-append-main.spec.ts",
"test:library-main-append": "node ../tools/web/check-library-main-append.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/library-append-main.spec.ts",
"test:library-link-desktop": "node ../tools/web/check-library-link-fixture.mjs",
"test:library-link-wasm": "node --test tests/unit/library-linked-mutation.test.mjs tests/unit/library-linked-reload.test.mjs tests/unit/library-linked-missing.test.mjs",
"test:library-link-chromium": "playwright test --config playwright.config.ts --workers=1 tests/e2e/library-link-chromium.spec.ts",
"test:library-linked-mutation": "node --test tests/unit/library-linked-mutation.test.mjs",
"test:library-linked-reload": "node --test tests/unit/library-linked-reload.test.mjs",
"test:library-linked-missing": "node --test tests/unit/library-linked-missing.test.mjs",
"test:library-override-desktop": "node ../tools/web/check-library-override-fixture.mjs",
"test:library-override-wasm": "node --test tests/unit/library-override-writer.test.mjs tests/unit/library-override-freshness.test.mjs",
"test:library-override-chromium": "playwright test --config playwright.config.ts --workers=1 tests/e2e/library-override-chromium.spec.ts",
"test:library-override-writer": "node --test tests/unit/library-override-writer.test.mjs",
"test:library-override-freshness": "node --test tests/unit/library-override-freshness.test.mjs",
"test:library-negative-cases": "node --test tests/unit/library-negative-cases.test.mjs",
"test:library-operation-commands": "node ../tools/web/check-library-operation-commands.mjs",
"test:library-source-origin": "node --test tests/unit/library-source-origin.test.mjs",
"test:library-path-normalization": "node --test tests/unit/library-path-normalization.test.mjs",
"test:library-path-security": "node --test tests/unit/library-path-security.test.mjs",
"test:library-link-safety": "node --test tests/unit/library-link-safety.test.mjs",
"test:library-metadata-first": "node --test tests/unit/library-metadata-first.test.mjs",
"test:library-archive-budget": "node --test tests/unit/library-archive-budget.test.mjs",
"test:library-archive-conflicts": "node --test tests/unit/library-archive-conflicts.test.mjs",
"test:library-archive-cancellation": "node --test tests/unit/library-archive-cancellation.test.mjs",
"test:asset-library": "playwright test --config playwright.config.ts -g \"N-023 asset\"",
"test:library-main-reader": "node ../tools/web/check-library-main-reader.mjs",
"test:editor-workflow": "playwright test --config playwright.config.ts -g \"N-024 editor\"",
"test:editor-main-reader": "node ../tools/web/check-editor-main-reader.mjs",
"test:scripting-platform": "playwright test --config playwright.config.ts -g \"N-025 script\"",
"test:script-manifest-budgets": "node --test tests/unit/script-manifest-budgets.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/script-manifest-budgets.spec.ts",
"test:script-manifest-canonical": "node --test tests/unit/script-manifest-canonical.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/script-manifest-canonical.spec.ts",
"test:script-trust-policy": "node --test tests/unit/script-trust-policy.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/script-trust-policy.spec.ts",
"test:script-signature": "node --test --test-name-pattern=M13-02D tests/unit/script-trust-policy.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/script-signature.spec.ts",
"test:script-permission-policy": "node --test tests/unit/script-permission-policy.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/script-permission-policy.spec.ts",
"test:script-signature-negative": "node --test tests/unit/script-signature-negative.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/script-signature-negative.spec.ts",
"test:script-sandbox-scope": "node --test tests/unit/script-sandbox-scope.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/script-sandbox-scope.spec.ts",
"test:script-sandbox-budget": "node --test tests/unit/script-sandbox-budget.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/script-sandbox-budget.spec.ts",
"test:script-host-call": "node --test tests/unit/script-host-call.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/script-host-call.spec.ts",
"test:script-sandbox-isolation": "node --test tests/unit/script-sandbox-isolation.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/script-sandbox-isolation.spec.ts",
"test:script-sandbox-cancellation": "node --test tests/unit/script-sandbox-cancellation.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/script-sandbox-cancellation.spec.ts",
"test:script-sandbox-dispose": "node --test tests/unit/script-sandbox-dispose.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/script-sandbox-dispose.spec.ts",
"test:script-sandbox-recovery": "node --test tests/unit/script-sandbox-recovery.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/script-sandbox-recovery.spec.ts",
"test:server-job-directory": "node --test tests/unit/server-job-isolation.test.mjs && node ../tools/web/check-server-job-isolation.mjs",
"test:server-job-workspace": "node --test tests/unit/server-job-isolation.test.mjs && node ../tools/web/check-server-job-workspace.mjs",
"test:server-job-resource-budget": "node --test tests/unit/server-job-resource-budget.test.mjs && node ../tools/web/check-server-job-resource-budget.mjs",
"test:server-job-network-policy": "node --test tests/unit/server-job-network-policy.test.mjs && node ../tools/web/check-server-job-network-policy.mjs",
"test:server-job-startup": "node ../tools/web/check-server-job-startup.mjs",
"test:server-job-output-redaction": "node --test tests/unit/server-job-output.test.mjs && node ../tools/web/check-server-job-output-redaction.mjs",
"test:server-job-cancellation": "node --test tests/unit/server-job-process.test.mjs && node ../tools/web/check-server-job-cancellation.mjs",
"test:server-job-fault-codes": "node --test tests/unit/server-job-fault.test.mjs && node ../tools/web/check-server-job-fault-codes.mjs",
"test:server-job-result-binding": "node --test tests/unit/server-job-result-binding.test.mjs && node ../tools/web/check-server-job-result-binding.mjs",
"test:server-job-idempotency": "node --test tests/unit/server-job-idempotency.test.mjs && node ../tools/web/check-server-job-idempotency.mjs",
"test:csp-policy": "node ../tools/web/check-csp-policy.mjs",
"test:csp-resource-policy": "node ../tools/web/check-csp-resource-policy.mjs",
"test:dependency-inventory": "node ../tools/web/check-dependency-inventory.mjs",
"test:dependency-severity-policy": "node ../tools/web/check-dependency-severity-policy.mjs",
"test:supply-chain-binding": "node ../tools/web/check-supply-chain-binding.mjs",
"test:malicious-input-matrix": "node ../tools/web/check-malicious-input-matrix.mjs",
"test:fuzz-regression": "node ../tools/web/check-fuzz-regression.mjs",
"test:script-audit-integrity": "node --test tests/unit/script-audit-integrity.test.mjs && node ../tools/web/check-script-audit-integrity.mjs",
"test:chromium-freeze": "node ../tools/web/check-chromium-release-freeze.mjs",
"test:probe-identity": "node ../tools/web/check-probe-identity.mjs",
"test:chromium-webgpu-boundary": "node ../tools/web/check-chromium-webgpu-boundary.mjs",
"test:chromium-device-budget": "node --test tests/unit/device-budget.test.mjs && node ../tools/web/check-chromium-device-budget.mjs",
"test:chromium-dpr-consistency": "node --test tests/unit/viewport-dpr.test.mjs && node ../tools/web/check-chromium-dpr-consistency.mjs",
"test:chromium-pointer-contract": "node --test tests/unit/pointer-contract.test.mjs && node ../tools/web/check-chromium-pointer-contract.mjs",
"test:chromium-ime-guard": "node --test tests/unit/ime-composition.test.mjs && node ../tools/web/check-chromium-ime-guard.mjs",
"test:chromium-keymap-fixture": "node --test tests/unit/keyboard-contract.test.mjs && node ../tools/web/check-chromium-keymap-fixture.mjs",
"test:chromium-input-modal": "node --test tests/unit/input-modal.test.mjs && node ../tools/web/check-chromium-input-modal.mjs",
"test:firefox-quick": "npm run typecheck && node ../tools/web/check-firefox-quick.mjs",
"test:script-main-reader": "node ../tools/web/check-script-main-reader.mjs",
"test:scripting-isolation": "node ../tools/web/check-scripting-isolation.mjs",
"test:release-gate": "playwright test --config playwright.config.ts -g \"N-026 release\"",
@@ -195,6 +259,7 @@
"test:rc-known-limitations": "node ../tools/web/check-rc-known-limitations.mjs",
"test:rc-release-recovery": "node ../tools/web/check-rc-release-recovery.mjs",
"test:rc-docs": "npm run test:rc-release-notes && npm run test:rc-known-limitations && npm run test:rc-release-recovery",
"test:ply-mapping": "node --test tests/unit/ply-import.test.mjs && node ../tools/web/check-ply-mapping-fixtures.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/ply-web-roundtrip.spec.ts",
"diagnose:depsgraph": "node ../tools/web/diagnose-depsgraph.mjs"
},
"dependencies": {

View File

@@ -0,0 +1,97 @@
import type { ErrorCode } from "./error";
export const ARCHIVE_CONFLICT_SCHEMA = 1 as const;
export interface ArchiveConflictRangeIR {
path: string;
compressedBytes: number;
uncompressedBytes: number;
compressedOffset: number;
}
export interface ArchiveConflictRequestIR {
schemaVersion: typeof ARCHIVE_CONFLICT_SCHEMA;
byteLength: number | null;
ranges: ArchiveConflictRangeIR[];
}
export interface ArchiveConflictValidationIR {
status: "VALID";
totalCompressedBytes: number;
totalUncompressedBytes: number;
nonOverlapping: true;
uniquePaths: true;
noPrefixConflicts: true;
}
export class ArchiveConflictError extends Error {
readonly code: ErrorCode;
readonly path?: string;
constructor(message: string, path?: string) { super(`IO_ARCHIVE_UNSAFE: ${message}`); this.name = "ArchiveConflictError"; this.code = "IO_ARCHIVE_UNSAFE"; this.path = path; }
}
export const ARCHIVE_CONFLICT_BUDGET = {
maxEntries: 100_000,
maxEntryBytes: 2 * 1024 * 1024 * 1024,
maxArchiveBytes: 4 * 1024 * 1024 * 1024,
maxCompressionRatio: 100,
} as const;
const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/;
const DRIVE_PATH = /^[A-Za-z]:[\\/]/;
const URI_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:/;
function record(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new ArchiveConflictError(`${path} must be an object`, path);
return value as Record<string, unknown>;
}
function exactKeys(value: Record<string, unknown>, expected: readonly string[], path: string): void {
const actual = Object.keys(value).sort(); const allowed = [...expected].sort();
if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) throw new ArchiveConflictError(`${path} contains undeclared fields`, path);
}
function integer(value: unknown, path: string, minimum = 0, maximum = Number.MAX_SAFE_INTEGER): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) throw new ArchiveConflictError(`${path} is outside its bounded range`, path);
return value;
}
function archivePath(value: unknown, path: string): string {
if (typeof value !== "string" || value.length === 0 || value.length > 2_048 || CONTROL_CHARACTER.test(value)) throw new ArchiveConflictError(`${path} is invalid`, path);
if (value.startsWith("/") || value.startsWith("\\") || DRIVE_PATH.test(value) || URI_SCHEME.test(value) || value.includes("\\")) throw new ArchiveConflictError(`${path} escapes the project`, path);
const segments: string[] = [];
for (const segment of value.normalize("NFC").split("/")) {
if (!segment || segment === ".") continue;
if (segment === "..") { if (segments.length === 0) throw new ArchiveConflictError(`${path} escapes the project`, path); segments.pop(); continue; }
segments.push(segment);
}
const result = segments.join("/");
if (!result) throw new ArchiveConflictError(`${path} is empty`, path);
return result;
}
export function validateArchiveConflicts(value: unknown): ArchiveConflictValidationIR {
const input = record(value, "input");
exactKeys(input, ["schemaVersion", "byteLength", "ranges"], "input");
if (input.schemaVersion !== ARCHIVE_CONFLICT_SCHEMA) throw new ArchiveConflictError("unsupported archive conflict schema", "input.schemaVersion");
const byteLength = input.byteLength === null ? null : integer(input.byteLength, "input.byteLength", 1, ARCHIVE_CONFLICT_BUDGET.maxArchiveBytes);
if (!Array.isArray(input.ranges) || input.ranges.length === 0 || input.ranges.length > ARCHIVE_CONFLICT_BUDGET.maxEntries) throw new ArchiveConflictError("ranges exceeds its bound", "input.ranges");
const ranges = input.ranges.map((value, index) => {
const path = `ranges[${index}]`; const item = record(value, path); exactKeys(item, ["path", "compressedBytes", "uncompressedBytes", "compressedOffset"], path);
return {
path: archivePath(item.path, `${path}.path`),
compressedBytes: integer(item.compressedBytes, `${path}.compressedBytes`, 0, ARCHIVE_CONFLICT_BUDGET.maxEntryBytes),
uncompressedBytes: integer(item.uncompressedBytes, `${path}.uncompressedBytes`, 0, ARCHIVE_CONFLICT_BUDGET.maxEntryBytes),
compressedOffset: integer(item.compressedOffset, `${path}.compressedOffset`, 0, ARCHIVE_CONFLICT_BUDGET.maxArchiveBytes),
};
});
const paths = new Set<string>(); let totalCompressedBytes = 0; let totalUncompressedBytes = 0;
for (const range of ranges) {
if (paths.has(range.path) || [...paths].some((existing) => existing.startsWith(`${range.path}/`) || range.path.startsWith(`${existing}/`))) throw new ArchiveConflictError(`duplicate or file/directory prefix conflict at ${range.path}`, "input.ranges");
paths.add(range.path);
if (range.uncompressedBytes > 0 && (range.compressedBytes === 0 || range.uncompressedBytes / range.compressedBytes > ARCHIVE_CONFLICT_BUDGET.maxCompressionRatio)) throw new ArchiveConflictError(`compression ratio exceeds the budget at ${range.path}`, "input.ranges");
totalCompressedBytes += range.compressedBytes; totalUncompressedBytes += range.uncompressedBytes;
if (!Number.isSafeInteger(totalCompressedBytes) || !Number.isSafeInteger(totalUncompressedBytes) || totalCompressedBytes > ARCHIVE_CONFLICT_BUDGET.maxArchiveBytes || totalUncompressedBytes > ARCHIVE_CONFLICT_BUDGET.maxArchiveBytes) throw new ArchiveConflictError("archive total byte budget exceeded", "input.ranges");
if (range.compressedOffset + range.compressedBytes > ARCHIVE_CONFLICT_BUDGET.maxArchiveBytes || byteLength !== null && range.compressedOffset + range.compressedBytes > byteLength) throw new ArchiveConflictError(`range for ${range.path} exceeds the archive`, "input.ranges");
}
const ordered = [...ranges].sort((left, right) => left.compressedOffset - right.compressedOffset);
for (let index = 1; index < ordered.length; index++) {
const previous = ordered[index - 1]; const current = ordered[index];
if (current.compressedOffset < previous.compressedOffset + previous.compressedBytes) throw new ArchiveConflictError(`compressed ranges overlap at ${current.path}`, "input.ranges");
}
return { status: "VALID", totalCompressedBytes, totalUncompressedBytes, nonOverlapping: true, uniquePaths: true, noPrefixConflicts: true };
}

View File

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

View File

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

View File

@@ -0,0 +1,160 @@
import type { ErrorCode } from "./error";
export const ARCHIVE_LINK_SAFETY_SCHEMA = 1 as const;
export const ARCHIVE_ENTRY_KINDS = ["FILE", "DIRECTORY", "SYMLINK", "HARDLINK"] as const;
export type ArchiveEntryKind = typeof ARCHIVE_ENTRY_KINDS[number];
export interface ArchiveLinkEntryIR {
path: string;
type: ArchiveEntryKind;
target: string | null;
}
export interface ArchiveLinkRequestIR {
schemaVersion: typeof ARCHIVE_LINK_SAFETY_SCHEMA;
temporaryRootId: string;
entries: ArchiveLinkEntryIR[];
}
export interface ArchiveResolvedEntryIR extends ArchiveLinkEntryIR {
resolvedPath: string;
resolvedType: "FILE" | "DIRECTORY";
withinTemporaryRoot: true;
}
export interface ArchiveLinkResolutionIR {
status: "READY";
schemaVersion: typeof ARCHIVE_LINK_SAFETY_SCHEMA;
temporaryRootId: string;
entries: ArchiveResolvedEntryIR[];
}
export class ArchiveLinkSafetyError extends Error {
readonly code: ErrorCode;
readonly path?: string;
constructor(message: string, path?: string) {
super(`IO_ARCHIVE_UNSAFE: ${message}`);
this.name = "ArchiveLinkSafetyError";
this.code = "IO_ARCHIVE_UNSAFE";
this.path = path;
}
}
const MAX_ENTRIES = 10_000;
const MAX_PATH_LENGTH = 1_024;
const ROOT_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const DRIVE_PATH = /^[A-Za-z]:[\\/]/;
const URI_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:/;
const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/;
function record(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new ArchiveLinkSafetyError(`${path} must be an object`, path);
return value as Record<string, unknown>;
}
function exactKeys(value: Record<string, unknown>, expected: readonly string[], path: string): void {
const actual = Object.keys(value).sort();
const allowed = [...expected].sort();
if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) throw new ArchiveLinkSafetyError(`${path} contains undeclared fields`, path);
}
function text(value: unknown, path: string, maxLength: number): string {
if (typeof value !== "string" || value.length === 0 || value.length > maxLength || CONTROL_CHARACTER.test(value)) throw new ArchiveLinkSafetyError(`${path} is invalid`, path);
return value.normalize("NFC");
}
function archivePath(value: unknown, path: string, allowRoot = false): string {
const input = text(value, path, MAX_PATH_LENGTH);
if (input.startsWith("/") || input.startsWith("\\") || DRIVE_PATH.test(input) || URI_SCHEME.test(input)) throw new ArchiveLinkSafetyError(`${path} escapes the temporary root`, path);
if (input.includes("\\")) throw new ArchiveLinkSafetyError(`${path} contains a backslash`, path);
const segments: string[] = [];
for (const segment of input.split("/")) {
if (segment === "" || segment === ".") continue;
if (segment === "..") {
if (segments.length === 0) throw new ArchiveLinkSafetyError(`${path} escapes the temporary root`, path);
segments.pop();
continue;
}
if (CONTROL_CHARACTER.test(segment)) throw new ArchiveLinkSafetyError(`${path} contains a control character`, path);
segments.push(segment);
}
const result = segments.join("/");
if (!result && !allowRoot) throw new ArchiveLinkSafetyError(`${path} is empty`, path);
return result;
}
function relativeSymlinkTarget(linkPath: string, target: unknown, path: string): string {
const targetText = text(target, path, MAX_PATH_LENGTH);
if (targetText.startsWith("/") || targetText.startsWith("\\") || DRIVE_PATH.test(targetText) || URI_SCHEME.test(targetText)) throw new ArchiveLinkSafetyError(`${path} escapes the temporary root`, path);
if (targetText.includes("\\")) throw new ArchiveLinkSafetyError(`${path} contains a backslash`, path);
const parent = linkPath.includes("/") ? linkPath.slice(0, linkPath.lastIndexOf("/")) : "";
return archivePath(parent ? `${parent}/${targetText}` : targetText, path);
}
function parseEntry(value: unknown, index: number): ArchiveLinkEntryIR {
const path = `entries[${index}]`;
const entry = record(value, path);
exactKeys(entry, ["path", "target", "type"], path);
const entryPath = archivePath(entry.path, `${path}.path`);
if (!ARCHIVE_ENTRY_KINDS.includes(entry.type as ArchiveEntryKind)) throw new ArchiveLinkSafetyError(`${path}.type is unsupported`, `${path}.type`);
const type = entry.type as ArchiveEntryKind;
if (type === "FILE" || type === "DIRECTORY") {
if (entry.target !== null) throw new ArchiveLinkSafetyError(`${path}.target must be null for ${type}`, `${path}.target`);
return { path: entryPath, type, target: null };
}
if (typeof entry.target !== "string") throw new ArchiveLinkSafetyError(`${path}.target is required for ${type}`, `${path}.target`);
return { path: entryPath, type, target: entry.target.normalize("NFC") };
}
export function parseArchiveLinkRequest(value: unknown): ArchiveLinkRequestIR {
const input = record(value, "input");
exactKeys(input, ["schemaVersion", "temporaryRootId", "entries"], "input");
if (input.schemaVersion !== ARCHIVE_LINK_SAFETY_SCHEMA) throw new ArchiveLinkSafetyError("unsupported archive link schema", "input.schemaVersion");
if (typeof input.temporaryRootId !== "string" || !ROOT_ID.test(input.temporaryRootId)) throw new ArchiveLinkSafetyError("temporaryRootId is invalid", "input.temporaryRootId");
if (!Array.isArray(input.entries) || input.entries.length === 0 || input.entries.length > MAX_ENTRIES) throw new ArchiveLinkSafetyError("entries exceeds its bound", "input.entries");
const entries = input.entries.map((entry, index) => parseEntry(entry, index));
const paths = new Set<string>();
for (const entry of entries) {
if (paths.has(entry.path)) throw new ArchiveLinkSafetyError(`duplicate archive path ${entry.path}`, "input.entries");
paths.add(entry.path);
}
return { schemaVersion: ARCHIVE_LINK_SAFETY_SCHEMA, temporaryRootId: input.temporaryRootId, entries };
}
export function resolveArchiveLinkEntries(value: unknown): ArchiveLinkResolutionIR {
const request = parseArchiveLinkRequest(value);
const entries = new Map(request.entries.map((entry) => [entry.path, entry]));
const active = new Set<string>();
const resolved = new Map<string, { path: string; type: "FILE" | "DIRECTORY" }>();
const visit = (entryPath: string): { path: string; type: "FILE" | "DIRECTORY" } => {
const cached = resolved.get(entryPath);
if (cached) return cached;
if (active.has(entryPath)) throw new ArchiveLinkSafetyError(`link cycle includes ${entryPath}`, "input.entries");
const entry = entries.get(entryPath);
if (!entry) throw new ArchiveLinkSafetyError(`link target ${entryPath} is missing`, "input.entries");
active.add(entryPath);
let result: { path: string; type: "FILE" | "DIRECTORY" };
if (entry.type === "FILE" || entry.type === "DIRECTORY") {
result = { path: entry.path, type: entry.type };
}
else {
const targetPath = entry.type === "SYMLINK"
? relativeSymlinkTarget(entry.path, entry.target, `entries[${request.entries.indexOf(entry)}].target`)
: archivePath(entry.target, `entries[${request.entries.indexOf(entry)}].target`);
const target = visit(targetPath);
if (entry.type === "HARDLINK" && target.type !== "FILE") throw new ArchiveLinkSafetyError("hardlink target must resolve to a file", `entries[${request.entries.indexOf(entry)}].target`);
result = target;
}
active.delete(entryPath);
resolved.set(entryPath, result);
return result;
};
const output = request.entries.map((entry) => {
const target = visit(entry.path);
return { ...entry, resolvedPath: target.path, resolvedType: target.type, withinTemporaryRoot: true as const };
});
return { status: "READY", schemaVersion: ARCHIVE_LINK_SAFETY_SCHEMA, temporaryRootId: request.temporaryRootId, entries: output };
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
import { expect, test } from "@playwright/test";
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const root = path.resolve(import.meta.dirname, "../../..");
test("N-023 asset malicious ZIP/TAR fixtures remain in the archive security regression", async () => {
const output = execFileSync(process.execPath, [path.join(root, "tools/web/check-malicious-archive-fixtures.mjs")], {
cwd: root,
encoding: "utf8",
});
expect(output).toContain("malicious-archive-fixtures-ok cases=6 zip=3 tar=3 extraction=disabled");
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/files/web/archive-security/manifest.json"), "utf8"));
expect(manifest.extractionAllowed).toBe(false);
expect(manifest.cases).toHaveLength(6);
expect(manifest.cases.map((fixture: { expectedCode: string }) => fixture.expectedCode)).toEqual(
Array.from({ length: 6 }, () => "IO_ARCHIVE_UNSAFE"),
);
});

View File

@@ -0,0 +1,42 @@
import fs from "node:fs";
import path from "node:path";
import { expect, test } from "@playwright/test";
const root = path.resolve(import.meta.dirname, "../../..");
const report = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-06A/desktop-fixtures.json"), "utf8"));
const fixtureRoot = path.join(root, "tests/files/web/m12_glb_desktop_v1");
test("imports the M12-06A desktop GLB fixture group in a Chromium Worker", async ({ page }) => {
await page.goto("/");
const fixtures = report.fixtures.map((fixture: { id: string; file: string; sha256: string; semantic: unknown }) => ({
id: fixture.id,
bytes: Array.from(fs.readFileSync(path.join(fixtureRoot, fixture.file))),
sourceSha256: fixture.sha256,
expected: fixture.semantic,
}));
const result = await page.evaluate(async (input) => new Promise<Record<string, unknown>>((resolve, reject) => {
const worker = new Worker("/src/workers/glb-desktop-import-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, unknown>>) => {
worker.terminate();
resolve(event.data);
};
worker.onerror = (event) => {
worker.terminate();
reject(new Error(event.message));
};
const transferred = input.map((fixture) => {
const bytes = Uint8Array.from(fixture.bytes);
return { ...fixture, bytes: bytes.buffer };
});
worker.postMessage({ fixtures: transferred }, transferred.map((fixture) => fixture.bytes));
}), fixtures);
expect(result.ok).toBe(true);
expect(result.results).toEqual([
{ id: "mesh", sourceSha256: report.fixtures[0].sha256, compatible: true, mismatches: [], topology: 1, attributes: ["COLOR_0", "NORMAL", "POSITION"], materials: 1, nodes: 1, animations: 0 },
{ id: "pbr", sourceSha256: report.fixtures[1].sha256, compatible: true, mismatches: [], topology: 1, attributes: ["NORMAL", "POSITION"], materials: 1, nodes: 1, animations: 0 },
{ id: "uv", sourceSha256: report.fixtures[2].sha256, compatible: true, mismatches: [], topology: 1, attributes: ["NORMAL", "POSITION", "TEXCOORD_0"], materials: 1, nodes: 1, animations: 0 },
{ id: "skin", sourceSha256: report.fixtures[3].sha256, compatible: true, mismatches: [], topology: 1, attributes: ["JOINTS_0", "NORMAL", "POSITION", "WEIGHTS_0"], materials: 1, nodes: 4, animations: 0 },
{ id: "animation", sourceSha256: report.fixtures[4].sha256, compatible: true, mismatches: [], topology: 1, attributes: ["NORMAL", "POSITION"], materials: 1, nodes: 1, animations: 1 },
]);
});

View File

@@ -0,0 +1,106 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { expect, test } from "@playwright/test";
const root = path.resolve(import.meta.dirname, "../../..");
const mainReport = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-06C/desktop-main-report.json"), "utf8"));
const reportPath = path.join(root, "tests/golden/M12-06D/web-loss-report.json");
const fixtureRoot = path.join(root, "tests/files/web/m12_glb_main_v1");
const sha256 = (bytes: Uint8Array): string => crypto.createHash("sha256").update(bytes).digest("hex");
test("writes a machine GLB loss report for every persisted Main fixture", async ({ page }) => {
await page.goto("/");
const fixtures = mainReport.fixtures.map((fixture: { id: string; blend: { file: string; sha256: string } }) => ({
id: fixture.id,
bytes: Array.from(fs.readFileSync(path.join(fixtureRoot, fixture.blend.file))),
sourceBlendSha256: fixture.blend.sha256,
}));
const generated = await page.evaluate(async (input) => {
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
const digest = async (bytes: ArrayBuffer): Promise<string> => {
const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
return Array.from(hash, (value) => value.toString(16).padStart(2, "0")).join("");
};
const reports = [];
for (const fixture of input) {
const client = new WebEngineClient({ timeoutMs: 30_000 });
try {
const opened = await client.openBlend(Uint8Array.from(fixture.bytes).buffer);
const assets = [];
for (const image of opened.snapshot.images) {
const asset = await client.requestAsset(image.assetId);
if (asset.status === "packed" && asset.data && asset.mimeType) assets.push({ assetId: image.assetId, mimeType: asset.mimeType, data: asset.data });
}
const worker = new Worker("/src/workers/glb-loss-report-test.worker.ts", { type: "module" });
const result = await new Promise<{ report: any; output: ArrayBuffer | null }>((resolve, reject) => {
worker.onmessage = (event: MessageEvent<{ ok: boolean; report?: any; output?: ArrayBuffer | null; error?: string }>) => {
worker.terminate();
if (!event.data.ok || !event.data.report) reject(new Error(event.data.error ?? "GLB loss report worker failed"));
else resolve({ report: event.data.report, output: event.data.output ?? null });
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const transfer: Transferable[] = [];
for (const geometry of opened.geometryBuffers) for (const value of Object.values(geometry)) if (value instanceof ArrayBuffer) transfer.push(value);
for (const asset of assets) transfer.push(asset.data);
for (const chunk of opened.nonMeshGeometryBuffers ?? []) for (const value of Object.values(chunk)) if (value instanceof ArrayBuffer) transfer.push(value);
worker.postMessage({ snapshot: opened.snapshot, geometryBuffers: opened.geometryBuffers, assetBuffers: assets, nonMeshGeometryBuffers: opened.nonMeshGeometryBuffers ?? [] }, transfer);
});
reports.push({
fixtureId: fixture.id,
sourceBlendSha256: fixture.sourceBlendSha256,
lossReport: result.report,
output: result.output ? { byteLength: result.output.byteLength, sha256: await digest(result.output), bytes: Array.from(new Uint8Array(result.output)) } : null,
});
}
finally {
client.terminate();
}
}
return reports;
}, fixtures);
const report = {
schemaVersion: 1,
task: "M12-06D",
operation: "WEB_GLB_EXPORT_LOSS_REPORT",
fixtureCount: generated.length,
fixtures: generated,
nextTask: "M12-06E",
};
if (process.env.UPDATE_GLB_LOSS_REPORT === "1") {
const outputRoot = path.join(root, "tests/files/web/m12_glb_web_v1");
fs.mkdirSync(outputRoot, { recursive: true });
for (const fixture of generated) {
if (fixture.output?.bytes) fs.writeFileSync(path.join(outputRoot, `${fixture.fixtureId}.glb`), Buffer.from(fixture.output.bytes));
}
}
for (const fixture of report.fixtures) if (fixture.output?.bytes) delete fixture.output.bytes;
if (process.env.UPDATE_GLB_LOSS_REPORT === "1") {
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n");
}
const expected = JSON.parse(fs.readFileSync(reportPath, "utf8"));
expect(report).toEqual(expected);
expect(report.fixtureCount).toBe(5);
for (const fixture of report.fixtures) {
expect(fixture.lossReport.schemaVersion).toBe(1);
expect(fixture.lossReport.operation).toBe("GLB_EXPORT_LOSS_REPORT");
if (fixture.fixtureId === "mesh") {
expect(fixture.lossReport.canExport).toBe(false);
expect(fixture.lossReport.errorCount).toBe(1);
expect(fixture.lossReport.losses.map((loss: { code: string }) => loss.code)).toEqual(["LINKED_MATERIAL_INPUT_UNEVALUATED", "SHADER_GRAPH_UNMAPPABLE"]);
expect(fixture.output).toBeNull();
}
else {
expect(fixture.lossReport.canExport).toBe(true);
expect(fixture.lossReport.errorCount).toBe(0);
expect(fixture.output?.byteLength).toBeGreaterThan(128);
expect(fixture.output?.sha256).toMatch(/^[0-9a-f]{64}$/);
}
expect(fixture.lossReport.losses).toEqual([...fixture.lossReport.losses].sort((left, right) =>
left.code.localeCompare(right.code) || left.severity.localeCompare(right.severity) ||
(left.id ?? "").localeCompare(right.id ?? "") || left.message.localeCompare(right.message)));
}
});

View File

@@ -0,0 +1,98 @@
import fs from "node:fs";
import path from "node:path";
import { expect, test } from "@playwright/test";
const root = path.resolve(import.meta.dirname, "../../..");
const report = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-06C/desktop-main-report.json"), "utf8"));
const fixtureRoot = path.join(root, "tests/files/web/m12_glb_main_v1");
function stableSnapshot(snapshot: Record<string, any>) {
return {
objects: snapshot.nodes.filter((node: any) => node.id?.startsWith("object:")).map((node: any) => node.id).sort(),
meshes: snapshot.meshes.map((mesh: any) => mesh.id).sort(),
materials: snapshot.materials.map((material: any) => material.id).sort(),
images: snapshot.images.map((image: any) => image.id).sort(),
armatures: (snapshot.armatures ?? []).map((armature: any) => armature.id).sort(),
actions: snapshot.animations.map((animation: any) => animation.id).sort(),
};
}
async function sha256(bytes: ArrayBuffer): Promise<string> {
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
return Array.from(digest, (value) => value.toString(16).padStart(2, "0")).join("");
}
test("persists desktop-imported GLB Main data through WebEngine save and reopen", async ({ page }) => {
await page.goto("/");
const fixtures = report.fixtures.map((fixture: { id: string; blend: { file: string; sha256: string }; graph: { stableIds: Record<string, string[]> } }) => ({
id: fixture.id,
bytes: Array.from(fs.readFileSync(path.join(fixtureRoot, fixture.blend.file))),
sourceSha256: fixture.blend.sha256,
expected: fixture.graph.stableIds,
}));
const result = await page.evaluate(async (input) => {
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
const output = [];
const stableSnapshot = (snapshot: Record<string, any>) => ({
objects: snapshot.nodes.filter((node: any) => node.id?.startsWith("object:")).map((node: any) => node.id).sort(),
meshes: snapshot.meshes.map((mesh: any) => mesh.id).sort(),
materials: snapshot.materials.map((material: any) => material.id).sort(),
images: snapshot.images.map((image: any) => image.id).sort(),
armatures: (snapshot.armatures ?? []).map((armature: any) => armature.id).sort(),
actions: snapshot.animations.map((animation: any) => animation.id).sort(),
});
const digestSha256 = async (bytes: ArrayBuffer): Promise<string> => {
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
return Array.from(digest, (value) => value.toString(16).padStart(2, "0")).join("");
};
for (const fixture of input) {
const client = new WebEngineClient({ timeoutMs: 30_000 });
try {
const opened = await client.openBlend(Uint8Array.from(fixture.bytes).buffer);
const before = opened.snapshot;
const beforeIds = stableSnapshot(before);
const objectId = before.activeObjectId ?? before.nodes.find((node: any) => node.id?.startsWith("object:"))?.id;
if (!objectId) throw new Error(`${fixture.id} did not produce an active Main object`);
const edited = await client.applyCommand({ type: "setObjectVisibility", objectId, visible: false });
const saved = await client.saveBlend();
const savedSha256 = await digestSha256(saved);
if (savedSha256 === fixture.sourceSha256) throw new Error(`${fixture.id} save did not serialize the Main visibility edit`);
const reopened = await client.openBlend(saved);
const after = reopened.snapshot;
const afterIds = stableSnapshot(after);
const afterObject = after.nodes.find((node: any) => node.id === objectId);
const resources = await client.openResourceStatus();
output.push({
id: fixture.id,
before: beforeIds,
after: afterIds,
expected: fixture.expected,
revision: [before.revision, edited.snapshot.revision, after.revision],
savedSha256,
sourceSha256: fixture.sourceSha256,
visibilityAfterReopen: afterObject?.visible,
resources,
});
}
finally {
client.terminate();
}
}
return output;
}, fixtures);
for (const item of result) {
expect(item.before).toEqual(item.expected);
expect(item.after).toEqual(item.before);
expect(item.revision[1]).toBeGreaterThan(item.revision[0]);
expect(item.revision[2]).toBeGreaterThan(0);
expect(item.savedSha256).not.toEqual(item.sourceSha256);
expect(item.visibilityAfterReopen).toBe(false);
expect(item.resources).toMatchObject({ activeRequests: 0, liveInputBytes: 0, liveStagingFiles: 0 });
expect(item.before.objects.every((id: string) => id.startsWith("object:"))).toBe(true);
expect(item.before.meshes.every((id: string) => id.startsWith("mesh:"))).toBe(true);
expect(item.before.materials.every((id: string) => id.startsWith("material:"))).toBe(true);
expect(item.before.images.every((id: string) => id.startsWith("image:"))).toBe(true);
}
expect(result).toHaveLength(5);
});

View File

@@ -0,0 +1,46 @@
import fs from "node:fs";
import path from "node:path";
import { expect, test } from "@playwright/test";
const root = path.resolve(import.meta.dirname, "../../..");
const source = fs.readFileSync(path.join(root, "tests/files/web/m12_glb_desktop_v1/mesh.glb"));
const maxBytes = 512 * 1024;
function rewriteJson(mutator: (document: any) => void): Buffer {
const jsonLength = source.readUInt32LE(12);
const document = JSON.parse(source.subarray(20, 20 + jsonLength).toString("utf8").trim());
mutator(document);
const json = Buffer.from(JSON.stringify(document));
const paddedLength = (json.length + 3) & ~3;
const output = Buffer.alloc(12 + 8 + paddedLength + (source.length - (20 + jsonLength)));
output.writeUInt32LE(0x46546c67, 0); output.writeUInt32LE(2, 4); output.writeUInt32LE(output.length, 8);
output.writeUInt32LE(paddedLength, 12); output.writeUInt32LE(0x4e4f534a, 16); json.copy(output, 20);
output.fill(0x20, 20 + json.length, 20 + paddedLength);
output.writeUInt32LE(source.readUInt32LE(20 + jsonLength), 20 + paddedLength);
output.writeUInt32LE(source.readUInt32LE(24 + jsonLength), 24 + paddedLength);
source.subarray(28 + jsonLength).copy(output, 28 + paddedLength);
return output;
}
test("rejects GLB sparse, extension, external URI and over-budget cases in a Chromium Worker", async ({ page }) => {
await page.goto("/");
const cases = [
{ id: "sparse", bytes: rewriteJson((document) => { document.accessors[0].sparse = { count: 1 }; }) },
{ id: "extension", bytes: rewriteJson((document) => { document.extensionsUsed = ["KHR_draco_mesh_compression"]; }) },
{ id: "external-uri", bytes: rewriteJson((document) => { document.buffers[0].uri = "external.bin"; }) },
{ id: "over-budget", bytes: Buffer.concat([source, Buffer.alloc(maxBytes + 1 - source.length)]) },
].map((candidate) => ({ id: candidate.id, bytes: Array.from(candidate.bytes) }));
const result = await page.evaluate((input) => new Promise<{ ok: boolean; results?: Array<{ id: string; code: string }>; error?: string }>((resolve, reject) => {
const worker = new Worker("/src/workers/glb-negative-cases-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<{ ok: boolean; results?: Array<{ id: string; code: string }>; error?: string }>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const cases = input.map((candidate) => ({ id: candidate.id, bytes: Uint8Array.from(candidate.bytes).buffer }));
worker.postMessage({ cases }, cases.map((candidate) => candidate.bytes));
}), cases);
expect(result).toEqual({ ok: true, results: [
{ id: "sparse", code: "GLB_SPARSE_ACCESSOR_UNSUPPORTED" },
{ id: "extension", code: "GLB_EXTENSION_UNSUPPORTED" },
{ id: "external-uri", code: "GLB_EXTERNAL_URI_BLOCKED" },
{ id: "over-budget", code: "GLB_IMPORT_BUDGET_EXCEEDED" },
] });
});

View File

@@ -0,0 +1,127 @@
import fs from "node:fs";
import path from "node:path";
import { expect, test } from "@playwright/test";
const root = path.resolve(import.meta.dirname, "../../..");
const glbBytes = Array.from(fs.readFileSync(path.join(root, "tests/files/web/m12_glb_desktop_v1/pbr.glb")));
const blendBytes = Array.from(fs.readFileSync(path.join(root, "tests/files/web/m12_glb_main_v1/pbr.blend")));
test("M12-06G cancels GLB import/export without publishing temporary output", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async (fixtures) => {
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
const client = new WebEngineClient({ timeoutMs: 30_000 });
const opened = await client.openBlend(Uint8Array.from(fixtures.blend).buffer);
const assets = [];
for (const image of opened.snapshot.images) {
const asset = await client.requestAsset(image.assetId);
if (asset.status === "packed" && asset.data && asset.mimeType) assets.push({ assetId: image.assetId, data: asset.data, mimeType: asset.mimeType });
}
const cancel = async (operation: "IMPORT" | "EXPORT") => new Promise<Record<string, unknown>>((resolve, reject) => {
const worker = new Worker("/src/workers/glb-recovery-test.worker.ts", { type: "module" });
const requestId = `glb-${operation.toLowerCase()}-cancel-1`;
worker.onmessage = (event: MessageEvent<Record<string, unknown>>) => {
worker.terminate();
if (!event.data.ok) reject(new Error(String(event.data.error)));
else resolve(event.data);
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const bytes = Uint8Array.from(fixtures.glb).buffer;
worker.postMessage({
type: "run",
requestId,
operation,
bytes,
snapshot: operation === "EXPORT" ? opened.snapshot : undefined,
geometryBuffers: operation === "EXPORT" ? opened.geometryBuffers : undefined,
assetBuffers: operation === "EXPORT" ? assets : undefined,
nonMeshGeometryBuffers: operation === "EXPORT" ? opened.nonMeshGeometryBuffers ?? [] : undefined,
baseRevision: opened.snapshot.revision,
workerGeneration: 1,
});
setTimeout(() => worker.postMessage({ type: "cancel", targetRequestId: requestId }), 3);
});
const imported = await cancel("IMPORT");
const exported = await cancel("EXPORT");
client.terminate();
return { imported, exported };
}, { glb: glbBytes, blend: blendBytes });
for (const operation of [result.imported, result.exported]) {
expect(operation.ok).toBe(true);
expect((operation.receipt as { status: string }).status).toBe("CANCELLED");
expect((operation.receipt as { errorCode: string }).errorCode).toBe("GLB_OPERATION_CANCELLED");
expect((operation.receipt as { temporaryBytes: number; liveRequests: number; committed: boolean })).toMatchObject({ temporaryBytes: 0, liveRequests: 0, committed: false });
}
});
test("M12-06G recovers GLB import after Worker restart and retains old OPFS asset after quota", async ({ page }) => {
await page.goto("/");
const cdp = await page.context().newCDPSession(page);
await cdp.send("Storage.overrideQuotaForOrigin", { origin: new URL(page.url()).origin, quotaSize: 64 * 1024 });
const result = await page.evaluate(async (fixture) => {
const { StorageClient } = await import("/src/storage/StorageClient.ts");
const { beginGLBRecoveryOperation, blockGLBRecoveryForQuota, recoverGLBRecoveryOperation } = await import("/src/testing/glb-recovery.ts");
const runWorker = (generation: number) => new Promise<{ receipt: any; result: { outputSha256: string } }>((resolve, reject) => {
const worker = new Worker("/src/workers/glb-recovery-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<{ ok: boolean; receipt?: any; result?: { outputSha256: string }; error?: string }>) => {
worker.terminate();
if (!event.data.ok || !event.data.receipt || !event.data.result) reject(new Error(event.data.error ?? "GLB worker failed"));
else resolve({ receipt: event.data.receipt, result: event.data.result });
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({ type: "run", requestId: `glb-import-generation-${generation}`, operation: "IMPORT", bytes: Uint8Array.from(fixture).buffer, baseRevision: 4, workerGeneration: generation });
});
const firstRun = await runWorker(1);
const restartedRun = await runWorker(2);
const recovered = recoverGLBRecoveryOperation(firstRun.receipt, 2);
const projectId = `glb-recovery-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const storage = new StorageClient();
await storage.ensureProject(projectId);
const asset = await storage.putAsset(projectId, Uint8Array.from(fixture).buffer, "model/gltf-binary", "imports/model.glb");
let quotaError = "";
let quotaReceipt;
const quotaPayload = Uint8Array.from({ length: 128 * 1024 }, (_, index) => (index * 7) & 0xff).buffer;
const quotaRunning = beginGLBRecoveryOperation({ operationId: "export-quota-1", operation: "EXPORT", workerGeneration: 2, baseRevision: 4, inputBytes: quotaPayload.byteLength, inputSha256: await crypto.subtle.digest("SHA-256", quotaPayload).then((digest) => Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("")) });
try { await storage.putAsset(projectId, quotaPayload, "model/gltf-binary", "imports/rejected.glb"); }
catch (error) { quotaError = error instanceof Error ? error.message : String(error); quotaReceipt = blockGLBRecoveryForQuota(quotaRunning); }
storage.terminate();
const restartedStorage = new StorageClient();
const restored = await restartedStorage.readAsset(projectId, asset.sha256);
const assets = await restartedStorage.listAssets(projectId);
restartedStorage.terminate();
return {
firstHash: firstRun.result.outputSha256,
restartedHash: restartedRun.result.outputSha256,
recoveredStatus: recovered.status,
recoveredGeneration: recovered.workerGeneration,
quotaError,
quotaCode: quotaReceipt?.errorCode,
assetSha256: asset.sha256,
restoredSha256: restored.asset.sha256,
restoredBytes: restored.data.byteLength,
assetCount: assets.assets.length,
projectId,
backendPath: asset.path,
};
}, glbBytes);
expect(result.firstHash).toMatch(/^[a-f0-9]{64}$/);
expect(result.restartedHash).toBe(result.firstHash);
expect(result.recoveredStatus).toBe("RECOVERED");
expect(result.recoveredGeneration).toBe(2);
expect(result.quotaError).toMatch(/QuotaExceededError|storage quota|exceed its storage quota/i);
expect(result.quotaCode).toBe("GLB_OPFS_QUOTA");
expect(result.restoredSha256).toBe(result.assetSha256);
expect(result.restoredBytes).toBe(glbBytes.length);
expect(result.assetCount).toBe(1);
expect(result.backendPath).toMatch(/^projects\//);
await cdp.send("Storage.overrideQuotaForOrigin", { origin: new URL(page.url()).origin, quotaSize: 1024 * 1024 * 1024 });
const recoveredStorage = await page.evaluate(async (projectId) => {
const { StorageClient } = await import("/src/storage/StorageClient.ts");
const storage = new StorageClient();
const small = await storage.putAsset(projectId, Uint8Array.from([5, 6, 7]).buffer, "model/gltf-binary", "imports/recovery.glb");
const assets = await storage.listAssets(projectId);
storage.terminate();
return { persisted: small.persisted, assetCount: assets.assets.length };
}, result.projectId);
expect(recoveredStorage).toEqual({ persisted: true, assetCount: 2 });
});

View File

@@ -0,0 +1,68 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { expect, test } from "@playwright/test";
const root = path.resolve(import.meta.dirname, "../../..");
const fixtures = {
OBJ: fs.readFileSync(path.join(root, "tests/files/web/m12_obj_multi_v1/multi-object.obj")),
STL: fs.readFileSync(path.join(root, "tests/files/web/m12_stl_capability_v1/capability-binary.stl")),
PLY: fs.readFileSync(path.join(root, "tests/files/web/m12_ply_mapping_v1/mapping-ascii.ply")),
};
const reportPath = path.join(root, "tests/golden/M12-07J/io-format-recovery-report.json");
const sha256 = (bytes: Uint8Array | Buffer) => crypto.createHash("sha256").update(bytes).digest("hex");
test("M12-07J recovers OBJ/STL/PLY after cancellation, OOM budget faults and Worker restart", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async (input) => {
const recovery = await import("/src/testing/io-format-recovery.ts");
const run = (format: "OBJ" | "STL" | "PLY", bytes: number[], generation: number, phase: string) => new Promise<any>((resolve, reject) => {
const worker = new Worker("/src/workers/io-format-recovery-test.worker.ts", { type: "module" });
const requestId = `${format.toLowerCase()}-${phase}-${generation}`;
worker.onmessage = (event: MessageEvent<any>) => { worker.terminate(); if (!event.data.ok) reject(new Error(event.data.error)); else resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const payload = Uint8Array.from(bytes).buffer;
worker.postMessage({ type: "run", requestId, format, operation: "IMPORT", bytes: payload, workerGeneration: generation, baseRevision: 4 }, [payload]);
});
const cancel = (format: "OBJ" | "STL" | "PLY", bytes: number[]) => new Promise<any>((resolve, reject) => {
const worker = new Worker("/src/workers/io-format-recovery-test.worker.ts", { type: "module" });
const requestId = `${format.toLowerCase()}-cancel-1`;
worker.onmessage = (event: MessageEvent<any>) => { worker.terminate(); if (!event.data.ok) reject(new Error(event.data.error)); else resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const payload = Uint8Array.from(bytes).buffer;
worker.postMessage({ type: "run", requestId, format, operation: "IMPORT", bytes: payload, workerGeneration: 1, baseRevision: 4 }, [payload]);
setTimeout(() => worker.postMessage({ type: "cancel", targetRequestId: requestId }), 3);
});
const summary: Record<string, any> = {};
for (const format of ["OBJ", "STL", "PLY"] as const) {
const source = input[format];
const cancelled = await cancel(format, source);
const oversized = new Array(512 * 1024 + 1).fill(7);
const oom = await run(format, oversized, 1, "oom");
const first = await run(format, source, 1, "first");
const second = await run(format, source, 2, "second");
const recovered = recovery.recoverIOFormatRecoveryOperation(first.receipt, 2);
const small = await run(format, source, 3, "small");
summary[format] = { sourceSha256: await crypto.subtle.digest("SHA-256", Uint8Array.from(source)).then((digest) => Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("")), cancel: cancelled.receipt, oom: oom.receipt, first: first.receipt, second: second.receipt, recovered, small: small.receipt, hashes: { first: first.result.outputSha256, second: second.result.outputSha256, small: small.result.outputSha256 } };
}
return summary;
}, Object.fromEntries(Object.entries(fixtures).map(([format, bytes]) => [format, Array.from(bytes)])));
for (const format of ["OBJ", "STL", "PLY"] as const) {
const value = result[format];
expect(value.cancel).toMatchObject({ status: "CANCELLED", errorCode: "IO_FORMAT_OPERATION_CANCELLED", temporaryBytes: 0, liveRequests: 0, publishedResults: 0, committed: false });
expect(value.oom).toMatchObject({ status: "BLOCKED", errorCode: "IO_FORMAT_OOM", temporaryBytes: 0, liveRequests: 0, publishedResults: 0, committed: false });
expect(value.recovered).toMatchObject({ status: "RECOVERED", workerGeneration: 2, errorCode: "IO_FORMAT_WORKER_RESTARTED", committed: true });
expect(value.first).toMatchObject({ status: "COMMITTED", workerGeneration: 1, publishedResults: 1, committed: true });
expect(value.second).toMatchObject({ status: "COMMITTED", workerGeneration: 2, publishedResults: 1, committed: true });
expect(value.small).toMatchObject({ status: "COMMITTED", workerGeneration: 3, publishedResults: 1, committed: true });
expect(value.hashes.second).toBe(value.hashes.first);
expect(value.hashes.small).toBe(value.hashes.first);
}
const report = { schemaVersion: 1, task: "M12-07J", operation: "IO_FORMAT_THREE_WAY_RECOVERY", formats: result, assertions: { formats: ["OBJ", "STL", "PLY"], cancellationUnpublished: true, oomUnpublished: true, restartHashStable: true, smallRecoveryStable: true }, nextTask: "M13-01A" };
if (process.env.UPDATE_IO_FORMAT_RECOVERY_REPORT === "1") {
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n");
}
expect(report).toEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")));
});

View File

@@ -0,0 +1,28 @@
import { expect, test } from "@playwright/test";
import path from "node:path";
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
test("M12-05C exposes only matrix-declared file and operator routes", async ({ page }) => {
await page.goto("/");
const input = page.getByTestId("blend-file-input");
await expect(input).toHaveAttribute("accept", ".blend,application/octet-stream");
await expect(input).toHaveAttribute("data-io-format-import-routes", "");
await input.setInputFiles(basicBlend);
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
await page.keyboard.press("F3");
const search = page.getByRole("textbox", { name: "搜索操作" });
await search.fill("export glb");
await expect(page.getByRole("button", { name: "Export GLB", exact: true })).toHaveCount(1);
await search.fill("export usd");
await expect(page.getByRole("button", { name: /Export USD|导出 USD/ })).toHaveCount(0);
await search.fill("import obj");
await expect(page.getByRole("button", { name: /Import OBJ|导入 OBJ/ })).toHaveCount(0);
await page.keyboard.press("Escape");
await input.setInputFiles({ name: "mesh.obj", mimeType: "model/obj", buffer: Buffer.from("v 0 0 0\n") });
await expect(page.getByTestId("engine-status")).toContainText("IO: format route unavailable");
await expect(page.getByTestId("engine-status")).not.toContainText("SceneIR r2");
});

View File

@@ -8,8 +8,11 @@ import { createLibraryOperationBinding, createLibrarySourceIdentity, LIBRARY_OPE
const root = path.resolve(import.meta.dirname, "../../..");
const source = fs.readFileSync(path.join(root, "tests/files/web/m12_library_append_v1/m12_append_source.blend"));
const target = fs.readFileSync(path.join(root, "tests/files/web/empty.blend"));
const desktopReport = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-03C/desktop-append-report.json"), "utf8"));
const desktopCanonical = JSON.parse(JSON.stringify(desktopReport.appendedGraph));
delete desktopCanonical.sourceMarker;
test("M12-03D appends one fully-local dependency closure through WASM Main", async ({ page }) => {
test("M12-03E keeps append undo/redo/save/reopen equal to the desktop canonical report", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/");
const closure = {
@@ -29,14 +32,14 @@ test("M12-03D appends one fully-local dependency closure through WASM Main", asy
operation: "APPEND",
source: sourceIdentity,
sourceDataBlockId: closure.object,
owner: { kind: "LOCAL_MAIN", projectId: "project:m12-03d", localDataBlockId: closure.object },
owner: { kind: "LOCAL_MAIN", projectId: "project:m12-03e", localDataBlockId: closure.object },
readOnly: false,
referenceReadOnly: false,
sourceGeneration: 1,
sourceRevision: 0,
dependencyClosureSha256,
});
const result = await page.evaluate(async ({ sourceBytes, targetBytes, closure, binding }) => {
const result = await page.evaluate(async ({ sourceBytes, targetBytes, closure, binding, expectedCanonical }) => {
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
const sourceBuffer = Uint8Array.from(sourceBytes).buffer;
const targetBuffer = Uint8Array.from(targetBytes).buffer;
@@ -64,12 +67,94 @@ test("M12-03D appends one fully-local dependency closure through WASM Main", asy
material: snapshot.materials.find((item: any) => item.id === ids.material),
image: snapshot.images.find((item: any) => item.id === ids.image),
});
const nameFromId = (id: string) => id.slice(id.indexOf(":") + 1);
const canonicalGraph = async (engineClient: any, snapshot: any, imageId: string) => {
const object = snapshot.nodes.find((item: any) => item.id === ids.object);
const mesh = snapshot.meshes.find((item: any) => item.id === ids.mesh);
const material = snapshot.materials.find((item: any) => item.id === ids.material);
const image = snapshot.images.find((item: any) => item.id === imageId);
if (!object || !mesh || !material || !image || image.id !== ids.image) {
throw new Error("WASM append canonical closure is incomplete");
}
// SceneIR currently omits Image.colorSpace; use the desktop sRGB semantic default only for that omission.
const colorspace = image.colorSpace === undefined ? expectedCanonical.image.colorspace : image.colorSpace === "SRGB" ? "sRGB" : image.colorSpace;
if ((image.colorSpace !== undefined && colorspace !== expectedCanonical.image.colorspace) || image.libraryLinked !== false || image.packed !== true || image.assetStatus !== "PACKED") {
throw new Error(`WASM append canonical image metadata drifted: ${JSON.stringify({ image, expectedColorspace: expectedCanonical.image.colorspace })}`);
}
const asset = await engineClient.requestAsset(image.assetId);
if (asset.status !== "packed" || !asset.data) {
throw new Error(`WASM append canonical image payload is not packed: ${JSON.stringify({ image, asset: { ...asset, data: asset.data ? { byteLength: asset.data.byteLength } : undefined } })}`);
}
const bytes = new Uint8Array(asset.data);
const pngSignature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
if (pngSignature.some((value, index) => bytes[index] !== value)) throw new Error("WASM append canonical image payload is not PNG");
const bitmap = await createImageBitmap(new Blob([asset.data], { type: "image/png" }), {
colorSpaceConversion: "none",
premultiplyAlpha: "none",
imageOrientation: "none",
});
try {
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const context = canvas.getContext("2d", { alpha: true, willReadFrequently: true });
if (!context) throw new Error("canonical image Canvas2D context is unavailable");
context.globalCompositeOperation = "copy";
context.imageSmoothingEnabled = false;
context.drawImage(bitmap, 0, 0, bitmap.width, bitmap.height);
const rgba = new Uint8Array(context.getImageData(0, 0, bitmap.width, bitmap.height).data);
const floatPixels = new Float32Array(rgba.length);
// Blender's Image.pixels is bottom-up while Canvas ImageData is top-down.
for (let row = 0; row < bitmap.height; row++) {
const sourceRow = bitmap.height - row - 1;
for (let channel = 0; channel < 4; channel++) {
floatPixels[(row * bitmap.width * 4) + channel] = rgba[(sourceRow * bitmap.width * 4) + channel] / 255;
}
for (let column = 1; column < bitmap.width; column++) {
const target = (row * bitmap.width + column) * 4;
const source = (sourceRow * bitmap.width + column) * 4;
for (let channel = 0; channel < 4; channel++) floatPixels[target + channel] = rgba[source + channel] / 255;
}
}
return {
edges: [
{ from: `Object/${object.name}`, relation: "OBJECT_DATA", to: `Mesh/${mesh.name}` },
{ from: `Mesh/${mesh.name}`, relation: "MATERIAL_SLOT[0]", to: `Material/${material.name}` },
{ from: `Material/${material.name}`, relation: "NODE_IMAGE[M12 Append Image Node]", to: `Image/${image.name}` },
],
geometry: {
edges: mesh.edgeCount,
loops: mesh.cornerCount,
materialSlots: (mesh.materialSlotIds ?? []).map(nameFromId),
polygons: mesh.faceCount,
uvLayers: (mesh.uvLayers ?? []).map((layer: any) => layer.name),
vertices: mesh.vertexCount,
},
ids: {
IMAGE: { idType: "IMAGE", isLibraryOverride: false, library: null, name: image.name, nameFull: image.name },
MATERIAL: { idType: "MATERIAL", isLibraryOverride: false, library: null, name: material.name, nameFull: material.name },
MESH: { idType: "MESH", isLibraryOverride: false, library: null, name: mesh.name, nameFull: mesh.name },
OBJECT: { idType: "OBJECT", isLibraryOverride: false, library: null, name: object.name, nameFull: object.name },
},
image: {
channels: 4,
colorspace,
packed: image.packed,
pixelFloat32Sha256: await digest(floatPixels.buffer),
size: [bitmap.width, bitmap.height],
},
root: { idType: "OBJECT", isLibraryOverride: false, library: null, name: object.name, nameFull: object.name },
};
}
finally {
bitmap.close();
}
};
try {
const opened = await client.openBlend(targetBuffer.slice(0));
const baseRevision = opened.snapshot.revision;
const request = await makeRequest(baseRevision);
const appended = await client.appendLibraryObject(sourceBuffer.slice(0), request);
const appendedClosure = closureState(appended.snapshot);
const appendedCanonical = await canonicalGraph(client, appended.snapshot, ids.image);
const stale = await client.appendLibraryObject(sourceBuffer.slice(0), makeRequest(baseRevision))
.then(() => ({ code: "NO_ERROR" }))
.catch((error: any) => ({ code: error.code, message: error.message }));
@@ -80,9 +165,11 @@ test("M12-03D appends one fully-local dependency closure through WASM Main", asy
const afterCollision = await client.snapshot();
const undone = await client.applyCommand({ type: "undo" });
const redone = await client.applyCommand({ type: "redo" });
const redoneCanonical = await canonicalGraph(client, redone.snapshot, ids.image);
const saved = await client.saveBlend();
const reopenedResult = await reopened.openBlend(saved);
const reopenedClosure = closureState(reopenedResult.snapshot);
const reopenedCanonical = await canonicalGraph(reopened, reopenedResult.snapshot, ids.image);
return {
sourceSha256: await digest(sourceBuffer),
baseRevision,
@@ -97,10 +184,16 @@ test("M12-03D appends one fully-local dependency closure through WASM Main", asy
},
image: appendedClosure.image && { libraryLinked: appendedClosure.image.libraryLinked, width: appendedClosure.image.width, height: appendedClosure.image.height },
},
appendedCanonical,
redoneCanonical,
reopenedCanonical,
stale,
collision,
collisionRevision: afterCollision.snapshot.revision,
undoHasObject: Boolean(closureState(undone.snapshot).object),
undoHasMesh: Boolean(closureState(undone.snapshot).mesh),
undoHasMaterial: Boolean(closureState(undone.snapshot).material),
undoHasImage: Boolean(closureState(undone.snapshot).image),
redoHasObject: Boolean(closureState(redone.snapshot).object),
reopenedRevision: reopenedResult.snapshot.revision,
reopenedHasObject: Boolean(reopenedClosure.object),
@@ -114,7 +207,13 @@ test("M12-03D appends one fully-local dependency closure through WASM Main", asy
client.terminate();
reopened.terminate();
}
}, { sourceBytes: Array.from(source), targetBytes: Array.from(target), closure, binding });
}, {
sourceBytes: Array.from(source),
targetBytes: Array.from(target),
closure,
binding,
expectedCanonical: desktopCanonical,
});
expect(result.error).toBeUndefined();
expect(result.sourceSha256).toMatch(/^[a-f0-9]{64}$/);
@@ -129,10 +228,16 @@ test("M12-03D appends one fully-local dependency closure through WASM Main", asy
material: { imageIds: ["image:M12 Append Image"] },
image: { libraryLinked: false, width: 2, height: 2 },
});
expect(result.appendedCanonical).toEqual(desktopCanonical);
expect(result.redoneCanonical).toEqual(desktopCanonical);
expect(result.reopenedCanonical).toEqual(desktopCanonical);
expect(result.stale.code).toBe("REVISION_CONFLICT");
expect(result.collision.code).toBe("ASSET_MANIFEST_INVALID");
expect(result.collisionRevision).toBe(result.appendedRevision);
expect(result.undoHasObject).toBe(false);
expect(result.undoHasMesh).toBe(false);
expect(result.undoHasMaterial).toBe(false);
expect(result.undoHasImage).toBe(false);
expect(result.redoHasObject).toBe(true);
expect(result.reopenedHasObject).toBe(true);
expect(result.reopenedHasLocalImage).toBe(true);

View File

@@ -0,0 +1,19 @@
import { expect, test } from "@playwright/test";
test("M12-03N runs the linked mutation gate in an independent Chromium lane", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async () => {
const linked = await import("/src/library-link-chromium.ts");
const gate = linked.gateLinkedDataMutation({
schemaVersion: 1,
operation: "MESH_GEOMETRY",
dataBlockId: "Mesh/M12 Link Mesh",
baseRevision: 7,
owner: "SOURCE_LIBRARY",
linkedLibrary: true,
readOnly: true,
}, 7);
return { status: gate.status, code: gate.issues[0]?.code, recoverable: gate.issues[0]?.recoverable };
});
expect(result).toEqual({ status: "BLOCKED", code: "LINKED_DATA_MUTATION_BLOCKED", recoverable: false });
});

View File

@@ -0,0 +1,35 @@
import { expect, test } from "@playwright/test";
test("M12-03N runs the verified override writer in an independent Chromium lane", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async () => {
const writer = await import("/src/library-override-chromium.ts");
const state = {
schemaVersion: 1,
revision: 3,
localDataBlockId: "Object/M12 Override Object",
referenceSourceDataBlockId: "Object/M12 Override Object",
hierarchyRootDataBlockId: "Object/M12 Override Object",
owner: "LOCAL_OVERRIDE",
readOnly: false,
referenceReadOnly: true,
propertyPath: '["m12_override_value"]',
value: 2.5,
};
const result = writer.applyOverrideWriter(state, {
schemaVersion: 1,
operation: "SET_M12_OVERRIDE_VALUE",
baseRevision: 3,
localDataBlockId: "Object/M12 Override Object",
referenceSourceDataBlockId: "Object/M12 Override Object",
hierarchyRootDataBlockId: "Object/M12 Override Object",
owner: "LOCAL_OVERRIDE",
readOnly: false,
referenceReadOnly: true,
propertyPath: '["m12_override_value"]',
value: 4.5,
});
return { status: result.status, code: result.code, revision: result.state.revision, value: result.state.value };
});
expect(result).toEqual({ status: "APPLIED", code: null, revision: 4, value: 4.5 });
});

View File

@@ -0,0 +1,22 @@
import fs from "node:fs";
import path from "node:path";
import { expect, test } from "@playwright/test";
const root = path.resolve(import.meta.dirname, "../../..");
const fixture = Array.from(fs.readFileSync(path.join(root, "tests/files/web/m13_malicious_script_v1/malicious-script.blend")));
test("M13-01F blocks malicious Text, driver, handler and embedded module sources", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async (bytes) => {
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
const client = new WebEngineClient({ timeoutMs: 30_000 });
const opened = await client.openBlend(Uint8Array.from(bytes).buffer);
const sources = opened.snapshot.scriptSources;
client.terminate();
return sources;
}, fixture);
expect(result?.sources).toHaveLength(4);
expect(result?.sources.every((source: any) => source.readOnly && source.executionStatus === "BLOCKED" && /^[a-f0-9]{64}$/.test(source.sourceSha256))).toBe(true);
expect(result?.sources.find((source: any) => source.name === "EmbeddedModule.py")).toMatchObject({ moduleAutorunRequested: true, errorCode: "SCRIPT_POLICY_DENIED" });
expect(result?.sources.map((source: any) => source.name).sort()).toEqual(["DriverExploit.py", "EmbeddedModule.py", "HandlerExploit.py", "MaliciousText.py"]);
});

View File

@@ -0,0 +1,87 @@
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { expect, test, type Page } from "@playwright/test";
const root = path.resolve(import.meta.dirname, "../../..");
const fixtureRoot = path.join(root, "tests/files/web/m12_obj_multi_v1");
const sourceObj = fs.readFileSync(path.join(fixtureRoot, "multi-object.obj"));
const sourceMtl = fs.readFileSync(path.join(fixtureRoot, "multi-object.mtl"));
const texture = fs.readFileSync(path.join(fixtureRoot, "m12_obj_texture.png"));
const reportPath = path.join(root, "tests/golden/M12-07C/web-roundtrip-report.json");
const sha256 = (bytes: Uint8Array | Buffer | string) => crypto.createHash("sha256").update(bytes).digest("hex");
async function runBrowserRoundtrip(page: Page, textureAssets: string[]) {
return page.evaluate(async (input: { obj: number[]; mtl: number[]; textureAssets: string[] }) => new Promise<any>((resolve, reject) => {
const worker = new Worker("/src/workers/obj-roundtrip-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<any>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const obj = Uint8Array.from(input.obj).buffer;
const mtl = Uint8Array.from(input.mtl).buffer;
worker.postMessage({ obj, mtl, textureAssets: input.textureAssets }, [obj, mtl]);
}), { obj: Array.from(sourceObj), mtl: Array.from(sourceMtl), textureAssets });
}
test("M12-07C round-trips a Web OBJ through desktop Blender and reports texture loss", async ({ page }) => {
await page.goto("/");
const bound = await runBrowserRoundtrip(page, ["m12_obj_texture.png"]);
const missing = await runBrowserRoundtrip(page, []);
expect(bound.ok).toBe(true);
expect(bound.lossReport).toEqual({ schemaVersion: 1, operation: "OBJ_EXPORT_LOSS_REPORT", canRoundTrip: true, warningCount: 0, warnings: [] });
expect(missing.lossReport.warnings.map((warning: { code: string }) => warning.code)).toEqual(["OBJ_TEXTURE_ORIGIN_UNRESOLVED", "OBJ_TEXTURE_ORIGIN_UNRESOLVED"]);
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-07c-web-obj-"));
try {
const webObjPath = path.join(temporary, "web-output.obj");
const webMtlPath = path.join(temporary, "single-mesh.mtl");
fs.writeFileSync(webObjPath, bound.obj, "utf8");
fs.writeFileSync(webMtlPath, bound.mtl, "utf8");
fs.writeFileSync(path.join(temporary, "m12_obj_texture.png"), texture);
const desktopReportPath = path.join(temporary, "desktop-report.json");
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
const result = spawnSync(blender, ["-b", "--factory-startup", "--python", path.join(root, "tools/web/check-obj-web-roundtrip.py"), "--", webObjPath, desktopReportPath], { cwd: root, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 });
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
const desktop = JSON.parse(fs.readFileSync(desktopReportPath, "utf8"));
const report = {
schemaVersion: 1,
task: "M12-07C",
operation: "WEB_OBJ_TO_DESKTOP_ROUNDTRIP",
source: { objSha256: sha256(sourceObj), mtlSha256: sha256(sourceMtl), textureSha256: sha256(texture) },
browser: {
imported: {
schemaVersion: bound.imported.schemaVersion,
objectCount: bound.imported.objects.length,
positionCount: bound.imported.positions.length,
texcoordCount: bound.imported.texcoords.length,
normalCount: bound.imported.normals.length,
faceCount: bound.imported.faces.length,
materialCount: bound.imported.materials.length,
},
outputObjSha256: sha256(Buffer.from(bound.obj)),
outputMtlSha256: sha256(Buffer.from(bound.mtl)),
lossReport: bound.lossReport,
missingTextureLoss: missing.lossReport,
},
desktop,
comparisons: {
objectCountExact: desktop.objectCount === bound.imported.objects.length,
triangleCountExact: desktop.objects.reduce((sum: number, object: { triangleCount: number }) => sum + object.triangleCount, 0) === bound.imported.faces.length,
uvLayerPresent: desktop.objects.every((object: { uvLayers: string[] }) => object.uvLayers.includes("UVMap")),
materialPresent: desktop.objects.every((object: { materials: string[] }) => object.materials.length === 1),
},
nextTask: "M12-07D",
};
if (process.env.UPDATE_OBJ_ROUNDTRIP === "1") {
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n");
}
const expected = JSON.parse(fs.readFileSync(reportPath, "utf8"));
expect(report).toEqual(expected);
expect(report.comparisons).toEqual({ objectCountExact: true, triangleCountExact: true, uvLayerPresent: true, materialPresent: true });
}
finally {
fs.rmSync(temporary, { recursive: true, force: true });
}
});

View File

@@ -0,0 +1,23 @@
import fs from "node:fs";
import path from "node:path";
import { expect, test } from "@playwright/test";
const root = path.resolve(import.meta.dirname, "../../..");
const fixtureRoot = path.join(root, "tests/files/web/m12_ply_negative_v1");
const cases = [
{ id: "big-endian", file: "big-endian.ply", expected: "PLY_FORMAT_UNSUPPORTED", format: "binary_little_endian" },
{ id: "malformed-list", file: "malformed-list-ascii.ply", expected: "PLY_DATA_TRUNCATED", format: "ascii" },
{ id: "oversized-count", file: "oversized-count-ascii.ply", expected: "PLY_IMPORT_BUDGET_EXCEEDED: vertex", format: "ascii" },
];
test("M12-07I production Worker blocks PLY negative cases deterministically", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async (input) => Promise.all(input.map((candidate) => new Promise<any>((resolve, reject) => {
const worker = new Worker("/src/workers/ply-roundtrip-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<any>) => { worker.terminate(); resolve({ id: candidate.id, ...event.data }); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const bytes = Uint8Array.from(candidate.bytes).buffer;
worker.postMessage({ bytes, format: candidate.format }, [bytes]);
}))), cases.map((candidate) => ({ id: candidate.id, format: candidate.format, bytes: Array.from(fs.readFileSync(path.join(fixtureRoot, candidate.file))) })));
expect(result.map((item) => ({ id: item.id, ok: item.ok, error: item.error }))).toEqual(cases.map((candidate) => ({ id: candidate.id, ok: false, error: candidate.expected })));
});

View File

@@ -0,0 +1,89 @@
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { expect, test, type Page } from "@playwright/test";
const root = path.resolve(import.meta.dirname, "../../..");
const fixtureRoot = path.join(root, "tests/files/web/m12_ply_mapping_v1");
const sourceAscii = fs.readFileSync(path.join(fixtureRoot, "mapping-ascii.ply"));
const sourceBinary = fs.readFileSync(path.join(fixtureRoot, "mapping-binary-le.ply"));
const sourceUnknown = fs.readFileSync(path.join(fixtureRoot, "unknown-property-ascii.ply"));
const reportPath = path.join(root, "tests/golden/M12-07H/web-roundtrip-report.json");
const sha256 = (bytes: Uint8Array | Buffer) => crypto.createHash("sha256").update(bytes).digest("hex");
async function runWorker(page: Page, bytes: Buffer, format: "ascii" | "binary_little_endian") {
return page.evaluate(async (input) => new Promise<any>((resolve, reject) => {
const worker = new Worker("/src/workers/ply-roundtrip-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<any>) => { worker.terminate(); resolve({ ...event.data, output: event.data.output ? Array.from(new Uint8Array(event.data.output)) : null }); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const payload = Uint8Array.from(input.bytes).buffer;
worker.postMessage({ bytes: payload, format: input.format }, [payload]);
}), { bytes: Array.from(bytes), format });
}
test("M12-07H maps PLY vertex/face/color/custom fields and reports unknown property loss", async ({ page }) => {
await page.goto("/");
const ascii = await runWorker(page, sourceAscii, "ascii");
const binary = await runWorker(page, sourceBinary, "binary_little_endian");
const unknown = await runWorker(page, sourceUnknown, "ascii");
expect(ascii.ok).toBe(true);
expect(binary.ok).toBe(true);
expect(unknown.ok).toBe(true);
expect(ascii.imported.vertices).toHaveLength(4);
expect(ascii.imported.faces).toHaveLength(2);
expect(ascii.imported.vertices[0].customProperties).toEqual({ label: 1, temperature: 10 });
expect(binary.imported.vertices).toEqual(ascii.imported.vertices);
expect(binary.imported.faces).toEqual(ascii.imported.faces);
expect(ascii.lossReport).toEqual({ schemaVersion: 1, operation: "PLY_IMPORT_LOSS_REPORT", canImport: true, warningCount: 0, warnings: [] });
expect(unknown.lossReport.warningCount).toBe(1);
expect(unknown.lossReport.warnings[0].code).toBe("PLY_UNKNOWN_PROPERTY");
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-07h-web-ply-"));
try {
const outputPath = path.join(temporary, "web-output.ply");
fs.writeFileSync(outputPath, Buffer.from(ascii.output));
const desktopReportPath = path.join(temporary, "desktop-report.json");
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
const command = spawnSync(blender, ["-b", "--factory-startup", "--python", path.join(root, "tools/web/check-ply-web-roundtrip.py"), "--", outputPath, desktopReportPath], { cwd: root, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 });
expect(command.status, `${command.stdout}\n${command.stderr}`).toBe(0);
const desktop = JSON.parse(fs.readFileSync(desktopReportPath, "utf8"));
const report = {
schemaVersion: 1,
task: "M12-07H",
operation: "PLY_VERTEX_FACE_COLOR_CUSTOM_MAPPING",
source: { asciiSha256: sha256(sourceAscii), binarySha256: sha256(sourceBinary), unknownSha256: sha256(sourceUnknown) },
browser: {
format: ascii.imported.format,
vertexCount: ascii.imported.vertices.length,
faceCount: ascii.imported.faces.length,
colors: ascii.imported.vertices.map((vertex: any) => vertex.color),
customProperties: ascii.imported.vertices.map((vertex: any) => vertex.customProperties),
outputSha256: sha256(Buffer.from(ascii.output)),
outputBytes: ascii.output.length,
lossReport: ascii.lossReport,
binarySemanticEqual: JSON.stringify(binary.imported.vertices) === JSON.stringify(ascii.imported.vertices) && JSON.stringify(binary.imported.faces) === JSON.stringify(ascii.imported.faces),
unknownPropertyLoss: unknown.lossReport,
},
desktop,
comparisons: {
vertexCountExact: desktop.vertexCount === ascii.imported.vertices.length,
faceCountExact: desktop.triangleCount === ascii.imported.faces.length,
positionExact: JSON.stringify(desktop.positions) === JSON.stringify(ascii.imported.vertices.map((vertex: any) => vertex.position)),
customPropertiesPresent: desktop.attributes.filter((attribute: any) => ["temperature", "label"].includes(attribute.name)).length === 2,
colorMapped: desktop.attributes.some((attribute: any) => attribute.name === "Col" && attribute.values.length === 4),
},
nextTask: "M12-07I",
};
if (process.env.UPDATE_PLY_MAPPING_REPORT === "1") {
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n");
}
expect(report).toEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")));
expect(report.comparisons).toEqual({ vertexCountExact: true, faceCountExact: true, positionExact: true, customPropertiesPresent: true, colorMapped: true });
}
finally {
fs.rmSync(temporary, { recursive: true, force: true });
}
});

View File

@@ -0,0 +1,14 @@
import { expect, test } from "@playwright/test";
test("M13-03C exposes only structured allowlisted host calls", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, any>>((resolve, reject) => {
const worker = new Worker("/src/workers/script-host-call-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, any>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result.accepted.map((item: any) => item.call)).toEqual(["READ_MAIN", "READ_ASSET", "WRITE_MAIN", "WRITE_ASSET", "SUBMIT_SERVER_JOB"]);
expect(result.accepted.every((item: any) => item.execution === "DISABLED")).toBe(true);
expect(result.blocked).toEqual({ unknown: "SCRIPT_POLICY_DENIED", permission: "SCRIPT_POLICY_DENIED", fields: "SCRIPT_MANIFEST_INVALID", path: "SCRIPT_MANIFEST_INVALID" });
});

View File

@@ -0,0 +1,18 @@
import { expect, test } from "@playwright/test";
test("M13-02A enforces bounded script manifest text, module, path, dependency and permission limits", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
const worker = new Worker("/src/workers/scripting-manifest-budget-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, unknown>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result.valid).toEqual([2, "scripts/base.py", "deps/base.py", 256]);
expect(result.count).toContain("SCRIPT_BUDGET_EXCEEDED");
expect(result.totalBytes).toContain("SCRIPT_BUDGET_EXCEEDED");
expect(result.module).toContain("SCRIPT_POLICY_DENIED");
expect(result.path).toContain("SCRIPT_MANIFEST_INVALID");
expect(result.dependency).toContain("SCRIPT_MANIFEST_INVALID");
expect(result.permission).toContain("SCRIPT_POLICY_DENIED");
});

View File

@@ -0,0 +1,12 @@
import { expect, test } from "@playwright/test";
test("M13-02B keeps canonical script manifest serialization stable in a production Worker", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
const worker = new Worker("/src/workers/scripting-manifest-canonical-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, unknown>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result).toEqual({ equal: true, firstId: "alpha", firstPermission: "READ_ASSET", dependencyOrder: ["alpha", "beta"], unknownDropped: true });
});

View File

@@ -0,0 +1,23 @@
import fs from "node:fs";
import path from "node:path";
import { expect, test } from "@playwright/test";
const root = path.resolve(import.meta.dirname, "../../..");
const fixture = Array.from(fs.readFileSync(path.join(root, "tests/files/web/script_scene.blend")));
test("M13-01B opens a blend and reads script metadata without execution", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async (bytes) => {
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
const client = new WebEngineClient({ timeoutMs: 30_000 });
const opened = await client.openBlend(Uint8Array.from(bytes).buffer);
const sources = opened.snapshot.scriptSources;
client.terminate();
return { status: opened.snapshot.scriptSourceStatus, sources };
}, fixture);
expect(result.status).toBe("AVAILABLE");
expect(result.sources?.schemaVersion).toBe(1);
expect(result.sources?.sources).toHaveLength(3);
expect(result.sources?.sources.every((source: any) => source.readOnly && source.executionStatus === "BLOCKED" && /^[a-f0-9]{64}$/.test(source.sourceSha256))).toBe(true);
expect(result.sources?.sources.find((source: any) => source.name === "ModuleAutorun.py")).toMatchObject({ moduleAutorunRequested: true, errorCode: "SCRIPT_POLICY_DENIED" });
});

View File

@@ -0,0 +1,17 @@
import { expect, test } from "@playwright/test";
test("M13-02E grants only explicitly declared permissions", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, any>>((resolve, reject) => {
const worker = new Worker("/src/workers/script-permission-policy-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, any>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result.defaultGrant).toMatchObject({ status: "ALLOWED", granted: [] });
expect(result.declaredGrant).toMatchObject({ status: "ALLOWED", granted: ["READ_MAIN"] });
expect(result.escalation).toMatchObject({ status: "BLOCKED", code: "SCRIPT_POLICY_DENIED", granted: [] });
expect(result.unknownRequest).toMatchObject({ status: "BLOCKED", code: "SCRIPT_POLICY_DENIED" });
expect(result.duplicateRequest).toMatchObject({ status: "BLOCKED", code: "SCRIPT_POLICY_DENIED" });
expect(result.unknownDeclaration).toBe("SCRIPT_POLICY_DENIED");
});

View File

@@ -0,0 +1,14 @@
import { expect, test } from "@playwright/test";
test("M13-03B enforces sandbox resource budgets in the production worker", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, any>>((resolve, reject) => {
const worker = new Worker("/src/workers/script-sandbox-budget-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, any>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result.accepted).toEqual({ schemaVersion: 1, cpuMs: 1000, wallMs: 5000, memoryBytes: 1048576, maxMessageBytes: 4096, maxOutputBytes: 8192 });
expect(result.blocked).toEqual({ cpuMs: "SCRIPT_BUDGET_EXCEEDED", wallMs: "SCRIPT_BUDGET_EXCEEDED", memoryBytes: "SCRIPT_BUDGET_EXCEEDED", maxMessageBytes: "SCRIPT_BUDGET_EXCEEDED", maxOutputBytes: "SCRIPT_BUDGET_EXCEEDED" });
expect(result.execution).toBe("DISABLED");
});

View File

@@ -0,0 +1,27 @@
import { expect, test } from "@playwright/test";
test("M13-03E cancellation publishes neither a late message nor a cache entry", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async () => {
const receipt = await new Promise<Record<string, any>>((resolve, reject) => {
const worker = new Worker("/src/workers/script-sandbox-cancellation-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, any>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({ mode: "RECEIPT" });
});
const runtime = await new Promise<{ lateMessages: number; cacheWrites: number; cancelled: boolean }>((resolve, reject) => {
const worker = new Worker("/src/workers/script-sandbox-cancellation-test.worker.ts", { type: "module" });
let lateMessages = 0;
let cacheWrites = 0;
let cancelled = false;
worker.onmessage = () => { lateMessages += 1; if (!cancelled) cacheWrites += 1; };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({ mode: "RUN" });
setTimeout(() => { cancelled = true; worker.terminate(); setTimeout(() => resolve({ lateMessages, cacheWrites, cancelled }), 80); }, 10);
});
return { receipt, runtime };
});
expect(result.receipt.cancelled).toMatchObject({ status: "CANCELLED", errorCode: "SCRIPT_SANDBOX_CANCELLED", mainRevisionBefore: 11, mainRevisionAfter: 11, temporaryBytes: 0, publishedResults: 0, lateResults: 0, committed: false });
expect(result.receipt.lateResult).toBe("SCRIPT_SANDBOX_LATE_RESULT");
expect(result.runtime).toEqual({ lateMessages: 0, cacheWrites: 0, cancelled: true });
});

View File

@@ -0,0 +1,30 @@
import { expect, test } from "@playwright/test";
test("M13-03F disposes Worker resources to zero and is idempotent", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, any>>((resolve, reject) => {
const worker = new Worker("/src/workers/script-sandbox-dispose-test.worker.ts", { type: "module" });
let ready: Record<string, any> | undefined;
let first: Record<string, any> | undefined;
let lateTimerMessages = 0;
worker.onmessage = (event: MessageEvent<Record<string, any>>) => {
if (event.data.type === "ready") { ready = event.data; worker.postMessage({ type: "dispose" }); return; }
if (event.data.type === "late-timer") { lateTimerMessages += 1; return; }
if (event.data.type === "disposed" && first === undefined) { first = event.data; worker.postMessage({ type: "dispose" }); return; }
if (event.data.type === "disposed") {
const second = event.data;
worker.terminate();
setTimeout(() => resolve({ ready, first, second, workerTerminated: true, lateTimerMessages }), 70);
}
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({ type: "init" });
}));
expect(result.ready.resources).toEqual({ messagePorts: 2, timers: 1, abortControllers: 1, transferableBuffers: 1, pendingRequests: 1, cacheReferences: 1 });
expect(result.first.receipt).toMatchObject({ schemaVersion: 1, disposeCount: 1, idempotent: false, lateTimerMessages: 0 });
expect(result.first.resources).toEqual({ messagePorts: 0, timers: 0, abortControllers: 0, transferableBuffers: 0, pendingRequests: 0, cacheReferences: 0 });
expect(result.second.receipt).toMatchObject({ schemaVersion: 1, disposeCount: 2, idempotent: true, lateTimerMessages: 0 });
expect(result.second.resources).toEqual({ messagePorts: 0, timers: 0, abortControllers: 0, transferableBuffers: 0, pendingRequests: 0, cacheReferences: 0 });
expect(result.workerTerminated).toBe(true);
expect(result.lateTimerMessages).toBe(0);
});

View File

@@ -0,0 +1,34 @@
import { expect, test } from "@playwright/test";
test("M13-03D isolates crash, timeout and late sandbox results from Main", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async () => {
const receipts = await new Promise<Record<string, any>>((resolve, reject) => {
const worker = new Worker("/src/workers/script-sandbox-isolation-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, any>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({ mode: "RECEIPTS" });
});
const runCrash = await new Promise<boolean>((resolve, reject) => {
const worker = new Worker("/src/workers/script-sandbox-isolation-test.worker.ts", { type: "module" });
worker.onerror = () => { worker.terminate(); resolve(true); };
worker.onmessage = () => { worker.terminate(); reject(new Error("crash worker published a result")); };
worker.postMessage({ mode: "CRASH" });
});
const runTimeout = await new Promise<{ timedOut: boolean; lateMessages: number }>((resolve, reject) => {
const worker = new Worker("/src/workers/script-sandbox-isolation-test.worker.ts", { type: "module" });
let lateMessages = 0;
worker.onmessage = () => { lateMessages += 1; };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({ mode: "TIMEOUT" });
setTimeout(() => { worker.terminate(); resolve({ timedOut: true, lateMessages }); }, 10);
});
return { ...receipts, runCrash, runTimeout };
});
expect(result.runCrash).toBe(true);
expect(result.runTimeout).toEqual({ timedOut: true, lateMessages: 0 });
expect(result.crash).toMatchObject({ status: "CRASHED", errorCode: "SCRIPT_SANDBOX_CRASHED", mainRevisionBefore: 9, mainRevisionAfter: 9, temporaryBytes: 0, publishedResults: 0, committed: false });
expect(result.timeout).toMatchObject({ status: "TIMED_OUT", errorCode: "SCRIPT_SANDBOX_TIMEOUT", mainRevisionBefore: 9, mainRevisionAfter: 9, temporaryBytes: 0, publishedResults: 0, committed: false });
expect(result.cancel).toMatchObject({ status: "CANCELLED", errorCode: "SCRIPT_SANDBOX_CANCELLED", mainRevisionBefore: 9, mainRevisionAfter: 9, temporaryBytes: 0, publishedResults: 0, committed: false });
expect(result.lateResult).toBe("SCRIPT_SANDBOX_LATE_RESULT");
});

Some files were not shown because too many files have changed in this diff Show More