Checkpoint web parity through Chromium input tasks
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
282
web/app/src/capabilities/io-format-runtime-receipts.json
Normal file
282
web/app/src/capabilities/io-format-runtime-receipts.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
17
web/app/src/capabilities/io-format-ui-registry.json
Normal file
17
web/app/src/capabilities/io-format-ui-registry.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
1
web/app/src/library-link-chromium.ts
Normal file
1
web/app/src/library-link-chromium.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { gateLinkedDataMutation } from "../../protocol/library-linked-mutation";
|
||||
1
web/app/src/library-override-chromium.ts
Normal file
1
web/app/src/library-override-chromium.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { applyOverrideWriter } from "../../protocol/library-override-writer";
|
||||
@@ -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> {
|
||||
|
||||
7
web/app/src/testing/glb-recovery.ts
Normal file
7
web/app/src/testing/glb-recovery.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
GLB_RECOVERY_SCHEMA_VERSION,
|
||||
beginGLBRecoveryOperation,
|
||||
blockGLBRecoveryForQuota,
|
||||
parseGLBRecoveryReceipt,
|
||||
recoverGLBRecoveryOperation,
|
||||
} from "../../../protocol/glb-recovery";
|
||||
9
web/app/src/testing/io-format-recovery.ts
Normal file
9
web/app/src/testing/io-format-recovery.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
IO_FORMAT_RECOVERY_SCHEMA_VERSION,
|
||||
beginIOFormatRecoveryOperation,
|
||||
blockIOFormatRecoveryOperation,
|
||||
cancelIOFormatRecoveryOperation,
|
||||
commitIOFormatRecoveryOperation,
|
||||
parseIOFormatRecoveryReceipt,
|
||||
recoverIOFormatRecoveryOperation,
|
||||
} from "../../../protocol/io-format-recovery";
|
||||
27
web/app/src/testing/script-sandbox-dispose.ts
Normal file
27
web/app/src/testing/script-sandbox-dispose.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
56
web/app/src/testing/script-sandbox-recovery.ts
Normal file
56
web/app/src/testing/script-sandbox-recovery.ts
Normal 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" };
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
47
web/app/src/workers/glb-desktop-import-test.worker.ts
Normal file
47
web/app/src/workers/glb-desktop-import-test.worker.ts
Normal 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 {};
|
||||
33
web/app/src/workers/glb-loss-report-test.worker.ts
Normal file
33
web/app/src/workers/glb-loss-report-test.worker.ts
Normal 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 {};
|
||||
26
web/app/src/workers/glb-negative-cases-test.worker.ts
Normal file
26
web/app/src/workers/glb-negative-cases-test.worker.ts
Normal 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 {};
|
||||
106
web/app/src/workers/glb-recovery-test.worker.ts
Normal file
106
web/app/src/workers/glb-recovery-test.worker.ts
Normal 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);
|
||||
};
|
||||
62
web/app/src/workers/io-format-recovery-test.worker.ts
Normal file
62
web/app/src/workers/io-format-recovery-test.worker.ts
Normal 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 {};
|
||||
26
web/app/src/workers/obj-roundtrip-test.worker.ts
Normal file
26
web/app/src/workers/obj-roundtrip-test.worker.ts
Normal 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 {};
|
||||
22
web/app/src/workers/ply-roundtrip-test.worker.ts
Normal file
22
web/app/src/workers/ply-roundtrip-test.worker.ts
Normal 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 {};
|
||||
25
web/app/src/workers/script-host-call-test.worker.ts
Normal file
25
web/app/src/workers/script-host-call-test.worker.ts
Normal 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 });
|
||||
};
|
||||
17
web/app/src/workers/script-permission-policy-test.worker.ts
Normal file
17
web/app/src/workers/script-permission-policy-test.worker.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
12
web/app/src/workers/script-sandbox-budget-test.worker.ts
Normal file
12
web/app/src/workers/script-sandbox-budget-test.worker.ts
Normal 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" });
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
52
web/app/src/workers/script-sandbox-dispose-test.worker.ts
Normal file
52
web/app/src/workers/script-sandbox-dispose-test.worker.ts
Normal 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() });
|
||||
}
|
||||
};
|
||||
24
web/app/src/workers/script-sandbox-isolation-test.worker.ts
Normal file
24
web/app/src/workers/script-sandbox-isolation-test.worker.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
39
web/app/src/workers/script-sandbox-recovery-test.worker.ts
Normal file
39
web/app/src/workers/script-sandbox-recovery-test.worker.ts
Normal 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 });
|
||||
};
|
||||
12
web/app/src/workers/script-sandbox-scope-test.worker.ts
Normal file
12
web/app/src/workers/script-sandbox-scope-test.worker.ts
Normal 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" });
|
||||
};
|
||||
17
web/app/src/workers/script-signature-negative-test.worker.ts
Normal file
17
web/app/src/workers/script-signature-negative-test.worker.ts
Normal 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 });
|
||||
};
|
||||
15
web/app/src/workers/script-signature-test.worker.ts
Normal file
15
web/app/src/workers/script-signature-test.worker.ts
Normal 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 });
|
||||
};
|
||||
19
web/app/src/workers/script-trust-policy-test.worker.ts
Normal file
19
web/app/src/workers/script-trust-policy-test.worker.ts
Normal 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 });
|
||||
};
|
||||
52
web/app/src/workers/scripting-manifest-budget-test.worker.ts
Normal file
52
web/app/src/workers/scripting-manifest-budget-test.worker.ts
Normal 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"] })])),
|
||||
});
|
||||
};
|
||||
@@ -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]) });
|
||||
};
|
||||
@@ -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 () => {
|
||||
|
||||
24
web/app/src/workers/stl-edge-test.worker.ts
Normal file
24
web/app/src/workers/stl-edge-test.worker.ts
Normal 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 {};
|
||||
23
web/app/src/workers/stl-roundtrip-test.worker.ts
Normal file
23
web/app/src/workers/stl-roundtrip-test.worker.ts
Normal 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 {};
|
||||
@@ -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));
|
||||
|
||||
@@ -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": {
|
||||
|
||||
97
web/protocol/archive-conflicts.ts
Normal file
97
web/protocol/archive-conflicts.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const ARCHIVE_CONFLICT_SCHEMA = 1 as const;
|
||||
export interface ArchiveConflictRangeIR {
|
||||
path: string;
|
||||
compressedBytes: number;
|
||||
uncompressedBytes: number;
|
||||
compressedOffset: number;
|
||||
}
|
||||
export interface ArchiveConflictRequestIR {
|
||||
schemaVersion: typeof ARCHIVE_CONFLICT_SCHEMA;
|
||||
byteLength: number | null;
|
||||
ranges: ArchiveConflictRangeIR[];
|
||||
}
|
||||
export interface ArchiveConflictValidationIR {
|
||||
status: "VALID";
|
||||
totalCompressedBytes: number;
|
||||
totalUncompressedBytes: number;
|
||||
nonOverlapping: true;
|
||||
uniquePaths: true;
|
||||
noPrefixConflicts: true;
|
||||
}
|
||||
|
||||
export class ArchiveConflictError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
readonly path?: string;
|
||||
constructor(message: string, path?: string) { super(`IO_ARCHIVE_UNSAFE: ${message}`); this.name = "ArchiveConflictError"; this.code = "IO_ARCHIVE_UNSAFE"; this.path = path; }
|
||||
}
|
||||
|
||||
export const ARCHIVE_CONFLICT_BUDGET = {
|
||||
maxEntries: 100_000,
|
||||
maxEntryBytes: 2 * 1024 * 1024 * 1024,
|
||||
maxArchiveBytes: 4 * 1024 * 1024 * 1024,
|
||||
maxCompressionRatio: 100,
|
||||
} as const;
|
||||
|
||||
const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/;
|
||||
const DRIVE_PATH = /^[A-Za-z]:[\\/]/;
|
||||
const URI_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:/;
|
||||
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new ArchiveConflictError(`${path} must be an object`, path);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
function exactKeys(value: Record<string, unknown>, expected: readonly string[], path: string): void {
|
||||
const actual = Object.keys(value).sort(); const allowed = [...expected].sort();
|
||||
if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) throw new ArchiveConflictError(`${path} contains undeclared fields`, path);
|
||||
}
|
||||
function integer(value: unknown, path: string, minimum = 0, maximum = Number.MAX_SAFE_INTEGER): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) throw new ArchiveConflictError(`${path} is outside its bounded range`, path);
|
||||
return value;
|
||||
}
|
||||
function archivePath(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > 2_048 || CONTROL_CHARACTER.test(value)) throw new ArchiveConflictError(`${path} is invalid`, path);
|
||||
if (value.startsWith("/") || value.startsWith("\\") || DRIVE_PATH.test(value) || URI_SCHEME.test(value) || value.includes("\\")) throw new ArchiveConflictError(`${path} escapes the project`, path);
|
||||
const segments: string[] = [];
|
||||
for (const segment of value.normalize("NFC").split("/")) {
|
||||
if (!segment || segment === ".") continue;
|
||||
if (segment === "..") { if (segments.length === 0) throw new ArchiveConflictError(`${path} escapes the project`, path); segments.pop(); continue; }
|
||||
segments.push(segment);
|
||||
}
|
||||
const result = segments.join("/");
|
||||
if (!result) throw new ArchiveConflictError(`${path} is empty`, path);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function validateArchiveConflicts(value: unknown): ArchiveConflictValidationIR {
|
||||
const input = record(value, "input");
|
||||
exactKeys(input, ["schemaVersion", "byteLength", "ranges"], "input");
|
||||
if (input.schemaVersion !== ARCHIVE_CONFLICT_SCHEMA) throw new ArchiveConflictError("unsupported archive conflict schema", "input.schemaVersion");
|
||||
const byteLength = input.byteLength === null ? null : integer(input.byteLength, "input.byteLength", 1, ARCHIVE_CONFLICT_BUDGET.maxArchiveBytes);
|
||||
if (!Array.isArray(input.ranges) || input.ranges.length === 0 || input.ranges.length > ARCHIVE_CONFLICT_BUDGET.maxEntries) throw new ArchiveConflictError("ranges exceeds its bound", "input.ranges");
|
||||
const ranges = input.ranges.map((value, index) => {
|
||||
const path = `ranges[${index}]`; const item = record(value, path); exactKeys(item, ["path", "compressedBytes", "uncompressedBytes", "compressedOffset"], path);
|
||||
return {
|
||||
path: archivePath(item.path, `${path}.path`),
|
||||
compressedBytes: integer(item.compressedBytes, `${path}.compressedBytes`, 0, ARCHIVE_CONFLICT_BUDGET.maxEntryBytes),
|
||||
uncompressedBytes: integer(item.uncompressedBytes, `${path}.uncompressedBytes`, 0, ARCHIVE_CONFLICT_BUDGET.maxEntryBytes),
|
||||
compressedOffset: integer(item.compressedOffset, `${path}.compressedOffset`, 0, ARCHIVE_CONFLICT_BUDGET.maxArchiveBytes),
|
||||
};
|
||||
});
|
||||
const paths = new Set<string>(); let totalCompressedBytes = 0; let totalUncompressedBytes = 0;
|
||||
for (const range of ranges) {
|
||||
if (paths.has(range.path) || [...paths].some((existing) => existing.startsWith(`${range.path}/`) || range.path.startsWith(`${existing}/`))) throw new ArchiveConflictError(`duplicate or file/directory prefix conflict at ${range.path}`, "input.ranges");
|
||||
paths.add(range.path);
|
||||
if (range.uncompressedBytes > 0 && (range.compressedBytes === 0 || range.uncompressedBytes / range.compressedBytes > ARCHIVE_CONFLICT_BUDGET.maxCompressionRatio)) throw new ArchiveConflictError(`compression ratio exceeds the budget at ${range.path}`, "input.ranges");
|
||||
totalCompressedBytes += range.compressedBytes; totalUncompressedBytes += range.uncompressedBytes;
|
||||
if (!Number.isSafeInteger(totalCompressedBytes) || !Number.isSafeInteger(totalUncompressedBytes) || totalCompressedBytes > ARCHIVE_CONFLICT_BUDGET.maxArchiveBytes || totalUncompressedBytes > ARCHIVE_CONFLICT_BUDGET.maxArchiveBytes) throw new ArchiveConflictError("archive total byte budget exceeded", "input.ranges");
|
||||
if (range.compressedOffset + range.compressedBytes > ARCHIVE_CONFLICT_BUDGET.maxArchiveBytes || byteLength !== null && range.compressedOffset + range.compressedBytes > byteLength) throw new ArchiveConflictError(`range for ${range.path} exceeds the archive`, "input.ranges");
|
||||
}
|
||||
const ordered = [...ranges].sort((left, right) => left.compressedOffset - right.compressedOffset);
|
||||
for (let index = 1; index < ordered.length; index++) {
|
||||
const previous = ordered[index - 1]; const current = ordered[index];
|
||||
if (current.compressedOffset < previous.compressedOffset + previous.compressedBytes) throw new ArchiveConflictError(`compressed ranges overlap at ${current.path}`, "input.ranges");
|
||||
}
|
||||
return { status: "VALID", totalCompressedBytes, totalUncompressedBytes, nonOverlapping: true, uniquePaths: true, noPrefixConflicts: true };
|
||||
}
|
||||
50
web/protocol/archive-extraction-recovery.ts
Normal file
50
web/protocol/archive-extraction-recovery.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
import {
|
||||
ArchiveExtractionError,
|
||||
runArchiveExtractionTransaction as runArchiveExtractionTransactionBase,
|
||||
type ArchiveExtractionEntryReader,
|
||||
type ArchiveExtractionReceiptIR,
|
||||
type ArchiveExtractionStorage,
|
||||
} from "./archive-extraction-transaction";
|
||||
|
||||
export type ArchiveExtractionResourceFaultCode = Extract<ErrorCode, "STORAGE_QUOTA" | "WASM_OUT_OF_MEMORY">;
|
||||
|
||||
/** Maps platform-specific allocation failures to the stable archive error contract. */
|
||||
export function archiveExtractionResourceFaultCode(error: unknown): ArchiveExtractionResourceFaultCode | undefined {
|
||||
if (typeof error !== "object" || error === null) return undefined;
|
||||
const candidate = error as { code?: unknown; name?: unknown; message?: unknown };
|
||||
if (candidate.code === "STORAGE_QUOTA" || candidate.code === "WASM_OUT_OF_MEMORY") return candidate.code;
|
||||
if (candidate.name === "QuotaExceededError" || candidate.name === "NotEnoughSpaceError") return "STORAGE_QUOTA";
|
||||
if (candidate.name === "OutOfMemoryError") return "WASM_OUT_OF_MEMORY";
|
||||
if (candidate.name === "RangeError" && typeof candidate.message === "string" && /out[ -]?of[ -]?memory|oom/i.test(candidate.message)) {
|
||||
return "WASM_OUT_OF_MEMORY";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the existing atomic extraction transaction and normalizes resource failures only after
|
||||
* the base transaction has removed staging and revalidated the committed identity.
|
||||
*/
|
||||
export async function runArchiveExtractionTransaction(
|
||||
value: unknown,
|
||||
storage: ArchiveExtractionStorage,
|
||||
readEntry: ArchiveExtractionEntryReader,
|
||||
signal: AbortSignal,
|
||||
): Promise<ArchiveExtractionReceiptIR> {
|
||||
try {
|
||||
return await runArchiveExtractionTransactionBase(value, storage, readEntry, signal);
|
||||
}
|
||||
catch (error) {
|
||||
const code = archiveExtractionResourceFaultCode(error);
|
||||
if (code) throw new ArchiveExtractionError(code, "archive extraction released staging after a resource fault");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export type {
|
||||
ArchiveExtractionEntryIR,
|
||||
ArchiveExtractionRequestIR,
|
||||
ArchiveExtractionReceiptIR,
|
||||
ArchiveExtractionStorage,
|
||||
} from "./archive-extraction-transaction";
|
||||
258
web/protocol/archive-extraction-transaction.ts
Normal file
258
web/protocol/archive-extraction-transaction.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const ARCHIVE_EXTRACTION_SCHEMA = 1 as const;
|
||||
|
||||
export interface ArchiveProjectIdentityIR {
|
||||
projectId: string;
|
||||
revision: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface ArchiveExtractionEntryIR {
|
||||
path: string;
|
||||
uncompressedBytes: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface ArchiveExtractionRequestIR {
|
||||
schemaVersion: typeof ARCHIVE_EXTRACTION_SCHEMA;
|
||||
transactionId: string;
|
||||
archiveId: string;
|
||||
committed: ArchiveProjectIdentityIR;
|
||||
candidate: ArchiveProjectIdentityIR;
|
||||
entries: ArchiveExtractionEntryIR[];
|
||||
}
|
||||
|
||||
export interface ArchiveExtractionStorage {
|
||||
readCommitted(projectId: string): Promise<ArchiveProjectIdentityIR>;
|
||||
createStaging(transactionId: string, projectId: string): Promise<void>;
|
||||
writeStaging(transactionId: string, path: string, bytes: Uint8Array): Promise<void>;
|
||||
countStagingEntries(transactionId: string): Promise<number>;
|
||||
discardStaging(transactionId: string): Promise<number>;
|
||||
commitStaging(
|
||||
transactionId: string,
|
||||
expected: ArchiveProjectIdentityIR,
|
||||
candidate: ArchiveProjectIdentityIR,
|
||||
): Promise<ArchiveProjectIdentityIR>;
|
||||
}
|
||||
|
||||
export type ArchiveExtractionEntryReader = (
|
||||
entry: ArchiveExtractionEntryIR,
|
||||
signal: AbortSignal,
|
||||
) => Promise<Uint8Array>;
|
||||
|
||||
export type ArchiveExtractionReceiptIR =
|
||||
| {
|
||||
status: "COMMITTED";
|
||||
transactionId: string;
|
||||
committed: ArchiveProjectIdentityIR;
|
||||
stagingEntriesAfter: 0;
|
||||
}
|
||||
| {
|
||||
status: "CANCELLED";
|
||||
code: "IO_ARCHIVE_CANCELLED";
|
||||
transactionId: string;
|
||||
committedBefore: ArchiveProjectIdentityIR;
|
||||
committedAfter: ArchiveProjectIdentityIR;
|
||||
removedStagingEntries: number;
|
||||
stagingEntriesAfter: 0;
|
||||
publishedProjects: 0;
|
||||
};
|
||||
|
||||
export class ArchiveExtractionError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
readonly path?: string;
|
||||
|
||||
constructor(code: ErrorCode, message: string, path?: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "ArchiveExtractionError";
|
||||
this.code = code;
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/;
|
||||
const DRIVE_PATH = /^[A-Za-z]:\//;
|
||||
const URI_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:/;
|
||||
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new ArchiveExtractionError("IO_ARCHIVE_UNSAFE", `${path} must be an object`, path);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, expected: readonly string[], path: string): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const allowed = [...expected].sort();
|
||||
if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) {
|
||||
throw new ArchiveExtractionError("IO_ARCHIVE_UNSAFE", `${path} contains undeclared fields`, path);
|
||||
}
|
||||
}
|
||||
|
||||
function parseIdentity(value: unknown, path: string): ArchiveProjectIdentityIR {
|
||||
const input = record(value, path);
|
||||
exactKeys(input, ["projectId", "revision", "sha256"], path);
|
||||
if (typeof input.projectId !== "string" || !ID.test(input.projectId)) {
|
||||
throw new ArchiveExtractionError("IO_ARCHIVE_UNSAFE", `${path}.projectId is invalid`, `${path}.projectId`);
|
||||
}
|
||||
if (typeof input.revision !== "number" || !Number.isSafeInteger(input.revision) || input.revision < 0) {
|
||||
throw new ArchiveExtractionError("IO_ARCHIVE_UNSAFE", `${path}.revision is invalid`, `${path}.revision`);
|
||||
}
|
||||
if (typeof input.sha256 !== "string" || !SHA256.test(input.sha256)) {
|
||||
throw new ArchiveExtractionError("IO_ARCHIVE_UNSAFE", `${path}.sha256 is invalid`, `${path}.sha256`);
|
||||
}
|
||||
return { projectId: input.projectId, revision: input.revision, sha256: input.sha256 };
|
||||
}
|
||||
|
||||
function parseArchivePath(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > 2_048 || CONTROL_CHARACTER.test(value)) {
|
||||
throw new ArchiveExtractionError("IO_ARCHIVE_UNSAFE", `${path} is invalid`, path);
|
||||
}
|
||||
const normalized = value.normalize("NFC");
|
||||
if (normalized !== value || value.startsWith("/") || value.startsWith("\\") || value.includes("\\") || DRIVE_PATH.test(value) || URI_SCHEME.test(value)) {
|
||||
throw new ArchiveExtractionError("IO_ARCHIVE_UNSAFE", `${path} is not canonical`, path);
|
||||
}
|
||||
const segments = value.split("/");
|
||||
if (segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) {
|
||||
throw new ArchiveExtractionError("IO_ARCHIVE_UNSAFE", `${path} escapes staging`, path);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseArchiveExtractionRequest(value: unknown): ArchiveExtractionRequestIR {
|
||||
const input = record(value, "input");
|
||||
exactKeys(input, ["schemaVersion", "transactionId", "archiveId", "committed", "candidate", "entries"], "input");
|
||||
if (input.schemaVersion !== ARCHIVE_EXTRACTION_SCHEMA) {
|
||||
throw new ArchiveExtractionError("IO_ARCHIVE_UNSAFE", "unsupported archive extraction schema", "input.schemaVersion");
|
||||
}
|
||||
if (typeof input.transactionId !== "string" || !ID.test(input.transactionId)) {
|
||||
throw new ArchiveExtractionError("IO_ARCHIVE_UNSAFE", "transactionId is invalid", "input.transactionId");
|
||||
}
|
||||
if (typeof input.archiveId !== "string" || !ID.test(input.archiveId)) {
|
||||
throw new ArchiveExtractionError("IO_ARCHIVE_UNSAFE", "archiveId is invalid", "input.archiveId");
|
||||
}
|
||||
const committed = parseIdentity(input.committed, "input.committed");
|
||||
const candidate = parseIdentity(input.candidate, "input.candidate");
|
||||
if (candidate.projectId !== committed.projectId || candidate.revision !== committed.revision + 1) {
|
||||
throw new ArchiveExtractionError("REVISION_CONFLICT", "candidate must advance the same project by one revision", "input.candidate");
|
||||
}
|
||||
if (!Array.isArray(input.entries) || input.entries.length === 0 || input.entries.length > 100_000) {
|
||||
throw new ArchiveExtractionError("IO_ARCHIVE_UNSAFE", "entries exceeds its bound", "input.entries");
|
||||
}
|
||||
const paths = new Set<string>();
|
||||
const entries = input.entries.map((value, index): ArchiveExtractionEntryIR => {
|
||||
const path = `input.entries[${index}]`;
|
||||
const entry = record(value, path);
|
||||
exactKeys(entry, ["path", "uncompressedBytes", "sha256"], path);
|
||||
const entryPath = parseArchivePath(entry.path, `${path}.path`);
|
||||
if (paths.has(entryPath)) throw new ArchiveExtractionError("IO_ARCHIVE_UNSAFE", `duplicate entry ${entryPath}`, `${path}.path`);
|
||||
paths.add(entryPath);
|
||||
if (typeof entry.uncompressedBytes !== "number" || !Number.isSafeInteger(entry.uncompressedBytes) || entry.uncompressedBytes < 0 || entry.uncompressedBytes > 2 * 1024 * 1024 * 1024) {
|
||||
throw new ArchiveExtractionError("IO_ARCHIVE_UNSAFE", `${path}.uncompressedBytes is outside its bound`, `${path}.uncompressedBytes`);
|
||||
}
|
||||
if (typeof entry.sha256 !== "string" || !SHA256.test(entry.sha256)) {
|
||||
throw new ArchiveExtractionError("IO_ARCHIVE_UNSAFE", `${path}.sha256 is invalid`, `${path}.sha256`);
|
||||
}
|
||||
return { path: entryPath, uncompressedBytes: entry.uncompressedBytes, sha256: entry.sha256 };
|
||||
});
|
||||
const orderedPaths = [...paths].sort();
|
||||
for (let index = 1; index < orderedPaths.length; index++) {
|
||||
if (orderedPaths[index].startsWith(`${orderedPaths[index - 1]}/`)) {
|
||||
throw new ArchiveExtractionError("IO_ARCHIVE_UNSAFE", `file/directory prefix conflict at ${orderedPaths[index]}`, "input.entries");
|
||||
}
|
||||
}
|
||||
return {
|
||||
schemaVersion: ARCHIVE_EXTRACTION_SCHEMA,
|
||||
transactionId: input.transactionId,
|
||||
archiveId: input.archiveId,
|
||||
committed,
|
||||
candidate,
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
||||
function sameIdentity(left: ArchiveProjectIdentityIR, right: ArchiveProjectIdentityIR): boolean {
|
||||
return left.projectId === right.projectId && left.revision === right.revision && left.sha256 === right.sha256;
|
||||
}
|
||||
|
||||
async function digest(bytes: Uint8Array): Promise<string> {
|
||||
const source = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
const result = await crypto.subtle.digest("SHA-256", source);
|
||||
return [...new Uint8Array(result)].map((value) => value.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function cancellation(error: unknown, signal: AbortSignal): boolean {
|
||||
return signal.aborted || typeof error === "object" && error !== null && "name" in error && error.name === "AbortError";
|
||||
}
|
||||
|
||||
function cancelled(): never {
|
||||
throw new DOMException("Archive extraction was cancelled", "AbortError");
|
||||
}
|
||||
|
||||
export async function runArchiveExtractionTransaction(
|
||||
value: unknown,
|
||||
storage: ArchiveExtractionStorage,
|
||||
readEntry: ArchiveExtractionEntryReader,
|
||||
signal: AbortSignal,
|
||||
): Promise<ArchiveExtractionReceiptIR> {
|
||||
const request = parseArchiveExtractionRequest(value);
|
||||
const committedBefore = await storage.readCommitted(request.committed.projectId);
|
||||
if (!sameIdentity(committedBefore, request.committed)) {
|
||||
throw new ArchiveExtractionError("REVISION_CONFLICT", "committed project identity is stale", "input.committed");
|
||||
}
|
||||
|
||||
let stagingCreated = false;
|
||||
let published = false;
|
||||
try {
|
||||
if (signal.aborted) cancelled();
|
||||
stagingCreated = true;
|
||||
await storage.createStaging(request.transactionId, request.committed.projectId);
|
||||
for (const entry of request.entries) {
|
||||
if (signal.aborted) cancelled();
|
||||
const bytes = await readEntry(entry, signal);
|
||||
if (signal.aborted) cancelled();
|
||||
if (!(bytes instanceof Uint8Array) || bytes.byteLength !== entry.uncompressedBytes || await digest(bytes) !== entry.sha256) {
|
||||
throw new ArchiveExtractionError("ASSET_SOURCE_HASH_MISMATCH", `payload identity mismatch for ${entry.path}`, entry.path);
|
||||
}
|
||||
await storage.writeStaging(request.transactionId, entry.path, bytes);
|
||||
if (signal.aborted) cancelled();
|
||||
}
|
||||
|
||||
// Cancellation linearizes here. Once the atomic commit starts, it completes as a commit.
|
||||
if (signal.aborted) cancelled();
|
||||
const committed = await storage.commitStaging(request.transactionId, committedBefore, request.candidate);
|
||||
published = true;
|
||||
if (!sameIdentity(committed, request.candidate)) {
|
||||
throw new ArchiveExtractionError("STORAGE_TRANSACTION", "storage published an unexpected project identity");
|
||||
}
|
||||
if (await storage.countStagingEntries(request.transactionId) !== 0) {
|
||||
throw new ArchiveExtractionError("STORAGE_TRANSACTION", "committed extraction retained staging entries");
|
||||
}
|
||||
return { status: "COMMITTED", transactionId: request.transactionId, committed, stagingEntriesAfter: 0 };
|
||||
}
|
||||
catch (error) {
|
||||
if (published) throw error;
|
||||
const removedStagingEntries = stagingCreated ? await storage.discardStaging(request.transactionId) : 0;
|
||||
const stagingEntriesAfter = stagingCreated ? await storage.countStagingEntries(request.transactionId) : 0;
|
||||
const committedAfter = await storage.readCommitted(request.committed.projectId);
|
||||
if (stagingEntriesAfter !== 0 || !sameIdentity(committedBefore, committedAfter)) {
|
||||
throw new ArchiveExtractionError("STORAGE_TRANSACTION", "archive rollback did not preserve the committed project");
|
||||
}
|
||||
if (cancellation(error, signal)) {
|
||||
return {
|
||||
status: "CANCELLED",
|
||||
code: "IO_ARCHIVE_CANCELLED",
|
||||
transactionId: request.transactionId,
|
||||
committedBefore,
|
||||
committedAfter,
|
||||
removedStagingEntries,
|
||||
stagingEntriesAfter: 0,
|
||||
publishedProjects: 0,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
160
web/protocol/archive-link-safety.ts
Normal file
160
web/protocol/archive-link-safety.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const ARCHIVE_LINK_SAFETY_SCHEMA = 1 as const;
|
||||
export const ARCHIVE_ENTRY_KINDS = ["FILE", "DIRECTORY", "SYMLINK", "HARDLINK"] as const;
|
||||
export type ArchiveEntryKind = typeof ARCHIVE_ENTRY_KINDS[number];
|
||||
|
||||
export interface ArchiveLinkEntryIR {
|
||||
path: string;
|
||||
type: ArchiveEntryKind;
|
||||
target: string | null;
|
||||
}
|
||||
|
||||
export interface ArchiveLinkRequestIR {
|
||||
schemaVersion: typeof ARCHIVE_LINK_SAFETY_SCHEMA;
|
||||
temporaryRootId: string;
|
||||
entries: ArchiveLinkEntryIR[];
|
||||
}
|
||||
|
||||
export interface ArchiveResolvedEntryIR extends ArchiveLinkEntryIR {
|
||||
resolvedPath: string;
|
||||
resolvedType: "FILE" | "DIRECTORY";
|
||||
withinTemporaryRoot: true;
|
||||
}
|
||||
|
||||
export interface ArchiveLinkResolutionIR {
|
||||
status: "READY";
|
||||
schemaVersion: typeof ARCHIVE_LINK_SAFETY_SCHEMA;
|
||||
temporaryRootId: string;
|
||||
entries: ArchiveResolvedEntryIR[];
|
||||
}
|
||||
|
||||
export class ArchiveLinkSafetyError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
readonly path?: string;
|
||||
|
||||
constructor(message: string, path?: string) {
|
||||
super(`IO_ARCHIVE_UNSAFE: ${message}`);
|
||||
this.name = "ArchiveLinkSafetyError";
|
||||
this.code = "IO_ARCHIVE_UNSAFE";
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_ENTRIES = 10_000;
|
||||
const MAX_PATH_LENGTH = 1_024;
|
||||
const ROOT_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const DRIVE_PATH = /^[A-Za-z]:[\\/]/;
|
||||
const URI_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:/;
|
||||
const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new ArchiveLinkSafetyError(`${path} must be an object`, path);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, expected: readonly string[], path: string): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const allowed = [...expected].sort();
|
||||
if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) throw new ArchiveLinkSafetyError(`${path} contains undeclared fields`, path);
|
||||
}
|
||||
|
||||
function text(value: unknown, path: string, maxLength: number): string {
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > maxLength || CONTROL_CHARACTER.test(value)) throw new ArchiveLinkSafetyError(`${path} is invalid`, path);
|
||||
return value.normalize("NFC");
|
||||
}
|
||||
|
||||
function archivePath(value: unknown, path: string, allowRoot = false): string {
|
||||
const input = text(value, path, MAX_PATH_LENGTH);
|
||||
if (input.startsWith("/") || input.startsWith("\\") || DRIVE_PATH.test(input) || URI_SCHEME.test(input)) throw new ArchiveLinkSafetyError(`${path} escapes the temporary root`, path);
|
||||
if (input.includes("\\")) throw new ArchiveLinkSafetyError(`${path} contains a backslash`, path);
|
||||
const segments: string[] = [];
|
||||
for (const segment of input.split("/")) {
|
||||
if (segment === "" || segment === ".") continue;
|
||||
if (segment === "..") {
|
||||
if (segments.length === 0) throw new ArchiveLinkSafetyError(`${path} escapes the temporary root`, path);
|
||||
segments.pop();
|
||||
continue;
|
||||
}
|
||||
if (CONTROL_CHARACTER.test(segment)) throw new ArchiveLinkSafetyError(`${path} contains a control character`, path);
|
||||
segments.push(segment);
|
||||
}
|
||||
const result = segments.join("/");
|
||||
if (!result && !allowRoot) throw new ArchiveLinkSafetyError(`${path} is empty`, path);
|
||||
return result;
|
||||
}
|
||||
|
||||
function relativeSymlinkTarget(linkPath: string, target: unknown, path: string): string {
|
||||
const targetText = text(target, path, MAX_PATH_LENGTH);
|
||||
if (targetText.startsWith("/") || targetText.startsWith("\\") || DRIVE_PATH.test(targetText) || URI_SCHEME.test(targetText)) throw new ArchiveLinkSafetyError(`${path} escapes the temporary root`, path);
|
||||
if (targetText.includes("\\")) throw new ArchiveLinkSafetyError(`${path} contains a backslash`, path);
|
||||
const parent = linkPath.includes("/") ? linkPath.slice(0, linkPath.lastIndexOf("/")) : "";
|
||||
return archivePath(parent ? `${parent}/${targetText}` : targetText, path);
|
||||
}
|
||||
|
||||
function parseEntry(value: unknown, index: number): ArchiveLinkEntryIR {
|
||||
const path = `entries[${index}]`;
|
||||
const entry = record(value, path);
|
||||
exactKeys(entry, ["path", "target", "type"], path);
|
||||
const entryPath = archivePath(entry.path, `${path}.path`);
|
||||
if (!ARCHIVE_ENTRY_KINDS.includes(entry.type as ArchiveEntryKind)) throw new ArchiveLinkSafetyError(`${path}.type is unsupported`, `${path}.type`);
|
||||
const type = entry.type as ArchiveEntryKind;
|
||||
if (type === "FILE" || type === "DIRECTORY") {
|
||||
if (entry.target !== null) throw new ArchiveLinkSafetyError(`${path}.target must be null for ${type}`, `${path}.target`);
|
||||
return { path: entryPath, type, target: null };
|
||||
}
|
||||
if (typeof entry.target !== "string") throw new ArchiveLinkSafetyError(`${path}.target is required for ${type}`, `${path}.target`);
|
||||
return { path: entryPath, type, target: entry.target.normalize("NFC") };
|
||||
}
|
||||
|
||||
export function parseArchiveLinkRequest(value: unknown): ArchiveLinkRequestIR {
|
||||
const input = record(value, "input");
|
||||
exactKeys(input, ["schemaVersion", "temporaryRootId", "entries"], "input");
|
||||
if (input.schemaVersion !== ARCHIVE_LINK_SAFETY_SCHEMA) throw new ArchiveLinkSafetyError("unsupported archive link schema", "input.schemaVersion");
|
||||
if (typeof input.temporaryRootId !== "string" || !ROOT_ID.test(input.temporaryRootId)) throw new ArchiveLinkSafetyError("temporaryRootId is invalid", "input.temporaryRootId");
|
||||
if (!Array.isArray(input.entries) || input.entries.length === 0 || input.entries.length > MAX_ENTRIES) throw new ArchiveLinkSafetyError("entries exceeds its bound", "input.entries");
|
||||
const entries = input.entries.map((entry, index) => parseEntry(entry, index));
|
||||
const paths = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
if (paths.has(entry.path)) throw new ArchiveLinkSafetyError(`duplicate archive path ${entry.path}`, "input.entries");
|
||||
paths.add(entry.path);
|
||||
}
|
||||
return { schemaVersion: ARCHIVE_LINK_SAFETY_SCHEMA, temporaryRootId: input.temporaryRootId, entries };
|
||||
}
|
||||
|
||||
export function resolveArchiveLinkEntries(value: unknown): ArchiveLinkResolutionIR {
|
||||
const request = parseArchiveLinkRequest(value);
|
||||
const entries = new Map(request.entries.map((entry) => [entry.path, entry]));
|
||||
const active = new Set<string>();
|
||||
const resolved = new Map<string, { path: string; type: "FILE" | "DIRECTORY" }>();
|
||||
|
||||
const visit = (entryPath: string): { path: string; type: "FILE" | "DIRECTORY" } => {
|
||||
const cached = resolved.get(entryPath);
|
||||
if (cached) return cached;
|
||||
if (active.has(entryPath)) throw new ArchiveLinkSafetyError(`link cycle includes ${entryPath}`, "input.entries");
|
||||
const entry = entries.get(entryPath);
|
||||
if (!entry) throw new ArchiveLinkSafetyError(`link target ${entryPath} is missing`, "input.entries");
|
||||
active.add(entryPath);
|
||||
let result: { path: string; type: "FILE" | "DIRECTORY" };
|
||||
if (entry.type === "FILE" || entry.type === "DIRECTORY") {
|
||||
result = { path: entry.path, type: entry.type };
|
||||
}
|
||||
else {
|
||||
const targetPath = entry.type === "SYMLINK"
|
||||
? relativeSymlinkTarget(entry.path, entry.target, `entries[${request.entries.indexOf(entry)}].target`)
|
||||
: archivePath(entry.target, `entries[${request.entries.indexOf(entry)}].target`);
|
||||
const target = visit(targetPath);
|
||||
if (entry.type === "HARDLINK" && target.type !== "FILE") throw new ArchiveLinkSafetyError("hardlink target must resolve to a file", `entries[${request.entries.indexOf(entry)}].target`);
|
||||
result = target;
|
||||
}
|
||||
active.delete(entryPath);
|
||||
resolved.set(entryPath, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
const output = request.entries.map((entry) => {
|
||||
const target = visit(entry.path);
|
||||
return { ...entry, resolvedPath: target.path, resolvedType: target.type, withinTemporaryRoot: true as const };
|
||||
});
|
||||
return { status: "READY", schemaVersion: ARCHIVE_LINK_SAFETY_SCHEMA, temporaryRootId: request.temporaryRootId, entries: output };
|
||||
}
|
||||
135
web/protocol/archive-metadata-first.ts
Normal file
135
web/protocol/archive-metadata-first.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const ARCHIVE_METADATA_FIRST_SCHEMA = 1 as const;
|
||||
export const ARCHIVE_METADATA_FORMATS = ["ZIP", "TAR"] as const;
|
||||
export type ArchiveMetadataFormat = typeof ARCHIVE_METADATA_FORMATS[number];
|
||||
|
||||
export interface ArchiveMetadataFirstRequestIR {
|
||||
schemaVersion: typeof ARCHIVE_METADATA_FIRST_SCHEMA;
|
||||
archiveId: string;
|
||||
format: ArchiveMetadataFormat;
|
||||
archiveByteLength: number;
|
||||
metadataOffset: number;
|
||||
metadataByteLength: number;
|
||||
}
|
||||
|
||||
export interface ArchiveMetadataReadIR {
|
||||
kind: "CENTRAL_DIRECTORY" | "MANIFEST";
|
||||
byteOffset: number;
|
||||
byteLength: number;
|
||||
}
|
||||
|
||||
export interface ArchiveMetadataFirstPlanIR {
|
||||
status: "METADATA_ONLY";
|
||||
schemaVersion: typeof ARCHIVE_METADATA_FIRST_SCHEMA;
|
||||
archiveId: string;
|
||||
format: ArchiveMetadataFormat;
|
||||
firstRead: ArchiveMetadataReadIR;
|
||||
payloadReads: [];
|
||||
}
|
||||
|
||||
export interface ArchiveReadTraceItemIR {
|
||||
sequence: number;
|
||||
kind: "CENTRAL_DIRECTORY" | "MANIFEST" | "PAYLOAD";
|
||||
byteOffset: number;
|
||||
byteLength: number;
|
||||
}
|
||||
|
||||
export interface ArchiveReadTraceIR {
|
||||
schemaVersion: typeof ARCHIVE_METADATA_FIRST_SCHEMA;
|
||||
archiveId: string;
|
||||
reads: ArchiveReadTraceItemIR[];
|
||||
}
|
||||
|
||||
export interface ArchiveReadTraceValidationIR {
|
||||
status: "VALID";
|
||||
metadataFirst: true;
|
||||
payloadReadsAfterMetadata: true;
|
||||
}
|
||||
|
||||
export class ArchiveMetadataFirstError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
readonly path?: string;
|
||||
|
||||
constructor(message: string, path?: string) {
|
||||
super(`IO_ARCHIVE_UNSAFE: ${message}`);
|
||||
this.name = "ArchiveMetadataFirstError";
|
||||
this.code = "IO_ARCHIVE_UNSAFE";
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
const ARCHIVE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const MAX_ARCHIVE_BYTES = Number.MAX_SAFE_INTEGER;
|
||||
const MAX_METADATA_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new ArchiveMetadataFirstError(`${path} must be an object`, path);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, expected: readonly string[], path: string): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const allowed = [...expected].sort();
|
||||
if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) throw new ArchiveMetadataFirstError(`${path} contains undeclared fields`, path);
|
||||
}
|
||||
|
||||
function integer(value: unknown, path: string, minimum = 0): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > MAX_ARCHIVE_BYTES) throw new ArchiveMetadataFirstError(`${path} must be a safe integer`, path);
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseRequest(value: unknown): ArchiveMetadataFirstRequestIR {
|
||||
const input = record(value, "input");
|
||||
exactKeys(input, ["schemaVersion", "archiveId", "format", "archiveByteLength", "metadataOffset", "metadataByteLength"], "input");
|
||||
if (input.schemaVersion !== ARCHIVE_METADATA_FIRST_SCHEMA) throw new ArchiveMetadataFirstError("unsupported archive metadata schema", "input.schemaVersion");
|
||||
if (typeof input.archiveId !== "string" || !ARCHIVE_ID.test(input.archiveId)) throw new ArchiveMetadataFirstError("archiveId is invalid", "input.archiveId");
|
||||
if (!ARCHIVE_METADATA_FORMATS.includes(input.format as ArchiveMetadataFormat)) throw new ArchiveMetadataFirstError("archive format is unsupported", "input.format");
|
||||
const archiveByteLength = integer(input.archiveByteLength, "input.archiveByteLength", 1);
|
||||
const metadataOffset = integer(input.metadataOffset, "input.metadataOffset");
|
||||
const metadataByteLength = integer(input.metadataByteLength, "input.metadataByteLength", 1);
|
||||
if (metadataByteLength > MAX_METADATA_BYTES || metadataOffset + metadataByteLength > archiveByteLength) throw new ArchiveMetadataFirstError("metadata range is outside the archive or exceeds its bound", "input.metadataByteLength");
|
||||
return { schemaVersion: ARCHIVE_METADATA_FIRST_SCHEMA, archiveId: input.archiveId, format: input.format as ArchiveMetadataFormat, archiveByteLength, metadataOffset, metadataByteLength };
|
||||
}
|
||||
|
||||
export function planArchiveMetadataRead(value: unknown): ArchiveMetadataFirstPlanIR {
|
||||
const request = parseRequest(value);
|
||||
return {
|
||||
status: "METADATA_ONLY",
|
||||
schemaVersion: ARCHIVE_METADATA_FIRST_SCHEMA,
|
||||
archiveId: request.archiveId,
|
||||
format: request.format,
|
||||
firstRead: { kind: request.format === "ZIP" ? "CENTRAL_DIRECTORY" : "MANIFEST", byteOffset: request.metadataOffset, byteLength: request.metadataByteLength },
|
||||
payloadReads: [],
|
||||
};
|
||||
}
|
||||
|
||||
function parseTraceItem(value: unknown, index: number): ArchiveReadTraceItemIR {
|
||||
const path = `reads[${index}]`;
|
||||
const item = record(value, path);
|
||||
exactKeys(item, ["sequence", "kind", "byteOffset", "byteLength"], path);
|
||||
const sequence = integer(item.sequence, `${path}.sequence`);
|
||||
if (!(["CENTRAL_DIRECTORY", "MANIFEST", "PAYLOAD"] as const).includes(item.kind as ArchiveReadTraceItemIR["kind"])) throw new ArchiveMetadataFirstError(`${path}.kind is unsupported`, `${path}.kind`);
|
||||
return { sequence, kind: item.kind as ArchiveReadTraceItemIR["kind"], byteOffset: integer(item.byteOffset, `${path}.byteOffset`), byteLength: integer(item.byteLength, `${path}.byteLength`, 1) };
|
||||
}
|
||||
|
||||
function parseTrace(value: unknown): ArchiveReadTraceIR {
|
||||
const input = record(value, "trace");
|
||||
exactKeys(input, ["schemaVersion", "archiveId", "reads"], "trace");
|
||||
if (input.schemaVersion !== ARCHIVE_METADATA_FIRST_SCHEMA) throw new ArchiveMetadataFirstError("unsupported archive trace schema", "trace.schemaVersion");
|
||||
if (typeof input.archiveId !== "string" || !ARCHIVE_ID.test(input.archiveId)) throw new ArchiveMetadataFirstError("trace archiveId is invalid", "trace.archiveId");
|
||||
if (!Array.isArray(input.reads) || input.reads.length === 0 || input.reads.length > 10_000) throw new ArchiveMetadataFirstError("trace reads exceeds its bound", "trace.reads");
|
||||
const reads = input.reads.map(parseTraceItem).sort((left, right) => left.sequence - right.sequence);
|
||||
if (reads.some((item, index) => item.sequence !== index)) throw new ArchiveMetadataFirstError("trace sequence must be contiguous and unique", "trace.reads");
|
||||
return { schemaVersion: ARCHIVE_METADATA_FIRST_SCHEMA, archiveId: input.archiveId, reads };
|
||||
}
|
||||
|
||||
export function validateArchiveReadTrace(planValue: unknown, traceValue: unknown): ArchiveReadTraceValidationIR {
|
||||
const plan = planArchiveMetadataRead(planValue);
|
||||
const trace = parseTrace(traceValue);
|
||||
if (trace.archiveId !== plan.archiveId) throw new ArchiveMetadataFirstError("trace archiveId does not match the plan", "trace.archiveId");
|
||||
const first = trace.reads[0];
|
||||
if (first.kind !== plan.firstRead.kind || first.byteOffset !== plan.firstRead.byteOffset || first.byteLength !== plan.firstRead.byteLength) throw new ArchiveMetadataFirstError("payload was read before the central directory or manifest", "trace.reads[0]");
|
||||
if (trace.reads.slice(1).some((item) => item.kind === plan.firstRead.kind || item.kind === (plan.format === "ZIP" ? "MANIFEST" : "CENTRAL_DIRECTORY"))) throw new ArchiveMetadataFirstError("metadata read sequence is duplicated or out of order", "trace.reads");
|
||||
return { status: "VALID", metadataFirst: true, payloadReadsAfterMetadata: true };
|
||||
}
|
||||
@@ -13,6 +13,8 @@ export const ASSET_LIBRARY_BUDGET = {
|
||||
maxEntryBytes: 2 * 1024 * 1024 * 1024,
|
||||
maxArchiveBytes: 4 * 1024 * 1024 * 1024,
|
||||
maxCompressionRatio: 100,
|
||||
maxArchivePathDepth: 64,
|
||||
maxArchiveFileNameBytes: 255,
|
||||
maxExternalUris: 10_000,
|
||||
} as const;
|
||||
|
||||
@@ -51,6 +53,11 @@ const FORMATS = new Set<IOFormat>(["GLB", "GLTF", "OBJ", "PLY", "STL", "USD", "A
|
||||
function record(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
||||
function text(value: unknown, name: string, maximum = 256): string { if (typeof value !== "string" || value.length === 0 || value.length > maximum) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${name} is invalid`); return value; }
|
||||
function integer(value: unknown, name: string, minimum: number, maximum: number): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${name} is outside the bounded range`); return value; }
|
||||
function archiveInteger(value: unknown, name: string, minimum: number, maximum: number): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${name} is outside the bounded range`);
|
||||
if (value > maximum) throw new AssetLibraryValidationError("ASSET_BUDGET_EXCEEDED", `${name} exceeds the archive entry budget`);
|
||||
return value;
|
||||
}
|
||||
function digest(value: unknown, name: string): string { if (typeof value !== "string" || !SHA256.test(value)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${name} must be a lowercase SHA-256 digest`); return value; }
|
||||
function projectPath(value: unknown, name: string, code: ErrorCode = "ASSET_MANIFEST_INVALID"): string { try { return normalizeProjectAssetPath(text(value, name, 2048)); } catch { throw new AssetLibraryValidationError(code, `${name} is outside the project`); } }
|
||||
|
||||
@@ -131,7 +138,10 @@ export function parseIORequest(value: unknown): IORequestIR {
|
||||
let totalCompressed = 0; let totalUncompressed = 0; const archivePaths = new Set<string>();
|
||||
request.archiveEntries = value.archiveEntries.map((entry, index): IOArchiveEntryIR => {
|
||||
if (!record(entry)) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", `archiveEntries[${index}] is invalid`);
|
||||
const path = projectPath(entry.path, `archiveEntries[${index}].path`, "IO_ARCHIVE_UNSAFE"); const compressedBytes = integer(entry.compressedBytes, `archiveEntries[${index}].compressedBytes`, 0, ASSET_LIBRARY_BUDGET.maxEntryBytes); const uncompressedBytes = integer(entry.uncompressedBytes, `archiveEntries[${index}].uncompressedBytes`, 0, ASSET_LIBRARY_BUDGET.maxEntryBytes);
|
||||
const path = projectPath(entry.path, `archiveEntries[${index}].path`, "IO_ARCHIVE_UNSAFE"); const compressedBytes = archiveInteger(entry.compressedBytes, `archiveEntries[${index}].compressedBytes`, 0, ASSET_LIBRARY_BUDGET.maxEntryBytes); const uncompressedBytes = archiveInteger(entry.uncompressedBytes, `archiveEntries[${index}].uncompressedBytes`, 0, ASSET_LIBRARY_BUDGET.maxEntryBytes);
|
||||
const pathSegments = path.split("/");
|
||||
const fileNameBytes = new TextEncoder().encode(pathSegments[pathSegments.length - 1]).byteLength;
|
||||
if (pathSegments.length - 1 > ASSET_LIBRARY_BUDGET.maxArchivePathDepth || fileNameBytes > ASSET_LIBRARY_BUDGET.maxArchiveFileNameBytes) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", `Archive path ${path} exceeds its depth or filename budget`);
|
||||
if (archivePaths.has(path) || [...archivePaths].some((existing) => existing.startsWith(`${path}/`) || path.startsWith(`${existing}/`))) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", `Archive path ${path} is duplicated or conflicts with a file prefix`);
|
||||
archivePaths.add(path);
|
||||
totalCompressed += compressedBytes; totalUncompressed += uncompressedBytes;
|
||||
|
||||
@@ -1,23 +1,64 @@
|
||||
const DRIVE_PATH = /^[A-Za-z]:[\\/]/;
|
||||
const URI_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:/;
|
||||
const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
function invalidPath(message: "ASSET_PATH_INVALID" | "ASSET_PATH_OUTSIDE_PROJECT"): never {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function decodePath(sourcePath: string): string {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(sourcePath);
|
||||
}
|
||||
catch {
|
||||
return invalidPath("ASSET_PATH_INVALID");
|
||||
}
|
||||
// A canonical path must be safe to normalize again. Residual percent octets could otherwise
|
||||
// become separators or dot segments in a second decoder.
|
||||
if (decoded.includes("%")) return invalidPath("ASSET_PATH_OUTSIDE_PROJECT");
|
||||
try {
|
||||
encodeURIComponent(decoded);
|
||||
}
|
||||
catch {
|
||||
return invalidPath("ASSET_PATH_INVALID");
|
||||
}
|
||||
return decoded.normalize("NFC");
|
||||
}
|
||||
|
||||
export function normalizeProjectAssetPath(sourcePath: string): string {
|
||||
if (typeof sourcePath !== "string" || sourcePath.length === 0 || sourcePath.length > 2048) {
|
||||
throw new Error("ASSET_PATH_INVALID");
|
||||
return invalidPath("ASSET_PATH_INVALID");
|
||||
}
|
||||
if (sourcePath.includes("\0") || sourcePath.includes("\\") || sourcePath.includes("%")) {
|
||||
throw new Error("ASSET_PATH_OUTSIDE_PROJECT");
|
||||
|
||||
const blenderRelative = sourcePath.startsWith("//");
|
||||
if (sourcePath.startsWith("\\") || sourcePath.startsWith("/") && !blenderRelative) {
|
||||
return invalidPath("ASSET_PATH_OUTSIDE_PROJECT");
|
||||
}
|
||||
let relative = sourcePath.startsWith("//") ? sourcePath.slice(2) : sourcePath;
|
||||
|
||||
let relative = decodePath(sourcePath);
|
||||
if (relative.startsWith("//")) {
|
||||
if (!blenderRelative) return invalidPath("ASSET_PATH_OUTSIDE_PROJECT");
|
||||
relative = relative.slice(2);
|
||||
}
|
||||
relative = relative.replaceAll("\\", "/");
|
||||
if (relative.startsWith("/") || DRIVE_PATH.test(relative) || URI_SCHEME.test(relative)) {
|
||||
throw new Error("ASSET_PATH_OUTSIDE_PROJECT");
|
||||
return invalidPath("ASSET_PATH_OUTSIDE_PROJECT");
|
||||
}
|
||||
const segments = relative.split("/");
|
||||
if (segments.length === 0 || segments.some((segment) =>
|
||||
segment.length === 0 || segment === "." || segment === ".." || /[\u0000-\u001f\u007f]/.test(segment))) {
|
||||
throw new Error("ASSET_PATH_OUTSIDE_PROJECT");
|
||||
|
||||
const canonicalSegments: string[] = [];
|
||||
for (const segment of relative.split("/")) {
|
||||
if (segment.length === 0 || segment === ".") continue;
|
||||
if (segment === "..") {
|
||||
if (canonicalSegments.length === 0) return invalidPath("ASSET_PATH_OUTSIDE_PROJECT");
|
||||
canonicalSegments.pop();
|
||||
continue;
|
||||
}
|
||||
if (CONTROL_CHARACTER.test(segment)) return invalidPath("ASSET_PATH_OUTSIDE_PROJECT");
|
||||
canonicalSegments.push(segment);
|
||||
}
|
||||
relative = segments.join("/");
|
||||
if (!relative) throw new Error("ASSET_PATH_INVALID");
|
||||
return relative;
|
||||
|
||||
const canonical = canonicalSegments.join("/");
|
||||
if (!canonical || canonical.length > 2048) return invalidPath("ASSET_PATH_INVALID");
|
||||
return canonical;
|
||||
}
|
||||
|
||||
69
web/protocol/device-budget.ts
Normal file
69
web/protocol/device-budget.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
export const DEVICE_BUDGET_SCHEMA_VERSION = 1 as const;
|
||||
|
||||
export type DeviceBudgetTier = "CONSERVATIVE" | "BALANCED" | "HIGH";
|
||||
|
||||
export interface DeviceBudgetObservation {
|
||||
schemaVersion: typeof DEVICE_BUDGET_SCHEMA_VERSION;
|
||||
identitySha256: string;
|
||||
webgl2: { status: "PASS" | "BLOCKED"; renderer?: string; vendor?: string };
|
||||
webgpu: { status: "PASS" | "BLOCKED"; device?: string; description?: string; isFallbackAdapter?: boolean };
|
||||
hardwareConcurrency: number | null;
|
||||
deviceMemory: number | null;
|
||||
}
|
||||
|
||||
export interface DeviceBudgetLimits {
|
||||
maxTextureGPUBytes: number;
|
||||
maxTexturePayloadBytes: number;
|
||||
maxTextureDimension: number;
|
||||
maxLights: number;
|
||||
maxShadowMaps: number;
|
||||
}
|
||||
|
||||
export interface DeviceBudgetSelection {
|
||||
schemaVersion: typeof DEVICE_BUDGET_SCHEMA_VERSION;
|
||||
identitySha256: string;
|
||||
tier: DeviceBudgetTier;
|
||||
reason: "MISSING_GPU" | "UNTRUSTED_ADAPTER" | "WEBGPU_UNAVAILABLE" | "BALANCED_CAPABILITY" | "HIGH_CAPABILITY";
|
||||
limits: DeviceBudgetLimits;
|
||||
}
|
||||
|
||||
const mib = 1024 * 1024;
|
||||
const LIMITS: Readonly<Record<DeviceBudgetTier, DeviceBudgetLimits>> = Object.freeze({
|
||||
CONSERVATIVE: Object.freeze({ maxTextureGPUBytes: 256 * mib, maxTexturePayloadBytes: 256 * mib, maxTextureDimension: 8192, maxLights: 8, maxShadowMaps: 2 }),
|
||||
BALANCED: Object.freeze({ maxTextureGPUBytes: 512 * mib, maxTexturePayloadBytes: 512 * mib, maxTextureDimension: 16384, maxLights: 16, maxShadowMaps: 4 }),
|
||||
HIGH: Object.freeze({ maxTextureGPUBytes: 1024 * mib, maxTexturePayloadBytes: 512 * mib, maxTextureDimension: 16384, maxLights: 64, maxShadowMaps: 8 }),
|
||||
});
|
||||
|
||||
function validHash(value: unknown): value is string {
|
||||
return typeof value === "string" && /^[a-f0-9]{64}$/u.test(value);
|
||||
}
|
||||
|
||||
function validPositive(value: number | null): value is number {
|
||||
return value !== null && Number.isSafeInteger(value) && value > 0;
|
||||
}
|
||||
|
||||
export function selectDeviceBudget(observation: DeviceBudgetObservation): DeviceBudgetSelection {
|
||||
if (!observation || observation.schemaVersion !== DEVICE_BUDGET_SCHEMA_VERSION || !validHash(observation.identitySha256)) {
|
||||
throw new Error("DEVICE_BUDGET_IDENTITY_INVALID");
|
||||
}
|
||||
const conservative = (reason: DeviceBudgetSelection["reason"]): DeviceBudgetSelection => ({
|
||||
schemaVersion: DEVICE_BUDGET_SCHEMA_VERSION,
|
||||
identitySha256: observation.identitySha256,
|
||||
tier: "CONSERVATIVE",
|
||||
reason,
|
||||
limits: LIMITS.CONSERVATIVE,
|
||||
});
|
||||
if (observation.webgl2.status !== "PASS") return conservative("MISSING_GPU");
|
||||
const renderer = `${observation.webgl2.renderer ?? ""} ${observation.webgpu.description ?? ""}`.toLowerCase();
|
||||
if (!renderer || renderer.includes("swiftshader") || renderer.includes("unknown") || observation.webgpu.isFallbackAdapter) return conservative("UNTRUSTED_ADAPTER");
|
||||
if (observation.webgpu.status !== "PASS") return conservative("WEBGPU_UNAVAILABLE");
|
||||
if (!validPositive(observation.hardwareConcurrency) || !validPositive(observation.deviceMemory)) return conservative("UNTRUSTED_ADAPTER");
|
||||
const tier: DeviceBudgetTier = observation.hardwareConcurrency >= 8 && observation.deviceMemory >= 8 ? "HIGH" : "BALANCED";
|
||||
return { schemaVersion: DEVICE_BUDGET_SCHEMA_VERSION, identitySha256: observation.identitySha256, tier, reason: tier === "HIGH" ? "HIGH_CAPABILITY" : "BALANCED_CAPABILITY", limits: LIMITS[tier] };
|
||||
}
|
||||
|
||||
export function deviceBudgetLimits(tier: DeviceBudgetTier): DeviceBudgetLimits {
|
||||
const limits = LIMITS[tier];
|
||||
if (!limits) throw new Error("DEVICE_BUDGET_TIER_INVALID");
|
||||
return limits;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export const APP_DIAGNOSTIC_MESSAGES = {
|
||||
STORAGE_START_FAILED: "Storage: unavailable",
|
||||
PBR_ASSET_INVALID: "PBR asset unavailable",
|
||||
BLEND_OPEN_FAILED: "Engine: .blend open failed",
|
||||
IO_FORMAT_UNSUPPORTED: "IO: format route unavailable",
|
||||
POST_COMMIT_MAINTENANCE_FAILED: "Storage: post-commit maintenance failed",
|
||||
PROJECT_RECOVERY_FAILED: "Recovery: project could not be restored",
|
||||
WORKER_RECOVERY_FAILED: "Recovery: Worker restart failed",
|
||||
|
||||
@@ -166,6 +166,7 @@ export type ErrorCode =
|
||||
| "LIBRARY_MUTATION_UNAVAILABLE"
|
||||
| "IO_FORMAT_UNSUPPORTED"
|
||||
| "IO_ARCHIVE_UNSAFE"
|
||||
| "IO_ARCHIVE_CANCELLED"
|
||||
| "IO_EXTERNAL_URI_BLOCKED"
|
||||
| "EDITOR_LAYOUT_INVALID"
|
||||
| "EDITOR_LAYOUT_BUDGET_EXCEEDED"
|
||||
@@ -177,6 +178,10 @@ export type ErrorCode =
|
||||
| "SCRIPT_POLICY_DENIED"
|
||||
| "SCRIPT_SIGNATURE_INVALID"
|
||||
| "SCRIPT_SANDBOX_UNAVAILABLE"
|
||||
| "SCRIPT_SANDBOX_CRASHED"
|
||||
| "SCRIPT_SANDBOX_TIMEOUT"
|
||||
| "SCRIPT_SANDBOX_CANCELLED"
|
||||
| "SCRIPT_SANDBOX_LATE_RESULT"
|
||||
| "SCRIPT_BUDGET_EXCEEDED"
|
||||
| "PLATFORM_CAPABILITY_UNAVAILABLE"
|
||||
| "SERVER_JOB_UNAVAILABLE"
|
||||
|
||||
@@ -11,6 +11,10 @@ interface GLBAccessor {
|
||||
componentType: number;
|
||||
count: number;
|
||||
type: string;
|
||||
normalized?: boolean;
|
||||
min?: number[];
|
||||
max?: number[];
|
||||
sparse?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface GLBBufferView {
|
||||
@@ -22,20 +26,62 @@ interface GLBBufferView {
|
||||
interface GLBPrimitive {
|
||||
attributes?: Record<string, number>;
|
||||
indices?: number;
|
||||
material?: number;
|
||||
mode?: number;
|
||||
targets?: Array<Record<string, number>>;
|
||||
}
|
||||
|
||||
interface GLBDocument {
|
||||
asset?: { version?: string };
|
||||
buffers?: Array<{ byteLength?: number }>;
|
||||
asset?: { version?: string; generator?: string };
|
||||
scene?: number;
|
||||
scenes?: Array<{ nodes?: number[] }>;
|
||||
extensionsUsed?: string[];
|
||||
extensionsRequired?: string[];
|
||||
buffers?: Array<{ byteLength?: number; uri?: string }>;
|
||||
bufferViews?: GLBBufferView[];
|
||||
accessors?: GLBAccessor[];
|
||||
meshes?: Array<{ name?: string; primitives?: GLBPrimitive[]; extras?: Record<string, unknown> }>;
|
||||
images?: Array<{ name?: string; mimeType?: string; bufferView?: number; extras?: Record<string, unknown> }>;
|
||||
skins?: Array<{ joints?: number[]; inverseBindMatrices?: number; extras?: Record<string, unknown> }>;
|
||||
animations?: Array<{ name?: string; channels?: Array<{ target?: { node?: number; path?: string } }>; extras?: Record<string, unknown> }>;
|
||||
images?: Array<{ name?: string; mimeType?: string; bufferView?: number; uri?: string; extras?: Record<string, unknown> }>;
|
||||
samplers?: Array<Record<string, unknown>>;
|
||||
textures?: Array<{ sampler?: number; source?: number }>;
|
||||
materials?: Array<{
|
||||
name?: string;
|
||||
alphaMode?: string;
|
||||
doubleSided?: boolean;
|
||||
pbrMetallicRoughness?: {
|
||||
baseColorFactor?: number[];
|
||||
baseColorTexture?: Record<string, unknown>;
|
||||
metallicFactor?: number;
|
||||
roughnessFactor?: number;
|
||||
};
|
||||
normalTexture?: Record<string, unknown>;
|
||||
emissiveFactor?: number[];
|
||||
}>;
|
||||
nodes?: Array<{
|
||||
name?: string;
|
||||
mesh?: number;
|
||||
skin?: number;
|
||||
children?: number[];
|
||||
translation?: number[];
|
||||
rotation?: number[];
|
||||
scale?: number[];
|
||||
}>;
|
||||
skins?: Array<{ name?: string; joints?: number[]; inverseBindMatrices?: number; skeleton?: number; extras?: Record<string, unknown> }>;
|
||||
animations?: Array<{
|
||||
name?: string;
|
||||
samplers?: Array<{ input?: number; output?: number; interpolation?: string }>;
|
||||
channels?: Array<{ sampler?: number; target?: { node?: number; path?: string } }>;
|
||||
extras?: Record<string, unknown>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const GLB_IMPORT_BUDGET = {
|
||||
maxBytes: 512 * 1024,
|
||||
maxJsonBytes: 256 * 1024,
|
||||
maxBufferViews: 4096,
|
||||
maxAccessors: 8192,
|
||||
} as const;
|
||||
|
||||
export interface ImportedGLBImage {
|
||||
name?: string;
|
||||
blenderId?: string;
|
||||
@@ -62,6 +108,73 @@ export interface GLBSemanticComparison {
|
||||
mismatches: string[];
|
||||
}
|
||||
|
||||
export interface GLBDesktopAccessorSemantics {
|
||||
componentType: number;
|
||||
count: number;
|
||||
type: string;
|
||||
normalized: boolean;
|
||||
min: number[] | null;
|
||||
max: number[] | null;
|
||||
}
|
||||
|
||||
export interface GLBDesktopFixtureSemantics {
|
||||
asset: { version?: string; generator?: string } | null;
|
||||
extensionsUsed: string[];
|
||||
extensionsRequired: string[];
|
||||
scene: number | null;
|
||||
nodeNames: Array<string | null>;
|
||||
nodes: Array<{
|
||||
name: string | null;
|
||||
mesh: number | null;
|
||||
skin: number | null;
|
||||
children: number[];
|
||||
translation: number[] | null;
|
||||
rotation: number[] | null;
|
||||
scale: number[] | null;
|
||||
}>;
|
||||
meshes: Array<{
|
||||
name: string | null;
|
||||
primitives: Array<{
|
||||
attributes: Record<string, GLBDesktopAccessorSemantics>;
|
||||
indices: GLBDesktopAccessorSemantics | null;
|
||||
material: number | null;
|
||||
mode: number;
|
||||
targets: Array<Record<string, GLBDesktopAccessorSemantics>>;
|
||||
}>;
|
||||
}>;
|
||||
materials: Array<{
|
||||
name: string | null;
|
||||
alphaMode: string;
|
||||
doubleSided: boolean;
|
||||
pbr: {
|
||||
baseColorFactor: number[] | null;
|
||||
baseColorTexture: Record<string, unknown> | null;
|
||||
metallicFactor: number | null;
|
||||
roughnessFactor: number | null;
|
||||
};
|
||||
normalTexture: Record<string, unknown> | null;
|
||||
emissiveFactor: number[] | null;
|
||||
}>;
|
||||
textures: Array<Record<string, unknown>>;
|
||||
images: Array<Record<string, unknown>>;
|
||||
samplers: Array<Record<string, unknown>>;
|
||||
skins: Array<{
|
||||
name: string | null;
|
||||
joints: number[];
|
||||
inverseBindMatrices: GLBDesktopAccessorSemantics | null;
|
||||
skeleton: number | null;
|
||||
}>;
|
||||
animations: Array<{
|
||||
name: string | null;
|
||||
samplers: Array<{
|
||||
interpolation: string;
|
||||
input: GLBDesktopAccessorSemantics | null;
|
||||
output: GLBDesktopAccessorSemantics | null;
|
||||
}>;
|
||||
channels: Array<{ sampler: number; target: { node: number; path: string } }>;
|
||||
}>;
|
||||
}
|
||||
|
||||
function recordId(extras: Record<string, unknown> | undefined): string | undefined {
|
||||
return typeof extras?.blenderId === "string" ? extras.blenderId : undefined;
|
||||
}
|
||||
@@ -72,11 +185,12 @@ function requireIndex(value: unknown, size: number, label: string): number {
|
||||
}
|
||||
|
||||
function jsonChunk(bytes: Uint8Array, length: number): GLBDocument {
|
||||
if (bytes.byteLength > GLB_IMPORT_BUDGET.maxBytes) throw new Error(`GLB_IMPORT_BUDGET_EXCEEDED: file exceeds ${GLB_IMPORT_BUDGET.maxBytes} bytes`);
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
if (bytes.byteLength < 20 || view.getUint32(0, true) !== GLB_MAGIC || view.getUint32(4, true) !== 2) throw new Error("GLB header is invalid");
|
||||
if (view.getUint32(8, true) !== bytes.byteLength) throw new Error("GLB length does not match header");
|
||||
const jsonLength = view.getUint32(12, true);
|
||||
if (view.getUint32(16, true) !== JSON_CHUNK || jsonLength % 4 !== 0 || 20 + jsonLength > bytes.byteLength) throw new Error("GLB JSON chunk is invalid");
|
||||
if (jsonLength > GLB_IMPORT_BUDGET.maxJsonBytes || view.getUint32(16, true) !== JSON_CHUNK || jsonLength % 4 !== 0 || 20 + jsonLength > bytes.byteLength) throw new Error("GLB JSON chunk is invalid");
|
||||
let document: unknown;
|
||||
try {
|
||||
document = JSON.parse(new TextDecoder().decode(bytes.subarray(20, 20 + jsonLength)).trim());
|
||||
@@ -102,11 +216,15 @@ export function importGLBSemantics(glb: ArrayBuffer): ImportedGLBSemantics {
|
||||
const view = new DataView(glb);
|
||||
const jsonLength = view.getUint32(12, true);
|
||||
const document = jsonChunk(bytes, glb.byteLength);
|
||||
if ((document.extensionsUsed?.length ?? 0) > 0 || (document.extensionsRequired?.length ?? 0) > 0) throw new Error("GLB_EXTENSION_UNSUPPORTED: extensions are outside the bounded importer");
|
||||
if ((document.buffers ?? []).some((buffer) => buffer.uri !== undefined) || (document.images ?? []).some((image) => image.uri !== undefined)) throw new Error("GLB_EXTERNAL_URI_BLOCKED: external URI resources are not accepted");
|
||||
const bufferViews = document.bufferViews ?? [];
|
||||
const accessors = document.accessors ?? [];
|
||||
if (bufferViews.length > GLB_IMPORT_BUDGET.maxBufferViews || accessors.length > GLB_IMPORT_BUDGET.maxAccessors) throw new Error("GLB_IMPORT_BUDGET_EXCEEDED: accessor or bufferView count exceeds the bounded importer");
|
||||
for (const [index, bufferView] of bufferViews.entries()) bufferViewBytes(bytes, jsonLength, bufferView, `bufferViews[${index}]`);
|
||||
const accessorType = (index: number): string => accessors[requireIndex(index, accessors.length, "accessor")]?.type ?? "";
|
||||
for (const [index, accessor] of accessors.entries()) {
|
||||
if (accessor.sparse !== undefined) throw new Error(`GLB_SPARSE_ACCESSOR_UNSUPPORTED: accessors[${index}]`);
|
||||
if (!Number.isSafeInteger(accessor.count) || accessor.count < 0 || (accessor.byteOffset ?? 0) < 0) throw new Error(`accessors[${index}] is invalid`);
|
||||
if (accessor.bufferView !== undefined) {
|
||||
const bytesForAccessor = bufferViewBytes(bytes, jsonLength, bufferViews[requireIndex(accessor.bufferView, bufferViews.length, `accessors[${index}]`)], `accessors[${index}]`);
|
||||
@@ -153,6 +271,173 @@ export function importGLBSemantics(glb: ArrayBuffer): ImportedGLBSemantics {
|
||||
return { version: 2, meshCount: meshes.length, primitiveCount: meshes.reduce((sum, mesh) => sum + mesh.primitiveCount, 0), meshes, images, skinCount: skins.length, skins, animationCount: animations.length, animationChannelCount: animationPaths.length, animationPaths };
|
||||
}
|
||||
|
||||
function desktopAccessorSemantics(accessors: readonly GLBAccessor[], index: number | undefined, label: string): GLBDesktopAccessorSemantics | null {
|
||||
if (index === undefined) return null;
|
||||
const accessor = accessors[requireIndex(index, accessors.length, label)]!;
|
||||
return {
|
||||
componentType: accessor.componentType,
|
||||
count: accessor.count,
|
||||
type: accessor.type,
|
||||
normalized: accessor.normalized === true,
|
||||
min: accessor.min ?? null,
|
||||
max: accessor.max ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports the canonical, bounded semantic surface used by the M12 desktop GLB
|
||||
* fixtures. This intentionally does not create Blender Main data; that writer
|
||||
* and its stable-ID persistence gate belong to M12-06C.
|
||||
*/
|
||||
export function importGLBDesktopFixtureSemantics(glb: ArrayBuffer): GLBDesktopFixtureSemantics {
|
||||
importGLBSemantics(glb);
|
||||
const bytes = new Uint8Array(glb);
|
||||
const jsonLength = new DataView(glb).getUint32(12, true);
|
||||
const document = jsonChunk(bytes, glb.byteLength);
|
||||
const accessors = document.accessors ?? [];
|
||||
const meshes = document.meshes ?? [];
|
||||
const materials = document.materials ?? [];
|
||||
const nodes = document.nodes ?? [];
|
||||
const textures = document.textures ?? [];
|
||||
const images = document.images ?? [];
|
||||
const samplers = document.samplers ?? [];
|
||||
const skins = document.skins ?? [];
|
||||
const animations = document.animations ?? [];
|
||||
|
||||
if (document.scene !== undefined) requireIndex(document.scene, document.scenes?.length ?? 0, "scene");
|
||||
for (const [index, node] of nodes.entries()) {
|
||||
if (node.mesh !== undefined) requireIndex(node.mesh, meshes.length, `nodes[${index}].mesh`);
|
||||
if (node.skin !== undefined) requireIndex(node.skin, skins.length, `nodes[${index}].skin`);
|
||||
for (const child of node.children ?? []) requireIndex(child, nodes.length, `nodes[${index}].children`);
|
||||
}
|
||||
for (const [index, texture] of textures.entries()) {
|
||||
if (texture.source !== undefined) requireIndex(texture.source, images.length, `textures[${index}].source`);
|
||||
if (texture.sampler !== undefined) requireIndex(texture.sampler, samplers.length, `textures[${index}].sampler`);
|
||||
}
|
||||
|
||||
const importedMeshes = meshes.map((mesh, meshIndex) => ({
|
||||
name: mesh.name ?? null,
|
||||
primitives: (mesh.primitives ?? []).map((primitive, primitiveIndex) => {
|
||||
if (primitive.material !== undefined) requireIndex(primitive.material, materials.length, `meshes[${meshIndex}].primitives[${primitiveIndex}].material`);
|
||||
const attributes: Record<string, GLBDesktopAccessorSemantics> = {};
|
||||
for (const [name, accessor] of Object.entries(primitive.attributes ?? {}).sort(([left], [right]) => left.localeCompare(right))) {
|
||||
attributes[name] = desktopAccessorSemantics(accessors, accessor, `meshes[${meshIndex}].primitives[${primitiveIndex}].attributes.${name}`)!;
|
||||
}
|
||||
return {
|
||||
attributes,
|
||||
indices: desktopAccessorSemantics(accessors, primitive.indices, `meshes[${meshIndex}].primitives[${primitiveIndex}].indices`),
|
||||
material: primitive.material ?? null,
|
||||
mode: primitive.mode ?? 4,
|
||||
targets: (primitive.targets ?? []).map((target, targetIndex) => {
|
||||
const imported: Record<string, GLBDesktopAccessorSemantics> = {};
|
||||
for (const [name, accessor] of Object.entries(target).sort(([left], [right]) => left.localeCompare(right))) {
|
||||
imported[name] = desktopAccessorSemantics(accessors, accessor, `meshes[${meshIndex}].primitives[${primitiveIndex}].targets[${targetIndex}].${name}`)!;
|
||||
}
|
||||
return imported;
|
||||
}),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
const importedSkins = skins.map((skin, skinIndex) => {
|
||||
const joints = skin.joints ?? [];
|
||||
for (const joint of joints) requireIndex(joint, nodes.length, `skins[${skinIndex}].joints`);
|
||||
if (skin.skeleton !== undefined) requireIndex(skin.skeleton, nodes.length, `skins[${skinIndex}].skeleton`);
|
||||
return {
|
||||
name: skin.name ?? null,
|
||||
joints,
|
||||
inverseBindMatrices: desktopAccessorSemantics(accessors, skin.inverseBindMatrices, `skins[${skinIndex}].inverseBindMatrices`),
|
||||
skeleton: skin.skeleton ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const importedAnimations = animations.map((animation, animationIndex) => {
|
||||
const animationSamplers = animation.samplers ?? [];
|
||||
return {
|
||||
name: animation.name ?? null,
|
||||
samplers: animationSamplers.map((sampler, samplerIndex) => ({
|
||||
interpolation: sampler.interpolation ?? "LINEAR",
|
||||
input: desktopAccessorSemantics(accessors, sampler.input, `animations[${animationIndex}].samplers[${samplerIndex}].input`),
|
||||
output: desktopAccessorSemantics(accessors, sampler.output, `animations[${animationIndex}].samplers[${samplerIndex}].output`),
|
||||
})),
|
||||
channels: (animation.channels ?? []).map((channel, channelIndex) => {
|
||||
const sampler = requireIndex(channel.sampler, animationSamplers.length, `animations[${animationIndex}].channels[${channelIndex}].sampler`);
|
||||
const node = requireIndex(channel.target?.node, nodes.length, `animations[${animationIndex}].channels[${channelIndex}].target.node`);
|
||||
const path = channel.target?.path;
|
||||
if (path !== "translation" && path !== "rotation" && path !== "scale" && path !== "weights") throw new Error(`animations[${animationIndex}].channels[${channelIndex}].target.path is invalid`);
|
||||
return { sampler, target: { node, path } };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
asset: document.asset ?? null,
|
||||
extensionsUsed: [...(document.extensionsUsed ?? [])].sort(),
|
||||
extensionsRequired: [...(document.extensionsRequired ?? [])].sort(),
|
||||
scene: document.scene ?? null,
|
||||
nodeNames: nodes.map((node) => node.name ?? null),
|
||||
nodes: nodes.map((node) => ({
|
||||
name: node.name ?? null,
|
||||
mesh: node.mesh ?? null,
|
||||
skin: node.skin ?? null,
|
||||
children: node.children ?? [],
|
||||
translation: node.translation ?? null,
|
||||
rotation: node.rotation ?? null,
|
||||
scale: node.scale ?? null,
|
||||
})),
|
||||
meshes: importedMeshes,
|
||||
materials: materials.map((material) => {
|
||||
const pbr = material.pbrMetallicRoughness ?? {};
|
||||
return {
|
||||
name: material.name ?? null,
|
||||
alphaMode: material.alphaMode ?? "OPAQUE",
|
||||
doubleSided: material.doubleSided === true,
|
||||
pbr: {
|
||||
baseColorFactor: pbr.baseColorFactor ?? null,
|
||||
baseColorTexture: pbr.baseColorTexture ?? null,
|
||||
metallicFactor: pbr.metallicFactor ?? null,
|
||||
roughnessFactor: pbr.roughnessFactor ?? null,
|
||||
},
|
||||
normalTexture: material.normalTexture ?? null,
|
||||
emissiveFactor: material.emissiveFactor ?? null,
|
||||
};
|
||||
}),
|
||||
textures: textures.map((texture) => ({ ...texture })),
|
||||
images: images.map((image) => ({ ...image })),
|
||||
samplers: samplers.map((sampler) => ({ ...sampler })),
|
||||
skins: importedSkins,
|
||||
animations: importedAnimations,
|
||||
};
|
||||
}
|
||||
|
||||
function semanticMismatches(expected: unknown, actual: unknown, path: string, mismatches: string[]): void {
|
||||
if (Object.is(expected, actual)) return;
|
||||
if (Array.isArray(expected) || Array.isArray(actual)) {
|
||||
if (!Array.isArray(expected) || !Array.isArray(actual)) {
|
||||
mismatches.push(`${path}: expected ${JSON.stringify(expected)} got ${JSON.stringify(actual)}`);
|
||||
return;
|
||||
}
|
||||
if (expected.length !== actual.length) mismatches.push(`${path}.length: expected ${expected.length} got ${actual.length}`);
|
||||
for (let index = 0; index < Math.min(expected.length, actual.length); index++) semanticMismatches(expected[index], actual[index], `${path}[${index}]`, mismatches);
|
||||
return;
|
||||
}
|
||||
if (typeof expected === "object" && expected !== null && typeof actual === "object" && actual !== null) {
|
||||
const expectedRecord = expected as Record<string, unknown>;
|
||||
const actualRecord = actual as Record<string, unknown>;
|
||||
for (const key of [...new Set([...Object.keys(expectedRecord), ...Object.keys(actualRecord)])].sort()) {
|
||||
semanticMismatches(expectedRecord[key], actualRecord[key], `${path}.${key}`, mismatches);
|
||||
}
|
||||
return;
|
||||
}
|
||||
mismatches.push(`${path}: expected ${JSON.stringify(expected)} got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
|
||||
export function compareGLBDesktopFixtureSemantics(expected: unknown, actual: GLBDesktopFixtureSemantics): GLBSemanticComparison {
|
||||
const mismatches: string[] = [];
|
||||
semanticMismatches(expected, actual, "$", mismatches);
|
||||
return { compatible: mismatches.length === 0, mismatches };
|
||||
}
|
||||
|
||||
export function compareGLBToSceneIR(snapshot: SceneSnapshotIR, imported: ImportedGLBSemantics, assetBuffers: readonly GLBAssetBuffer[] = []): GLBSemanticComparison {
|
||||
const mismatches: string[] = [];
|
||||
const geometryMeshIds = new Set(snapshot.meshes.filter((mesh) => mesh.geometryStatus !== "summary-only").map((mesh) => mesh.id));
|
||||
|
||||
63
web/protocol/glb-loss-report.ts
Normal file
63
web/protocol/glb-loss-report.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { GLBExportReport } from "./glb-export";
|
||||
import type { SceneSnapshotIR } from "./scene-ir";
|
||||
|
||||
export const GLB_LOSS_REPORT_SCHEMA_VERSION = 1 as const;
|
||||
|
||||
export interface GLBLossReport {
|
||||
schemaVersion: typeof GLB_LOSS_REPORT_SCHEMA_VERSION;
|
||||
operation: "GLB_EXPORT_LOSS_REPORT";
|
||||
sceneId: string;
|
||||
sourceRevision: number;
|
||||
canExport: boolean;
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
losses: Array<{
|
||||
code: string;
|
||||
severity: "warning" | "error";
|
||||
message: string;
|
||||
id: string | null;
|
||||
}>;
|
||||
surface: {
|
||||
nodeCount: number;
|
||||
meshCount: number;
|
||||
materialCount: number;
|
||||
imageCount: number;
|
||||
animationCount: number;
|
||||
nonMeshCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert the exporter result into a stable, machine-consumable loss report. */
|
||||
export function createGLBLossReport(snapshot: SceneSnapshotIR, report: GLBExportReport): GLBLossReport {
|
||||
const losses = report.warnings
|
||||
.map((warning) => ({
|
||||
code: warning.code,
|
||||
severity: warning.severity,
|
||||
message: warning.message,
|
||||
id: warning.id ?? null,
|
||||
}))
|
||||
.sort((left, right) =>
|
||||
left.code.localeCompare(right.code) ||
|
||||
left.severity.localeCompare(right.severity) ||
|
||||
(left.id ?? "").localeCompare(right.id ?? "") ||
|
||||
left.message.localeCompare(right.message),
|
||||
);
|
||||
return {
|
||||
schemaVersion: GLB_LOSS_REPORT_SCHEMA_VERSION,
|
||||
operation: "GLB_EXPORT_LOSS_REPORT",
|
||||
sceneId: snapshot.sceneId,
|
||||
sourceRevision: snapshot.revision,
|
||||
canExport: report.canExport,
|
||||
errorCount: losses.filter((loss) => loss.severity === "error").length,
|
||||
warningCount: losses.filter((loss) => loss.severity === "warning").length,
|
||||
losses,
|
||||
surface: {
|
||||
nodeCount: snapshot.nodes.length,
|
||||
meshCount: snapshot.meshes.length,
|
||||
materialCount: snapshot.materials.length,
|
||||
imageCount: snapshot.images.length,
|
||||
animationCount: snapshot.animations.length,
|
||||
nonMeshCount: snapshot.nonMeshData?.length ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
134
web/protocol/glb-recovery.ts
Normal file
134
web/protocol/glb-recovery.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
export const GLB_RECOVERY_SCHEMA_VERSION = 1 as const;
|
||||
|
||||
export type GLBRecoveryOperation = "IMPORT" | "EXPORT";
|
||||
export type GLBRecoveryStatus = "RUNNING" | "CANCELLED" | "COMMITTED" | "RECOVERED" | "BLOCKED";
|
||||
export type GLBRecoveryErrorCode =
|
||||
| "GLB_OPERATION_CANCELLED"
|
||||
| "GLB_WORKER_RESTARTED"
|
||||
| "GLB_OPFS_QUOTA"
|
||||
| "GLB_RECOVERY_INVALID";
|
||||
|
||||
export interface GLBRecoveryReceipt {
|
||||
schemaVersion: typeof GLB_RECOVERY_SCHEMA_VERSION;
|
||||
operationId: string;
|
||||
operation: GLBRecoveryOperation;
|
||||
status: GLBRecoveryStatus;
|
||||
workerGeneration: number;
|
||||
baseRevision: number;
|
||||
candidateRevision: number;
|
||||
inputBytes: number;
|
||||
inputSha256: string;
|
||||
outputBytes: number;
|
||||
outputSha256: string | null;
|
||||
temporaryBytes: number;
|
||||
liveRequests: number;
|
||||
committed: boolean;
|
||||
errorCode?: GLBRecoveryErrorCode;
|
||||
}
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const OPERATION_ID = /^[A-Za-z0-9_-]{1,96}$/;
|
||||
|
||||
function assertBase(receipt: GLBRecoveryReceipt): void {
|
||||
if (receipt.schemaVersion !== GLB_RECOVERY_SCHEMA_VERSION || !OPERATION_ID.test(receipt.operationId) ||
|
||||
(receipt.operation !== "IMPORT" && receipt.operation !== "EXPORT") || !Number.isSafeInteger(receipt.workerGeneration) || receipt.workerGeneration < 1 ||
|
||||
!Number.isSafeInteger(receipt.baseRevision) || receipt.baseRevision < 0 || !Number.isSafeInteger(receipt.candidateRevision) || receipt.candidateRevision < receipt.baseRevision ||
|
||||
!Number.isSafeInteger(receipt.inputBytes) || receipt.inputBytes <= 0 || !SHA256.test(receipt.inputSha256) ||
|
||||
!Number.isSafeInteger(receipt.outputBytes) || receipt.outputBytes < 0 || (receipt.outputSha256 !== null && !SHA256.test(receipt.outputSha256)) ||
|
||||
!Number.isSafeInteger(receipt.temporaryBytes) || receipt.temporaryBytes < 0 || !Number.isSafeInteger(receipt.liveRequests) || receipt.liveRequests < 0 ||
|
||||
typeof receipt.committed !== "boolean") {
|
||||
throw new Error("GLB_RECOVERY_INVALID: receipt fields are malformed");
|
||||
}
|
||||
}
|
||||
|
||||
function clone(receipt: GLBRecoveryReceipt): GLBRecoveryReceipt {
|
||||
assertBase(receipt);
|
||||
return { ...receipt };
|
||||
}
|
||||
|
||||
export function beginGLBRecoveryOperation(input: {
|
||||
operationId: string;
|
||||
operation: GLBRecoveryOperation;
|
||||
workerGeneration: number;
|
||||
baseRevision: number;
|
||||
inputBytes: number;
|
||||
inputSha256: string;
|
||||
}): GLBRecoveryReceipt {
|
||||
const receipt: GLBRecoveryReceipt = {
|
||||
schemaVersion: GLB_RECOVERY_SCHEMA_VERSION,
|
||||
operationId: input.operationId,
|
||||
operation: input.operation,
|
||||
status: "RUNNING",
|
||||
workerGeneration: input.workerGeneration,
|
||||
baseRevision: input.baseRevision,
|
||||
candidateRevision: input.baseRevision + 1,
|
||||
inputBytes: input.inputBytes,
|
||||
inputSha256: input.inputSha256,
|
||||
outputBytes: 0,
|
||||
outputSha256: null,
|
||||
temporaryBytes: input.inputBytes,
|
||||
liveRequests: 1,
|
||||
committed: false,
|
||||
};
|
||||
assertBase(receipt);
|
||||
return receipt;
|
||||
}
|
||||
|
||||
export function commitGLBRecoveryOperation(receipt: GLBRecoveryReceipt, output: { bytes: number; sha256: string }): GLBRecoveryReceipt {
|
||||
const next = clone(receipt);
|
||||
if (next.status !== "RUNNING" || !Number.isSafeInteger(output.bytes) || output.bytes <= 0 || !SHA256.test(output.sha256)) {
|
||||
throw new Error("GLB_RECOVERY_INVALID: operation cannot commit");
|
||||
}
|
||||
next.status = "COMMITTED";
|
||||
next.outputBytes = output.bytes;
|
||||
next.outputSha256 = output.sha256;
|
||||
next.temporaryBytes = 0;
|
||||
next.liveRequests = 0;
|
||||
next.committed = true;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function cancelGLBRecoveryOperation(receipt: GLBRecoveryReceipt): GLBRecoveryReceipt {
|
||||
const next = clone(receipt);
|
||||
if (next.status !== "RUNNING") throw new Error("GLB_RECOVERY_INVALID: operation is not running");
|
||||
next.status = "CANCELLED";
|
||||
next.errorCode = "GLB_OPERATION_CANCELLED";
|
||||
next.temporaryBytes = 0;
|
||||
next.liveRequests = 0;
|
||||
next.committed = false;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function blockGLBRecoveryForQuota(receipt: GLBRecoveryReceipt): GLBRecoveryReceipt {
|
||||
const next = clone(receipt);
|
||||
if (next.status !== "RUNNING") throw new Error("GLB_RECOVERY_INVALID: operation is not running");
|
||||
next.status = "BLOCKED";
|
||||
next.errorCode = "GLB_OPFS_QUOTA";
|
||||
next.temporaryBytes = 0;
|
||||
next.liveRequests = 0;
|
||||
next.committed = false;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function recoverGLBRecoveryOperation(receipt: GLBRecoveryReceipt, workerGeneration: number): GLBRecoveryReceipt {
|
||||
const next = clone(receipt);
|
||||
if (next.status !== "COMMITTED" || !Number.isSafeInteger(workerGeneration) || workerGeneration <= next.workerGeneration) {
|
||||
throw new Error("GLB_RECOVERY_INVALID: only a committed operation can recover");
|
||||
}
|
||||
next.status = "RECOVERED";
|
||||
next.workerGeneration = workerGeneration;
|
||||
next.errorCode = "GLB_WORKER_RESTARTED";
|
||||
return next;
|
||||
}
|
||||
|
||||
export function parseGLBRecoveryReceipt(value: unknown): GLBRecoveryReceipt {
|
||||
if (!value || typeof value !== "object") throw new Error("GLB_RECOVERY_INVALID: receipt is not an object");
|
||||
const receipt = value as GLBRecoveryReceipt;
|
||||
assertBase(receipt);
|
||||
if (!["RUNNING", "CANCELLED", "COMMITTED", "RECOVERED", "BLOCKED"].includes(receipt.status)) throw new Error("GLB_RECOVERY_INVALID: status");
|
||||
if (receipt.status === "CANCELLED" && receipt.errorCode !== "GLB_OPERATION_CANCELLED") throw new Error("GLB_RECOVERY_INVALID: cancellation code");
|
||||
if (receipt.status === "BLOCKED" && receipt.errorCode !== "GLB_OPFS_QUOTA") throw new Error("GLB_RECOVERY_INVALID: quota code");
|
||||
if (receipt.status === "COMMITTED" && (!receipt.committed || receipt.outputBytes <= 0 || !receipt.outputSha256)) throw new Error("GLB_RECOVERY_INVALID: committed receipt");
|
||||
if (receipt.status === "RECOVERED" && (!receipt.committed || receipt.errorCode !== "GLB_WORKER_RESTARTED")) throw new Error("GLB_RECOVERY_INVALID: recovered receipt");
|
||||
return { ...receipt };
|
||||
}
|
||||
34
web/protocol/ime-composition.ts
Normal file
34
web/protocol/ime-composition.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export const IME_COMPOSITION_SCHEMA_VERSION = 1 as const;
|
||||
|
||||
export interface IMECompositionState {
|
||||
schemaVersion: typeof IME_COMPOSITION_SCHEMA_VERSION;
|
||||
composing: boolean;
|
||||
revision: number;
|
||||
pendingText: string;
|
||||
lastEvent: "IDLE" | "START" | "UPDATE" | "END";
|
||||
}
|
||||
|
||||
export type IMECompositionEvent =
|
||||
| { type: "compositionstart"; data?: string }
|
||||
| { type: "compositionupdate"; data?: string }
|
||||
| { type: "compositionend"; data?: string };
|
||||
|
||||
export function createIMECompositionState(): IMECompositionState {
|
||||
return { schemaVersion: IME_COMPOSITION_SCHEMA_VERSION, composing: false, revision: 0, pendingText: "", lastEvent: "IDLE" };
|
||||
}
|
||||
|
||||
export function reduceIMEComposition(state: IMECompositionState, event: IMECompositionEvent): IMECompositionState {
|
||||
if (!state || state.schemaVersion !== IME_COMPOSITION_SCHEMA_VERSION) throw new Error("IME_STATE_INVALID");
|
||||
const text = typeof event.data === "string" ? event.data : "";
|
||||
if (event.type === "compositionstart") return { schemaVersion: 1, composing: true, revision: state.revision + 1, pendingText: text, lastEvent: "START" };
|
||||
if (event.type === "compositionupdate") {
|
||||
if (!state.composing) return state;
|
||||
return { schemaVersion: 1, composing: true, revision: state.revision + 1, pendingText: text, lastEvent: "UPDATE" };
|
||||
}
|
||||
return { schemaVersion: 1, composing: false, revision: state.revision + 1, pendingText: text, lastEvent: "END" };
|
||||
}
|
||||
|
||||
export function shouldBlockOperatorShortcuts(state: IMECompositionState, eventIsComposing = false): boolean {
|
||||
if (!state || state.schemaVersion !== IME_COMPOSITION_SCHEMA_VERSION) throw new Error("IME_STATE_INVALID");
|
||||
return state.composing || eventIsComposing;
|
||||
}
|
||||
41
web/protocol/input-modal.ts
Normal file
41
web/protocol/input-modal.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export const INPUT_MODAL_SCHEMA_VERSION = 1 as const;
|
||||
|
||||
export type InputModalKind = "NONE" | "TOUCH_NAVIGATION" | "PEN_STROKE";
|
||||
|
||||
export interface InputModalState {
|
||||
schemaVersion: typeof INPUT_MODAL_SCHEMA_VERSION;
|
||||
kind: InputModalKind;
|
||||
activePointerIds: number[];
|
||||
cancelled: boolean;
|
||||
navigationRevision: number;
|
||||
mainCommitCount: number;
|
||||
}
|
||||
|
||||
export function createInputModalState(): InputModalState {
|
||||
return { schemaVersion: 1, kind: "NONE", activePointerIds: [], cancelled: false, navigationRevision: 0, mainCommitCount: 0 };
|
||||
}
|
||||
|
||||
export function beginTouch(state: InputModalState, pointerId: number): InputModalState {
|
||||
if (!Number.isSafeInteger(pointerId) || pointerId < 0) throw new Error("POINTER_ID_INVALID");
|
||||
const ids = state.activePointerIds.includes(pointerId) ? state.activePointerIds : [...state.activePointerIds, pointerId].sort((a, b) => a - b);
|
||||
return { ...state, kind: "TOUCH_NAVIGATION", activePointerIds: ids, cancelled: false, navigationRevision: ids.length >= 2 && state.activePointerIds.length < 2 ? state.navigationRevision + 1 : state.navigationRevision };
|
||||
}
|
||||
|
||||
export function cancelInputModal(state: InputModalState): InputModalState {
|
||||
return { ...state, kind: "NONE", activePointerIds: [], cancelled: true };
|
||||
}
|
||||
|
||||
export function endTouch(state: InputModalState, pointerId: number): InputModalState {
|
||||
const ids = state.activePointerIds.filter((id) => id !== pointerId);
|
||||
return { ...state, kind: ids.length > 0 ? "TOUCH_NAVIGATION" : "NONE", activePointerIds: ids };
|
||||
}
|
||||
|
||||
export function beginPenStroke(state: InputModalState, pointerId: number): InputModalState {
|
||||
if (!Number.isSafeInteger(pointerId) || pointerId < 0) throw new Error("POINTER_ID_INVALID");
|
||||
return { ...state, kind: "PEN_STROKE", activePointerIds: [pointerId], cancelled: false };
|
||||
}
|
||||
|
||||
export function commitPenStroke(state: InputModalState, pointerId: number): InputModalState {
|
||||
if (state.kind !== "PEN_STROKE" || !state.activePointerIds.includes(pointerId) || state.cancelled) return state;
|
||||
return { ...state, kind: "NONE", activePointerIds: [], mainCommitCount: state.mainCommitCount + 1 };
|
||||
}
|
||||
135
web/protocol/io-format-capability-matrix.ts
Normal file
135
web/protocol/io-format-capability-matrix.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const IO_FORMAT_CAPABILITY_MATRIX_SCHEMA = 1 as const;
|
||||
export const IO_FORMAT_MATRIX_FORMATS = ["GLTF", "GLB", "OBJ", "STL", "PLY", "USD", "ALEMBIC"] as const;
|
||||
export const IO_FORMAT_MATRIX_OPERATIONS = ["IMPORT", "EXPORT"] as const;
|
||||
export type IOFormatMatrixFormat = typeof IO_FORMAT_MATRIX_FORMATS[number];
|
||||
export type IOFormatMatrixOperation = typeof IO_FORMAT_MATRIX_OPERATIONS[number];
|
||||
export type IOFormatMatrixFeatureStatus = "SUPPORTED" | "PARTIAL" | "UNVERIFIED";
|
||||
export type IOFormatMatrixRouteStatus = "READY" | "BLOCKED";
|
||||
export type IOFormatMatrixExecution = "LOCAL" | "SERVER" | "NONE";
|
||||
|
||||
export interface IOFormatMatrixRouteIR {
|
||||
status: IOFormatMatrixRouteStatus;
|
||||
execution: IOFormatMatrixExecution;
|
||||
code: Extract<ErrorCode, "IO_FORMAT_UNSUPPORTED" | "SERVER_JOB_UNAVAILABLE"> | null;
|
||||
}
|
||||
|
||||
export interface IOFormatMatrixFeatureIR {
|
||||
status: IOFormatMatrixFeatureStatus;
|
||||
evidence: string;
|
||||
}
|
||||
|
||||
export interface IOFormatMatrixOperationIR {
|
||||
local: IOFormatMatrixRouteIR;
|
||||
server: IOFormatMatrixRouteIR;
|
||||
geometry: IOFormatMatrixFeatureIR;
|
||||
material: IOFormatMatrixFeatureIR;
|
||||
animation: IOFormatMatrixFeatureIR;
|
||||
}
|
||||
|
||||
export interface IOFormatMatrixEntryIR {
|
||||
format: IOFormatMatrixFormat;
|
||||
runtimeImportStatus: "AVAILABLE" | "OPERATOR_UNREGISTERED";
|
||||
runtimeExportStatus: "AVAILABLE" | "OPERATOR_UNREGISTERED";
|
||||
operations: Record<IOFormatMatrixOperation, IOFormatMatrixOperationIR>;
|
||||
}
|
||||
|
||||
export interface IOFormatCapabilityMatrixIR {
|
||||
schemaVersion: typeof IO_FORMAT_CAPABILITY_MATRIX_SCHEMA;
|
||||
task: "M12-05B";
|
||||
runtimeInventorySha256: string;
|
||||
formats: IOFormatMatrixEntryIR[];
|
||||
}
|
||||
|
||||
export class IOFormatCapabilityMatrixError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
|
||||
constructor(code: ErrorCode, message: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "IOFormatCapabilityMatrixError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const FEATURE_STATUSES = ["SUPPORTED", "PARTIAL", "UNVERIFIED"] as const;
|
||||
const ROUTE_STATUSES = ["READY", "BLOCKED"] as const;
|
||||
const EXECUTIONS = ["LOCAL", "SERVER", "NONE"] as const;
|
||||
const RUNTIME_STATUSES = ["AVAILABLE", "OPERATOR_UNREGISTERED"] as const;
|
||||
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new IOFormatCapabilityMatrixError("ASSET_MANIFEST_INVALID", `${path} must be an object`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, expected: readonly string[], path: string): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const allowed = [...expected].sort();
|
||||
if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) throw new IOFormatCapabilityMatrixError("ASSET_MANIFEST_INVALID", `${path} contains undeclared fields`);
|
||||
}
|
||||
|
||||
function text(value: unknown, path: string, maximum = 512): string {
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > maximum) throw new IOFormatCapabilityMatrixError("ASSET_MANIFEST_INVALID", `${path} is invalid`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseRoute(value: unknown, path: string): IOFormatMatrixRouteIR {
|
||||
const input = record(value, path);
|
||||
exactKeys(input, ["status", "execution", "code"], path);
|
||||
if (!ROUTE_STATUSES.includes(input.status as IOFormatMatrixRouteStatus) || !EXECUTIONS.includes(input.execution as IOFormatMatrixExecution)) throw new IOFormatCapabilityMatrixError("ASSET_MANIFEST_INVALID", `${path} route status is invalid`);
|
||||
const status = input.status as IOFormatMatrixRouteStatus;
|
||||
const execution = input.execution as IOFormatMatrixExecution;
|
||||
if (status === "READY" && ((execution !== "LOCAL" && execution !== "SERVER") || input.code !== null)) throw new IOFormatCapabilityMatrixError("IO_FORMAT_UNSUPPORTED", `${path} ready route is not bound to an executor`);
|
||||
if (status === "BLOCKED" && (execution !== "NONE" || !["IO_FORMAT_UNSUPPORTED", "SERVER_JOB_UNAVAILABLE"].includes(input.code as string))) throw new IOFormatCapabilityMatrixError("IO_FORMAT_UNSUPPORTED", `${path} blocked route is not fail-closed`);
|
||||
return { status, execution, code: input.code as IOFormatMatrixRouteIR["code"] };
|
||||
}
|
||||
|
||||
function parseFeature(value: unknown, path: string): IOFormatMatrixFeatureIR {
|
||||
const input = record(value, path);
|
||||
exactKeys(input, ["status", "evidence"], path);
|
||||
if (!FEATURE_STATUSES.includes(input.status as IOFormatMatrixFeatureStatus)) throw new IOFormatCapabilityMatrixError("ASSET_MANIFEST_INVALID", `${path}.status is invalid`);
|
||||
return { status: input.status as IOFormatMatrixFeatureStatus, evidence: text(input.evidence, `${path}.evidence`) };
|
||||
}
|
||||
|
||||
function parseOperation(value: unknown, path: string): IOFormatMatrixOperationIR {
|
||||
const input = record(value, path);
|
||||
exactKeys(input, ["local", "server", "geometry", "material", "animation"], path);
|
||||
const local = parseRoute(input.local, `${path}.local`);
|
||||
const server = parseRoute(input.server, `${path}.server`);
|
||||
const geometry = parseFeature(input.geometry, `${path}.geometry`);
|
||||
const material = parseFeature(input.material, `${path}.material`);
|
||||
const animation = parseFeature(input.animation, `${path}.animation`);
|
||||
if (local.status === "READY" && [geometry, material, animation].some((feature) => feature.status === "UNVERIFIED")) throw new IOFormatCapabilityMatrixError("ASSET_MANIFEST_INVALID", `${path} ready local route has unverified feature support`);
|
||||
return { local, server, geometry, material, animation };
|
||||
}
|
||||
|
||||
function parseEntry(value: unknown, index: number): IOFormatMatrixEntryIR {
|
||||
const path = `formats[${index}]`;
|
||||
const input = record(value, path);
|
||||
exactKeys(input, ["format", "runtimeImportStatus", "runtimeExportStatus", "operations"], path);
|
||||
if (!IO_FORMAT_MATRIX_FORMATS.includes(input.format as IOFormatMatrixFormat)) throw new IOFormatCapabilityMatrixError("ASSET_MANIFEST_INVALID", `${path}.format is invalid`);
|
||||
if (!RUNTIME_STATUSES.includes(input.runtimeImportStatus as IOFormatMatrixEntryIR["runtimeImportStatus"]) || !RUNTIME_STATUSES.includes(input.runtimeExportStatus as IOFormatMatrixEntryIR["runtimeExportStatus"])) throw new IOFormatCapabilityMatrixError("ASSET_MANIFEST_INVALID", `${path} runtime status is invalid`);
|
||||
const operations = record(input.operations, `${path}.operations`);
|
||||
exactKeys(operations, IO_FORMAT_MATRIX_OPERATIONS, `${path}.operations`);
|
||||
return {
|
||||
format: input.format as IOFormatMatrixFormat,
|
||||
runtimeImportStatus: input.runtimeImportStatus as IOFormatMatrixEntryIR["runtimeImportStatus"],
|
||||
runtimeExportStatus: input.runtimeExportStatus as IOFormatMatrixEntryIR["runtimeExportStatus"],
|
||||
operations: {
|
||||
IMPORT: parseOperation(operations.IMPORT, `${path}.operations.IMPORT`),
|
||||
EXPORT: parseOperation(operations.EXPORT, `${path}.operations.EXPORT`),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function parseIOFormatCapabilityMatrix(value: unknown): IOFormatCapabilityMatrixIR {
|
||||
const input = record(value, "input");
|
||||
exactKeys(input, ["schemaVersion", "task", "runtimeInventorySha256", "formats"], "input");
|
||||
if (input.schemaVersion !== IO_FORMAT_CAPABILITY_MATRIX_SCHEMA || input.task !== "M12-05B" || typeof input.runtimeInventorySha256 !== "string" || !SHA256.test(input.runtimeInventorySha256)) throw new IOFormatCapabilityMatrixError("ASSET_MANIFEST_INVALID", "matrix header is invalid");
|
||||
if (!Array.isArray(input.formats) || input.formats.length !== IO_FORMAT_MATRIX_FORMATS.length) throw new IOFormatCapabilityMatrixError("ASSET_MANIFEST_INVALID", "matrix must contain each inventoried format exactly once");
|
||||
const formats = input.formats.map(parseEntry);
|
||||
const seen = new Set(formats.map((entry) => entry.format));
|
||||
if (seen.size !== IO_FORMAT_MATRIX_FORMATS.length || IO_FORMAT_MATRIX_FORMATS.some((format) => !seen.has(format))) throw new IOFormatCapabilityMatrixError("ASSET_MANIFEST_INVALID", "matrix format identities are incomplete or duplicated");
|
||||
return { schemaVersion: IO_FORMAT_CAPABILITY_MATRIX_SCHEMA, task: "M12-05B", runtimeInventorySha256: input.runtimeInventorySha256, formats };
|
||||
}
|
||||
115
web/protocol/io-format-receipt-binding.ts
Normal file
115
web/protocol/io-format-receipt-binding.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { IOFormatRuntimeIdentityIR, IOFormatRuntimeReceiptIR, IOFormatRuntimeReceiptSetIR } from "./io-format-runtime-receipt";
|
||||
|
||||
export const IO_FORMAT_RECEIPT_BINDING_SCHEMA = 1 as const;
|
||||
export const IO_FORMAT_RECEIPT_BINDING_TASK = "M12-05E" as const;
|
||||
|
||||
export interface IOFormatReceiptBindingIR {
|
||||
sourceSha256: string;
|
||||
settingsSha256: string;
|
||||
runtimeSha256: string;
|
||||
}
|
||||
|
||||
export interface IOFormatBoundRuntimeReceiptIR extends IOFormatRuntimeReceiptIR, IOFormatReceiptBindingIR {}
|
||||
|
||||
export interface IOFormatBoundRuntimeReceiptSetIR {
|
||||
schemaVersion: typeof IO_FORMAT_RECEIPT_BINDING_SCHEMA;
|
||||
task: typeof IO_FORMAT_RECEIPT_BINDING_TASK;
|
||||
parentReceiptSetSha256: string;
|
||||
inventorySha256: string;
|
||||
runtime: IOFormatRuntimeIdentityIR;
|
||||
receipts: IOFormatBoundRuntimeReceiptIR[];
|
||||
}
|
||||
|
||||
export class IOFormatReceiptBindingError extends Error {
|
||||
readonly code = "IO_FORMAT_UNSUPPORTED" as const;
|
||||
|
||||
constructor(message: string) {
|
||||
super(`IO_FORMAT_UNSUPPORTED: ${message}`);
|
||||
this.name = "IOFormatReceiptBindingError";
|
||||
}
|
||||
}
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const FORMAT_ORDER = ["GLTF", "GLB", "OBJ", "STL", "PLY", "USD", "ALEMBIC"] as const;
|
||||
|
||||
function assertSha(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || !SHA256.test(value)) throw new IOFormatReceiptBindingError(`${path} is not SHA-256`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new IOFormatReceiptBindingError(`${path} must be an object`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, expected: readonly string[], path: string): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const allowed = [...expected].sort();
|
||||
if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) throw new IOFormatReceiptBindingError(`${path} contains undeclared fields`);
|
||||
}
|
||||
|
||||
function parseReceipt(value: unknown, index: number): IOFormatBoundRuntimeReceiptIR {
|
||||
const path = `receipts[${index}]`;
|
||||
const input = record(value, path);
|
||||
exactKeys(input, ["format", "family", "operation", "operator", "registered", "rnaIdentifier", "buildOption", "buildOptionEnabled", "runtimeStatus", "variants", "extensions", "sourceSha256", "settingsSha256", "runtimeSha256"], path);
|
||||
if (typeof input.format !== "string" || !FORMAT_ORDER.includes(input.format as typeof FORMAT_ORDER[number])) throw new IOFormatReceiptBindingError(`${path}.format is invalid`);
|
||||
if (input.operation !== "IMPORT" && input.operation !== "EXPORT") throw new IOFormatReceiptBindingError(`${path}.operation is invalid`);
|
||||
if (typeof input.family !== "string" || !input.family || typeof input.operator !== "string" || !input.operator) throw new IOFormatReceiptBindingError(`${path} identity is invalid`);
|
||||
if (typeof input.registered !== "boolean" || (input.rnaIdentifier !== null && typeof input.rnaIdentifier !== "string") || (input.buildOption !== null && typeof input.buildOption !== "string") || (input.buildOptionEnabled !== null && typeof input.buildOptionEnabled !== "boolean")) throw new IOFormatReceiptBindingError(`${path} runtime fields are invalid`);
|
||||
if (input.runtimeStatus !== "AVAILABLE" && input.runtimeStatus !== "OPERATOR_UNREGISTERED") throw new IOFormatReceiptBindingError(`${path}.runtimeStatus is invalid`);
|
||||
if (!Array.isArray(input.variants) || !Array.isArray(input.extensions) || input.variants.length === 0 || input.extensions.length === 0 || input.variants.some((item) => typeof item !== "string") || input.extensions.some((item) => typeof item !== "string")) throw new IOFormatReceiptBindingError(`${path} variants/extensions are invalid`);
|
||||
return {
|
||||
format: input.format as IOFormatBoundRuntimeReceiptIR["format"],
|
||||
family: input.family,
|
||||
operation: input.operation as IOFormatBoundRuntimeReceiptIR["operation"],
|
||||
operator: input.operator,
|
||||
registered: input.registered,
|
||||
rnaIdentifier: input.rnaIdentifier as string | null,
|
||||
buildOption: input.buildOption as string | null,
|
||||
buildOptionEnabled: input.buildOptionEnabled as boolean | null,
|
||||
runtimeStatus: input.runtimeStatus as IOFormatBoundRuntimeReceiptIR["runtimeStatus"],
|
||||
variants: [...input.variants as string[]],
|
||||
extensions: [...input.extensions as string[]],
|
||||
sourceSha256: assertSha(input.sourceSha256, `${path}.sourceSha256`),
|
||||
settingsSha256: assertSha(input.settingsSha256, `${path}.settingsSha256`),
|
||||
runtimeSha256: assertSha(input.runtimeSha256, `${path}.runtimeSha256`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseRuntime(value: unknown): IOFormatRuntimeIdentityIR {
|
||||
const input = record(value, "runtime");
|
||||
if (!Array.isArray(input.versionTuple) || input.versionTuple.length !== 3 || input.versionTuple.some((item) => !Number.isSafeInteger(item))) throw new IOFormatReceiptBindingError("runtime.versionTuple is invalid");
|
||||
if (typeof input.binarySha256 !== "string" || !SHA256.test(input.binarySha256)) throw new IOFormatReceiptBindingError("runtime.binarySha256 is invalid");
|
||||
if (typeof input.blenderVersion !== "string" || typeof input.buildHash !== "string" || typeof input.buildBranch !== "string" || typeof input.buildPlatform !== "string" || typeof input.buildType !== "string" || typeof input.buildDate !== "string" || typeof input.buildTime !== "string" || !Number.isSafeInteger(input.buildCommitTimestamp) || typeof input.buildOptions !== "object" || input.buildOptions === null || Array.isArray(input.buildOptions)) throw new IOFormatReceiptBindingError("runtime identity is invalid");
|
||||
return input as unknown as IOFormatRuntimeIdentityIR;
|
||||
}
|
||||
|
||||
export function canonicalReceiptSource(receipt: IOFormatRuntimeReceiptIR): Record<string, unknown> {
|
||||
return { format: receipt.format, family: receipt.family, operation: receipt.operation, operator: receipt.operator, registered: receipt.registered, rnaIdentifier: receipt.rnaIdentifier };
|
||||
}
|
||||
|
||||
export function canonicalReceiptSettings(receipt: IOFormatRuntimeReceiptIR): Record<string, unknown> {
|
||||
return { buildOption: receipt.buildOption, buildOptionEnabled: receipt.buildOptionEnabled, variants: receipt.variants, extensions: receipt.extensions };
|
||||
}
|
||||
|
||||
export function canonicalRuntimeIdentity(runtime: IOFormatRuntimeIdentityIR): IOFormatRuntimeIdentityIR {
|
||||
return runtime;
|
||||
}
|
||||
|
||||
export function validateIOFormatBoundReceiptSet(value: unknown, expectedParentReceiptSetSha256: string, expectedInventorySha256: string): IOFormatBoundRuntimeReceiptSetIR {
|
||||
const input = record(value, "input");
|
||||
exactKeys(input, ["schemaVersion", "task", "parentReceiptSetSha256", "inventorySha256", "runtime", "receipts"], "input");
|
||||
if (input.schemaVersion !== IO_FORMAT_RECEIPT_BINDING_SCHEMA || input.task !== IO_FORMAT_RECEIPT_BINDING_TASK) throw new IOFormatReceiptBindingError("receipt binding header is invalid");
|
||||
if (!SHA256.test(expectedParentReceiptSetSha256) || !SHA256.test(expectedInventorySha256) || input.parentReceiptSetSha256 !== expectedParentReceiptSetSha256 || input.inventorySha256 !== expectedInventorySha256) throw new IOFormatReceiptBindingError("receipt binding parent identity drifted");
|
||||
if (!Array.isArray(input.receipts) || input.receipts.length !== FORMAT_ORDER.length * 2) throw new IOFormatReceiptBindingError("bound receipt count is invalid");
|
||||
const receipts = input.receipts.map(parseReceipt);
|
||||
const identities = receipts.map((receipt) => `${receipt.format}:${receipt.operation}`);
|
||||
if (new Set(identities).size !== identities.length || FORMAT_ORDER.some((format) => !["IMPORT", "EXPORT"].every((operation) => identities.includes(`${format}:${operation}`)))) throw new IOFormatReceiptBindingError("bound receipt identities are incomplete or duplicated");
|
||||
return { schemaVersion: IO_FORMAT_RECEIPT_BINDING_SCHEMA, task: IO_FORMAT_RECEIPT_BINDING_TASK, parentReceiptSetSha256: input.parentReceiptSetSha256, inventorySha256: input.inventorySha256, runtime: parseRuntime(input.runtime), receipts };
|
||||
}
|
||||
|
||||
export function resolveBoundReceipt(receiptSet: IOFormatBoundRuntimeReceiptSetIR, format: IOFormatBoundRuntimeReceiptIR["format"], operation: IOFormatBoundRuntimeReceiptIR["operation"]): IOFormatBoundRuntimeReceiptIR {
|
||||
const receipt = receiptSet.receipts.find((candidate) => candidate.format === format && candidate.operation === operation);
|
||||
if (!receipt || !SHA256.test(receipt.sourceSha256) || !SHA256.test(receipt.settingsSha256) || !SHA256.test(receipt.runtimeSha256)) throw new IOFormatReceiptBindingError("bound runtime receipt is unavailable");
|
||||
return receipt;
|
||||
}
|
||||
185
web/protocol/io-format-receipt-freshness.ts
Normal file
185
web/protocol/io-format-receipt-freshness.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import {
|
||||
validateIOFormatBoundReceiptSet,
|
||||
type IOFormatBoundRuntimeReceiptSetIR,
|
||||
} from "./io-format-receipt-binding";
|
||||
import {
|
||||
type IOFormatRuntimeIdentityIR,
|
||||
type IOFormatRuntimeRouteQuery,
|
||||
} from "./io-format-runtime-receipt";
|
||||
|
||||
export const IO_FORMAT_RECEIPT_FRESHNESS_SCHEMA = 1 as const;
|
||||
export const IO_FORMAT_RECEIPT_FRESHNESS_TASK = "M12-05F" as const;
|
||||
|
||||
export interface IOFormatReceiptFreshnessEnvelopeIR {
|
||||
schemaVersion: typeof IO_FORMAT_RECEIPT_FRESHNESS_SCHEMA;
|
||||
task: typeof IO_FORMAT_RECEIPT_FRESHNESS_TASK;
|
||||
parentBindingSha256: string;
|
||||
boundReceiptSetSha256: string;
|
||||
runtimeSha256: string;
|
||||
bound: IOFormatBoundRuntimeReceiptSetIR;
|
||||
}
|
||||
|
||||
export interface IOFormatReceiptFreshnessExpectedIR {
|
||||
parentBindingSha256: string;
|
||||
parentReceiptSetSha256: string;
|
||||
inventorySha256: string;
|
||||
boundReceiptSetSha256: string;
|
||||
runtimeSha256: string;
|
||||
runtime: IOFormatRuntimeIdentityIR;
|
||||
receiptIdentities: Array<Pick<IOFormatBoundRuntimeReceiptSetIR["receipts"][number], "format" | "family" | "operation" | "operator" | "registered" | "rnaIdentifier" | "buildOption" | "buildOptionEnabled" | "runtimeStatus" | "variants" | "extensions" | "sourceSha256" | "settingsSha256" | "runtimeSha256">>;
|
||||
}
|
||||
|
||||
export type IOFormatReceiptFreshnessFailure =
|
||||
| "RECEIPT_INVALID"
|
||||
| "RECEIPT_FORGED"
|
||||
| "RECEIPT_STALE"
|
||||
| "RECEIPT_CROSS_VERSION";
|
||||
|
||||
export class IOFormatReceiptFreshnessError extends Error {
|
||||
readonly code = "IO_FORMAT_UNSUPPORTED" as const;
|
||||
readonly reason: IOFormatReceiptFreshnessFailure;
|
||||
|
||||
constructor(reason: IOFormatReceiptFreshnessFailure, message: string) {
|
||||
super(`IO_FORMAT_UNSUPPORTED: ${reason}: ${message}`);
|
||||
this.name = "IOFormatReceiptFreshnessError";
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new IOFormatReceiptFreshnessError("RECEIPT_INVALID", `${path} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, expected: readonly string[], path: string): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const allowed = [...expected].sort();
|
||||
if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) {
|
||||
throw new IOFormatReceiptFreshnessError("RECEIPT_FORGED", `${path} contains undeclared fields`);
|
||||
}
|
||||
}
|
||||
|
||||
function sha(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || !SHA256.test(value)) {
|
||||
throw new IOFormatReceiptFreshnessError("RECEIPT_INVALID", `${path} is not SHA-256`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function stableValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(stableValue);
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return Object.fromEntries(Object.keys(value as Record<string, unknown>).sort().map((key) => [key, stableValue((value as Record<string, unknown>)[key])]));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function stableJSON(value: unknown): string {
|
||||
return JSON.stringify(stableValue(value));
|
||||
}
|
||||
|
||||
function sameRuntime(left: IOFormatRuntimeIdentityIR, right: IOFormatRuntimeIdentityIR): boolean {
|
||||
return stableJSON(left) === stableJSON(right);
|
||||
}
|
||||
|
||||
export function parseIOFormatReceiptFreshness(value: unknown): IOFormatReceiptFreshnessEnvelopeIR {
|
||||
const input = record(value, "input");
|
||||
exactKeys(input, ["schemaVersion", "task", "parentBindingSha256", "boundReceiptSetSha256", "runtimeSha256", "bound"], "input");
|
||||
if (input.schemaVersion !== IO_FORMAT_RECEIPT_FRESHNESS_SCHEMA || input.task !== IO_FORMAT_RECEIPT_FRESHNESS_TASK) {
|
||||
throw new IOFormatReceiptFreshnessError("RECEIPT_INVALID", "freshness envelope header is invalid");
|
||||
}
|
||||
const boundInput = record(input.bound, "bound");
|
||||
const parentReceiptSetSha256 = sha(boundInput.parentReceiptSetSha256, "bound.parentReceiptSetSha256");
|
||||
const inventorySha256 = sha(boundInput.inventorySha256, "bound.inventorySha256");
|
||||
let bound: IOFormatBoundRuntimeReceiptSetIR;
|
||||
try {
|
||||
bound = validateIOFormatBoundReceiptSet(input.bound, parentReceiptSetSha256, inventorySha256);
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof IOFormatReceiptFreshnessError) throw error;
|
||||
throw new IOFormatReceiptFreshnessError("RECEIPT_FORGED", error instanceof Error ? error.message : "bound receipt set is invalid");
|
||||
}
|
||||
return {
|
||||
schemaVersion: IO_FORMAT_RECEIPT_FRESHNESS_SCHEMA,
|
||||
task: IO_FORMAT_RECEIPT_FRESHNESS_TASK,
|
||||
parentBindingSha256: sha(input.parentBindingSha256, "input.parentBindingSha256"),
|
||||
boundReceiptSetSha256: sha(input.boundReceiptSetSha256, "input.boundReceiptSetSha256"),
|
||||
runtimeSha256: sha(input.runtimeSha256, "input.runtimeSha256"),
|
||||
bound,
|
||||
};
|
||||
}
|
||||
|
||||
function expectedHashes(expected: IOFormatReceiptFreshnessExpectedIR): void {
|
||||
sha(expected.parentBindingSha256, "expected.parentBindingSha256");
|
||||
sha(expected.parentReceiptSetSha256, "expected.parentReceiptSetSha256");
|
||||
sha(expected.inventorySha256, "expected.inventorySha256");
|
||||
sha(expected.boundReceiptSetSha256, "expected.boundReceiptSetSha256");
|
||||
sha(expected.runtimeSha256, "expected.runtimeSha256");
|
||||
if (!Array.isArray(expected.receiptIdentities) || expected.receiptIdentities.length !== 14) {
|
||||
throw new IOFormatReceiptFreshnessError("RECEIPT_INVALID", "expected receipt identity set is incomplete");
|
||||
}
|
||||
}
|
||||
|
||||
export function validateIOFormatReceiptFreshness(value: unknown, expected: IOFormatReceiptFreshnessExpectedIR): IOFormatReceiptFreshnessEnvelopeIR {
|
||||
expectedHashes(expected);
|
||||
const parsed = parseIOFormatReceiptFreshness(value);
|
||||
if (parsed.parentBindingSha256 !== expected.parentBindingSha256 || parsed.bound.parentReceiptSetSha256 !== expected.parentReceiptSetSha256 || parsed.bound.inventorySha256 !== expected.inventorySha256) {
|
||||
throw new IOFormatReceiptFreshnessError("RECEIPT_STALE", "receipt parent or inventory identity is stale");
|
||||
}
|
||||
if (parsed.boundReceiptSetSha256 !== expected.boundReceiptSetSha256) {
|
||||
throw new IOFormatReceiptFreshnessError("RECEIPT_FORGED", "receipt-set content identity does not match the trusted build");
|
||||
}
|
||||
if (parsed.runtimeSha256 !== expected.runtimeSha256 || !sameRuntime(parsed.bound.runtime, expected.runtime)) {
|
||||
throw new IOFormatReceiptFreshnessError("RECEIPT_CROSS_VERSION", "runtime identity does not match the trusted build");
|
||||
}
|
||||
for (const receipt of parsed.bound.receipts) {
|
||||
if (receipt.runtimeSha256 !== expected.runtimeSha256) {
|
||||
throw new IOFormatReceiptFreshnessError("RECEIPT_CROSS_VERSION", `${receipt.format}:${receipt.operation} runtime identity is stale`);
|
||||
}
|
||||
const expectedReceipt = expected.receiptIdentities.find((candidate) => candidate.format === receipt.format && candidate.operation === receipt.operation);
|
||||
if (!expectedReceipt || stableJSON(receipt) !== stableJSON(expectedReceipt)) {
|
||||
throw new IOFormatReceiptFreshnessError("RECEIPT_FORGED", `${receipt.format}:${receipt.operation} receipt content is not trusted`);
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function sha256Text(value: string): Promise<string> {
|
||||
const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
||||
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
/** Recomputes the canonical receipt-set and runtime digests for an independent checker. */
|
||||
export async function verifyIOFormatReceiptFreshness(value: unknown, expected: IOFormatReceiptFreshnessExpectedIR): Promise<IOFormatReceiptFreshnessEnvelopeIR> {
|
||||
const parsed = validateIOFormatReceiptFreshness(value, expected);
|
||||
if (await sha256Text(stableJSON(parsed.bound)) !== parsed.boundReceiptSetSha256) {
|
||||
throw new IOFormatReceiptFreshnessError("RECEIPT_FORGED", "receipt-set canonical digest does not match its contents");
|
||||
}
|
||||
if (await sha256Text(stableJSON(parsed.bound.runtime)) !== parsed.runtimeSha256) {
|
||||
throw new IOFormatReceiptFreshnessError("RECEIPT_FORGED", "runtime canonical digest does not match its contents");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export type IOFormatFreshRuntimeRouteResult =
|
||||
| { status: "READY"; format: IOFormatRuntimeRouteQuery["format"]; operation: IOFormatRuntimeRouteQuery["operation"]; operator: string; receipt: IOFormatReceiptFreshnessEnvelopeIR["bound"]["receipts"][number]; freshness: "VERIFIED" }
|
||||
| { status: "BLOCKED"; code: "IO_FORMAT_UNSUPPORTED"; reason: IOFormatReceiptFreshnessFailure | "UNAVAILABLE"; format: IOFormatRuntimeRouteQuery["format"]; operation: IOFormatRuntimeRouteQuery["operation"] };
|
||||
|
||||
export function resolveFreshIOFormatRuntimeRoute(value: unknown, expected: IOFormatReceiptFreshnessExpectedIR, query: IOFormatRuntimeRouteQuery): IOFormatFreshRuntimeRouteResult {
|
||||
try {
|
||||
const parsed = validateIOFormatReceiptFreshness(value, expected);
|
||||
const receipt = parsed.bound.receipts.find((candidate) => candidate.format === query.format && candidate.operation === query.operation);
|
||||
if (!receipt || receipt.runtimeStatus !== "AVAILABLE" || receipt.registered !== true || receipt.rnaIdentifier === null || receipt.buildOptionEnabled === false) {
|
||||
return { status: "BLOCKED", code: "IO_FORMAT_UNSUPPORTED", reason: "UNAVAILABLE", format: query.format, operation: query.operation };
|
||||
}
|
||||
return { status: "READY", format: query.format, operation: query.operation, operator: receipt.operator, receipt, freshness: "VERIFIED" };
|
||||
}
|
||||
catch (error) {
|
||||
const reason = error instanceof IOFormatReceiptFreshnessError ? error.reason : "RECEIPT_INVALID";
|
||||
return { status: "BLOCKED", code: "IO_FORMAT_UNSUPPORTED", reason, format: query.format, operation: query.operation };
|
||||
}
|
||||
}
|
||||
73
web/protocol/io-format-recovery.ts
Normal file
73
web/protocol/io-format-recovery.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export const IO_FORMAT_RECOVERY_SCHEMA_VERSION = 1 as const;
|
||||
export type IOFormat = "OBJ" | "STL" | "PLY";
|
||||
export type IOFormatRecoveryStatus = "RUNNING" | "CANCELLED" | "BLOCKED" | "COMMITTED" | "RECOVERED";
|
||||
export type IOFormatRecoveryErrorCode = "IO_FORMAT_OPERATION_CANCELLED" | "IO_FORMAT_OOM" | "IO_FORMAT_WORKER_RESTARTED" | "IO_FORMAT_RECOVERY_INVALID";
|
||||
|
||||
export interface IOFormatRecoveryReceipt {
|
||||
schemaVersion: typeof IO_FORMAT_RECOVERY_SCHEMA_VERSION;
|
||||
operationId: string;
|
||||
format: IOFormat;
|
||||
operation: "IMPORT" | "EXPORT";
|
||||
status: IOFormatRecoveryStatus;
|
||||
workerGeneration: number;
|
||||
baseRevision: number;
|
||||
candidateRevision: number;
|
||||
inputBytes: number;
|
||||
inputSha256: string;
|
||||
outputBytes: number;
|
||||
outputSha256: string | null;
|
||||
temporaryBytes: number;
|
||||
liveRequests: number;
|
||||
publishedResults: number;
|
||||
committed: boolean;
|
||||
errorCode?: IOFormatRecoveryErrorCode;
|
||||
}
|
||||
|
||||
const HASH = /^[a-f0-9]{64}$/;
|
||||
const ID = /^[A-Za-z0-9_-]{1,96}$/;
|
||||
|
||||
function validate(receipt: IOFormatRecoveryReceipt): void {
|
||||
if (receipt.schemaVersion !== 1 || !ID.test(receipt.operationId) || !["OBJ", "STL", "PLY"].includes(receipt.format) || !["IMPORT", "EXPORT"].includes(receipt.operation) || !["RUNNING", "CANCELLED", "BLOCKED", "COMMITTED", "RECOVERED"].includes(receipt.status) || !Number.isSafeInteger(receipt.workerGeneration) || receipt.workerGeneration < 1 || !Number.isSafeInteger(receipt.baseRevision) || receipt.baseRevision < 0 || !Number.isSafeInteger(receipt.candidateRevision) || receipt.candidateRevision < receipt.baseRevision || !Number.isSafeInteger(receipt.inputBytes) || receipt.inputBytes <= 0 || !HASH.test(receipt.inputSha256) || !Number.isSafeInteger(receipt.outputBytes) || receipt.outputBytes < 0 || (receipt.outputSha256 !== null && !HASH.test(receipt.outputSha256)) || !Number.isSafeInteger(receipt.temporaryBytes) || receipt.temporaryBytes < 0 || !Number.isSafeInteger(receipt.liveRequests) || receipt.liveRequests < 0 || !Number.isSafeInteger(receipt.publishedResults) || receipt.publishedResults < 0 || typeof receipt.committed !== "boolean") {
|
||||
throw new Error("IO_FORMAT_RECOVERY_INVALID: receipt fields are malformed");
|
||||
}
|
||||
}
|
||||
|
||||
function clone(receipt: IOFormatRecoveryReceipt): IOFormatRecoveryReceipt { validate(receipt); return { ...receipt }; }
|
||||
|
||||
export function beginIOFormatRecoveryOperation(input: { operationId: string; format: IOFormat; operation: "IMPORT" | "EXPORT"; workerGeneration: number; baseRevision: number; inputBytes: number; inputSha256: string }): IOFormatRecoveryReceipt {
|
||||
const receipt: IOFormatRecoveryReceipt = { schemaVersion: 1, operationId: input.operationId, format: input.format, operation: input.operation, status: "RUNNING", workerGeneration: input.workerGeneration, baseRevision: input.baseRevision, candidateRevision: input.baseRevision + 1, inputBytes: input.inputBytes, inputSha256: input.inputSha256, outputBytes: 0, outputSha256: null, temporaryBytes: input.inputBytes, liveRequests: 1, publishedResults: 0, committed: false };
|
||||
validate(receipt); return receipt;
|
||||
}
|
||||
|
||||
export function commitIOFormatRecoveryOperation(receipt: IOFormatRecoveryReceipt, output: { bytes: number; sha256: string }): IOFormatRecoveryReceipt {
|
||||
const next = clone(receipt);
|
||||
if (next.status !== "RUNNING" || !Number.isSafeInteger(output.bytes) || output.bytes <= 0 || !HASH.test(output.sha256)) throw new Error("IO_FORMAT_RECOVERY_INVALID: operation cannot commit");
|
||||
next.status = "COMMITTED"; next.outputBytes = output.bytes; next.outputSha256 = output.sha256; next.temporaryBytes = 0; next.liveRequests = 0; next.publishedResults = 1; next.committed = true; validate(next); return next;
|
||||
}
|
||||
|
||||
export function cancelIOFormatRecoveryOperation(receipt: IOFormatRecoveryReceipt): IOFormatRecoveryReceipt {
|
||||
const next = clone(receipt);
|
||||
if (next.status !== "RUNNING") throw new Error("IO_FORMAT_RECOVERY_INVALID: operation is not running");
|
||||
next.status = "CANCELLED"; next.errorCode = "IO_FORMAT_OPERATION_CANCELLED"; next.temporaryBytes = 0; next.liveRequests = 0; next.publishedResults = 0; next.committed = false; validate(next); return next;
|
||||
}
|
||||
|
||||
export function blockIOFormatRecoveryOperation(receipt: IOFormatRecoveryReceipt): IOFormatRecoveryReceipt {
|
||||
const next = clone(receipt);
|
||||
if (next.status !== "RUNNING") throw new Error("IO_FORMAT_RECOVERY_INVALID: operation is not running");
|
||||
next.status = "BLOCKED"; next.errorCode = "IO_FORMAT_OOM"; next.temporaryBytes = 0; next.liveRequests = 0; next.publishedResults = 0; next.committed = false; validate(next); return next;
|
||||
}
|
||||
|
||||
export function recoverIOFormatRecoveryOperation(receipt: IOFormatRecoveryReceipt, workerGeneration: number): IOFormatRecoveryReceipt {
|
||||
const next = clone(receipt);
|
||||
if (next.status !== "COMMITTED" || !Number.isSafeInteger(workerGeneration) || workerGeneration <= next.workerGeneration) throw new Error("IO_FORMAT_RECOVERY_INVALID: only a committed operation can recover");
|
||||
next.status = "RECOVERED"; next.workerGeneration = workerGeneration; next.errorCode = "IO_FORMAT_WORKER_RESTARTED"; validate(next); return next;
|
||||
}
|
||||
|
||||
export function parseIOFormatRecoveryReceipt(value: unknown): IOFormatRecoveryReceipt {
|
||||
if (!value || typeof value !== "object") throw new Error("IO_FORMAT_RECOVERY_INVALID: receipt is not an object");
|
||||
const receipt = value as IOFormatRecoveryReceipt; validate(receipt);
|
||||
if (receipt.status === "CANCELLED" && receipt.errorCode !== "IO_FORMAT_OPERATION_CANCELLED") throw new Error("IO_FORMAT_RECOVERY_INVALID: cancellation code");
|
||||
if (receipt.status === "BLOCKED" && receipt.errorCode !== "IO_FORMAT_OOM") throw new Error("IO_FORMAT_RECOVERY_INVALID: oom code");
|
||||
if ((receipt.status === "COMMITTED" || receipt.status === "RECOVERED") && (!receipt.committed || receipt.outputBytes <= 0 || !receipt.outputSha256 || receipt.publishedResults !== 1)) throw new Error("IO_FORMAT_RECOVERY_INVALID: committed receipt");
|
||||
return { ...receipt };
|
||||
}
|
||||
167
web/protocol/io-format-runtime-receipt.ts
Normal file
167
web/protocol/io-format-runtime-receipt.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import type { IOFormatMatrixFormat, IOFormatMatrixOperation } from "./io-format-capability-matrix";
|
||||
|
||||
export const IO_FORMAT_RUNTIME_RECEIPT_SCHEMA = 1 as const;
|
||||
export const IO_FORMAT_RUNTIME_RECEIPT_TASK = "M12-05D" as const;
|
||||
export const IO_FORMAT_RUNTIME_FORMATS = ["GLTF", "GLB", "OBJ", "STL", "PLY", "USD", "ALEMBIC"] as const;
|
||||
export type IOFormatRuntimeFormat = typeof IO_FORMAT_RUNTIME_FORMATS[number];
|
||||
export type IOFormatRuntimeOperation = IOFormatMatrixOperation;
|
||||
export type IOFormatRuntimeStatus = "AVAILABLE" | "OPERATOR_UNREGISTERED";
|
||||
|
||||
export interface IOFormatRuntimeIdentityIR {
|
||||
blenderVersion: string;
|
||||
versionTuple: [number, number, number];
|
||||
buildHash: string;
|
||||
buildBranch: string;
|
||||
buildPlatform: string;
|
||||
buildType: string;
|
||||
buildDate: string;
|
||||
buildTime: string;
|
||||
buildCommitTimestamp: number;
|
||||
binarySha256: string;
|
||||
buildOptions: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface IOFormatRuntimeReceiptIR {
|
||||
format: IOFormatRuntimeFormat;
|
||||
family: string;
|
||||
operation: IOFormatRuntimeOperation;
|
||||
operator: string;
|
||||
registered: boolean;
|
||||
rnaIdentifier: string | null;
|
||||
buildOption: string | null;
|
||||
buildOptionEnabled: boolean | null;
|
||||
runtimeStatus: IOFormatRuntimeStatus;
|
||||
variants: string[];
|
||||
extensions: string[];
|
||||
}
|
||||
|
||||
export interface IOFormatRuntimeReceiptSetIR {
|
||||
schemaVersion: typeof IO_FORMAT_RUNTIME_RECEIPT_SCHEMA;
|
||||
task: typeof IO_FORMAT_RUNTIME_RECEIPT_TASK;
|
||||
inventorySha256: string;
|
||||
runtime: IOFormatRuntimeIdentityIR;
|
||||
receipts: IOFormatRuntimeReceiptIR[];
|
||||
}
|
||||
|
||||
export interface IOFormatRuntimeRouteQuery {
|
||||
format: IOFormatRuntimeFormat;
|
||||
operation: IOFormatRuntimeOperation;
|
||||
}
|
||||
|
||||
export type IOFormatRuntimeRouteResult =
|
||||
| { status: "READY"; format: IOFormatRuntimeFormat; operation: IOFormatRuntimeOperation; operator: string; receipt: IOFormatRuntimeReceiptIR }
|
||||
| { status: "BLOCKED"; code: "IO_FORMAT_UNSUPPORTED"; format: IOFormatRuntimeFormat; operation: IOFormatRuntimeOperation };
|
||||
|
||||
export class IOFormatRuntimeReceiptError extends Error {
|
||||
readonly code = "IO_FORMAT_UNSUPPORTED" as const;
|
||||
|
||||
constructor(message: string) {
|
||||
super(`IO_FORMAT_UNSUPPORTED: ${message}`);
|
||||
this.name = "IOFormatRuntimeReceiptError";
|
||||
}
|
||||
}
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:\s+.*)?$/;
|
||||
const IDENTIFIER = /^[A-Za-z0-9_.:-]+$/;
|
||||
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new IOFormatRuntimeReceiptError(`${path} must be an object`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, expected: readonly string[], path: string): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const allowed = [...expected].sort();
|
||||
if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) throw new IOFormatRuntimeReceiptError(`${path} contains undeclared fields`);
|
||||
}
|
||||
|
||||
function nonEmpty(value: unknown, path: string, maximum = 256): string {
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > maximum) throw new IOFormatRuntimeReceiptError(`${path} is invalid`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function sha(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || !SHA256.test(value)) throw new IOFormatRuntimeReceiptError(`${path} is not SHA-256`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseRuntime(value: unknown): IOFormatRuntimeIdentityIR {
|
||||
const input = record(value, "runtime");
|
||||
exactKeys(input, ["blenderVersion", "versionTuple", "buildHash", "buildBranch", "buildPlatform", "buildType", "buildDate", "buildTime", "buildCommitTimestamp", "binarySha256", "buildOptions"], "runtime");
|
||||
if (typeof input.blenderVersion !== "string" || !VERSION.test(input.blenderVersion)) throw new IOFormatRuntimeReceiptError("runtime.blenderVersion is invalid");
|
||||
if (!Array.isArray(input.versionTuple) || input.versionTuple.length !== 3 || input.versionTuple.some((part) => !Number.isSafeInteger(part) || (part as number) < 0)) throw new IOFormatRuntimeReceiptError("runtime.versionTuple is invalid");
|
||||
const buildOptions = record(input.buildOptions, "runtime.buildOptions");
|
||||
const parsedOptions: Record<string, boolean> = {};
|
||||
for (const key of Object.keys(buildOptions).sort()) {
|
||||
if (!IDENTIFIER.test(key) || typeof buildOptions[key] !== "boolean") throw new IOFormatRuntimeReceiptError("runtime.buildOptions is invalid");
|
||||
parsedOptions[key] = buildOptions[key] as boolean;
|
||||
}
|
||||
if (!Number.isSafeInteger(input.buildCommitTimestamp) || (input.buildCommitTimestamp as number) < 0) throw new IOFormatRuntimeReceiptError("runtime.buildCommitTimestamp is invalid");
|
||||
return {
|
||||
blenderVersion: input.blenderVersion,
|
||||
versionTuple: [...input.versionTuple] as [number, number, number],
|
||||
buildHash: nonEmpty(input.buildHash, "runtime.buildHash"),
|
||||
buildBranch: nonEmpty(input.buildBranch, "runtime.buildBranch"),
|
||||
buildPlatform: nonEmpty(input.buildPlatform, "runtime.buildPlatform"),
|
||||
buildType: nonEmpty(input.buildType, "runtime.buildType"),
|
||||
buildDate: nonEmpty(input.buildDate, "runtime.buildDate"),
|
||||
buildTime: nonEmpty(input.buildTime, "runtime.buildTime"),
|
||||
buildCommitTimestamp: input.buildCommitTimestamp as number,
|
||||
binarySha256: sha(input.binarySha256, "runtime.binarySha256"),
|
||||
buildOptions: parsedOptions,
|
||||
};
|
||||
}
|
||||
|
||||
function parseReceipt(value: unknown, index: number): IOFormatRuntimeReceiptIR {
|
||||
const path = `receipts[${index}]`;
|
||||
const input = record(value, path);
|
||||
exactKeys(input, ["format", "family", "operation", "operator", "registered", "rnaIdentifier", "buildOption", "buildOptionEnabled", "runtimeStatus", "variants", "extensions"], path);
|
||||
if (!IO_FORMAT_RUNTIME_FORMATS.includes(input.format as IOFormatRuntimeFormat) || !["IMPORT", "EXPORT"].includes(input.operation as string)) throw new IOFormatRuntimeReceiptError(`${path} identity is invalid`);
|
||||
if (typeof input.registered !== "boolean" || !["AVAILABLE", "OPERATOR_UNREGISTERED"].includes(input.runtimeStatus as string)) throw new IOFormatRuntimeReceiptError(`${path} registration status is invalid`);
|
||||
if (input.rnaIdentifier !== null && (typeof input.rnaIdentifier !== "string" || !IDENTIFIER.test(input.rnaIdentifier))) throw new IOFormatRuntimeReceiptError(`${path}.rnaIdentifier is invalid`);
|
||||
if (input.buildOption !== null && (typeof input.buildOption !== "string" || !IDENTIFIER.test(input.buildOption))) throw new IOFormatRuntimeReceiptError(`${path}.buildOption is invalid`);
|
||||
if (input.buildOptionEnabled !== null && typeof input.buildOptionEnabled !== "boolean") throw new IOFormatRuntimeReceiptError(`${path}.buildOptionEnabled is invalid`);
|
||||
if (!Array.isArray(input.variants) || input.variants.length === 0 || input.variants.some((variant) => typeof variant !== "string" || !IDENTIFIER.test(variant))) throw new IOFormatRuntimeReceiptError(`${path}.variants are invalid`);
|
||||
if (!Array.isArray(input.extensions) || input.extensions.length === 0 || input.extensions.some((extension) => typeof extension !== "string" || !/^\.[a-z0-9]+$/.test(extension))) throw new IOFormatRuntimeReceiptError(`${path}.extensions are invalid`);
|
||||
const available = input.runtimeStatus === "AVAILABLE";
|
||||
if (available !== input.registered || (available && input.rnaIdentifier === null) || (!available && input.rnaIdentifier !== null) || (!available && input.buildOptionEnabled !== null)) throw new IOFormatRuntimeReceiptError(`${path} has inconsistent runtime receipt state`);
|
||||
return {
|
||||
format: input.format as IOFormatRuntimeFormat,
|
||||
family: nonEmpty(input.family, `${path}.family`),
|
||||
operation: input.operation as IOFormatRuntimeOperation,
|
||||
operator: nonEmpty(input.operator, `${path}.operator`),
|
||||
registered: input.registered as boolean,
|
||||
rnaIdentifier: input.rnaIdentifier as string | null,
|
||||
buildOption: input.buildOption as string | null,
|
||||
buildOptionEnabled: input.buildOptionEnabled as boolean | null,
|
||||
runtimeStatus: input.runtimeStatus as IOFormatRuntimeStatus,
|
||||
variants: [...input.variants as string[]],
|
||||
extensions: [...input.extensions as string[]],
|
||||
};
|
||||
}
|
||||
|
||||
export function parseIOFormatRuntimeReceiptSet(value: unknown): IOFormatRuntimeReceiptSetIR {
|
||||
const input = record(value, "input");
|
||||
exactKeys(input, ["schemaVersion", "task", "inventorySha256", "runtime", "receipts"], "input");
|
||||
if (input.schemaVersion !== IO_FORMAT_RUNTIME_RECEIPT_SCHEMA || input.task !== IO_FORMAT_RUNTIME_RECEIPT_TASK) throw new IOFormatRuntimeReceiptError("receipt set header is invalid");
|
||||
const receipts = Array.isArray(input.receipts) ? input.receipts.map(parseReceipt) : (() => { throw new IOFormatRuntimeReceiptError("input.receipts must be an array"); })();
|
||||
if (typeof input.inventorySha256 !== "string" || !SHA256.test(input.inventorySha256)) throw new IOFormatRuntimeReceiptError("input.inventorySha256 is invalid");
|
||||
if (receipts.length !== IO_FORMAT_RUNTIME_FORMATS.length * 2) throw new IOFormatRuntimeReceiptError("receipt set must contain one import and export receipt per format");
|
||||
const identities = receipts.map((receipt) => `${receipt.format}:${receipt.operation}`);
|
||||
if (new Set(identities).size !== identities.length || IO_FORMAT_RUNTIME_FORMATS.some((format) => !["IMPORT", "EXPORT"].every((operation) => identities.includes(`${format}:${operation}`)))) throw new IOFormatRuntimeReceiptError("receipt identities are incomplete or duplicated");
|
||||
return { schemaVersion: IO_FORMAT_RUNTIME_RECEIPT_SCHEMA, task: IO_FORMAT_RUNTIME_RECEIPT_TASK, inventorySha256: input.inventorySha256, runtime: parseRuntime(input.runtime), receipts };
|
||||
}
|
||||
|
||||
export function validateIOFormatRuntimeReceiptSet(value: unknown, expectedInventorySha256: string): IOFormatRuntimeReceiptSetIR {
|
||||
if (!SHA256.test(expectedInventorySha256)) throw new IOFormatRuntimeReceiptError("expected inventory SHA-256 is invalid");
|
||||
const parsed = parseIOFormatRuntimeReceiptSet(value);
|
||||
if (parsed.inventorySha256 !== expectedInventorySha256) throw new IOFormatRuntimeReceiptError("runtime receipt inventory identity does not match");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function resolveIOFormatRuntimeRoute(receiptSet: IOFormatRuntimeReceiptSetIR, query: IOFormatRuntimeRouteQuery): IOFormatRuntimeRouteResult {
|
||||
const receipt = receiptSet.receipts.find((candidate) => candidate.format === query.format && candidate.operation === query.operation);
|
||||
if (!receipt || receipt.runtimeStatus !== "AVAILABLE" || receipt.registered !== true || receipt.rnaIdentifier === null || receipt.buildOptionEnabled === false) return { status: "BLOCKED", code: "IO_FORMAT_UNSUPPORTED", format: query.format, operation: query.operation };
|
||||
return { status: "READY", format: query.format, operation: query.operation, operator: receipt.operator, receipt };
|
||||
}
|
||||
149
web/protocol/io-format-ui-gate.ts
Normal file
149
web/protocol/io-format-ui-gate.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type {
|
||||
IOFormatCapabilityMatrixIR,
|
||||
IOFormatMatrixExecution,
|
||||
IOFormatMatrixFormat,
|
||||
IOFormatMatrixOperation,
|
||||
} from "./io-format-capability-matrix";
|
||||
|
||||
export const IO_FORMAT_UI_GATE_SCHEMA = 1 as const;
|
||||
export const IO_FORMAT_UI_TASK = "M12-05C" as const;
|
||||
export const IO_FORMAT_PROJECT_ACCEPT = ".blend,application/octet-stream" as const;
|
||||
|
||||
export interface IOFormatUIRouteIR {
|
||||
format: IOFormatMatrixFormat;
|
||||
operation: IOFormatMatrixOperation;
|
||||
execution: IOFormatMatrixExecution;
|
||||
extensions: string[];
|
||||
}
|
||||
|
||||
export interface IOFormatUIRegistryIR {
|
||||
schemaVersion: typeof IO_FORMAT_UI_GATE_SCHEMA;
|
||||
task: typeof IO_FORMAT_UI_TASK;
|
||||
parentMatrixSha256: string;
|
||||
projectFileAccept: typeof IO_FORMAT_PROJECT_ACCEPT;
|
||||
importRoutes: IOFormatUIRouteIR[];
|
||||
exportRoutes: IOFormatUIRouteIR[];
|
||||
}
|
||||
|
||||
export interface IOFormatUICommandRef {
|
||||
format: IOFormatMatrixFormat;
|
||||
operation: IOFormatMatrixOperation;
|
||||
execution: IOFormatMatrixExecution;
|
||||
}
|
||||
|
||||
export class IOFormatUIGateError extends Error {
|
||||
readonly code = "IO_FORMAT_UNSUPPORTED" as const;
|
||||
|
||||
constructor(message: string) {
|
||||
super(`IO_FORMAT_UNSUPPORTED: ${message}`);
|
||||
this.name = "IOFormatUIGateError";
|
||||
}
|
||||
}
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const FORMAT_EXTENSIONS: Record<IOFormatMatrixFormat, readonly string[]> = {
|
||||
GLTF: [".gltf"],
|
||||
GLB: [".glb"],
|
||||
OBJ: [".obj"],
|
||||
STL: [".stl"],
|
||||
PLY: [".ply"],
|
||||
USD: [".usd", ".usda", ".usdc"],
|
||||
ALEMBIC: [".abc"],
|
||||
};
|
||||
|
||||
function routeFor(
|
||||
matrix: IOFormatCapabilityMatrixIR,
|
||||
operation: IOFormatMatrixOperation,
|
||||
execution: IOFormatMatrixExecution,
|
||||
): IOFormatUIRouteIR[] {
|
||||
return matrix.formats
|
||||
.filter((entry) => {
|
||||
const route = entry.operations[operation][execution.toLowerCase() as "local" | "server"];
|
||||
const runtimeStatus = operation === "IMPORT" ? entry.runtimeImportStatus : entry.runtimeExportStatus;
|
||||
return runtimeStatus === "AVAILABLE" && route.status === "READY" && route.execution === execution;
|
||||
})
|
||||
.map((entry) => ({
|
||||
format: entry.format,
|
||||
operation,
|
||||
execution,
|
||||
extensions: [...FORMAT_EXTENSIONS[entry.format]],
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildIOFormatUIRegistry(matrix: IOFormatCapabilityMatrixIR, parentMatrixSha256: string): IOFormatUIRegistryIR {
|
||||
if (!SHA256.test(parentMatrixSha256)) throw new IOFormatUIGateError("parent matrix SHA-256 is invalid");
|
||||
return {
|
||||
schemaVersion: IO_FORMAT_UI_GATE_SCHEMA,
|
||||
task: IO_FORMAT_UI_TASK,
|
||||
parentMatrixSha256,
|
||||
projectFileAccept: IO_FORMAT_PROJECT_ACCEPT,
|
||||
importRoutes: routeFor(matrix, "IMPORT", "LOCAL"),
|
||||
exportRoutes: routeFor(matrix, "EXPORT", "LOCAL"),
|
||||
};
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, expected: readonly string[], path: string): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const allowed = [...expected].sort();
|
||||
if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) throw new IOFormatUIGateError(`${path} contains undeclared fields`);
|
||||
}
|
||||
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new IOFormatUIGateError(`${path} must be an object`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function parseRoute(value: unknown, path: string, operation: IOFormatMatrixOperation): IOFormatUIRouteIR {
|
||||
const input = record(value, path);
|
||||
exactKeys(input, ["format", "operation", "execution", "extensions"], path);
|
||||
if (typeof input.format !== "string" || !(input.format in FORMAT_EXTENSIONS) || input.operation !== operation || input.execution !== "LOCAL") throw new IOFormatUIGateError(`${path} route identity is invalid`);
|
||||
if (!Array.isArray(input.extensions) || input.extensions.length !== FORMAT_EXTENSIONS[input.format as IOFormatMatrixFormat].length || input.extensions.some((extension, index) => extension !== FORMAT_EXTENSIONS[input.format as IOFormatMatrixFormat][index])) throw new IOFormatUIGateError(`${path}.extensions are invalid`);
|
||||
return { format: input.format as IOFormatMatrixFormat, operation, execution: "LOCAL", extensions: [...input.extensions as string[]] };
|
||||
}
|
||||
|
||||
export function parseIOFormatUIRegistry(value: unknown): IOFormatUIRegistryIR {
|
||||
const input = record(value, "input");
|
||||
exactKeys(input, ["schemaVersion", "task", "parentMatrixSha256", "projectFileAccept", "importRoutes", "exportRoutes"], "input");
|
||||
if (input.schemaVersion !== IO_FORMAT_UI_GATE_SCHEMA || input.task !== IO_FORMAT_UI_TASK || typeof input.parentMatrixSha256 !== "string" || !SHA256.test(input.parentMatrixSha256) || input.projectFileAccept !== IO_FORMAT_PROJECT_ACCEPT) throw new IOFormatUIGateError("registry header is invalid");
|
||||
if (!Array.isArray(input.importRoutes) || !Array.isArray(input.exportRoutes)) throw new IOFormatUIGateError("registry routes are invalid");
|
||||
const importRoutes = input.importRoutes.map((route, index) => parseRoute(route, `importRoutes[${index}]`, "IMPORT"));
|
||||
const exportRoutes = input.exportRoutes.map((route, index) => parseRoute(route, `exportRoutes[${index}]`, "EXPORT"));
|
||||
const identities = [...importRoutes, ...exportRoutes].map((route) => `${route.operation}:${route.execution}:${route.format}`);
|
||||
if (new Set(identities).size !== identities.length) throw new IOFormatUIGateError("registry contains duplicate route identities");
|
||||
return { schemaVersion: IO_FORMAT_UI_GATE_SCHEMA, task: IO_FORMAT_UI_TASK, parentMatrixSha256: input.parentMatrixSha256, projectFileAccept: IO_FORMAT_PROJECT_ACCEPT, importRoutes, exportRoutes };
|
||||
}
|
||||
|
||||
function routeIdentity(route: IOFormatUIRouteIR): string {
|
||||
return `${route.operation}:${route.execution}:${route.format}:${route.extensions.join("|")}`;
|
||||
}
|
||||
|
||||
export function validateIOFormatUIRegistry(value: unknown, matrix: IOFormatCapabilityMatrixIR, parentMatrixSha256: string): IOFormatUIRegistryIR {
|
||||
const parsed = parseIOFormatUIRegistry(value);
|
||||
const expected = buildIOFormatUIRegistry(matrix, parentMatrixSha256);
|
||||
const actualRoutes = [...parsed.importRoutes, ...parsed.exportRoutes].map(routeIdentity);
|
||||
const expectedRoutes = [...expected.importRoutes, ...expected.exportRoutes].map(routeIdentity);
|
||||
if (parsed.parentMatrixSha256 !== parentMatrixSha256 || actualRoutes.length !== expectedRoutes.length || actualRoutes.some((route, index) => route !== expectedRoutes[index])) throw new IOFormatUIGateError("registry route is not declared by the capability matrix");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function routeMatches(registry: IOFormatUIRegistryIR, command: IOFormatUICommandRef): boolean {
|
||||
const routes = command.operation === "IMPORT" ? registry.importRoutes : registry.exportRoutes;
|
||||
return routes.some((route) => route.format === command.format && route.execution === command.execution);
|
||||
}
|
||||
|
||||
export function filterIOFormatOperatorCommands<T extends { ioFormat?: IOFormatUICommandRef }>(commands: readonly T[], registry: IOFormatUIRegistryIR): T[] {
|
||||
return commands.filter((command) => !command.ioFormat || routeMatches(registry, command.ioFormat));
|
||||
}
|
||||
|
||||
export function gateIOFormatFileSelection(fileName: string, registry: IOFormatUIRegistryIR): { status: "READY"; kind: "BLEND" } | { status: "BLOCKED"; code: "IO_FORMAT_UNSUPPORTED"; extension: string } {
|
||||
const extension = fileName.trim().toLowerCase().match(/\.[a-z0-9]+$/)?.[0] ?? "";
|
||||
if (extension === ".blend") return { status: "READY", kind: "BLEND" };
|
||||
const route = registry.importRoutes.find((candidate) => candidate.extensions.includes(extension));
|
||||
if (route) return { status: "BLOCKED", code: "IO_FORMAT_UNSUPPORTED", extension };
|
||||
return { status: "BLOCKED", code: "IO_FORMAT_UNSUPPORTED", extension };
|
||||
}
|
||||
|
||||
export function ioFormatUIAccept(registry: IOFormatUIRegistryIR): string {
|
||||
const importExtensions = registry.importRoutes.flatMap((route) => route.extensions);
|
||||
return [registry.projectFileAccept, ...importExtensions].join(",");
|
||||
}
|
||||
34
web/protocol/keyboard-contract.ts
Normal file
34
web/protocol/keyboard-contract.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export const KEYBOARD_CONTRACT_SCHEMA_VERSION = 1 as const;
|
||||
|
||||
export interface KeyboardObservation {
|
||||
schemaVersion: typeof KEYBOARD_CONTRACT_SCHEMA_VERSION;
|
||||
key: string;
|
||||
code: string;
|
||||
location: 0 | 1 | 2 | 3;
|
||||
shiftKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
altKey: boolean;
|
||||
metaKey: boolean;
|
||||
repeat: boolean;
|
||||
isComposing: boolean;
|
||||
deadKey: boolean;
|
||||
}
|
||||
|
||||
export function observeKeyboardEvent(event: { key?: string; code?: string; location?: number; shiftKey?: boolean; ctrlKey?: boolean; altKey?: boolean; metaKey?: boolean; repeat?: boolean; isComposing?: boolean }): KeyboardObservation {
|
||||
if (typeof event.key !== "string" || event.key.length === 0 || event.key.length > 128) throw new Error("KEY_IDENTITY_INVALID");
|
||||
if (typeof event.code !== "string" || event.code.length === 0 || event.code.length > 64) throw new Error("KEY_CODE_INVALID");
|
||||
if (!Number.isInteger(event.location) || event.location! < 0 || event.location! > 3) throw new Error("KEY_LOCATION_INVALID");
|
||||
return {
|
||||
schemaVersion: KEYBOARD_CONTRACT_SCHEMA_VERSION,
|
||||
key: event.key,
|
||||
code: event.code,
|
||||
location: event.location as 0 | 1 | 2 | 3,
|
||||
shiftKey: Boolean(event.shiftKey),
|
||||
ctrlKey: Boolean(event.ctrlKey),
|
||||
altKey: Boolean(event.altKey),
|
||||
metaKey: Boolean(event.metaKey),
|
||||
repeat: Boolean(event.repeat),
|
||||
isComposing: Boolean(event.isComposing),
|
||||
deadKey: event.key === "Dead",
|
||||
};
|
||||
}
|
||||
228
web/protocol/library-linked-missing.ts
Normal file
228
web/protocol/library-linked-missing.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const LIBRARY_LINKED_MISSING_SCHEMA = 1 as const;
|
||||
export const LINKED_MISSING_OPERATION = "MARK_MISSING" as const;
|
||||
|
||||
export interface MissingLibraryPlaceholderIR {
|
||||
kind: "MISSING_LIBRARY";
|
||||
sourceLibraryId: string;
|
||||
dataBlockIds: string[];
|
||||
}
|
||||
|
||||
export interface LinkedLibraryReferenceIR {
|
||||
sourceLibraryId: string;
|
||||
sourceLocator: string;
|
||||
sourceSha256: string;
|
||||
sourceGeneration: number;
|
||||
sourceRevision: number;
|
||||
dataBlockIds: string[];
|
||||
status: "AVAILABLE" | "MISSING";
|
||||
placeholder: MissingLibraryPlaceholderIR | null;
|
||||
}
|
||||
|
||||
export interface LinkedMissingStateIR {
|
||||
schemaVersion: typeof LIBRARY_LINKED_MISSING_SCHEMA;
|
||||
references: LinkedLibraryReferenceIR[];
|
||||
}
|
||||
|
||||
export interface LinkedMissingRequestIR {
|
||||
schemaVersion: typeof LIBRARY_LINKED_MISSING_SCHEMA;
|
||||
operation: typeof LINKED_MISSING_OPERATION;
|
||||
sourceLibraryId: string;
|
||||
sourceLocator: string;
|
||||
sourceSha256: string;
|
||||
expectedGeneration: number;
|
||||
expectedRevision: number;
|
||||
}
|
||||
|
||||
export interface LinkedMissingDecisionIR {
|
||||
status: "MARKED" | "STALE";
|
||||
code: ErrorCode | null;
|
||||
sourceLibraryId: string;
|
||||
state: LinkedMissingStateIR;
|
||||
}
|
||||
|
||||
export class LinkedMissingValidationError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
readonly path?: string;
|
||||
|
||||
constructor(code: ErrorCode, message: string, path?: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "LinkedMissingValidationError";
|
||||
this.code = code;
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
const LIBRARY_ID = /^library:[a-f0-9]{64}$/;
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const SOURCE_LOCATOR = /^[^\u0000\r\n]{1,4096}$/;
|
||||
const DATA_BLOCK_ID = /^[A-Za-z0-9][A-Za-z0-9:._/ -]{0,255}$/;
|
||||
const MAX_REFERENCES = 10_000;
|
||||
const MAX_DATA_BLOCKS = 256;
|
||||
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path} must be an object`, path);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, expected: readonly string[], path: string): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const allowed = [...expected].sort();
|
||||
if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) {
|
||||
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path} contains undeclared fields`, path);
|
||||
}
|
||||
}
|
||||
|
||||
function integer(value: unknown, path: string): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
||||
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path} must be a safe integer >= 0`, path);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function libraryId(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || !LIBRARY_ID.test(value)) {
|
||||
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path} must be a library identity`, path);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || !SHA256.test(value)) {
|
||||
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path} must be a lowercase SHA-256 digest`, path);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function locator(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || !SOURCE_LOCATOR.test(value)) {
|
||||
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path} is outside the source locator budget`, path);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function dataBlockIds(value: unknown, path: string): string[] {
|
||||
if (!Array.isArray(value) || value.length === 0 || value.length > MAX_DATA_BLOCKS ||
|
||||
value.some((item) => typeof item !== "string" || !DATA_BLOCK_ID.test(item))) {
|
||||
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path} is outside its bounded ID list`, path);
|
||||
}
|
||||
const result = [...value] as string[];
|
||||
if (new Set(result).size !== result.length) {
|
||||
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path} contains duplicate IDs`, path);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parsePlaceholder(value: unknown, path: string): MissingLibraryPlaceholderIR | null {
|
||||
if (value === null) return null;
|
||||
const placeholder = record(value, path);
|
||||
exactKeys(placeholder, ["kind", "sourceLibraryId", "dataBlockIds"], path);
|
||||
if (placeholder.kind !== "MISSING_LIBRARY") {
|
||||
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path}.kind is invalid`, `${path}.kind`);
|
||||
}
|
||||
return {
|
||||
kind: "MISSING_LIBRARY",
|
||||
sourceLibraryId: libraryId(placeholder.sourceLibraryId, `${path}.sourceLibraryId`),
|
||||
dataBlockIds: dataBlockIds(placeholder.dataBlockIds, `${path}.dataBlockIds`),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseLinkedLibraryReference(value: unknown, path = "reference"): LinkedLibraryReferenceIR {
|
||||
const reference = record(value, path);
|
||||
exactKeys(reference, ["sourceLibraryId", "sourceLocator", "sourceSha256", "sourceGeneration", "sourceRevision", "dataBlockIds", "status", "placeholder"], path);
|
||||
const sourceLibraryId = libraryId(reference.sourceLibraryId, `${path}.sourceLibraryId`);
|
||||
const sourceLocator = locator(reference.sourceLocator, `${path}.sourceLocator`);
|
||||
const sourceSha256 = digest(reference.sourceSha256, `${path}.sourceSha256`);
|
||||
const sourceGeneration = integer(reference.sourceGeneration, `${path}.sourceGeneration`);
|
||||
const sourceRevision = integer(reference.sourceRevision, `${path}.sourceRevision`);
|
||||
const ids = dataBlockIds(reference.dataBlockIds, `${path}.dataBlockIds`);
|
||||
if (reference.status !== "AVAILABLE" && reference.status !== "MISSING") {
|
||||
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path}.status is invalid`, `${path}.status`);
|
||||
}
|
||||
const placeholder = parsePlaceholder(reference.placeholder, `${path}.placeholder`);
|
||||
if (reference.status === "AVAILABLE" && placeholder !== null) {
|
||||
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path} available reference cannot have a placeholder`, path);
|
||||
}
|
||||
if (reference.status === "MISSING" && (placeholder === null || placeholder.sourceLibraryId !== sourceLibraryId ||
|
||||
placeholder.dataBlockIds.length !== ids.length || placeholder.dataBlockIds.some((id, index) => id !== ids[index]))) {
|
||||
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path} missing placeholder must preserve the source IDs`, path);
|
||||
}
|
||||
return { sourceLibraryId, sourceLocator, sourceSha256, sourceGeneration, sourceRevision, dataBlockIds: ids, status: reference.status, placeholder };
|
||||
}
|
||||
|
||||
export function parseLinkedMissingState(value: unknown): LinkedMissingStateIR {
|
||||
const state = record(value, "state");
|
||||
exactKeys(state, ["schemaVersion", "references"], "state");
|
||||
if (state.schemaVersion !== LIBRARY_LINKED_MISSING_SCHEMA) {
|
||||
throw new LinkedMissingValidationError("PROTOCOL_MISMATCH", "Unsupported linked missing-library state schema", "schemaVersion");
|
||||
}
|
||||
if (!Array.isArray(state.references) || state.references.length > MAX_REFERENCES) {
|
||||
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", "state.references exceeds its bounded range", "references");
|
||||
}
|
||||
const references = state.references.map((item, index) => parseLinkedLibraryReference(item, `state.references[${index}]`));
|
||||
const identities = references.map((item) => `${item.sourceLibraryId}:${item.sourceGeneration}`);
|
||||
if (new Set(identities).size !== identities.length) {
|
||||
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", "state contains duplicate source generations", "references");
|
||||
}
|
||||
return { schemaVersion: LIBRARY_LINKED_MISSING_SCHEMA, references };
|
||||
}
|
||||
|
||||
export function parseLinkedMissingRequest(value: unknown): LinkedMissingRequestIR {
|
||||
const request = record(value, "request");
|
||||
exactKeys(request, ["schemaVersion", "operation", "sourceLibraryId", "sourceLocator", "sourceSha256", "expectedGeneration", "expectedRevision"], "request");
|
||||
if (request.schemaVersion !== LIBRARY_LINKED_MISSING_SCHEMA) {
|
||||
throw new LinkedMissingValidationError("PROTOCOL_MISMATCH", "Unsupported linked missing-library request schema", "schemaVersion");
|
||||
}
|
||||
if (request.operation !== LINKED_MISSING_OPERATION) {
|
||||
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", "missing-library operation is invalid", "operation");
|
||||
}
|
||||
return {
|
||||
schemaVersion: LIBRARY_LINKED_MISSING_SCHEMA,
|
||||
operation: LINKED_MISSING_OPERATION,
|
||||
sourceLibraryId: libraryId(request.sourceLibraryId, "request.sourceLibraryId"),
|
||||
sourceLocator: locator(request.sourceLocator, "request.sourceLocator"),
|
||||
sourceSha256: digest(request.sourceSha256, "request.sourceSha256"),
|
||||
expectedGeneration: integer(request.expectedGeneration, "request.expectedGeneration"),
|
||||
expectedRevision: integer(request.expectedRevision, "request.expectedRevision"),
|
||||
};
|
||||
}
|
||||
|
||||
function cloneState(state: LinkedMissingStateIR): LinkedMissingStateIR {
|
||||
return {
|
||||
schemaVersion: LIBRARY_LINKED_MISSING_SCHEMA,
|
||||
references: state.references.map((reference) => ({
|
||||
...reference,
|
||||
dataBlockIds: [...reference.dataBlockIds],
|
||||
placeholder: reference.placeholder === null ? null : {
|
||||
...reference.placeholder,
|
||||
dataBlockIds: [...reference.placeholder.dataBlockIds],
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function markLinkedLibraryMissing(stateValue: unknown, requestValue: unknown): LinkedMissingDecisionIR {
|
||||
const state = parseLinkedMissingState(stateValue);
|
||||
const request = parseLinkedMissingRequest(requestValue);
|
||||
const index = state.references.findIndex((reference) =>
|
||||
reference.sourceLibraryId === request.sourceLibraryId && reference.sourceGeneration === request.expectedGeneration,
|
||||
);
|
||||
if (index === -1 || state.references[index].sourceRevision !== request.expectedRevision) {
|
||||
return { status: "STALE", code: "REVISION_CONFLICT", sourceLibraryId: request.sourceLibraryId, state: cloneState(state) };
|
||||
}
|
||||
const current = state.references[index];
|
||||
if (current.sourceLocator !== request.sourceLocator || current.sourceSha256 !== request.sourceSha256) {
|
||||
return { status: "STALE", code: "ASSET_SOURCE_HASH_MISMATCH", sourceLibraryId: request.sourceLibraryId, state: cloneState(state) };
|
||||
}
|
||||
const missing: LinkedLibraryReferenceIR = {
|
||||
...current,
|
||||
status: "MISSING",
|
||||
placeholder: { kind: "MISSING_LIBRARY", sourceLibraryId: current.sourceLibraryId, dataBlockIds: [...current.dataBlockIds] },
|
||||
dataBlockIds: [...current.dataBlockIds],
|
||||
};
|
||||
const references = state.references.map((reference, itemIndex) => itemIndex === index ? missing : reference);
|
||||
return { status: "MARKED", code: null, sourceLibraryId: request.sourceLibraryId, state: { schemaVersion: 1, references: references.map((reference) => ({ ...reference, dataBlockIds: [...reference.dataBlockIds], placeholder: reference.placeholder === null ? null : { ...reference.placeholder, dataBlockIds: [...reference.placeholder.dataBlockIds] } })) } };
|
||||
}
|
||||
91
web/protocol/library-linked-mutation.ts
Normal file
91
web/protocol/library-linked-mutation.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { blockedGate, capabilityIssue, type CapabilityGateResult } from "./capability-gates";
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const LIBRARY_LINKED_MUTATION_SCHEMA = 1 as const;
|
||||
export const LINKED_DATA_WRITER_OPERATIONS = [
|
||||
"OBJECT_TRANSFORM",
|
||||
"MESH_GEOMETRY",
|
||||
"MESH_MATERIAL_SLOT",
|
||||
"MATERIAL_PROPERTIES",
|
||||
"MATERIAL_IMAGE_NODE",
|
||||
"IMAGE_PACKED_DATA",
|
||||
] as const;
|
||||
export type LinkedDataWriterOperation = typeof LINKED_DATA_WRITER_OPERATIONS[number];
|
||||
|
||||
export interface LinkedDataMutationIR {
|
||||
schemaVersion: typeof LIBRARY_LINKED_MUTATION_SCHEMA;
|
||||
operation: LinkedDataWriterOperation;
|
||||
dataBlockId: string;
|
||||
baseRevision: number;
|
||||
owner: "SOURCE_LIBRARY";
|
||||
linkedLibrary: true;
|
||||
readOnly: true;
|
||||
}
|
||||
|
||||
export class LinkedDataMutationError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
readonly path?: string;
|
||||
|
||||
constructor(code: ErrorCode, message: string, path?: string) {
|
||||
super(message);
|
||||
this.name = "LinkedDataMutationError";
|
||||
this.code = code;
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>): void {
|
||||
const expected = ["schemaVersion", "operation", "dataBlockId", "baseRevision", "owner", "linkedLibrary", "readOnly"];
|
||||
const actual = Object.keys(value).sort();
|
||||
if (actual.length !== expected.length || actual.some((key, index) => key !== expected.slice().sort()[index])) {
|
||||
throw new LinkedDataMutationError("TASK_VALIDATION_FAILED", "linked data mutation contains unsupported fields");
|
||||
}
|
||||
}
|
||||
|
||||
export function parseLinkedDataMutation(value: unknown): LinkedDataMutationIR {
|
||||
if (!record(value) || value.schemaVersion !== LIBRARY_LINKED_MUTATION_SCHEMA) {
|
||||
throw new LinkedDataMutationError("PROTOCOL_MISMATCH", "Unsupported linked data mutation schema");
|
||||
}
|
||||
exactKeys(value);
|
||||
if (!LINKED_DATA_WRITER_OPERATIONS.includes(value.operation as LinkedDataWriterOperation)) {
|
||||
throw new LinkedDataMutationError("TASK_VALIDATION_FAILED", "linked data mutation operation is unsupported", "operation");
|
||||
}
|
||||
if (typeof value.dataBlockId !== "string" || value.dataBlockId.length === 0 || value.dataBlockId.length > 256) {
|
||||
throw new LinkedDataMutationError("TASK_VALIDATION_FAILED", "dataBlockId is invalid", "dataBlockId");
|
||||
}
|
||||
if (typeof value.baseRevision !== "number" || !Number.isSafeInteger(value.baseRevision) || value.baseRevision < 0) {
|
||||
throw new LinkedDataMutationError("TASK_VALIDATION_FAILED", "baseRevision is invalid", "baseRevision");
|
||||
}
|
||||
if (value.owner !== "SOURCE_LIBRARY" || value.linkedLibrary !== true || value.readOnly !== true) {
|
||||
throw new LinkedDataMutationError("LINKED_DATA_MUTATION_BLOCKED", "linked data must remain SOURCE_LIBRARY/readOnly", "ownership");
|
||||
}
|
||||
return {
|
||||
schemaVersion: LIBRARY_LINKED_MUTATION_SCHEMA,
|
||||
operation: value.operation as LinkedDataWriterOperation,
|
||||
dataBlockId: value.dataBlockId,
|
||||
baseRevision: value.baseRevision,
|
||||
owner: "SOURCE_LIBRARY",
|
||||
linkedLibrary: true,
|
||||
readOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function gateLinkedDataMutation(value: unknown, currentRevision: number): CapabilityGateResult {
|
||||
let request: LinkedDataMutationIR;
|
||||
try {
|
||||
request = parseLinkedDataMutation(value);
|
||||
}
|
||||
catch (error) {
|
||||
const code = error instanceof LinkedDataMutationError ? error.code : "TASK_VALIDATION_FAILED";
|
||||
const message = error instanceof Error ? error.message : "linked data mutation is invalid";
|
||||
return blockedGate("N-023", "LINKED_DATA_WRITER", [capabilityIssue(code, message, error instanceof LinkedDataMutationError ? error.path : undefined, false)]);
|
||||
}
|
||||
if (!Number.isSafeInteger(currentRevision) || currentRevision < 0 || request.baseRevision !== currentRevision) {
|
||||
return blockedGate("N-023", `LINKED_${request.operation}`, [capabilityIssue("REVISION_CONFLICT", "linked data mutation revision is stale", "baseRevision", false)]);
|
||||
}
|
||||
return blockedGate("N-023", `LINKED_${request.operation}`, [capabilityIssue("LINKED_DATA_MUTATION_BLOCKED", "linked-library data is read-only", "readOnly", false)]);
|
||||
}
|
||||
222
web/protocol/library-linked-reload.ts
Normal file
222
web/protocol/library-linked-reload.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const LIBRARY_LINKED_RELOAD_SCHEMA = 1 as const;
|
||||
export const LINKED_RELOAD_OPERATION = "RELOAD" as const;
|
||||
|
||||
export interface LinkedSnapshotDataBlockIR {
|
||||
dataBlockId: string;
|
||||
owner: "SOURCE_LIBRARY";
|
||||
readOnly: true;
|
||||
}
|
||||
|
||||
export interface LinkedSnapshotIR {
|
||||
sourceLibraryId: string;
|
||||
sourceGeneration: number;
|
||||
sourceRevision: number;
|
||||
dependencyClosureSha256: string;
|
||||
graphSha256: string;
|
||||
dataBlocks: LinkedSnapshotDataBlockIR[];
|
||||
}
|
||||
|
||||
export interface LinkedReloadStateIR {
|
||||
schemaVersion: typeof LIBRARY_LINKED_RELOAD_SCHEMA;
|
||||
snapshots: LinkedSnapshotIR[];
|
||||
}
|
||||
|
||||
export interface LinkedReloadRequestIR {
|
||||
schemaVersion: typeof LIBRARY_LINKED_RELOAD_SCHEMA;
|
||||
operation: typeof LINKED_RELOAD_OPERATION;
|
||||
sourceLibraryId: string;
|
||||
expectedGeneration: number;
|
||||
expectedRevision: number;
|
||||
replacement: LinkedSnapshotIR;
|
||||
}
|
||||
|
||||
export interface LinkedReloadDecisionIR {
|
||||
status: "REPLACED" | "STALE";
|
||||
code: ErrorCode | null;
|
||||
sourceLibraryId: string;
|
||||
replacedGeneration: number;
|
||||
replacementGeneration: number;
|
||||
state: LinkedReloadStateIR;
|
||||
}
|
||||
|
||||
export class LinkedReloadValidationError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
readonly path?: string;
|
||||
|
||||
constructor(code: ErrorCode, message: string, path?: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "LinkedReloadValidationError";
|
||||
this.code = code;
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
const LIBRARY_ID = /^library:[a-f0-9]{64}$/;
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const DATA_BLOCK_ID = /^[A-Za-z0-9][A-Za-z0-9:._/ -]{0,255}$/;
|
||||
const MAX_SNAPSHOTS = 10_000;
|
||||
const MAX_DATA_BLOCKS = 256;
|
||||
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new LinkedReloadValidationError("TASK_VALIDATION_FAILED", `${path} must be an object`, path);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, expected: readonly string[], path: string): void {
|
||||
const keys = Object.keys(value).sort();
|
||||
const allowed = [...expected].sort();
|
||||
if (keys.length !== allowed.length || keys.some((key, index) => key !== allowed[index])) {
|
||||
throw new LinkedReloadValidationError("TASK_VALIDATION_FAILED", `${path} contains undeclared fields`, path);
|
||||
}
|
||||
}
|
||||
|
||||
function integer(value: unknown, path: string): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
||||
throw new LinkedReloadValidationError("TASK_VALIDATION_FAILED", `${path} must be a safe integer >= 0`, path);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function libraryId(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || !LIBRARY_ID.test(value)) {
|
||||
throw new LinkedReloadValidationError("TASK_VALIDATION_FAILED", `${path} must be a library identity`, path);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || !SHA256.test(value)) {
|
||||
throw new LinkedReloadValidationError("TASK_VALIDATION_FAILED", `${path} must be a lowercase SHA-256 digest`, path);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function dataBlock(value: unknown, path: string): LinkedSnapshotDataBlockIR {
|
||||
const item = record(value, path);
|
||||
exactKeys(item, ["dataBlockId", "owner", "readOnly"], path);
|
||||
if (typeof item.dataBlockId !== "string" || !DATA_BLOCK_ID.test(item.dataBlockId)) {
|
||||
throw new LinkedReloadValidationError("TASK_VALIDATION_FAILED", `${path}.dataBlockId is invalid`, `${path}.dataBlockId`);
|
||||
}
|
||||
if (item.owner !== "SOURCE_LIBRARY" || item.readOnly !== true) {
|
||||
throw new LinkedReloadValidationError("LINKED_DATA_MUTATION_BLOCKED", `${path} must remain source-library/read-only`, path);
|
||||
}
|
||||
return { dataBlockId: item.dataBlockId, owner: "SOURCE_LIBRARY", readOnly: true };
|
||||
}
|
||||
|
||||
export function parseLinkedSnapshot(value: unknown, path = "snapshot"): LinkedSnapshotIR {
|
||||
const snapshot = record(value, path);
|
||||
exactKeys(snapshot, ["sourceLibraryId", "sourceGeneration", "sourceRevision", "dependencyClosureSha256", "graphSha256", "dataBlocks"], path);
|
||||
if (!Array.isArray(snapshot.dataBlocks) || snapshot.dataBlocks.length === 0 || snapshot.dataBlocks.length > MAX_DATA_BLOCKS) {
|
||||
throw new LinkedReloadValidationError("TASK_VALIDATION_FAILED", `${path}.dataBlocks is outside its bounded range`, `${path}.dataBlocks`);
|
||||
}
|
||||
const dataBlocks = snapshot.dataBlocks.map((item, index) => dataBlock(item, `${path}.dataBlocks[${index}]`));
|
||||
if (new Set(dataBlocks.map((item) => item.dataBlockId)).size !== dataBlocks.length) {
|
||||
throw new LinkedReloadValidationError("TASK_VALIDATION_FAILED", `${path}.dataBlocks contains duplicate IDs`, `${path}.dataBlocks`);
|
||||
}
|
||||
return {
|
||||
sourceLibraryId: libraryId(snapshot.sourceLibraryId, `${path}.sourceLibraryId`),
|
||||
sourceGeneration: integer(snapshot.sourceGeneration, `${path}.sourceGeneration`),
|
||||
sourceRevision: integer(snapshot.sourceRevision, `${path}.sourceRevision`),
|
||||
dependencyClosureSha256: digest(snapshot.dependencyClosureSha256, `${path}.dependencyClosureSha256`),
|
||||
graphSha256: digest(snapshot.graphSha256, `${path}.graphSha256`),
|
||||
dataBlocks,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseLinkedReloadState(value: unknown): LinkedReloadStateIR {
|
||||
const state = record(value, "state");
|
||||
exactKeys(state, ["schemaVersion", "snapshots"], "state");
|
||||
if (state.schemaVersion !== LIBRARY_LINKED_RELOAD_SCHEMA) {
|
||||
throw new LinkedReloadValidationError("PROTOCOL_MISMATCH", "Unsupported linked reload state schema", "schemaVersion");
|
||||
}
|
||||
if (!Array.isArray(state.snapshots) || state.snapshots.length > MAX_SNAPSHOTS) {
|
||||
throw new LinkedReloadValidationError("TASK_VALIDATION_FAILED", "state.snapshots exceeds its bounded range", "snapshots");
|
||||
}
|
||||
const snapshots = state.snapshots.map((item, index) => parseLinkedSnapshot(item, `state.snapshots[${index}]`));
|
||||
const identities = snapshots.map((item) => `${item.sourceLibraryId}:${item.sourceGeneration}`);
|
||||
if (new Set(identities).size !== identities.length) {
|
||||
throw new LinkedReloadValidationError("TASK_VALIDATION_FAILED", "state contains duplicate library generations", "snapshots");
|
||||
}
|
||||
return { schemaVersion: LIBRARY_LINKED_RELOAD_SCHEMA, snapshots };
|
||||
}
|
||||
|
||||
export function parseLinkedReloadRequest(value: unknown): LinkedReloadRequestIR {
|
||||
const request = record(value, "request");
|
||||
exactKeys(request, ["schemaVersion", "operation", "sourceLibraryId", "expectedGeneration", "expectedRevision", "replacement"], "request");
|
||||
if (request.schemaVersion !== LIBRARY_LINKED_RELOAD_SCHEMA) {
|
||||
throw new LinkedReloadValidationError("PROTOCOL_MISMATCH", "Unsupported linked reload request schema", "schemaVersion");
|
||||
}
|
||||
if (request.operation !== LINKED_RELOAD_OPERATION) {
|
||||
throw new LinkedReloadValidationError("TASK_VALIDATION_FAILED", "linked reload operation is invalid", "operation");
|
||||
}
|
||||
const replacement = parseLinkedSnapshot(request.replacement, "request.replacement");
|
||||
const sourceLibraryId = libraryId(request.sourceLibraryId, "request.sourceLibraryId");
|
||||
if (replacement.sourceLibraryId !== sourceLibraryId) {
|
||||
throw new LinkedReloadValidationError("TASK_VALIDATION_FAILED", "replacement must retain the requested source library", "replacement.sourceLibraryId");
|
||||
}
|
||||
return {
|
||||
schemaVersion: LIBRARY_LINKED_RELOAD_SCHEMA,
|
||||
operation: LINKED_RELOAD_OPERATION,
|
||||
sourceLibraryId,
|
||||
expectedGeneration: integer(request.expectedGeneration, "request.expectedGeneration"),
|
||||
expectedRevision: integer(request.expectedRevision, "request.expectedRevision"),
|
||||
replacement,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneState(state: LinkedReloadStateIR): LinkedReloadStateIR {
|
||||
return {
|
||||
schemaVersion: LIBRARY_LINKED_RELOAD_SCHEMA,
|
||||
snapshots: state.snapshots.map((snapshot) => ({
|
||||
...snapshot,
|
||||
dataBlocks: snapshot.dataBlocks.map((dataBlock) => ({ ...dataBlock })),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function reloadMatchingLinkedSnapshot(stateValue: unknown, requestValue: unknown): LinkedReloadDecisionIR {
|
||||
const state = parseLinkedReloadState(stateValue);
|
||||
const request = parseLinkedReloadRequest(requestValue);
|
||||
const matchingIndex = state.snapshots.findIndex((snapshot) =>
|
||||
snapshot.sourceLibraryId === request.sourceLibraryId && snapshot.sourceGeneration === request.expectedGeneration,
|
||||
);
|
||||
if (matchingIndex === -1 || state.snapshots[matchingIndex].sourceRevision !== request.expectedRevision) {
|
||||
return {
|
||||
status: "STALE",
|
||||
code: "REVISION_CONFLICT",
|
||||
sourceLibraryId: request.sourceLibraryId,
|
||||
replacedGeneration: request.expectedGeneration,
|
||||
replacementGeneration: request.replacement.sourceGeneration,
|
||||
state: cloneState(state),
|
||||
};
|
||||
}
|
||||
const current = state.snapshots[matchingIndex];
|
||||
if (request.replacement.sourceLibraryId !== request.sourceLibraryId ||
|
||||
request.replacement.sourceGeneration !== request.expectedGeneration + 1 ||
|
||||
request.replacement.sourceRevision <= current.sourceRevision) {
|
||||
return {
|
||||
status: "STALE",
|
||||
code: "REVISION_CONFLICT",
|
||||
sourceLibraryId: request.sourceLibraryId,
|
||||
replacedGeneration: request.expectedGeneration,
|
||||
replacementGeneration: request.replacement.sourceGeneration,
|
||||
state: cloneState(state),
|
||||
};
|
||||
}
|
||||
const snapshots = state.snapshots.map((snapshot, index) => index === matchingIndex ? request.replacement : snapshot);
|
||||
return {
|
||||
status: "REPLACED",
|
||||
code: null,
|
||||
sourceLibraryId: request.sourceLibraryId,
|
||||
replacedGeneration: current.sourceGeneration,
|
||||
replacementGeneration: request.replacement.sourceGeneration,
|
||||
state: { schemaVersion: LIBRARY_LINKED_RELOAD_SCHEMA, snapshots: snapshots.map((snapshot) => ({
|
||||
...snapshot,
|
||||
dataBlocks: snapshot.dataBlocks.map((dataBlock) => ({ ...dataBlock })),
|
||||
})) },
|
||||
};
|
||||
}
|
||||
168
web/protocol/library-negative-cases.ts
Normal file
168
web/protocol/library-negative-cases.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const LIBRARY_NEGATIVE_CASE_SCHEMA = 1 as const;
|
||||
|
||||
export interface LibraryNegativeLibraryIR {
|
||||
libraryId: string;
|
||||
dependencyIds: string[];
|
||||
}
|
||||
|
||||
export interface LibraryNegativeDataBlockIR {
|
||||
dataBlockId: string;
|
||||
sourceLibraryId: string;
|
||||
}
|
||||
|
||||
export interface LibraryNegativeCrossReferenceIR {
|
||||
fromLibraryId: string;
|
||||
toLibraryId: string;
|
||||
}
|
||||
|
||||
export interface LibraryNegativeReloadIR {
|
||||
sourceLibraryId: string;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
export interface LibraryNegativeInputIR {
|
||||
schemaVersion: typeof LIBRARY_NEGATIVE_CASE_SCHEMA;
|
||||
libraries: LibraryNegativeLibraryIR[];
|
||||
dataBlocks: LibraryNegativeDataBlockIR[];
|
||||
crossReferences: LibraryNegativeCrossReferenceIR[];
|
||||
reloads: LibraryNegativeReloadIR[];
|
||||
}
|
||||
|
||||
export interface LibraryNegativeValidationIR {
|
||||
status: "VALID";
|
||||
}
|
||||
|
||||
export class LibraryNegativeValidationError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
readonly path?: string;
|
||||
|
||||
constructor(code: ErrorCode, message: string, path?: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "LibraryNegativeValidationError";
|
||||
this.code = code;
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
const LIBRARY_ID = /^library:[a-f0-9]{64}$/;
|
||||
const DATA_BLOCK_ID = /^[A-Za-z0-9][A-Za-z0-9:._/ -]{0,255}$/;
|
||||
const MAX_ENTRIES = 10_000;
|
||||
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", `${path} must be an object`, path);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, expected: readonly string[], path: string): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const allowed = [...expected].sort();
|
||||
if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", `${path} contains undeclared fields`, path);
|
||||
}
|
||||
|
||||
function libraryId(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || !LIBRARY_ID.test(value)) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", `${path} must be a library identity`, path);
|
||||
return value;
|
||||
}
|
||||
|
||||
function dataBlockId(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || !DATA_BLOCK_ID.test(value)) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", `${path} is invalid`, path);
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, path: string): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", `${path} must be a safe integer >= 0`, path);
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseLibrary(value: unknown, path: string): LibraryNegativeLibraryIR {
|
||||
const item = record(value, path);
|
||||
exactKeys(item, ["libraryId", "dependencyIds"], path);
|
||||
if (!Array.isArray(item.dependencyIds) || item.dependencyIds.length > MAX_ENTRIES) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", `${path}.dependencyIds exceeds its bound`, path);
|
||||
return { libraryId: libraryId(item.libraryId, `${path}.libraryId`), dependencyIds: item.dependencyIds.map((dependency, index) => libraryId(dependency, `${path}.dependencyIds[${index}]`)) };
|
||||
}
|
||||
|
||||
function parseDataBlock(value: unknown, path: string): LibraryNegativeDataBlockIR {
|
||||
const item = record(value, path);
|
||||
exactKeys(item, ["dataBlockId", "sourceLibraryId"], path);
|
||||
return { dataBlockId: dataBlockId(item.dataBlockId, `${path}.dataBlockId`), sourceLibraryId: libraryId(item.sourceLibraryId, `${path}.sourceLibraryId`) };
|
||||
}
|
||||
|
||||
function parseCrossReference(value: unknown, path: string): LibraryNegativeCrossReferenceIR {
|
||||
const item = record(value, path);
|
||||
exactKeys(item, ["fromLibraryId", "toLibraryId"], path);
|
||||
return { fromLibraryId: libraryId(item.fromLibraryId, `${path}.fromLibraryId`), toLibraryId: libraryId(item.toLibraryId, `${path}.toLibraryId`) };
|
||||
}
|
||||
|
||||
function parseReload(value: unknown, path: string): LibraryNegativeReloadIR {
|
||||
const item = record(value, path);
|
||||
exactKeys(item, ["sourceLibraryId", "generation"], path);
|
||||
return { sourceLibraryId: libraryId(item.sourceLibraryId, `${path}.sourceLibraryId`), generation: integer(item.generation, `${path}.generation`) };
|
||||
}
|
||||
|
||||
export function parseLibraryNegativeInput(value: unknown): LibraryNegativeInputIR {
|
||||
const input = record(value, "input");
|
||||
exactKeys(input, ["schemaVersion", "libraries", "dataBlocks", "crossReferences", "reloads"], "input");
|
||||
if (input.schemaVersion !== LIBRARY_NEGATIVE_CASE_SCHEMA) throw new LibraryNegativeValidationError("PROTOCOL_MISMATCH", "Unsupported library negative-case schema", "schemaVersion");
|
||||
const libraries = input.libraries;
|
||||
const dataBlocks = input.dataBlocks;
|
||||
const crossReferences = input.crossReferences;
|
||||
const reloads = input.reloads;
|
||||
if (!Array.isArray(libraries) || libraries.length > MAX_ENTRIES) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", "input.libraries exceeds its bound", "input.libraries");
|
||||
if (!Array.isArray(dataBlocks) || dataBlocks.length > MAX_ENTRIES) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", "input.dataBlocks exceeds its bound", "input.dataBlocks");
|
||||
if (!Array.isArray(crossReferences) || crossReferences.length > MAX_ENTRIES) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", "input.crossReferences exceeds its bound", "input.crossReferences");
|
||||
if (!Array.isArray(reloads) || reloads.length > MAX_ENTRIES) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", "input.reloads exceeds its bound", "input.reloads");
|
||||
return {
|
||||
schemaVersion: LIBRARY_NEGATIVE_CASE_SCHEMA,
|
||||
libraries: libraries.map((item, index) => parseLibrary(item, `libraries[${index}]`)),
|
||||
dataBlocks: dataBlocks.map((item, index) => parseDataBlock(item, `dataBlocks[${index}]`)),
|
||||
crossReferences: crossReferences.map((item, index) => parseCrossReference(item, `crossReferences[${index}]`)),
|
||||
reloads: reloads.map((item, index) => parseReload(item, `reloads[${index}]`)),
|
||||
};
|
||||
}
|
||||
|
||||
function assertNoCycle(nodes: Set<string>, edges: Map<string, string[]>, label: string): void {
|
||||
const active = new Set<string>();
|
||||
const complete = new Set<string>();
|
||||
const visit = (node: string): void => {
|
||||
if (active.has(node)) throw new LibraryNegativeValidationError("LIBRARY_DEPENDENCY_CYCLE", `${label} contains a cycle at ${node}`, label);
|
||||
if (complete.has(node)) return;
|
||||
active.add(node);
|
||||
for (const dependency of edges.get(node) ?? []) {
|
||||
if (!nodes.has(dependency)) throw new LibraryNegativeValidationError("ASSET_MANIFEST_INVALID", `${label} references a missing library ${dependency}`, label);
|
||||
visit(dependency);
|
||||
}
|
||||
active.delete(node);
|
||||
complete.add(node);
|
||||
};
|
||||
for (const node of nodes) visit(node);
|
||||
}
|
||||
|
||||
export function validateLibraryNegativeInput(value: unknown): LibraryNegativeValidationIR {
|
||||
const input = parseLibraryNegativeInput(value);
|
||||
const libraries = new Set(input.libraries.map((item) => item.libraryId));
|
||||
if (libraries.size !== input.libraries.length) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", "duplicate library ID", "libraries");
|
||||
const libraryEdges = new Map(input.libraries.map((item) => [item.libraryId, item.dependencyIds]));
|
||||
assertNoCycle(libraries, libraryEdges, "library dependencies");
|
||||
const crossEdges = new Map<string, string[]>();
|
||||
for (const item of input.crossReferences) {
|
||||
if (!libraries.has(item.fromLibraryId) || !libraries.has(item.toLibraryId)) throw new LibraryNegativeValidationError("ASSET_MANIFEST_INVALID", "cross-library reference names a missing library", "crossReferences");
|
||||
crossEdges.set(item.fromLibraryId, [...(crossEdges.get(item.fromLibraryId) ?? []), item.toLibraryId]);
|
||||
}
|
||||
assertNoCycle(libraries, crossEdges, "cross-library references");
|
||||
const dataBlocks = new Set<string>();
|
||||
for (const item of input.dataBlocks) {
|
||||
if (!libraries.has(item.sourceLibraryId)) throw new LibraryNegativeValidationError("ASSET_SOURCE_HASH_MISMATCH", "data-block source library is missing", "dataBlocks");
|
||||
if (dataBlocks.has(item.dataBlockId)) throw new LibraryNegativeValidationError("TASK_VALIDATION_FAILED", `duplicate data-block ID ${item.dataBlockId}`, "dataBlocks");
|
||||
dataBlocks.add(item.dataBlockId);
|
||||
}
|
||||
const reloads = new Set<string>();
|
||||
for (const item of input.reloads) {
|
||||
if (!libraries.has(item.sourceLibraryId)) throw new LibraryNegativeValidationError("ASSET_SOURCE_HASH_MISMATCH", "reload source library is missing", "reloads");
|
||||
const identity = `${item.sourceLibraryId}:${item.generation}`;
|
||||
if (reloads.has(identity)) throw new LibraryNegativeValidationError("REVISION_CONFLICT", `duplicate reload ${identity}`, "reloads");
|
||||
reloads.add(identity);
|
||||
}
|
||||
return { status: "VALID" };
|
||||
}
|
||||
165
web/protocol/library-override-freshness.ts
Normal file
165
web/protocol/library-override-freshness.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const LIBRARY_OVERRIDE_FRESHNESS_SCHEMA = 1 as const;
|
||||
export const LIBRARY_OVERRIDE_COMMIT_OPERATION = "COMMIT_OVERRIDE" as const;
|
||||
|
||||
export interface OverrideFreshnessStateIR {
|
||||
schemaVersion: typeof LIBRARY_OVERRIDE_FRESHNESS_SCHEMA;
|
||||
sourceLibraryId: string;
|
||||
sourceGeneration: number;
|
||||
sourceRevision: number;
|
||||
dependencyClosureSha256: string;
|
||||
invalidationToken: string;
|
||||
localDataBlockId: string;
|
||||
referenceSourceDataBlockId: string;
|
||||
hierarchyRootDataBlockId: string;
|
||||
owner: "LOCAL_OVERRIDE";
|
||||
readOnly: false;
|
||||
referenceReadOnly: true;
|
||||
}
|
||||
|
||||
export interface OverrideFreshnessRequestIR {
|
||||
schemaVersion: typeof LIBRARY_OVERRIDE_FRESHNESS_SCHEMA;
|
||||
operation: typeof LIBRARY_OVERRIDE_COMMIT_OPERATION;
|
||||
sourceLibraryId: string;
|
||||
sourceGeneration: number;
|
||||
sourceRevision: number;
|
||||
dependencyClosureSha256: string;
|
||||
invalidationToken: string;
|
||||
baseRevision: number;
|
||||
localDataBlockId: string;
|
||||
referenceSourceDataBlockId: string;
|
||||
hierarchyRootDataBlockId: string;
|
||||
owner: "LOCAL_OVERRIDE";
|
||||
readOnly: false;
|
||||
referenceReadOnly: true;
|
||||
}
|
||||
|
||||
export interface OverrideFreshnessDecisionIR {
|
||||
status: "READY" | "BLOCKED";
|
||||
code: ErrorCode | null;
|
||||
}
|
||||
|
||||
export class OverrideFreshnessValidationError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
readonly path?: string;
|
||||
|
||||
constructor(code: ErrorCode, message: string, path?: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "OverrideFreshnessValidationError";
|
||||
this.code = code;
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
const LIBRARY_ID = /^library:[a-f0-9]{64}$/;
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const TOKEN = /^override-token:[a-f0-9]{64}$/;
|
||||
const DATA_BLOCK_ID = /^[A-Za-z0-9][A-Za-z0-9:._/ -]{0,255}$/;
|
||||
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new OverrideFreshnessValidationError("TASK_VALIDATION_FAILED", `${path} must be an object`, path);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, expected: readonly string[], path: string): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const allowed = [...expected].sort();
|
||||
if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) throw new OverrideFreshnessValidationError("TASK_VALIDATION_FAILED", `${path} contains undeclared fields`, path);
|
||||
}
|
||||
|
||||
function integer(value: unknown, path: string): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new OverrideFreshnessValidationError("TASK_VALIDATION_FAILED", `${path} must be a safe integer >= 0`, path);
|
||||
return value;
|
||||
}
|
||||
|
||||
function id(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || !DATA_BLOCK_ID.test(value)) throw new OverrideFreshnessValidationError("TASK_VALIDATION_FAILED", `${path} is invalid`, path);
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || !SHA256.test(value)) throw new OverrideFreshnessValidationError("TASK_VALIDATION_FAILED", `${path} must be a SHA-256 digest`, path);
|
||||
return value;
|
||||
}
|
||||
|
||||
function library(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || !LIBRARY_ID.test(value)) throw new OverrideFreshnessValidationError("TASK_VALIDATION_FAILED", `${path} must be a library identity`, path);
|
||||
return value;
|
||||
}
|
||||
|
||||
function token(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || !TOKEN.test(value)) throw new OverrideFreshnessValidationError("TASK_VALIDATION_FAILED", `${path} must be an invalidation token`, path);
|
||||
return value;
|
||||
}
|
||||
|
||||
function ownership(value: Record<string, unknown>, path: string): void {
|
||||
if (value.owner !== "LOCAL_OVERRIDE" || value.readOnly !== false || value.referenceReadOnly !== true) throw new OverrideFreshnessValidationError("LINKED_DATA_MUTATION_BLOCKED", `${path} ownership semantics are invalid`, path);
|
||||
}
|
||||
|
||||
const COMMON_KEYS = ["schemaVersion", "sourceLibraryId", "sourceGeneration", "sourceRevision", "dependencyClosureSha256", "invalidationToken", "localDataBlockId", "referenceSourceDataBlockId", "hierarchyRootDataBlockId", "owner", "readOnly", "referenceReadOnly"] as const;
|
||||
|
||||
export function parseOverrideFreshnessState(value: unknown): OverrideFreshnessStateIR {
|
||||
const state = record(value, "state");
|
||||
exactKeys(state, COMMON_KEYS, "state");
|
||||
if (state.schemaVersion !== LIBRARY_OVERRIDE_FRESHNESS_SCHEMA) throw new OverrideFreshnessValidationError("PROTOCOL_MISMATCH", "Unsupported override freshness state schema", "schemaVersion");
|
||||
ownership(state, "state");
|
||||
const sourceLibraryId = library(state.sourceLibraryId, "sourceLibraryId");
|
||||
return {
|
||||
schemaVersion: LIBRARY_OVERRIDE_FRESHNESS_SCHEMA,
|
||||
sourceLibraryId,
|
||||
sourceGeneration: integer(state.sourceGeneration, "sourceGeneration"),
|
||||
sourceRevision: integer(state.sourceRevision, "sourceRevision"),
|
||||
dependencyClosureSha256: digest(state.dependencyClosureSha256, "dependencyClosureSha256"),
|
||||
invalidationToken: token(state.invalidationToken, "invalidationToken"),
|
||||
localDataBlockId: id(state.localDataBlockId, "localDataBlockId"),
|
||||
referenceSourceDataBlockId: id(state.referenceSourceDataBlockId, "referenceSourceDataBlockId"),
|
||||
hierarchyRootDataBlockId: id(state.hierarchyRootDataBlockId, "hierarchyRootDataBlockId"),
|
||||
owner: "LOCAL_OVERRIDE",
|
||||
readOnly: false,
|
||||
referenceReadOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseOverrideFreshnessRequest(value: unknown): OverrideFreshnessRequestIR {
|
||||
const request = record(value, "request");
|
||||
exactKeys(request, [...COMMON_KEYS, "operation", "baseRevision"], "request");
|
||||
if (request.schemaVersion !== LIBRARY_OVERRIDE_FRESHNESS_SCHEMA) throw new OverrideFreshnessValidationError("PROTOCOL_MISMATCH", "Unsupported override freshness request schema", "schemaVersion");
|
||||
if (request.operation !== LIBRARY_OVERRIDE_COMMIT_OPERATION) throw new OverrideFreshnessValidationError("TASK_VALIDATION_FAILED", "override commit operation is invalid", "operation");
|
||||
ownership(request, "request");
|
||||
return {
|
||||
schemaVersion: LIBRARY_OVERRIDE_FRESHNESS_SCHEMA,
|
||||
operation: LIBRARY_OVERRIDE_COMMIT_OPERATION,
|
||||
sourceLibraryId: library(request.sourceLibraryId, "sourceLibraryId"),
|
||||
sourceGeneration: integer(request.sourceGeneration, "sourceGeneration"),
|
||||
sourceRevision: integer(request.sourceRevision, "sourceRevision"),
|
||||
dependencyClosureSha256: digest(request.dependencyClosureSha256, "dependencyClosureSha256"),
|
||||
invalidationToken: token(request.invalidationToken, "invalidationToken"),
|
||||
baseRevision: integer(request.baseRevision, "baseRevision"),
|
||||
localDataBlockId: id(request.localDataBlockId, "localDataBlockId"),
|
||||
referenceSourceDataBlockId: id(request.referenceSourceDataBlockId, "referenceSourceDataBlockId"),
|
||||
hierarchyRootDataBlockId: id(request.hierarchyRootDataBlockId, "hierarchyRootDataBlockId"),
|
||||
owner: "LOCAL_OVERRIDE",
|
||||
readOnly: false,
|
||||
referenceReadOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function gateOverrideFreshness(stateValue: unknown, requestValue: unknown): OverrideFreshnessDecisionIR {
|
||||
let state: OverrideFreshnessStateIR;
|
||||
let request: OverrideFreshnessRequestIR;
|
||||
try {
|
||||
state = parseOverrideFreshnessState(stateValue);
|
||||
request = parseOverrideFreshnessRequest(requestValue);
|
||||
}
|
||||
catch (error) {
|
||||
return { status: "BLOCKED", code: error instanceof OverrideFreshnessValidationError ? error.code : "TASK_VALIDATION_FAILED" };
|
||||
}
|
||||
if (request.baseRevision !== state.sourceRevision || request.sourceLibraryId !== state.sourceLibraryId || request.sourceGeneration !== state.sourceGeneration || request.sourceRevision !== state.sourceRevision || request.dependencyClosureSha256 !== state.dependencyClosureSha256 || request.invalidationToken !== state.invalidationToken) {
|
||||
return { status: "BLOCKED", code: "REVISION_CONFLICT" };
|
||||
}
|
||||
if (request.localDataBlockId !== state.localDataBlockId || request.referenceSourceDataBlockId !== state.referenceSourceDataBlockId || request.hierarchyRootDataBlockId !== state.hierarchyRootDataBlockId) {
|
||||
return { status: "BLOCKED", code: "ASSET_SOURCE_HASH_MISMATCH" };
|
||||
}
|
||||
return { status: "READY", code: null };
|
||||
}
|
||||
156
web/protocol/library-override-writer.ts
Normal file
156
web/protocol/library-override-writer.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const LIBRARY_OVERRIDE_WRITER_SCHEMA = 1 as const;
|
||||
export const LIBRARY_OVERRIDE_WRITER_OPERATION = "SET_M12_OVERRIDE_VALUE" as const;
|
||||
export const LIBRARY_OVERRIDE_PROPERTY_PATH = '["m12_override_value"]' as const;
|
||||
|
||||
export interface OverrideWriterStateIR {
|
||||
schemaVersion: typeof LIBRARY_OVERRIDE_WRITER_SCHEMA;
|
||||
revision: number;
|
||||
localDataBlockId: string;
|
||||
referenceSourceDataBlockId: string;
|
||||
hierarchyRootDataBlockId: string;
|
||||
owner: "LOCAL_OVERRIDE";
|
||||
readOnly: false;
|
||||
referenceReadOnly: true;
|
||||
propertyPath: typeof LIBRARY_OVERRIDE_PROPERTY_PATH;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface OverrideWriterRequestIR {
|
||||
schemaVersion: typeof LIBRARY_OVERRIDE_WRITER_SCHEMA;
|
||||
operation: typeof LIBRARY_OVERRIDE_WRITER_OPERATION;
|
||||
baseRevision: number;
|
||||
localDataBlockId: string;
|
||||
referenceSourceDataBlockId: string;
|
||||
hierarchyRootDataBlockId: string;
|
||||
owner: "LOCAL_OVERRIDE";
|
||||
readOnly: false;
|
||||
referenceReadOnly: true;
|
||||
propertyPath: typeof LIBRARY_OVERRIDE_PROPERTY_PATH;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface OverrideWriterDecisionIR {
|
||||
status: "APPLIED" | "BLOCKED";
|
||||
code: ErrorCode | null;
|
||||
state: OverrideWriterStateIR;
|
||||
}
|
||||
|
||||
export class OverrideWriterValidationError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
readonly path?: string;
|
||||
|
||||
constructor(code: ErrorCode, message: string, path?: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "OverrideWriterValidationError";
|
||||
this.code = code;
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
const DATA_BLOCK_ID = /^[A-Za-z0-9][A-Za-z0-9:._/ -]{0,255}$/;
|
||||
const MAX_VALUE = 1_000_000;
|
||||
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new OverrideWriterValidationError("TASK_VALIDATION_FAILED", `${path} must be an object`, path);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, expected: readonly string[], path: string): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const allowed = [...expected].sort();
|
||||
if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) {
|
||||
throw new OverrideWriterValidationError("TASK_VALIDATION_FAILED", `${path} contains undeclared fields`, path);
|
||||
}
|
||||
}
|
||||
|
||||
function integer(value: unknown, path: string): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
||||
throw new OverrideWriterValidationError("TASK_VALIDATION_FAILED", `${path} must be a safe integer >= 0`, path);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function dataBlockId(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || !DATA_BLOCK_ID.test(value)) {
|
||||
throw new OverrideWriterValidationError("TASK_VALIDATION_FAILED", `${path} is invalid`, path);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function valueNumber(value: unknown, path: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) > MAX_VALUE) {
|
||||
throw new OverrideWriterValidationError("TASK_VALIDATION_FAILED", `${path} is outside the bounded float range`, path);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseOverrideWriterState(value: unknown): OverrideWriterStateIR {
|
||||
const state = record(value, "state");
|
||||
exactKeys(state, ["schemaVersion", "revision", "localDataBlockId", "referenceSourceDataBlockId", "hierarchyRootDataBlockId", "owner", "readOnly", "referenceReadOnly", "propertyPath", "value"], "state");
|
||||
if (state.schemaVersion !== LIBRARY_OVERRIDE_WRITER_SCHEMA) throw new OverrideWriterValidationError("PROTOCOL_MISMATCH", "Unsupported override writer state schema", "schemaVersion");
|
||||
if (state.owner !== "LOCAL_OVERRIDE" || state.readOnly !== false || state.referenceReadOnly !== true) throw new OverrideWriterValidationError("TASK_VALIDATION_FAILED", "state ownership semantics are invalid", "owner");
|
||||
if (state.propertyPath !== LIBRARY_OVERRIDE_PROPERTY_PATH) throw new OverrideWriterValidationError("EDITOR_WRITER_UNAVAILABLE", "state property path is not the verified writer path", "propertyPath");
|
||||
return {
|
||||
schemaVersion: LIBRARY_OVERRIDE_WRITER_SCHEMA,
|
||||
revision: integer(state.revision, "revision"),
|
||||
localDataBlockId: dataBlockId(state.localDataBlockId, "localDataBlockId"),
|
||||
referenceSourceDataBlockId: dataBlockId(state.referenceSourceDataBlockId, "referenceSourceDataBlockId"),
|
||||
hierarchyRootDataBlockId: dataBlockId(state.hierarchyRootDataBlockId, "hierarchyRootDataBlockId"),
|
||||
owner: "LOCAL_OVERRIDE",
|
||||
readOnly: false,
|
||||
referenceReadOnly: true,
|
||||
propertyPath: LIBRARY_OVERRIDE_PROPERTY_PATH,
|
||||
value: valueNumber(state.value, "value"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseOverrideWriterRequest(value: unknown): OverrideWriterRequestIR {
|
||||
const request = record(value, "request");
|
||||
exactKeys(request, ["schemaVersion", "operation", "baseRevision", "localDataBlockId", "referenceSourceDataBlockId", "hierarchyRootDataBlockId", "owner", "readOnly", "referenceReadOnly", "propertyPath", "value"], "request");
|
||||
if (request.schemaVersion !== LIBRARY_OVERRIDE_WRITER_SCHEMA) throw new OverrideWriterValidationError("PROTOCOL_MISMATCH", "Unsupported override writer request schema", "schemaVersion");
|
||||
if (request.operation !== LIBRARY_OVERRIDE_WRITER_OPERATION) throw new OverrideWriterValidationError("TASK_VALIDATION_FAILED", "override writer operation is invalid", "operation");
|
||||
if (request.owner !== "LOCAL_OVERRIDE" || request.readOnly !== false || request.referenceReadOnly !== true) throw new OverrideWriterValidationError("LINKED_DATA_MUTATION_BLOCKED", "override writer must retain local override ownership", "owner");
|
||||
if (request.propertyPath !== LIBRARY_OVERRIDE_PROPERTY_PATH) throw new OverrideWriterValidationError("EDITOR_WRITER_UNAVAILABLE", "only the verified override property is writable", "propertyPath");
|
||||
return {
|
||||
schemaVersion: LIBRARY_OVERRIDE_WRITER_SCHEMA,
|
||||
operation: LIBRARY_OVERRIDE_WRITER_OPERATION,
|
||||
baseRevision: integer(request.baseRevision, "baseRevision"),
|
||||
localDataBlockId: dataBlockId(request.localDataBlockId, "localDataBlockId"),
|
||||
referenceSourceDataBlockId: dataBlockId(request.referenceSourceDataBlockId, "referenceSourceDataBlockId"),
|
||||
hierarchyRootDataBlockId: dataBlockId(request.hierarchyRootDataBlockId, "hierarchyRootDataBlockId"),
|
||||
owner: "LOCAL_OVERRIDE",
|
||||
readOnly: false,
|
||||
referenceReadOnly: true,
|
||||
propertyPath: LIBRARY_OVERRIDE_PROPERTY_PATH,
|
||||
value: valueNumber(request.value, "value"),
|
||||
};
|
||||
}
|
||||
|
||||
function blocked(state: OverrideWriterStateIR, code: ErrorCode): OverrideWriterDecisionIR {
|
||||
return { status: "BLOCKED", code, state: { ...state } };
|
||||
}
|
||||
|
||||
export function applyOverrideWriter(stateValue: unknown, requestValue: unknown): OverrideWriterDecisionIR {
|
||||
const state = parseOverrideWriterState(stateValue);
|
||||
let request: OverrideWriterRequestIR;
|
||||
try {
|
||||
request = parseOverrideWriterRequest(requestValue);
|
||||
}
|
||||
catch (error) {
|
||||
const code = error instanceof OverrideWriterValidationError ? error.code : "TASK_VALIDATION_FAILED";
|
||||
return blocked(state, code);
|
||||
}
|
||||
if (request.baseRevision !== state.revision) return blocked(state, "REVISION_CONFLICT");
|
||||
if (request.localDataBlockId !== state.localDataBlockId || request.referenceSourceDataBlockId !== state.referenceSourceDataBlockId || request.hierarchyRootDataBlockId !== state.hierarchyRootDataBlockId) {
|
||||
return blocked(state, "ASSET_SOURCE_HASH_MISMATCH");
|
||||
}
|
||||
return {
|
||||
status: "APPLIED",
|
||||
code: null,
|
||||
state: { ...state, revision: state.revision + 1, value: request.value },
|
||||
};
|
||||
}
|
||||
148
web/protocol/library-source-origin.ts
Normal file
148
web/protocol/library-source-origin.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { normalizeProjectAssetPath } from "./asset-path";
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const LIBRARY_SOURCE_ORIGIN_SCHEMA = 1 as const;
|
||||
export type LibrarySourceKind = "HTTPS_ORIGIN" | "PROJECT_ASSET" | "USER_SELECTED_FILE";
|
||||
|
||||
export interface LibrarySourcePolicyIR {
|
||||
schemaVersion: typeof LIBRARY_SOURCE_ORIGIN_SCHEMA;
|
||||
declaredHttpsOrigins: string[];
|
||||
}
|
||||
|
||||
export type LibrarySourceRequestIR =
|
||||
| { schemaVersion: typeof LIBRARY_SOURCE_ORIGIN_SCHEMA; kind: "HTTPS_ORIGIN"; url: string }
|
||||
| { schemaVersion: typeof LIBRARY_SOURCE_ORIGIN_SCHEMA; kind: "PROJECT_ASSET"; path: string }
|
||||
| { schemaVersion: typeof LIBRARY_SOURCE_ORIGIN_SCHEMA; kind: "USER_SELECTED_FILE"; selectionId: string; fileName: string; byteLength: number; sourceSha256: string };
|
||||
|
||||
export interface AcceptedLibrarySourceIR {
|
||||
status: "READY";
|
||||
kind: LibrarySourceKind;
|
||||
canonicalLocator: string;
|
||||
}
|
||||
|
||||
export class LibrarySourceOriginValidationError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
readonly path?: string;
|
||||
|
||||
constructor(code: ErrorCode, message: string, path?: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "LibrarySourceOriginValidationError";
|
||||
this.code = code;
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const SELECTION_ID = /^file-selection:[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const FILE_NAME = /^[^\\/\u0000-\u001f\u007f]{1,255}$/;
|
||||
const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/;
|
||||
const MAX_FILE_BYTES = 4 * 1024 * 1024 * 1024;
|
||||
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new LibrarySourceOriginValidationError("ASSET_MANIFEST_INVALID", `${path} must be an object`, path);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, expected: readonly string[], path: string): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const allowed = [...expected].sort();
|
||||
if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) throw new LibrarySourceOriginValidationError("ASSET_MANIFEST_INVALID", `${path} contains undeclared fields`, path);
|
||||
}
|
||||
|
||||
function text(value: unknown, path: string, maximum: number): string {
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > maximum) throw new LibrarySourceOriginValidationError("ASSET_MANIFEST_INVALID", `${path} is invalid`, path);
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || !SHA256.test(value)) throw new LibrarySourceOriginValidationError("ASSET_MANIFEST_INVALID", `${path} must be a lowercase SHA-256 digest`, path);
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateUriText(value: string, path: string): void {
|
||||
if (value.includes("\\") || CONTROL_CHARACTER.test(value) || /%(?![0-9a-fA-F]{2})/.test(value)) {
|
||||
throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", `${path} contains unsafe URI characters`, path);
|
||||
}
|
||||
}
|
||||
|
||||
function validateUriPath(pathname: string, path: string): void {
|
||||
let decoded: string;
|
||||
try { decoded = decodeURIComponent(pathname); }
|
||||
catch { throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", `${path} contains malformed percent encoding`, path); }
|
||||
if (decoded.includes("%") || decoded.includes("\\") || CONTROL_CHARACTER.test(decoded)) {
|
||||
throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", `${path} contains an unsafe path`, path);
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalOrigin(value: unknown, path: string): string {
|
||||
const textValue = text(value, path, 2_048);
|
||||
validateUriText(textValue, path);
|
||||
let parsed: URL;
|
||||
try { parsed = new URL(textValue); } catch { throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", `${path} is not an absolute URL`, path); }
|
||||
if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.port) throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", `${path} must be a credential-free HTTPS origin`, path);
|
||||
// A policy entry is an origin, not a URL whose path/query/fragment is discarded by URL.origin.
|
||||
// Check the raw suffix too: URL parsing normalizes encoded and literal dot segments before exposing
|
||||
// pathname, which must not turn an origin-smuggling declaration into an apparently trusted origin.
|
||||
const schemeSeparator = textValue.indexOf("://");
|
||||
const authorityAndSuffix = schemeSeparator < 0 ? textValue : textValue.slice(schemeSeparator + 3);
|
||||
const suffixStart = authorityAndSuffix.search(/[/?#]/);
|
||||
const rawSuffix = suffixStart < 0 ? "" : authorityAndSuffix.slice(suffixStart);
|
||||
validateUriPath(parsed.pathname, path);
|
||||
if (schemeSeparator < 0 || parsed.pathname !== "/" || parsed.search || parsed.hash || rawSuffix !== "" && rawSuffix !== "/") throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", `${path} must not include a path, query, or fragment`, path);
|
||||
return parsed.origin;
|
||||
}
|
||||
|
||||
function httpsUrl(value: unknown, path: string): string {
|
||||
const textValue = text(value, path, 8_192);
|
||||
validateUriText(textValue, path);
|
||||
let parsed: URL;
|
||||
try { parsed = new URL(textValue); } catch { throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", `${path} is not an absolute URL`, path); }
|
||||
if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.port) throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", `${path} must be a credential-free HTTPS URL`, path);
|
||||
validateUriPath(parsed.pathname, path);
|
||||
return parsed.href;
|
||||
}
|
||||
|
||||
export function parseLibrarySourcePolicy(value: unknown): LibrarySourcePolicyIR {
|
||||
const policy = record(value, "policy");
|
||||
exactKeys(policy, ["schemaVersion", "declaredHttpsOrigins"], "policy");
|
||||
if (policy.schemaVersion !== LIBRARY_SOURCE_ORIGIN_SCHEMA || !Array.isArray(policy.declaredHttpsOrigins) || policy.declaredHttpsOrigins.length === 0 || policy.declaredHttpsOrigins.length > 1_024) throw new LibrarySourceOriginValidationError("PROTOCOL_MISMATCH", "Unsupported or empty library source policy", "policy");
|
||||
const declaredHttpsOrigins = policy.declaredHttpsOrigins.map((origin, index) => canonicalOrigin(origin, `declaredHttpsOrigins[${index}]`));
|
||||
if (new Set(declaredHttpsOrigins).size !== declaredHttpsOrigins.length) throw new LibrarySourceOriginValidationError("ASSET_MANIFEST_INVALID", "declared HTTPS origins must be unique", "declaredHttpsOrigins");
|
||||
return { schemaVersion: LIBRARY_SOURCE_ORIGIN_SCHEMA, declaredHttpsOrigins };
|
||||
}
|
||||
|
||||
export function parseLibrarySourceRequest(value: unknown): LibrarySourceRequestIR {
|
||||
const request = record(value, "request");
|
||||
if (request.schemaVersion !== LIBRARY_SOURCE_ORIGIN_SCHEMA) throw new LibrarySourceOriginValidationError("PROTOCOL_MISMATCH", "Unsupported library source request schema", "schemaVersion");
|
||||
if (request.kind === "HTTPS_ORIGIN") {
|
||||
exactKeys(request, ["schemaVersion", "kind", "url"], "request");
|
||||
return { schemaVersion: LIBRARY_SOURCE_ORIGIN_SCHEMA, kind: "HTTPS_ORIGIN", url: httpsUrl(request.url, "url") };
|
||||
}
|
||||
if (request.kind === "PROJECT_ASSET") {
|
||||
exactKeys(request, ["schemaVersion", "kind", "path"], "request");
|
||||
let path: string;
|
||||
try { path = normalizeProjectAssetPath(text(request.path, "path", 2_048)); }
|
||||
catch (error) { throw new LibrarySourceOriginValidationError(error instanceof Error && error.message === "ASSET_PATH_INVALID" ? "ASSET_MANIFEST_INVALID" : "IO_EXTERNAL_URI_BLOCKED", "project asset path is outside the project", "path"); }
|
||||
return { schemaVersion: LIBRARY_SOURCE_ORIGIN_SCHEMA, kind: "PROJECT_ASSET", path };
|
||||
}
|
||||
if (request.kind === "USER_SELECTED_FILE") {
|
||||
exactKeys(request, ["schemaVersion", "kind", "selectionId", "fileName", "byteLength", "sourceSha256"], "request");
|
||||
if (typeof request.selectionId !== "string" || !SELECTION_ID.test(request.selectionId)) throw new LibrarySourceOriginValidationError("ASSET_MANIFEST_INVALID", "selectionId is invalid", "selectionId");
|
||||
if (typeof request.fileName !== "string" || !FILE_NAME.test(request.fileName) || request.fileName === "." || request.fileName === "..") throw new LibrarySourceOriginValidationError("ASSET_MANIFEST_INVALID", "fileName is invalid", "fileName");
|
||||
if (typeof request.byteLength !== "number" || !Number.isSafeInteger(request.byteLength) || request.byteLength <= 0 || request.byteLength > MAX_FILE_BYTES) throw new LibrarySourceOriginValidationError("ASSET_BUDGET_EXCEEDED", "selected file length is outside the budget", "byteLength");
|
||||
return { schemaVersion: LIBRARY_SOURCE_ORIGIN_SCHEMA, kind: "USER_SELECTED_FILE", selectionId: request.selectionId, fileName: request.fileName, byteLength: request.byteLength, sourceSha256: digest(request.sourceSha256, "sourceSha256") };
|
||||
}
|
||||
throw new LibrarySourceOriginValidationError("ASSET_MANIFEST_INVALID", "source kind is unsupported", "kind");
|
||||
}
|
||||
|
||||
export function acceptLibrarySource(policyValue: unknown, requestValue: unknown): AcceptedLibrarySourceIR {
|
||||
const policy = parseLibrarySourcePolicy(policyValue);
|
||||
const request = parseLibrarySourceRequest(requestValue);
|
||||
if (request.kind === "HTTPS_ORIGIN") {
|
||||
const origin = new URL(request.url).origin;
|
||||
if (!policy.declaredHttpsOrigins.includes(origin)) throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", "HTTPS origin is not declared by the policy", "url");
|
||||
return { status: "READY", kind: request.kind, canonicalLocator: request.url };
|
||||
}
|
||||
if (request.kind === "PROJECT_ASSET") return { status: "READY", kind: request.kind, canonicalLocator: `project-assets/${request.path}` };
|
||||
return { status: "READY", kind: request.kind, canonicalLocator: `user-file/${request.selectionId}/${request.fileName}` };
|
||||
}
|
||||
219
web/protocol/obj-import.ts
Normal file
219
web/protocol/obj-import.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
export const OBJ_IMPORT_SCHEMA_VERSION = 1 as const;
|
||||
|
||||
export const OBJ_IMPORT_BUDGET = {
|
||||
maxObjBytes: 512 * 1024,
|
||||
maxMtlBytes: 128 * 1024,
|
||||
maxLines: 16_384,
|
||||
maxPositions: 65_536,
|
||||
maxTexcoords: 65_536,
|
||||
maxNormals: 65_536,
|
||||
maxFaces: 65_536,
|
||||
} as const;
|
||||
|
||||
export interface OBJFaceVertex {
|
||||
position: number;
|
||||
texcoord: number | null;
|
||||
normal: number | null;
|
||||
}
|
||||
|
||||
export interface OBJFace {
|
||||
object: string | null;
|
||||
groups: string[];
|
||||
material: string | null;
|
||||
vertices: OBJFaceVertex[];
|
||||
}
|
||||
|
||||
export interface OBJMaterial {
|
||||
name: string;
|
||||
mapKd: string | null;
|
||||
}
|
||||
|
||||
export interface OBJSemantics {
|
||||
schemaVersion: typeof OBJ_IMPORT_SCHEMA_VERSION;
|
||||
materialLibraries: string[];
|
||||
objects: string[];
|
||||
groups: string[];
|
||||
positions: number[][];
|
||||
texcoords: number[][];
|
||||
normals: number[][];
|
||||
faces: OBJFace[];
|
||||
materials: OBJMaterial[];
|
||||
}
|
||||
|
||||
export type OBJLossCode = "OBJ_TEXTURE_ORIGIN_UNRESOLVED";
|
||||
|
||||
export interface OBJLossWarning {
|
||||
code: OBJLossCode;
|
||||
severity: "warning";
|
||||
message: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface OBJLossReport {
|
||||
schemaVersion: typeof OBJ_IMPORT_SCHEMA_VERSION;
|
||||
operation: "OBJ_EXPORT_LOSS_REPORT";
|
||||
canRoundTrip: boolean;
|
||||
warningCount: number;
|
||||
warnings: OBJLossWarning[];
|
||||
}
|
||||
|
||||
function parseNumber(value: string, label: string): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) throw new Error(`OBJ_NUMBER_INVALID: ${label}`);
|
||||
return parsed === 0 ? 0 : parsed;
|
||||
}
|
||||
|
||||
function decode(bytes: ArrayBuffer, limit: number, label: string): string {
|
||||
if (bytes.byteLength > limit) throw new Error(`OBJ_IMPORT_BUDGET_EXCEEDED: ${label}`);
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
}
|
||||
catch {
|
||||
throw new Error(`OBJ_TEXT_INVALID: ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveIndex(raw: string, count: number, label: string): number {
|
||||
const value = Number(raw);
|
||||
if (!Number.isSafeInteger(value) || value === 0) throw new Error(`OBJ_INDEX_INVALID: ${label}`);
|
||||
const resolved = value < 0 ? count + value + 1 : value;
|
||||
if (resolved < 1 || resolved > count) throw new Error(`OBJ_INDEX_OUT_OF_RANGE: ${label}`);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function parseMaterialText(mtlText: string): OBJMaterial[] {
|
||||
const materials: OBJMaterial[] = [];
|
||||
let current: OBJMaterial | null = null;
|
||||
for (const rawLine of mtlText.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts[0] === "newmtl") {
|
||||
if (parts.length < 2) throw new Error("OBJ_MTL_INVALID: newmtl name is missing");
|
||||
current = { name: parts.slice(1).join(" "), mapKd: null };
|
||||
materials.push(current);
|
||||
}
|
||||
else if (parts[0] === "map_Kd" && current) {
|
||||
if (parts.length < 2) throw new Error("OBJ_MTL_INVALID: map_Kd path is missing");
|
||||
current.mapKd = parts.slice(1).join(" ");
|
||||
}
|
||||
}
|
||||
return materials;
|
||||
}
|
||||
|
||||
export function importOBJ(obj: ArrayBuffer, mtl?: ArrayBuffer): OBJSemantics {
|
||||
const objText = decode(obj, OBJ_IMPORT_BUDGET.maxObjBytes, "OBJ");
|
||||
const mtlText = mtl ? decode(mtl, OBJ_IMPORT_BUDGET.maxMtlBytes, "MTL") : "";
|
||||
const positions: number[][] = [];
|
||||
const texcoords: number[][] = [];
|
||||
const normals: number[][] = [];
|
||||
const faces: OBJFace[] = [];
|
||||
const materialLibraries: string[] = [];
|
||||
const objects: string[] = [];
|
||||
const groups: string[] = [];
|
||||
let currentObject: string | null = null;
|
||||
let currentGroups: string[] = [];
|
||||
let currentMaterial: string | null = null;
|
||||
const lines = objText.split(/\r?\n/);
|
||||
if (lines.length > OBJ_IMPORT_BUDGET.maxLines) throw new Error("OBJ_IMPORT_BUDGET_EXCEEDED: line count");
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const parts = line.split(/\s+/);
|
||||
const kind = parts[0];
|
||||
if (kind === "v") {
|
||||
if (parts.length < 4 || positions.length >= OBJ_IMPORT_BUDGET.maxPositions) throw new Error("OBJ_IMPORT_BUDGET_EXCEEDED: positions");
|
||||
positions.push([parseNumber(parts[1], "v.x"), parseNumber(parts[2], "v.y"), parseNumber(parts[3], "v.z")]);
|
||||
}
|
||||
else if (kind === "vt") {
|
||||
if (parts.length < 3 || texcoords.length >= OBJ_IMPORT_BUDGET.maxTexcoords) throw new Error("OBJ_IMPORT_BUDGET_EXCEEDED: texcoords");
|
||||
texcoords.push([parseNumber(parts[1], "vt.u"), parseNumber(parts[2], "vt.v")]);
|
||||
}
|
||||
else if (kind === "vn") {
|
||||
if (parts.length < 4 || normals.length >= OBJ_IMPORT_BUDGET.maxNormals) throw new Error("OBJ_IMPORT_BUDGET_EXCEEDED: normals");
|
||||
normals.push([parseNumber(parts[1], "vn.x"), parseNumber(parts[2], "vn.y"), parseNumber(parts[3], "vn.z")]);
|
||||
}
|
||||
else if (kind === "mtllib") materialLibraries.push(parts.slice(1).join(" "));
|
||||
else if (kind === "o") {
|
||||
currentObject = parts.slice(1).join(" ") || null;
|
||||
if (currentObject && !objects.includes(currentObject)) objects.push(currentObject);
|
||||
}
|
||||
else if (kind === "g") {
|
||||
currentGroups = parts.slice(1);
|
||||
for (const group of currentGroups) if (group && !groups.includes(group)) groups.push(group);
|
||||
const meshGroup = currentGroups.find((group) => group.endsWith("_Mesh"));
|
||||
if (meshGroup) {
|
||||
currentObject = meshGroup;
|
||||
if (!objects.includes(meshGroup)) objects.push(meshGroup);
|
||||
}
|
||||
}
|
||||
else if (kind === "usemtl") currentMaterial = parts.slice(1).join(" ") || null;
|
||||
else if (kind === "f") {
|
||||
if (parts.length < 4) throw new Error("OBJ_FACE_ARITY_INVALID: face requires at least three vertices");
|
||||
if (faces.length >= OBJ_IMPORT_BUDGET.maxFaces) throw new Error("OBJ_IMPORT_BUDGET_EXCEEDED: faces");
|
||||
const vertices = parts.slice(1).map((token, index) => {
|
||||
const indices = token.split("/");
|
||||
if (indices.length < 1 || indices.length > 3 || !indices[0] || (indices.length === 2 && !indices[1])) throw new Error(`OBJ_FACE_VERTEX_INVALID: face vertex ${index}`);
|
||||
return {
|
||||
position: resolveIndex(indices[0], positions.length, "face.position"),
|
||||
texcoord: indices.length > 1 && indices[1] ? resolveIndex(indices[1], texcoords.length, "face.texcoord") : null,
|
||||
normal: indices.length > 2 && indices[2] ? resolveIndex(indices[2], normals.length, "face.normal") : null,
|
||||
};
|
||||
});
|
||||
faces.push({ object: currentObject, groups: [...currentGroups], material: currentMaterial, vertices });
|
||||
}
|
||||
}
|
||||
if (faces.length === 0) throw new Error("OBJ_EMPTY: no faces were found");
|
||||
return {
|
||||
schemaVersion: OBJ_IMPORT_SCHEMA_VERSION,
|
||||
materialLibraries,
|
||||
objects,
|
||||
groups,
|
||||
positions,
|
||||
texcoords,
|
||||
normals,
|
||||
faces,
|
||||
materials: parseMaterialText(mtlText),
|
||||
};
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
if (!Number.isFinite(value)) throw new Error("OBJ_NUMBER_INVALID: cannot serialize non-finite value");
|
||||
return String(Object.is(value, -0) ? 0 : Number(value.toFixed(7)));
|
||||
}
|
||||
|
||||
export function serializeOBJ(document: OBJSemantics): { obj: string; mtl: string } {
|
||||
if (document.schemaVersion !== OBJ_IMPORT_SCHEMA_VERSION || document.faces.length === 0) throw new Error("OBJ_SERIALIZE_INVALID: semantic document");
|
||||
const lines = ["# Web Blender OBJ export", "# schema 1"];
|
||||
if (document.materials.length > 0) lines.push("mtllib " + (document.materialLibraries[0] ?? "materials.mtl"));
|
||||
for (const object of document.objects) lines.push(`o ${object}`);
|
||||
for (const position of document.positions) lines.push(`v ${position.map(formatNumber).join(" ")}`);
|
||||
for (const texcoord of document.texcoords) lines.push(`vt ${texcoord.map(formatNumber).join(" ")}`);
|
||||
for (const normal of document.normals) lines.push(`vn ${normal.map(formatNumber).join(" ")}`);
|
||||
let object = "";
|
||||
let groups = "";
|
||||
let material = "";
|
||||
for (const face of document.faces) {
|
||||
if (face.object && face.object !== object) { lines.push(`o ${face.object}`); object = face.object; }
|
||||
const nextGroups = face.groups.join(" ");
|
||||
if (nextGroups !== groups) { if (nextGroups) lines.push(`g ${nextGroups}`); groups = nextGroups; }
|
||||
const nextMaterial = face.material ?? "";
|
||||
if (nextMaterial !== material) { if (nextMaterial) lines.push(`usemtl ${nextMaterial}`); material = nextMaterial; }
|
||||
lines.push(`f ${face.vertices.map((vertex) => `${vertex.position}/${vertex.texcoord ?? ""}/${vertex.normal ?? ""}`).join(" ")}`);
|
||||
}
|
||||
const mtlLines = ["# Web Blender MTL export", "# schema 1"];
|
||||
for (const value of document.materials) {
|
||||
mtlLines.push(`newmtl ${value.name}`);
|
||||
if (value.mapKd) mtlLines.push(`map_Kd ${value.mapKd}`);
|
||||
}
|
||||
return { obj: lines.join("\n") + "\n", mtl: mtlLines.join("\n") + "\n" };
|
||||
}
|
||||
|
||||
export function createOBJLossReport(document: OBJSemantics, textureAssets: readonly string[] = []): OBJLossReport {
|
||||
const assets = new Set(textureAssets);
|
||||
const warnings = document.materials
|
||||
.filter((material) => material.mapKd && !assets.has(material.mapKd))
|
||||
.map((material) => ({ code: "OBJ_TEXTURE_ORIGIN_UNRESOLVED" as const, severity: "warning" as const, message: `OBJ texture ${material.mapKd} is not bound to a supplied asset`, path: material.mapKd! }))
|
||||
.sort((left, right) => left.path.localeCompare(right.path));
|
||||
return { schemaVersion: OBJ_IMPORT_SCHEMA_VERSION, operation: "OBJ_EXPORT_LOSS_REPORT", canRoundTrip: true, warningCount: warnings.length, warnings };
|
||||
}
|
||||
298
web/protocol/ply-import.ts
Normal file
298
web/protocol/ply-import.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
export const PLY_IMPORT_SCHEMA_VERSION = 1 as const;
|
||||
|
||||
export const PLY_IMPORT_BUDGET = {
|
||||
maxBytes: 512 * 1024,
|
||||
maxHeaderBytes: 64 * 1024,
|
||||
maxElements: 16,
|
||||
maxVertices: 65_536,
|
||||
maxFaces: 65_536,
|
||||
maxListLength: 256,
|
||||
maxCustomProperties: 64,
|
||||
} as const;
|
||||
|
||||
export type PLYFormat = "ascii" | "binary_little_endian";
|
||||
|
||||
export interface PLYVertex {
|
||||
position: [number, number, number];
|
||||
normal: [number, number, number] | null;
|
||||
color: [number, number, number, number] | null;
|
||||
customProperties: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface PLYFace {
|
||||
indices: number[];
|
||||
customProperties: Record<string, number>;
|
||||
}
|
||||
|
||||
export type PLYLossCode =
|
||||
| "PLY_UNKNOWN_ELEMENT"
|
||||
| "PLY_UNKNOWN_PROPERTY"
|
||||
| "PLY_NORMAL_PROPERTY_INCOMPLETE"
|
||||
| "PLY_COLOR_PROPERTY_INCOMPLETE";
|
||||
|
||||
export interface PLYLossWarning {
|
||||
code: PLYLossCode;
|
||||
severity: "warning";
|
||||
element: string;
|
||||
property: string | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface PLYLossReport {
|
||||
schemaVersion: typeof PLY_IMPORT_SCHEMA_VERSION;
|
||||
operation: "PLY_IMPORT_LOSS_REPORT";
|
||||
canImport: boolean;
|
||||
warningCount: number;
|
||||
warnings: PLYLossWarning[];
|
||||
}
|
||||
|
||||
export interface PLYImportResult {
|
||||
schemaVersion: typeof PLY_IMPORT_SCHEMA_VERSION;
|
||||
format: PLYFormat;
|
||||
vertices: PLYVertex[];
|
||||
faces: PLYFace[];
|
||||
warnings: PLYLossWarning[];
|
||||
}
|
||||
|
||||
type ScalarType = "int8" | "uint8" | "int16" | "uint16" | "int32" | "uint32" | "float32" | "float64";
|
||||
interface ScalarProperty { kind: "scalar"; name: string; type: ScalarType; }
|
||||
interface ListProperty { kind: "list"; name: string; countType: ScalarType; valueType: ScalarType; }
|
||||
type Property = ScalarProperty | ListProperty;
|
||||
interface Element { name: string; count: number; properties: Property[]; }
|
||||
|
||||
const SCALAR_TYPES: Record<string, ScalarType> = {
|
||||
char: "int8", int8: "int8", uchar: "uint8", uint8: "uint8", short: "int16", int16: "int16",
|
||||
ushort: "uint16", uint16: "uint16", int: "int32", int32: "int32", uint: "uint32", uint32: "uint32",
|
||||
float: "float32", float32: "float32", double: "float64", float64: "float64",
|
||||
};
|
||||
|
||||
function fail(code: string): never { throw new Error(code); }
|
||||
|
||||
function finite(value: number, label: string): number {
|
||||
if (!Number.isFinite(value)) fail(`PLY_NUMBER_INVALID: ${label}`);
|
||||
return Object.is(value, -0) ? 0 : value;
|
||||
}
|
||||
|
||||
function decodeHeader(bytes: Uint8Array): { format: PLYFormat; elements: Element[]; offset: number } {
|
||||
const limit = Math.min(bytes.byteLength, PLY_IMPORT_BUDGET.maxHeaderBytes);
|
||||
let end = -1;
|
||||
let terminatorLength = 0;
|
||||
for (let index = 0; index + 10 <= limit; index++) {
|
||||
if (bytes[index] === 101 && bytes[index + 1] === 110 && bytes[index + 2] === 100 && bytes[index + 3] === 95 && bytes[index + 4] === 104 && bytes[index + 5] === 101 && bytes[index + 6] === 97 && bytes[index + 7] === 100 && bytes[index + 8] === 101 && bytes[index + 9] === 114) {
|
||||
if (bytes[index + 10] === 10) { end = index; terminatorLength = 11; break; }
|
||||
if (bytes[index + 10] === 13 && bytes[index + 11] === 10) { end = index; terminatorLength = 12; break; }
|
||||
}
|
||||
}
|
||||
if (end < 0) fail("PLY_HEADER_INVALID");
|
||||
let header: string;
|
||||
try { header = new TextDecoder("ascii", { fatal: true }).decode(bytes.subarray(0, end)); }
|
||||
catch { fail("PLY_HEADER_INVALID"); }
|
||||
const lines = header.split(/\r?\n/);
|
||||
if (lines[0] !== "ply") fail("PLY_MAGIC_INVALID");
|
||||
let format: PLYFormat | null = null;
|
||||
const elements: Element[] = [];
|
||||
let current: Element | null = null;
|
||||
for (const rawLine of lines.slice(1)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("comment") || line.startsWith("obj_info")) continue;
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts[0] === "format") {
|
||||
if (parts[1] === "ascii") format = "ascii";
|
||||
else if (parts[1] === "binary_little_endian") format = "binary_little_endian";
|
||||
else fail("PLY_FORMAT_UNSUPPORTED");
|
||||
}
|
||||
else if (parts[0] === "element") {
|
||||
if (parts.length !== 3 || !Number.isSafeInteger(Number(parts[2])) || Number(parts[2]) < 0) fail("PLY_ELEMENT_INVALID");
|
||||
if (elements.length >= PLY_IMPORT_BUDGET.maxElements) fail("PLY_IMPORT_BUDGET_EXCEEDED: elements");
|
||||
const count = Number(parts[2]);
|
||||
if (count > PLY_IMPORT_BUDGET.maxVertices) fail(`PLY_IMPORT_BUDGET_EXCEEDED: ${parts[1]}`);
|
||||
current = { name: parts[1], count, properties: [] };
|
||||
elements.push(current);
|
||||
}
|
||||
else if (parts[0] === "property") {
|
||||
if (!current) fail("PLY_PROPERTY_WITHOUT_ELEMENT");
|
||||
if (parts[1] === "list") {
|
||||
if (parts.length !== 5) fail("PLY_PROPERTY_INVALID");
|
||||
const countType = SCALAR_TYPES[parts[2]];
|
||||
const valueType = SCALAR_TYPES[parts[3]];
|
||||
if (!countType || !valueType) fail("PLY_PROPERTY_TYPE_UNSUPPORTED");
|
||||
current.properties.push({ kind: "list", name: parts[4], countType, valueType });
|
||||
}
|
||||
else {
|
||||
if (parts.length !== 3) fail("PLY_PROPERTY_INVALID");
|
||||
const type = SCALAR_TYPES[parts[1]];
|
||||
if (!type) fail("PLY_PROPERTY_TYPE_UNSUPPORTED");
|
||||
current.properties.push({ kind: "scalar", name: parts[2], type });
|
||||
}
|
||||
}
|
||||
else if (parts[0] !== "end_header") fail("PLY_HEADER_INVALID");
|
||||
}
|
||||
if (!format) fail("PLY_FORMAT_MISSING");
|
||||
return { format, elements, offset: end + terminatorLength };
|
||||
}
|
||||
|
||||
function readScalar(view: DataView, offset: number, type: ScalarType): { value: number; next: number } {
|
||||
const size = type === "int8" || type === "uint8" ? 1 : type === "int16" || type === "uint16" ? 2 : 4;
|
||||
if (offset + size > view.byteLength) fail("PLY_DATA_TRUNCATED");
|
||||
let value: number;
|
||||
if (type === "int8") value = view.getInt8(offset);
|
||||
else if (type === "uint8") value = view.getUint8(offset);
|
||||
else if (type === "int16") value = view.getInt16(offset, true);
|
||||
else if (type === "uint16") value = view.getUint16(offset, true);
|
||||
else if (type === "int32") value = view.getInt32(offset, true);
|
||||
else if (type === "uint32") value = view.getUint32(offset, true);
|
||||
else if (type === "float32") value = view.getFloat32(offset, true);
|
||||
else value = view.getFloat64(offset, true);
|
||||
return { value: finite(value, "binary"), next: offset + size };
|
||||
}
|
||||
|
||||
function parseAsciiRecords(bytes: Uint8Array, offset: number, elements: Element[]): Map<string, Array<Record<string, number | number[]>>> {
|
||||
let text: string;
|
||||
try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(offset)); }
|
||||
catch { fail("PLY_ASCII_INVALID"); }
|
||||
const lines = text.split(/\r?\n/);
|
||||
let cursor = 0;
|
||||
const records = new Map<string, Array<Record<string, number | number[]>>>();
|
||||
for (const element of elements) {
|
||||
const values: Array<Record<string, number | number[]>> = [];
|
||||
for (let row = 0; row < element.count; row++) {
|
||||
while (cursor < lines.length && !lines[cursor].trim()) cursor++;
|
||||
if (cursor >= lines.length) fail("PLY_DATA_TRUNCATED");
|
||||
const tokens = lines[cursor++].trim().split(/\s+/);
|
||||
let tokenIndex = 0;
|
||||
const record: Record<string, number | number[]> = {};
|
||||
for (const property of element.properties) {
|
||||
if (property.kind === "scalar") {
|
||||
if (tokenIndex >= tokens.length) fail("PLY_DATA_TRUNCATED");
|
||||
record[property.name] = finite(Number(tokens[tokenIndex++]), `${element.name}.${property.name}`);
|
||||
}
|
||||
else {
|
||||
if (tokenIndex >= tokens.length) fail("PLY_DATA_TRUNCATED");
|
||||
const length = Number(tokens[tokenIndex++]);
|
||||
if (!Number.isSafeInteger(length) || length < 0 || length > PLY_IMPORT_BUDGET.maxListLength) fail("PLY_LIST_INVALID");
|
||||
const list: number[] = [];
|
||||
for (let index = 0; index < length; index++) {
|
||||
if (tokenIndex >= tokens.length) fail("PLY_DATA_TRUNCATED");
|
||||
list.push(finite(Number(tokens[tokenIndex++]), `${element.name}.${property.name}`));
|
||||
}
|
||||
record[property.name] = list;
|
||||
}
|
||||
}
|
||||
if (tokenIndex !== tokens.length) fail("PLY_DATA_EXTRA_TOKENS");
|
||||
values.push(record);
|
||||
}
|
||||
records.set(element.name, values);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
function parseBinaryRecords(bytes: Uint8Array, offset: number, elements: Element[]): Map<string, Array<Record<string, number | number[]>>> {
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
let cursor = offset;
|
||||
const records = new Map<string, Array<Record<string, number | number[]>>>();
|
||||
for (const element of elements) {
|
||||
const values: Array<Record<string, number | number[]>> = [];
|
||||
for (let row = 0; row < element.count; row++) {
|
||||
const record: Record<string, number | number[]> = {};
|
||||
for (const property of element.properties) {
|
||||
if (property.kind === "scalar") {
|
||||
const result = readScalar(view, cursor, property.type); record[property.name] = result.value; cursor = result.next;
|
||||
}
|
||||
else {
|
||||
const count = readScalar(view, cursor, property.countType); cursor = count.next;
|
||||
if (!Number.isSafeInteger(count.value) || count.value < 0 || count.value > PLY_IMPORT_BUDGET.maxListLength) fail("PLY_LIST_INVALID");
|
||||
const list: number[] = [];
|
||||
for (let index = 0; index < count.value; index++) { const result = readScalar(view, cursor, property.valueType); list.push(result.value); cursor = result.next; }
|
||||
record[property.name] = list;
|
||||
}
|
||||
}
|
||||
values.push(record);
|
||||
}
|
||||
records.set(element.name, values);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
function warning(code: PLYLossCode, element: string, property: string | null, message: string): PLYLossWarning {
|
||||
return { code, severity: "warning", element, property, message };
|
||||
}
|
||||
|
||||
function mapDocument(elements: Element[], records: Map<string, Array<Record<string, number | number[]>>>): PLYImportResult {
|
||||
const warnings: PLYLossWarning[] = [];
|
||||
const vertexElement = elements.find((element) => element.name === "vertex");
|
||||
if (!vertexElement) fail("PLY_VERTEX_ELEMENT_MISSING");
|
||||
const vertexRecords = records.get("vertex") ?? [];
|
||||
const vertexProperties = new Set(vertexElement.properties.filter((property): property is ScalarProperty => property.kind === "scalar").map((property) => property.name));
|
||||
for (const name of ["x", "y", "z"]) if (!vertexProperties.has(name)) fail("PLY_VERTEX_POSITION_MISSING");
|
||||
const hasNormals = ["nx", "ny", "nz"].every((name) => vertexProperties.has(name));
|
||||
if (!hasNormals && ["nx", "ny", "nz"].some((name) => vertexProperties.has(name))) warnings.push(warning("PLY_NORMAL_PROPERTY_INCOMPLETE", "vertex", null, "vertex normal requires nx, ny and nz"));
|
||||
const hasColor = ["red", "green", "blue"].every((name) => vertexProperties.has(name));
|
||||
if (!hasColor && ["red", "green", "blue", "alpha"].some((name) => vertexProperties.has(name))) warnings.push(warning("PLY_COLOR_PROPERTY_INCOMPLETE", "vertex", null, "vertex color requires red, green and blue"));
|
||||
const customNames = vertexElement.properties.filter((property): property is ScalarProperty => property.kind === "scalar" && !["x", "y", "z", "nx", "ny", "nz", "red", "green", "blue", "alpha"].includes(property.name)).map((property) => property.name);
|
||||
if (customNames.length > PLY_IMPORT_BUDGET.maxCustomProperties) fail("PLY_IMPORT_BUDGET_EXCEEDED: custom properties");
|
||||
for (const property of vertexElement.properties) if (property.kind === "list") warnings.push(warning("PLY_UNKNOWN_PROPERTY", "vertex", property.name, `vertex list property ${property.name} is not mapped`));
|
||||
const vertices = vertexRecords.map((record) => ({
|
||||
position: [record.x, record.y, record.z].map((value) => finite(value as number, "vertex position")) as [number, number, number],
|
||||
normal: hasNormals ? [record.nx, record.ny, record.nz].map((value) => finite(value as number, "vertex normal")) as [number, number, number] : null,
|
||||
color: hasColor ? ["red", "green", "blue", "alpha"].map((name) => Math.max(0, Math.min(255, Number(record[name] ?? (name === "alpha" ? 255 : 0)))) / 255) as [number, number, number, number] : null,
|
||||
customProperties: Object.fromEntries(customNames.map((name) => [name, finite(record[name] as number, `vertex.${name}`)])),
|
||||
}));
|
||||
const faceElement = elements.find((element) => element.name === "face");
|
||||
const faces: PLYFace[] = [];
|
||||
if (faceElement) {
|
||||
const indexProperty = faceElement.properties.find((property): property is ListProperty => property.kind === "list" && (property.name === "vertex_indices" || property.name === "vertex_index"));
|
||||
if (!indexProperty) fail("PLY_FACE_INDEX_MISSING");
|
||||
const faceCustomNames = faceElement.properties.filter((property): property is ScalarProperty => property.kind === "scalar").map((property) => property.name);
|
||||
for (const property of faceElement.properties) if (property.kind === "list" && property !== indexProperty) warnings.push(warning("PLY_UNKNOWN_PROPERTY", "face", property.name, `face list property ${property.name} is not mapped`));
|
||||
for (const record of records.get("face") ?? []) {
|
||||
const values = record[indexProperty.name];
|
||||
if (!Array.isArray(values) || values.length < 3) fail("PLY_FACE_ARITY_INVALID");
|
||||
const indices = values.map((value) => { if (!Number.isSafeInteger(value) || value < 0 || value >= vertices.length) fail("PLY_FACE_INDEX_OUT_OF_RANGE"); return value; });
|
||||
faces.push({ indices, customProperties: Object.fromEntries(faceCustomNames.map((name) => [name, finite(record[name] as number, `face.${name}`)])) });
|
||||
}
|
||||
}
|
||||
for (const element of elements) if (element.name !== "vertex" && element.name !== "face") warnings.push(warning("PLY_UNKNOWN_ELEMENT", element.name, null, `element ${element.name} is not mapped`));
|
||||
return { schemaVersion: PLY_IMPORT_SCHEMA_VERSION, format: "ascii", vertices, faces, warnings };
|
||||
}
|
||||
|
||||
export function importPLY(bytes: ArrayBuffer, options?: { format?: PLYFormat }): PLYImportResult {
|
||||
if (bytes.byteLength > PLY_IMPORT_BUDGET.maxBytes) fail("PLY_IMPORT_BUDGET_EXCEEDED: bytes");
|
||||
const payload = new Uint8Array(bytes);
|
||||
const header = decodeHeader(payload);
|
||||
if (options?.format && options.format !== header.format) fail("PLY_FORMAT_MISMATCH");
|
||||
const records = header.format === "ascii" ? parseAsciiRecords(payload, header.offset, header.elements) : parseBinaryRecords(payload, header.offset, header.elements);
|
||||
const result = mapDocument(header.elements, records);
|
||||
result.format = header.format;
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createPLYLossReport(document: PLYImportResult): PLYLossReport {
|
||||
const warnings = [...document.warnings].sort((left, right) => left.code.localeCompare(right.code) || left.element.localeCompare(right.element) || (left.property ?? "").localeCompare(right.property ?? ""));
|
||||
return { schemaVersion: PLY_IMPORT_SCHEMA_VERSION, operation: "PLY_IMPORT_LOSS_REPORT", canImport: true, warningCount: warnings.length, warnings };
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string { return Number.isInteger(value) ? String(value) : String(Number(value.toPrecision(9))); }
|
||||
|
||||
export function serializePLYAscii(document: PLYImportResult): ArrayBuffer {
|
||||
if (document.schemaVersion !== PLY_IMPORT_SCHEMA_VERSION || document.vertices.length > PLY_IMPORT_BUDGET.maxVertices || document.faces.length > PLY_IMPORT_BUDGET.maxFaces) fail("PLY_EXPORT_DOCUMENT_INVALID");
|
||||
const customNames = [...new Set(document.vertices.flatMap((vertex) => Object.keys(vertex.customProperties)))].sort();
|
||||
const faceCustomNames = [...new Set(document.faces.flatMap((face) => Object.keys(face.customProperties)))].sort();
|
||||
const lines = ["ply", "format ascii 1.0", "comment Web Blender PLY schema 1", `element vertex ${document.vertices.length}`, "property float x", "property float y", "property float z"];
|
||||
if (document.vertices.some((vertex) => vertex.normal)) lines.push("property float nx", "property float ny", "property float nz");
|
||||
if (document.vertices.some((vertex) => vertex.color)) lines.push("property uchar red", "property uchar green", "property uchar blue", "property uchar alpha");
|
||||
for (const name of customNames) lines.push(`property float ${name}`);
|
||||
lines.push(`element face ${document.faces.length}`, "property list uchar uint vertex_indices");
|
||||
for (const name of faceCustomNames) lines.push(`property float ${name}`);
|
||||
lines.push("end_header");
|
||||
for (const vertex of document.vertices) {
|
||||
const values = vertex.position.map(formatNumber);
|
||||
if (document.vertices.some((item) => item.normal)) values.push(...(vertex.normal ?? [0, 0, 0]).map(formatNumber));
|
||||
if (document.vertices.some((item) => item.color)) values.push(...(vertex.color ?? [0, 0, 0, 1]).map((value) => String(Math.max(0, Math.min(255, Math.round(value * 255))))));
|
||||
values.push(...customNames.map((name) => formatNumber(vertex.customProperties[name] ?? 0)));
|
||||
lines.push(values.join(" "));
|
||||
}
|
||||
for (const face of document.faces) lines.push(`${face.indices.length} ${face.indices.join(" ")} ${faceCustomNames.map((name) => formatNumber(face.customProperties[name] ?? 0)).join(" ")}`.trim());
|
||||
const output = new TextEncoder().encode(lines.join("\n") + "\n");
|
||||
if (output.byteLength > PLY_IMPORT_BUDGET.maxBytes) fail("PLY_IMPORT_BUDGET_EXCEEDED: output bytes");
|
||||
return output.buffer;
|
||||
}
|
||||
35
web/protocol/pointer-contract.ts
Normal file
35
web/protocol/pointer-contract.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export const POINTER_CONTRACT_SCHEMA_VERSION = 1 as const;
|
||||
export type PointerKind = "mouse" | "touch" | "pen";
|
||||
|
||||
export interface PointerObservation {
|
||||
schemaVersion: typeof POINTER_CONTRACT_SCHEMA_VERSION;
|
||||
pointerType: PointerKind;
|
||||
pointerId: number;
|
||||
pressure: number;
|
||||
tiltX: number;
|
||||
tiltY: number;
|
||||
button: number;
|
||||
buttons: number;
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
function bounded(value: number, min: number, max: number, fallback: number): number {
|
||||
return Number.isFinite(value) ? Math.max(min, Math.min(max, value)) : fallback;
|
||||
}
|
||||
|
||||
export function observePointerEvent(event: { pointerType?: string; pointerId?: number; pressure?: number; tiltX?: number; tiltY?: number; button?: number; buttons?: number; type?: string }): PointerObservation {
|
||||
const pointerType = event.pointerType === "touch" || event.pointerType === "pen" || event.pointerType === "mouse" ? event.pointerType : null;
|
||||
if (!pointerType) throw new Error("POINTER_TYPE_UNSUPPORTED");
|
||||
if (!Number.isSafeInteger(event.pointerId) || event.pointerId! < 0) throw new Error("POINTER_ID_INVALID");
|
||||
return {
|
||||
schemaVersion: POINTER_CONTRACT_SCHEMA_VERSION,
|
||||
pointerType,
|
||||
pointerId: event.pointerId!,
|
||||
pressure: bounded(event.pressure ?? (pointerType === "mouse" ? 0 : 0.5), 0, 1, 0),
|
||||
tiltX: bounded(event.tiltX ?? 0, -90, 90, 0),
|
||||
tiltY: bounded(event.tiltY ?? 0, -90, 90, 0),
|
||||
button: Number.isInteger(event.button) ? event.button! : -1,
|
||||
buttons: Number.isInteger(event.buttons) && event.buttons! >= 0 ? event.buttons! : 0,
|
||||
cancelled: event.type === "pointercancel",
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,14 @@ export const SCRIPTING_PLATFORM_SCHEMA = 1 as const;
|
||||
export const SCRIPT_SOURCE_SCHEMA = 1 as const;
|
||||
export const SCRIPT_EXECUTION_AUDIT_SCHEMA = 1 as const;
|
||||
export const SCRIPT_EXECUTION_AUDIT_LOG_SCHEMA = 1 as const;
|
||||
export const SCRIPT_TRUST_POLICY_SCHEMA = 1 as const;
|
||||
export const SCRIPT_SANDBOX_SCOPE_SCHEMA = 1 as const;
|
||||
export const SCRIPTING_BUDGET = { maxScripts: 1_024, maxPermissions: 64, maxDependencies: 128, maxCpuMs: 60_000, maxMemoryBytes: 512 * 1024 * 1024, maxWallMs: 300_000, maxSourceBytes: 1024 * 1024, maxSourceLines: 65_536, maxAuditEntries: 65_536 } as const;
|
||||
export const SCRIPT_TRUST_POLICY_BUDGET = { maxKeys: 1_024, maxClockSkewMs: 300_000 } as const;
|
||||
export const SCRIPT_SANDBOX_BUDGET = { maxCpuMs: 60_000, maxWallMs: 300_000, maxMemoryBytes: 512 * 1024 * 1024, maxMessageBytes: 1 * 1024 * 1024, maxOutputBytes: 16 * 1024 * 1024 } as const;
|
||||
export const SCRIPT_HOST_CALL_SCHEMA = 1 as const;
|
||||
export const SCRIPT_HOST_CALLS = ["READ_MAIN", "READ_ASSET", "WRITE_MAIN", "WRITE_ASSET", "SUBMIT_SERVER_JOB"] as const;
|
||||
export const SCRIPT_SANDBOX_JOB_SCHEMA = 1 as const;
|
||||
export const SCRIPT_PERMISSIONS = ["READ_MAIN", "WRITE_MAIN", "READ_ASSET", "WRITE_ASSET", "SUBMIT_SERVER_JOB"] as const;
|
||||
export type ScriptPermission = typeof SCRIPT_PERMISSIONS[number];
|
||||
|
||||
@@ -15,12 +22,14 @@ export interface ScriptManifestIR {
|
||||
id: string;
|
||||
name: string;
|
||||
entryPath: string;
|
||||
sourceByteLength: number;
|
||||
sourceSha256: string;
|
||||
publisher: string;
|
||||
signature: string;
|
||||
keyId: string;
|
||||
permissions: ScriptPermission[];
|
||||
dependencies: ScriptDependencyIR[];
|
||||
module: false;
|
||||
cpuMs: number;
|
||||
memoryBytes: number;
|
||||
wallMs: number;
|
||||
@@ -30,6 +39,94 @@ export interface ScriptManifestIR {
|
||||
addonInstall: false;
|
||||
}
|
||||
export interface ScriptingManifestIR { schemaVersion: typeof SCRIPTING_PLATFORM_SCHEMA; scripts: ScriptManifestIR[] }
|
||||
export interface ScriptTrustKeyIR {
|
||||
keyId: string;
|
||||
publisher: string;
|
||||
algorithm: "ED25519";
|
||||
publicKey: string;
|
||||
status: "ACTIVE" | "REVOKED";
|
||||
notBefore: string;
|
||||
notAfter: string;
|
||||
revokedAt?: string;
|
||||
replaces?: string;
|
||||
}
|
||||
export interface ScriptTrustPolicyIR {
|
||||
schemaVersion: typeof SCRIPT_TRUST_POLICY_SCHEMA;
|
||||
issuer: string;
|
||||
issuedAt: string;
|
||||
expiresAt: string;
|
||||
maxClockSkewMs: number;
|
||||
keys: ScriptTrustKeyIR[];
|
||||
}
|
||||
export interface ScriptSignerResolutionIR {
|
||||
status: "ELIGIBLE" | "BLOCKED";
|
||||
keyId: string;
|
||||
publisher: string;
|
||||
trust: "ACTIVE" | "REVOKED" | "NOT_FOUND" | "PUBLISHER_MISMATCH" | "POLICY_NOT_YET_VALID" | "POLICY_EXPIRED" | "KEY_NOT_YET_VALID" | "KEY_EXPIRED";
|
||||
cryptographicVerification: "REQUIRED";
|
||||
}
|
||||
export interface ScriptSignatureVerificationIR {
|
||||
status: "VERIFIED" | "BLOCKED";
|
||||
code: "SCRIPT_SIGNATURE_VERIFIED" | "SCRIPT_SIGNATURE_INVALID" | "SCRIPT_POLICY_DENIED";
|
||||
keyId: string;
|
||||
sourceSha256: string;
|
||||
inputSha256: string;
|
||||
}
|
||||
export interface ScriptPermissionResolutionIR {
|
||||
status: "ALLOWED" | "BLOCKED";
|
||||
code: "SCRIPT_PERMISSIONS_ALLOWED" | "SCRIPT_POLICY_DENIED";
|
||||
scriptId: string;
|
||||
declared: ScriptPermission[];
|
||||
requested: ScriptPermission[];
|
||||
granted: ScriptPermission[];
|
||||
}
|
||||
export interface ScriptSandboxScopeIR {
|
||||
schemaVersion: typeof SCRIPT_SANDBOX_SCOPE_SCHEMA;
|
||||
dom: false;
|
||||
hostWorker: false;
|
||||
opfs: false;
|
||||
indexedDB: false;
|
||||
network: false;
|
||||
}
|
||||
export interface ScriptSandboxBudgetIR {
|
||||
schemaVersion: typeof SCRIPT_SANDBOX_SCOPE_SCHEMA;
|
||||
cpuMs: number;
|
||||
wallMs: number;
|
||||
memoryBytes: number;
|
||||
maxMessageBytes: number;
|
||||
maxOutputBytes: number;
|
||||
}
|
||||
export type ScriptHostCallName = typeof SCRIPT_HOST_CALLS[number];
|
||||
export type ScriptHostCallParameters =
|
||||
| { revision: number }
|
||||
| { path: string; expectedSha256: string }
|
||||
| { revision: number; operation: string; payload: Record<string, unknown> }
|
||||
| { path: string; byteLength: number; sha256: string }
|
||||
| { inputBlendSha256: string; settingsSha256: string };
|
||||
export interface ScriptHostCallIR {
|
||||
schemaVersion: typeof SCRIPT_HOST_CALL_SCHEMA;
|
||||
requestId: string;
|
||||
scriptId: string;
|
||||
call: ScriptHostCallName;
|
||||
permission: ScriptPermission;
|
||||
parameters: ScriptHostCallParameters;
|
||||
execution: "DISABLED";
|
||||
}
|
||||
export interface ScriptSandboxJobIR {
|
||||
schemaVersion: typeof SCRIPT_SANDBOX_JOB_SCHEMA;
|
||||
jobId: string;
|
||||
workerGeneration: number;
|
||||
baseRevision: number;
|
||||
mainRevisionBefore: number;
|
||||
mainRevisionAfter: number;
|
||||
status: "CRASHED" | "TIMED_OUT" | "CANCELLED";
|
||||
errorCode: "SCRIPT_SANDBOX_CRASHED" | "SCRIPT_SANDBOX_TIMEOUT" | "SCRIPT_SANDBOX_CANCELLED";
|
||||
temporaryBytes: 0;
|
||||
publishedResults: 0;
|
||||
lateResults: 0;
|
||||
committed: false;
|
||||
execution: "DISABLED";
|
||||
}
|
||||
export interface ScriptSourceIR {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -111,11 +208,128 @@ function canonicalManifest(manifest: ScriptingManifestIR): ScriptingManifestIR {
|
||||
return {
|
||||
schemaVersion: manifest.schemaVersion,
|
||||
scripts: manifest.scripts
|
||||
.map((script) => ({ ...script, permissions: [...script.permissions].sort(), dependencies: script.dependencies.map((dependency) => ({ ...dependency })).sort((a, b) => a.id.localeCompare(b.id)) }))
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
.map((script) => ({ ...script, permissions: [...script.permissions].sort(), dependencies: script.dependencies.map((dependency) => ({ ...dependency })).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0) }))
|
||||
.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
|
||||
};
|
||||
}
|
||||
|
||||
export function canonicalizeScriptingManifest(value: unknown): ScriptingManifestIR {
|
||||
return canonicalManifest(parseScriptingManifest(value));
|
||||
}
|
||||
|
||||
export function serializeScriptingManifest(value: unknown): string {
|
||||
return stableJSON(canonicalizeScriptingManifest(value));
|
||||
}
|
||||
|
||||
export function serializeScriptSignatureInput(value: unknown, scriptId: string): string {
|
||||
const parsed = canonicalizeScriptingManifest(value);
|
||||
const script = parsed.scripts.find((item) => item.id === scriptId);
|
||||
if (!script) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Unknown script ${scriptId}`);
|
||||
return stableJSON({ schemaVersion: SCRIPTING_PLATFORM_SCHEMA, script: { ...script, signature: "" } });
|
||||
}
|
||||
|
||||
export function parseScriptTrustPolicy(value: unknown): ScriptTrustPolicyIR {
|
||||
if (!record(value) || value.schemaVersion !== SCRIPT_TRUST_POLICY_SCHEMA || !Array.isArray(value.keys)) throw new ScriptingPlatformValidationError("PROTOCOL_MISMATCH", "Unsupported script trust policy schema");
|
||||
const issuer = text(value.issuer, "trustPolicy.issuer", 256);
|
||||
const issuedAt = isoDate(value.issuedAt, "trustPolicy.issuedAt");
|
||||
const expiresAt = isoDate(value.expiresAt, "trustPolicy.expiresAt");
|
||||
if (expiresAt <= issuedAt) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", "trustPolicy.expiresAt must be after issuedAt");
|
||||
const maxClockSkewMs = integer(value.maxClockSkewMs, "trustPolicy.maxClockSkewMs", 0, SCRIPT_TRUST_POLICY_BUDGET.maxClockSkewMs);
|
||||
if (value.keys.length > SCRIPT_TRUST_POLICY_BUDGET.maxKeys) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", "Trust policy key count exceeds the budget");
|
||||
const keyIds = new Set<string>();
|
||||
const keys = value.keys.map((item, index): ScriptTrustKeyIR => {
|
||||
const name = `trustPolicy.keys[${index}]`;
|
||||
if (!record(item)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name} is invalid`);
|
||||
const keyId = text(item.keyId, `${name}.keyId`, 128);
|
||||
if (keyIds.has(keyId)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name}.keyId is duplicated`);
|
||||
keyIds.add(keyId);
|
||||
if (item.algorithm !== "ED25519" || typeof item.publicKey !== "string" || !/^[a-f0-9]{64}$/.test(item.publicKey)) throw new ScriptingPlatformValidationError("SCRIPT_SIGNATURE_INVALID", `${name} has an unsupported public key`);
|
||||
const publisher = text(item.publisher, `${name}.publisher`, 256);
|
||||
const notBefore = isoDate(item.notBefore, `${name}.notBefore`);
|
||||
const notAfter = isoDate(item.notAfter, `${name}.notAfter`);
|
||||
if (notAfter <= notBefore) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name} validity window is invalid`);
|
||||
if (item.status !== "ACTIVE" && item.status !== "REVOKED") throw new ScriptingPlatformValidationError("SCRIPT_POLICY_DENIED", `${name}.status is invalid`);
|
||||
const revokedAt = item.revokedAt === undefined ? undefined : isoDate(item.revokedAt, `${name}.revokedAt`);
|
||||
if (item.status === "REVOKED" ? revokedAt === undefined : revokedAt !== undefined) throw new ScriptingPlatformValidationError("SCRIPT_POLICY_DENIED", `${name}.revokedAt does not match status`);
|
||||
if (revokedAt !== undefined && (revokedAt < notBefore || revokedAt > notAfter)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name}.revokedAt is outside the key validity window`);
|
||||
const replaces = item.replaces === undefined ? undefined : text(item.replaces, `${name}.replaces`, 128);
|
||||
return { keyId, publisher, algorithm: "ED25519", publicKey: item.publicKey, status: item.status, notBefore, notAfter, ...(revokedAt === undefined ? {} : { revokedAt }), ...(replaces === undefined ? {} : { replaces }) };
|
||||
});
|
||||
const byId = new Map(keys.map((key) => [key.keyId, key]));
|
||||
const active = new Set<string>(); const complete = new Set<string>();
|
||||
const visit = (keyId: string): void => {
|
||||
if (active.has(keyId)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Trust key rotation cycle includes ${keyId}`);
|
||||
if (complete.has(keyId)) return;
|
||||
const key = byId.get(keyId); if (!key) return;
|
||||
active.add(keyId);
|
||||
if (key.replaces !== undefined) {
|
||||
const predecessor = byId.get(key.replaces);
|
||||
if (!predecessor) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${keyId} replaces missing key ${key.replaces}`);
|
||||
if (predecessor.publisher !== key.publisher) throw new ScriptingPlatformValidationError("SCRIPT_POLICY_DENIED", `${keyId} crosses publisher rotation boundary`);
|
||||
visit(predecessor.keyId);
|
||||
}
|
||||
active.delete(keyId); complete.add(keyId);
|
||||
};
|
||||
keys.forEach((key) => visit(key.keyId));
|
||||
return { schemaVersion: SCRIPT_TRUST_POLICY_SCHEMA, issuer, issuedAt, expiresAt, maxClockSkewMs, keys };
|
||||
}
|
||||
|
||||
export function canonicalizeScriptTrustPolicy(value: unknown): ScriptTrustPolicyIR {
|
||||
const parsed = parseScriptTrustPolicy(value);
|
||||
return { ...parsed, keys: [...parsed.keys].sort((a, b) => a.keyId < b.keyId ? -1 : a.keyId > b.keyId ? 1 : 0) };
|
||||
}
|
||||
|
||||
export function serializeScriptTrustPolicy(value: unknown): string {
|
||||
return stableJSON(canonicalizeScriptTrustPolicy(value));
|
||||
}
|
||||
|
||||
export function resolveScriptSigner(manifest: unknown, scriptId: string, policy: unknown, at: string): ScriptSignerResolutionIR {
|
||||
const parsedManifest = parseScriptingManifest(manifest);
|
||||
const script = parsedManifest.scripts.find((item) => item.id === scriptId);
|
||||
if (!script) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Unknown script ${scriptId}`);
|
||||
const parsedPolicy = parseScriptTrustPolicy(policy);
|
||||
const key = parsedPolicy.keys.find((item) => item.keyId === script.keyId);
|
||||
const blocked = (trust: ScriptSignerResolutionIR["trust"]): ScriptSignerResolutionIR => ({ status: "BLOCKED", keyId: script.keyId, publisher: script.publisher, trust, cryptographicVerification: "REQUIRED" });
|
||||
if (!key) return blocked("NOT_FOUND");
|
||||
const requestedAt = isoDate(at, "signer.at");
|
||||
const policyStart = new Date(parsedPolicy.issuedAt).getTime() - parsedPolicy.maxClockSkewMs;
|
||||
const policyEnd = new Date(parsedPolicy.expiresAt).getTime() + parsedPolicy.maxClockSkewMs;
|
||||
const requestedTime = new Date(requestedAt).getTime();
|
||||
if (requestedTime < policyStart) return blocked("POLICY_NOT_YET_VALID");
|
||||
if (requestedTime > policyEnd) return blocked("POLICY_EXPIRED");
|
||||
if (key.publisher !== script.publisher) return blocked("PUBLISHER_MISMATCH");
|
||||
if (key.status === "REVOKED") return blocked("REVOKED");
|
||||
if (requestedAt < key.notBefore) return blocked("KEY_NOT_YET_VALID");
|
||||
if (requestedAt > key.notAfter) return blocked("KEY_EXPIRED");
|
||||
return { status: "ELIGIBLE", keyId: key.keyId, publisher: key.publisher, trust: "ACTIVE", cryptographicVerification: "REQUIRED" };
|
||||
}
|
||||
|
||||
function hexBytes(value: string): Uint8Array {
|
||||
const bytes = new Uint8Array(value.length / 2);
|
||||
for (let index = 0; index < bytes.length; index += 1) bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export async function verifyScriptManifestSignature(manifest: unknown, scriptId: string, policy: unknown, at: string): Promise<ScriptSignatureVerificationIR> {
|
||||
const parsedManifest = parseScriptingManifest(manifest);
|
||||
const script = parsedManifest.scripts.find((item) => item.id === scriptId);
|
||||
if (!script) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Unknown script ${scriptId}`);
|
||||
const resolution = resolveScriptSigner(parsedManifest, scriptId, policy, at);
|
||||
const input = serializeScriptSignatureInput(parsedManifest, scriptId);
|
||||
const inputSha256 = await sha256(input);
|
||||
if (resolution.status !== "ELIGIBLE") return { status: "BLOCKED", code: "SCRIPT_POLICY_DENIED", keyId: script.keyId, sourceSha256: script.sourceSha256, inputSha256 };
|
||||
const signer = parseScriptTrustPolicy(policy).keys.find((key) => key.keyId === script.keyId);
|
||||
if (!signer) return { status: "BLOCKED", code: "SCRIPT_SIGNATURE_INVALID", keyId: script.keyId, sourceSha256: script.sourceSha256, inputSha256 };
|
||||
try {
|
||||
const key = await crypto.subtle.importKey("raw", hexBytes(signer.publicKey) as unknown as BufferSource, { name: "Ed25519" }, false, ["verify"]);
|
||||
const valid = await crypto.subtle.verify("Ed25519", key, hexBytes(script.signature) as unknown as BufferSource, new TextEncoder().encode(input) as unknown as BufferSource);
|
||||
return { status: valid ? "VERIFIED" : "BLOCKED", code: valid ? "SCRIPT_SIGNATURE_VERIFIED" : "SCRIPT_SIGNATURE_INVALID", keyId: script.keyId, sourceSha256: script.sourceSha256, inputSha256 };
|
||||
}
|
||||
catch {
|
||||
return { status: "BLOCKED", code: "SCRIPT_SIGNATURE_INVALID", keyId: script.keyId, sourceSha256: script.sourceSha256, inputSha256 };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createScriptExecutionAudit(
|
||||
manifest: unknown,
|
||||
scriptId: string,
|
||||
@@ -129,7 +343,7 @@ export async function createScriptExecutionAudit(
|
||||
const requestedAt = isoDate(options.requestedAt ?? new Date().toISOString(), "requestedAt");
|
||||
const approvedKey = approvedKeyIds.has(script.keyId);
|
||||
const reason = approvedKey ? "SCRIPT_SANDBOX_UNAVAILABLE" : "SCRIPT_SIGNATURE_INVALID";
|
||||
const manifestSha256 = await sha256(stableJSON(canonicalManifest(parsed)));
|
||||
const manifestSha256 = await sha256(serializeScriptingManifest(parsed));
|
||||
const request = canonicalAuditRequest({ requestId, requestedAt, scriptId, sourceSha256: script.sourceSha256, manifestSha256, permissions: [...script.permissions], budget: { cpuMs: script.cpuMs, memoryBytes: script.memoryBytes, wallMs: script.wallMs }, approvedKey, decision: "DENY", reason });
|
||||
const requestSha256 = await sha256(stableJSON(request));
|
||||
return Object.freeze({
|
||||
@@ -227,16 +441,20 @@ export async function verifyScriptSource(source: ScriptSourceIR): Promise<boolea
|
||||
export function parseScriptingManifest(value: unknown): ScriptingManifestIR {
|
||||
if (!record(value) || value.schemaVersion !== SCRIPTING_PLATFORM_SCHEMA || !Array.isArray(value.scripts)) throw new ScriptingPlatformValidationError("PROTOCOL_MISMATCH", "Unsupported scripting manifest schema");
|
||||
if (value.scripts.length > SCRIPTING_BUDGET.maxScripts) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", "Script count exceeds the budget");
|
||||
const ids = new Set<string>();
|
||||
const ids = new Set<string>(); let totalSourceBytes = 0;
|
||||
const scripts = value.scripts.map((item, index): ScriptManifestIR => {
|
||||
const name = `scripts[${index}]`; if (!record(item) || !Array.isArray(item.permissions) || !Array.isArray(item.dependencies)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name} is invalid`);
|
||||
const id = text(item.id, `${name}.id`); if (ids.has(id)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Duplicate script ${id}`); ids.add(id);
|
||||
if (item.permissions.length > SCRIPTING_BUDGET.maxPermissions || item.permissions.some((permission) => !SCRIPT_PERMISSIONS.includes(permission as ScriptPermission)) || new Set(item.permissions).size !== item.permissions.length) throw new ScriptingPlatformValidationError("SCRIPT_POLICY_DENIED", `${name}.permissions are invalid or exceed the allowlist`);
|
||||
if (item.dependencies.length > SCRIPTING_BUDGET.maxDependencies) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", `${name}.dependencies exceed the budget`);
|
||||
const dependencies = item.dependencies.map((dependency, dependencyIndex): ScriptDependencyIR => { const dependencyName = `${name}.dependencies[${dependencyIndex}]`; if (!record(dependency)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${dependencyName} is invalid`); return { id: text(dependency.id, `${dependencyName}.id`), sourceSha256: digest(dependency.sourceSha256, `${dependencyName}.sourceSha256`), sourcePath: path(dependency.sourcePath, `${dependencyName}.sourcePath`) }; });
|
||||
if (item.network !== false || item.autorun !== false || item.driverExpressions !== false || item.addonInstall !== false) throw new ScriptingPlatformValidationError(item.driverExpressions === true ? "DRIVER_EXECUTION_BLOCKED" : item.addonInstall === true ? "ADDON_INSTALL_BLOCKED" : "SCRIPT_POLICY_DENIED", `${name} requests a denied execution policy`);
|
||||
const dependencyIds = new Set<string>();
|
||||
const dependencies = item.dependencies.map((dependency, dependencyIndex): ScriptDependencyIR => { const dependencyName = `${name}.dependencies[${dependencyIndex}]`; if (!record(dependency)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${dependencyName} is invalid`); const dependencyId = text(dependency.id, `${dependencyName}.id`); if (dependencyIds.has(dependencyId)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${dependencyName}.id is duplicated`); dependencyIds.add(dependencyId); return { id: dependencyId, sourceSha256: digest(dependency.sourceSha256, `${dependencyName}.sourceSha256`), sourcePath: path(dependency.sourcePath, `${dependencyName}.sourcePath`) }; });
|
||||
if (item.module !== false || item.network !== false || item.autorun !== false || item.driverExpressions !== false || item.addonInstall !== false) throw new ScriptingPlatformValidationError(item.driverExpressions === true ? "DRIVER_EXECUTION_BLOCKED" : item.addonInstall === true ? "ADDON_INSTALL_BLOCKED" : "SCRIPT_POLICY_DENIED", `${name} requests a denied execution policy`);
|
||||
if (typeof item.signature !== "string" || !HEX_SIGNATURE.test(item.signature)) throw new ScriptingPlatformValidationError("SCRIPT_SIGNATURE_INVALID", `${name}.signature is invalid`);
|
||||
return { id, name: text(item.name, `${name}.name`), entryPath: path(item.entryPath, `${name}.entryPath`), sourceSha256: digest(item.sourceSha256, `${name}.sourceSha256`), publisher: text(item.publisher, `${name}.publisher`), signature: item.signature, keyId: text(item.keyId, `${name}.keyId`, 128), permissions: [...item.permissions] as ScriptPermission[], dependencies, cpuMs: integer(item.cpuMs, `${name}.cpuMs`, 1, SCRIPTING_BUDGET.maxCpuMs), memoryBytes: integer(item.memoryBytes, `${name}.memoryBytes`, 1, SCRIPTING_BUDGET.maxMemoryBytes), wallMs: integer(item.wallMs, `${name}.wallMs`, 1, SCRIPTING_BUDGET.maxWallMs), network: false, autorun: false, driverExpressions: false, addonInstall: false };
|
||||
const sourceByteLength = integer(item.sourceByteLength, `${name}.sourceByteLength`, 0, SCRIPTING_BUDGET.maxSourceBytes);
|
||||
totalSourceBytes += sourceByteLength;
|
||||
if (!Number.isSafeInteger(totalSourceBytes) || totalSourceBytes > SCRIPTING_BUDGET.maxSourceBytes) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", "Manifest source bytes exceed the total budget");
|
||||
return { id, name: text(item.name, `${name}.name`), entryPath: path(item.entryPath, `${name}.entryPath`), sourceByteLength, sourceSha256: digest(item.sourceSha256, `${name}.sourceSha256`), publisher: text(item.publisher, `${name}.publisher`), signature: item.signature, keyId: text(item.keyId, `${name}.keyId`, 128), permissions: [...item.permissions] as ScriptPermission[], dependencies, module: false, cpuMs: integer(item.cpuMs, `${name}.cpuMs`, 1, SCRIPTING_BUDGET.maxCpuMs), memoryBytes: integer(item.memoryBytes, `${name}.memoryBytes`, 1, SCRIPTING_BUDGET.maxMemoryBytes), wallMs: integer(item.wallMs, `${name}.wallMs`, 1, SCRIPTING_BUDGET.maxWallMs), network: false, autorun: false, driverExpressions: false, addonInstall: false };
|
||||
});
|
||||
const scriptIds = new Set(scripts.map((script) => script.id));
|
||||
const active = new Set<string>(); const complete = new Set<string>(); const visit = (id: string): void => { if (active.has(id)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Script dependency cycle includes ${id}`); if (complete.has(id)) return; const script = scripts.find((item) => item.id === id); if (!script) return; active.add(id); for (const dependency of script.dependencies) { if (!scriptIds.has(dependency.id)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${id} references missing script ${dependency.id}`); visit(dependency.id); } active.delete(id); complete.add(id); }; scripts.forEach((script) => visit(script.id));
|
||||
@@ -249,6 +467,81 @@ export function gateScriptExecution(manifest: unknown, scriptId: string, approve
|
||||
return blockedGate("N-025", `SCRIPT_${script.id}`, [capabilityIssue("SCRIPT_SANDBOX_UNAVAILABLE", "Local Python/Native execution requires an isolated sandbox")]);
|
||||
}
|
||||
|
||||
export function resolveScriptPermissions(manifest: unknown, scriptId: string, requested: unknown = []): ScriptPermissionResolutionIR {
|
||||
const parsed = parseScriptingManifest(manifest);
|
||||
const script = parsed.scripts.find((item) => item.id === scriptId);
|
||||
if (!script) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Unknown script ${scriptId}`);
|
||||
const declared = [...script.permissions].sort();
|
||||
const requestedList = Array.isArray(requested) ? requested : [];
|
||||
const requestedValid = requestedList.every((permission): permission is ScriptPermission => typeof permission === "string" && SCRIPT_PERMISSIONS.includes(permission as ScriptPermission));
|
||||
const requestedUnique = new Set(requestedList).size === requestedList.length;
|
||||
const requestedCanonical = [...requestedList].filter((permission): permission is ScriptPermission => typeof permission === "string" && SCRIPT_PERMISSIONS.includes(permission as ScriptPermission)).sort();
|
||||
const allowed = requestedValid && requestedUnique && requestedCanonical.every((permission) => declared.includes(permission));
|
||||
return {
|
||||
status: allowed ? "ALLOWED" : "BLOCKED",
|
||||
code: allowed ? "SCRIPT_PERMISSIONS_ALLOWED" : "SCRIPT_POLICY_DENIED",
|
||||
scriptId,
|
||||
declared,
|
||||
requested: requestedCanonical,
|
||||
granted: allowed ? requestedCanonical : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function parseScriptSandboxScope(value: unknown): ScriptSandboxScopeIR {
|
||||
if (!record(value) || value.schemaVersion !== SCRIPT_SANDBOX_SCOPE_SCHEMA) throw new ScriptingPlatformValidationError("PROTOCOL_MISMATCH", "Unsupported script sandbox scope schema");
|
||||
const denied = ["dom", "hostWorker", "opfs", "indexedDB", "network"] as const;
|
||||
if (denied.some((name) => value[name] !== false)) throw new ScriptingPlatformValidationError("SCRIPT_POLICY_DENIED", "Script sandbox scope must deny browser and host capabilities");
|
||||
return { schemaVersion: SCRIPT_SANDBOX_SCOPE_SCHEMA, dom: false, hostWorker: false, opfs: false, indexedDB: false, network: false };
|
||||
}
|
||||
|
||||
export function parseScriptSandboxBudget(value: unknown): ScriptSandboxBudgetIR {
|
||||
if (!record(value) || value.schemaVersion !== SCRIPT_SANDBOX_SCOPE_SCHEMA) throw new ScriptingPlatformValidationError("PROTOCOL_MISMATCH", "Unsupported script sandbox budget schema");
|
||||
return {
|
||||
schemaVersion: SCRIPT_SANDBOX_SCOPE_SCHEMA,
|
||||
cpuMs: integer(value.cpuMs, "sandbox.cpuMs", 1, SCRIPT_SANDBOX_BUDGET.maxCpuMs),
|
||||
wallMs: integer(value.wallMs, "sandbox.wallMs", 1, SCRIPT_SANDBOX_BUDGET.maxWallMs),
|
||||
memoryBytes: integer(value.memoryBytes, "sandbox.memoryBytes", 1, SCRIPT_SANDBOX_BUDGET.maxMemoryBytes),
|
||||
maxMessageBytes: integer(value.maxMessageBytes, "sandbox.maxMessageBytes", 1, SCRIPT_SANDBOX_BUDGET.maxMessageBytes),
|
||||
maxOutputBytes: integer(value.maxOutputBytes, "sandbox.maxOutputBytes", 1, SCRIPT_SANDBOX_BUDGET.maxOutputBytes),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseScriptHostCall(value: unknown, declaredPermissions: ReadonlySet<ScriptPermission>): ScriptHostCallIR {
|
||||
if (!record(value) || value.schemaVersion !== SCRIPT_HOST_CALL_SCHEMA) throw new ScriptingPlatformValidationError("PROTOCOL_MISMATCH", "Unsupported script host call schema");
|
||||
const requestId = auditRequestId(value.requestId, "hostCall.requestId");
|
||||
const scriptId = text(value.scriptId, "hostCall.scriptId");
|
||||
if (!SCRIPT_HOST_CALLS.includes(value.call as ScriptHostCallName)) throw new ScriptingPlatformValidationError("SCRIPT_POLICY_DENIED", "Host call is not allowlisted");
|
||||
const call = value.call as ScriptHostCallName;
|
||||
if (value.permission !== call || !declaredPermissions.has(call)) throw new ScriptingPlatformValidationError("SCRIPT_POLICY_DENIED", "Host call permission is not declared");
|
||||
if (!record(value.parameters)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", "Host call parameters must be a structured object");
|
||||
const parameters = value.parameters;
|
||||
const keys = Object.keys(parameters).sort();
|
||||
const exact = (expected: string[]): void => { if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", "Host call parameters contain unknown fields"); };
|
||||
let normalized: ScriptHostCallParameters;
|
||||
if (call === "READ_MAIN") { exact(["revision"]); normalized = { revision: integer(parameters.revision, "hostCall.parameters.revision", 0, Number.MAX_SAFE_INTEGER) }; }
|
||||
else if (call === "READ_ASSET") { exact(["expectedSha256", "path"]); normalized = { path: path(parameters.path, "hostCall.parameters.path"), expectedSha256: digest(parameters.expectedSha256, "hostCall.parameters.expectedSha256") }; }
|
||||
else if (call === "WRITE_MAIN") { exact(["operation", "payload", "revision"]); if (!record(parameters.payload)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", "hostCall.parameters.payload must be an object"); normalized = { revision: integer(parameters.revision, "hostCall.parameters.revision", 0, Number.MAX_SAFE_INTEGER), operation: text(parameters.operation, "hostCall.parameters.operation", 128), payload: { ...parameters.payload } }; }
|
||||
else if (call === "WRITE_ASSET") { exact(["byteLength", "path", "sha256"]); normalized = { path: path(parameters.path, "hostCall.parameters.path"), byteLength: integer(parameters.byteLength, "hostCall.parameters.byteLength", 0, SCRIPT_SANDBOX_BUDGET.maxOutputBytes), sha256: digest(parameters.sha256, "hostCall.parameters.sha256") }; }
|
||||
else { exact(["inputBlendSha256", "settingsSha256"]); normalized = { inputBlendSha256: digest(parameters.inputBlendSha256, "hostCall.parameters.inputBlendSha256"), settingsSha256: digest(parameters.settingsSha256, "hostCall.parameters.settingsSha256") }; }
|
||||
return { schemaVersion: SCRIPT_HOST_CALL_SCHEMA, requestId, scriptId, call, permission: call, parameters: normalized, execution: "DISABLED" };
|
||||
}
|
||||
|
||||
export function terminateScriptSandboxJob(value: unknown, reason: "CRASH" | "TIMEOUT" | "CANCEL"): ScriptSandboxJobIR {
|
||||
if (!record(value) || value.schemaVersion !== SCRIPT_SANDBOX_JOB_SCHEMA) throw new ScriptingPlatformValidationError("PROTOCOL_MISMATCH", "Unsupported script sandbox job schema");
|
||||
const jobId = auditRequestId(value.jobId, "sandbox.jobId");
|
||||
const workerGeneration = integer(value.workerGeneration, "sandbox.workerGeneration", 1, Number.MAX_SAFE_INTEGER);
|
||||
const baseRevision = integer(value.baseRevision, "sandbox.baseRevision", 0, Number.MAX_SAFE_INTEGER);
|
||||
const mainRevisionBefore = integer(value.mainRevisionBefore, "sandbox.mainRevisionBefore", 0, Number.MAX_SAFE_INTEGER);
|
||||
if (baseRevision !== mainRevisionBefore || value.status !== "RUNNING") throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", "Sandbox termination must start from the current running Main revision");
|
||||
const errorCode = reason === "CRASH" ? "SCRIPT_SANDBOX_CRASHED" : reason === "TIMEOUT" ? "SCRIPT_SANDBOX_TIMEOUT" : "SCRIPT_SANDBOX_CANCELLED";
|
||||
return { schemaVersion: SCRIPT_SANDBOX_JOB_SCHEMA, jobId, workerGeneration, baseRevision, mainRevisionBefore, mainRevisionAfter: mainRevisionBefore, status: reason === "CRASH" ? "CRASHED" : reason === "TIMEOUT" ? "TIMED_OUT" : "CANCELLED", errorCode, temporaryBytes: 0, publishedResults: 0, lateResults: 0, committed: false, execution: "DISABLED" };
|
||||
}
|
||||
|
||||
export function rejectLateScriptSandboxResult(value: unknown): never {
|
||||
if (!record(value) || value.schemaVersion !== SCRIPT_SANDBOX_JOB_SCHEMA || !["CRASHED", "TIMED_OUT", "CANCELLED"].includes(value.status as string)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", "Late sandbox result does not reference a terminated job");
|
||||
throw new ScriptingPlatformValidationError("SCRIPT_SANDBOX_LATE_RESULT", "Sandbox result arrived after job termination");
|
||||
}
|
||||
|
||||
export function gateServerScriptJob(value: unknown, manifest: unknown, inputBlendSha256: string): CapabilityGateResult {
|
||||
const parsed = parseScriptingManifest(manifest); if (!record(value)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", "Server script job is invalid"); const script = parsed.scripts.find((item) => item.id === value.scriptId); if (!script || script.sourceSha256 !== value.sourceSha256 || !SHA256.test(inputBlendSha256)) throw new ScriptingPlatformValidationError("ASSET_SOURCE_HASH_MISMATCH", "Server script job source hash is invalid");
|
||||
return blockedGate("N-025", `SERVER_SCRIPT_${script.id}`, [capabilityIssue("SERVER_JOB_UNAVAILABLE", "Server Blender job endpoint is not configured")]);
|
||||
|
||||
42
web/protocol/stl-export.ts
Normal file
42
web/protocol/stl-export.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { STLImportResult } from "./stl-import";
|
||||
|
||||
export const STL_EXPORT_SCHEMA_VERSION = 1 as const;
|
||||
|
||||
export interface STLLossReport {
|
||||
schemaVersion: typeof STL_EXPORT_SCHEMA_VERSION;
|
||||
operation: "STL_EXPORT_LOSS_REPORT";
|
||||
canRoundTrip: boolean;
|
||||
warningCount: number;
|
||||
warnings: Array<{ code: "STL_MATERIAL_UNSUPPORTED"; severity: "warning"; message: string }>;
|
||||
}
|
||||
|
||||
function writeFloat(view: DataView, offset: number, value: number): void {
|
||||
if (!Number.isFinite(value)) throw new Error("STL_EXPORT_NUMBER_INVALID");
|
||||
view.setFloat32(offset, value, true);
|
||||
}
|
||||
|
||||
export function exportBinarySTL(document: STLImportResult): ArrayBuffer {
|
||||
if (document.schemaVersion !== 1 || document.triangleCount !== document.vertices.length || document.triangleCount !== document.normals.length) throw new Error("STL_EXPORT_DOCUMENT_INVALID");
|
||||
const output = new ArrayBuffer(84 + document.triangleCount * 50);
|
||||
const bytes = new Uint8Array(output);
|
||||
bytes.set(new TextEncoder().encode("Web Blender STL schema 1").subarray(0, 80));
|
||||
const view = new DataView(output);
|
||||
view.setUint32(80, document.triangleCount, true);
|
||||
for (let triangle = 0; triangle < document.triangleCount; triangle++) {
|
||||
const offset = 84 + triangle * 50;
|
||||
for (let axis = 0; axis < 3; axis++) writeFloat(view, offset + axis * 4, document.normals[triangle][axis]);
|
||||
for (let vertex = 0; vertex < 3; vertex++) for (let axis = 0; axis < 3; axis++) writeFloat(view, offset + 12 + vertex * 12 + axis * 4, document.vertices[triangle][vertex][axis]);
|
||||
view.setUint16(offset + 48, 0, true);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function createSTLLossReport(sourceMaterialCount: number): STLLossReport {
|
||||
if (!Number.isSafeInteger(sourceMaterialCount) || sourceMaterialCount < 0) throw new Error("STL_EXPORT_MATERIAL_COUNT_INVALID");
|
||||
const warnings = sourceMaterialCount > 0 ? [{
|
||||
code: "STL_MATERIAL_UNSUPPORTED" as const,
|
||||
severity: "warning" as const,
|
||||
message: `STL has no material slots; ${sourceMaterialCount} source material assignments are omitted`,
|
||||
}] : [];
|
||||
return { schemaVersion: STL_EXPORT_SCHEMA_VERSION, operation: "STL_EXPORT_LOSS_REPORT", canRoundTrip: true, warningCount: warnings.length, warnings };
|
||||
}
|
||||
125
web/protocol/stl-import.ts
Normal file
125
web/protocol/stl-import.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
export const STL_IMPORT_SCHEMA_VERSION = 1 as const;
|
||||
|
||||
export type STLVariant = "STL_BINARY" | "STL_ASCII";
|
||||
|
||||
export const STL_IMPORT_BUDGET = {
|
||||
maxBytes: 512 * 1024,
|
||||
maxTriangles: 65_536,
|
||||
maxUnitScale: 1_000_000,
|
||||
} as const;
|
||||
|
||||
export interface STLImportResult {
|
||||
schemaVersion: typeof STL_IMPORT_SCHEMA_VERSION;
|
||||
variant: STLVariant;
|
||||
unitScale: number;
|
||||
declaredTriangleCount: number;
|
||||
triangleCount: number;
|
||||
removedDegenerateTriangles: number;
|
||||
normals: number[][];
|
||||
vertices: number[][][];
|
||||
bounds: { min: number[]; max: number[] };
|
||||
}
|
||||
|
||||
function unitScale(value: number): number {
|
||||
if (!Number.isFinite(value) || value <= 0 || value > STL_IMPORT_BUDGET.maxUnitScale) throw new Error("STL_UNIT_SCALE_INVALID");
|
||||
return value;
|
||||
}
|
||||
|
||||
function finite(values: number[], label: string): number[] {
|
||||
if (values.some((value) => !Number.isFinite(value))) throw new Error(`STL_NUMBER_INVALID: ${label}`);
|
||||
return values.map((value) => value === 0 ? 0 : value);
|
||||
}
|
||||
|
||||
function degenerate(vertices: number[][]): boolean {
|
||||
const left = vertices[1].map((value, index) => value - vertices[0][index]);
|
||||
const right = vertices[2].map((value, index) => value - vertices[0][index]);
|
||||
const cross = [left[1] * right[2] - left[2] * right[1], left[2] * right[0] - left[0] * right[2], left[0] * right[1] - left[1] * right[0]];
|
||||
return cross[0] * cross[0] + cross[1] * cross[1] + cross[2] * cross[2] <= 1e-20;
|
||||
}
|
||||
|
||||
function finish(variant: STLVariant, scale: number, declaredTriangleCount: number, normals: number[][], rawVertices: number[][][]): STLImportResult {
|
||||
const keptNormals: number[][] = [];
|
||||
const vertices: number[][][] = [];
|
||||
let removedDegenerateTriangles = 0;
|
||||
for (let index = 0; index < rawVertices.length; index++) {
|
||||
if (degenerate(rawVertices[index])) {
|
||||
removedDegenerateTriangles++;
|
||||
continue;
|
||||
}
|
||||
keptNormals.push(normals[index]);
|
||||
vertices.push(rawVertices[index].map((vertex) => vertex.map((value) => value * scale)));
|
||||
}
|
||||
const flat = vertices.flat();
|
||||
const bounds = flat.length > 0 ? {
|
||||
min: [0, 1, 2].map((axis) => Math.min(...flat.map((vertex) => vertex[axis]))),
|
||||
max: [0, 1, 2].map((axis) => Math.max(...flat.map((vertex) => vertex[axis]))),
|
||||
} : { min: [0, 0, 0], max: [0, 0, 0] };
|
||||
return {
|
||||
schemaVersion: STL_IMPORT_SCHEMA_VERSION,
|
||||
variant,
|
||||
unitScale: scale,
|
||||
declaredTriangleCount,
|
||||
triangleCount: vertices.length,
|
||||
removedDegenerateTriangles,
|
||||
normals: keptNormals,
|
||||
vertices,
|
||||
bounds,
|
||||
};
|
||||
}
|
||||
|
||||
function parseBinary(bytes: ArrayBuffer, scale: number): STLImportResult {
|
||||
if (bytes.byteLength < 84) throw new Error("STL_BINARY_TRUNCATED");
|
||||
const view = new DataView(bytes);
|
||||
const count = view.getUint32(80, true);
|
||||
if (count > STL_IMPORT_BUDGET.maxTriangles) throw new Error("STL_IMPORT_BUDGET_EXCEEDED: triangles");
|
||||
const expectedBytes = 84 + count * 50;
|
||||
if (bytes.byteLength < expectedBytes) throw new Error("STL_BINARY_TRUNCATED");
|
||||
if (bytes.byteLength > expectedBytes) throw new Error("STL_TRAILING_BYTES");
|
||||
const normals: number[][] = [];
|
||||
const vertices: number[][][] = [];
|
||||
for (let triangle = 0; triangle < count; triangle++) {
|
||||
const offset = 84 + triangle * 50;
|
||||
normals.push(finite([view.getFloat32(offset, true), view.getFloat32(offset + 4, true), view.getFloat32(offset + 8, true)], `normal ${triangle}`));
|
||||
const triangleVertices = [];
|
||||
for (let vertex = 0; vertex < 3; vertex++) {
|
||||
const vertexOffset = offset + 12 + vertex * 12;
|
||||
triangleVertices.push(finite([view.getFloat32(vertexOffset, true), view.getFloat32(vertexOffset + 4, true), view.getFloat32(vertexOffset + 8, true)], `vertex ${triangle}/${vertex}`));
|
||||
}
|
||||
vertices.push(triangleVertices);
|
||||
}
|
||||
return finish("STL_BINARY", scale, count, normals, vertices);
|
||||
}
|
||||
|
||||
function parseAscii(bytes: ArrayBuffer, scale: number): STLImportResult {
|
||||
let source: string;
|
||||
try { source = new TextDecoder("utf-8", { fatal: true }).decode(bytes); }
|
||||
catch { throw new Error("STL_ASCII_INVALID"); }
|
||||
const end = source.search(/^endsolid.*$/m);
|
||||
if (!/^solid(?:\s|$)/.test(source) || end < 0) throw new Error("STL_ASCII_INVALID");
|
||||
const endLine = source.indexOf("\n", end);
|
||||
const trailing = source.slice(endLine < 0 ? source.length : endLine + 1);
|
||||
if (trailing.trim()) throw new Error("STL_TRAILING_BYTES");
|
||||
const facetPattern = /facet\s+normal\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+outer\s+loop\s+vertex\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+vertex\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+vertex\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+endloop\s+endfacet/g;
|
||||
const normals: number[][] = [];
|
||||
const vertices: number[][][] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = facetPattern.exec(source.slice(0, end)))) {
|
||||
normals.push(finite(match.slice(1, 4).map(Number), `normal ${normals.length}`));
|
||||
vertices.push([
|
||||
finite(match.slice(4, 7).map(Number), `vertex ${vertices.length}/0`),
|
||||
finite(match.slice(7, 10).map(Number), `vertex ${vertices.length}/1`),
|
||||
finite(match.slice(10, 13).map(Number), `vertex ${vertices.length}/2`),
|
||||
]);
|
||||
if (vertices.length > STL_IMPORT_BUDGET.maxTriangles) throw new Error("STL_IMPORT_BUDGET_EXCEEDED: triangles");
|
||||
}
|
||||
if (vertices.length === 0) throw new Error("STL_ASCII_INVALID");
|
||||
return finish("STL_ASCII", scale, vertices.length, normals, vertices);
|
||||
}
|
||||
|
||||
export function importSTL(bytes: ArrayBuffer, options: { variant: STLVariant; unitScale: number }): STLImportResult {
|
||||
if (bytes.byteLength > STL_IMPORT_BUDGET.maxBytes) throw new Error("STL_IMPORT_BUDGET_EXCEEDED: bytes");
|
||||
const scale = unitScale(options.unitScale);
|
||||
if (options.variant === "STL_BINARY") return parseBinary(bytes, scale);
|
||||
if (options.variant === "STL_ASCII") return parseAscii(bytes, scale);
|
||||
throw new Error("STL_VARIANT_REQUIRED");
|
||||
}
|
||||
@@ -257,7 +257,7 @@ export interface StorageRequest {
|
||||
| { type: "saveSnapshot"; projectId: string; revision: number; buffer: ArrayBuffer; maxCount?: number; maxBytes?: number }
|
||||
| { type: "listSnapshots"; projectId: string }
|
||||
| { type: "readSnapshot"; projectId: string; revision: number }
|
||||
| { type: "putAsset"; projectId: string; data: ArrayBuffer; mimeType: string; sourcePath?: string }
|
||||
| { type: "putAsset"; projectId: string; data: ArrayBuffer; mimeType: string; sourcePath?: string; faultAt?: "quota" }
|
||||
| { type: "readAsset"; projectId: string; sha256: string }
|
||||
| { type: "listAssets"; projectId: string }
|
||||
| { type: "commitTexturePaintTile"; commit: TexturePaintTileCommitIR }
|
||||
|
||||
49
web/protocol/viewport-dpr.ts
Normal file
49
web/protocol/viewport-dpr.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export const VIEWPORT_DPR_SCHEMA_VERSION = 1 as const;
|
||||
export const VIEWPORT_MAX_DPR = 2 as const;
|
||||
|
||||
export interface ViewportPixelMetrics {
|
||||
schemaVersion: typeof VIEWPORT_DPR_SCHEMA_VERSION;
|
||||
cssWidth: number;
|
||||
cssHeight: number;
|
||||
pixelRatio: number;
|
||||
backingWidth: number;
|
||||
backingHeight: number;
|
||||
}
|
||||
|
||||
export interface ViewportNDC {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
function finitePositive(value: number): boolean {
|
||||
return Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
export function resolveViewportPixelRatio(devicePixelRatio: number | undefined, maximum = VIEWPORT_MAX_DPR): number {
|
||||
if (!finitePositive(maximum)) throw new Error("VIEWPORT_DPR_INVALID");
|
||||
const observed = finitePositive(devicePixelRatio ?? 1) ? devicePixelRatio! : 1;
|
||||
return Math.min(observed, maximum);
|
||||
}
|
||||
|
||||
export function resolveViewportPixelMetrics(cssWidth: number, cssHeight: number, devicePixelRatio: number | undefined, maximum = VIEWPORT_MAX_DPR): ViewportPixelMetrics {
|
||||
if (!finitePositive(cssWidth) || !finitePositive(cssHeight)) throw new Error("VIEWPORT_SIZE_INVALID");
|
||||
const pixelRatio = resolveViewportPixelRatio(devicePixelRatio, maximum);
|
||||
return {
|
||||
schemaVersion: VIEWPORT_DPR_SCHEMA_VERSION,
|
||||
cssWidth,
|
||||
cssHeight,
|
||||
pixelRatio,
|
||||
backingWidth: Math.max(1, Math.floor(cssWidth * pixelRatio)),
|
||||
backingHeight: Math.max(1, Math.floor(cssHeight * pixelRatio)),
|
||||
};
|
||||
}
|
||||
|
||||
export function viewportNDC(clientX: number, clientY: number, bounds: { left: number; top: number; width: number; height: number }): ViewportNDC {
|
||||
if (![clientX, clientY, bounds.left, bounds.top, bounds.width, bounds.height].every(Number.isFinite) || bounds.width <= 0 || bounds.height <= 0) {
|
||||
throw new Error("VIEWPORT_BOUNDS_INVALID");
|
||||
}
|
||||
return {
|
||||
x: ((clientX - bounds.left) / bounds.width) * 2 - 1,
|
||||
y: -((clientY - bounds.top) / bounds.height) * 2 + 1,
|
||||
};
|
||||
}
|
||||
21
web/tests/e2e/archive-security-fixtures.spec.ts
Normal file
21
web/tests/e2e/archive-security-fixtures.spec.ts
Normal 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"),
|
||||
);
|
||||
});
|
||||
42
web/tests/e2e/glb-desktop-import.spec.ts
Normal file
42
web/tests/e2e/glb-desktop-import.spec.ts
Normal 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 },
|
||||
]);
|
||||
});
|
||||
106
web/tests/e2e/glb-export-loss-report.spec.ts
Normal file
106
web/tests/e2e/glb-export-loss-report.spec.ts
Normal 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)));
|
||||
}
|
||||
});
|
||||
98
web/tests/e2e/glb-main-persistence.spec.ts
Normal file
98
web/tests/e2e/glb-main-persistence.spec.ts
Normal 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);
|
||||
});
|
||||
46
web/tests/e2e/glb-negative-cases.spec.ts
Normal file
46
web/tests/e2e/glb-negative-cases.spec.ts
Normal 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" },
|
||||
] });
|
||||
});
|
||||
127
web/tests/e2e/glb-recovery.spec.ts
Normal file
127
web/tests/e2e/glb-recovery.spec.ts
Normal 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 });
|
||||
});
|
||||
68
web/tests/e2e/io-format-recovery.spec.ts
Normal file
68
web/tests/e2e/io-format-recovery.spec.ts
Normal 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")));
|
||||
});
|
||||
28
web/tests/e2e/io-format-ui-gate.spec.ts
Normal file
28
web/tests/e2e/io-format-ui-gate.spec.ts
Normal 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");
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
19
web/tests/e2e/library-link-chromium.spec.ts
Normal file
19
web/tests/e2e/library-link-chromium.spec.ts
Normal 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 });
|
||||
});
|
||||
35
web/tests/e2e/library-override-chromium.spec.ts
Normal file
35
web/tests/e2e/library-override-chromium.spec.ts
Normal 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 });
|
||||
});
|
||||
22
web/tests/e2e/malicious-script.spec.ts
Normal file
22
web/tests/e2e/malicious-script.spec.ts
Normal 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"]);
|
||||
});
|
||||
87
web/tests/e2e/obj-web-roundtrip.spec.ts
Normal file
87
web/tests/e2e/obj-web-roundtrip.spec.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
23
web/tests/e2e/ply-negative.spec.ts
Normal file
23
web/tests/e2e/ply-negative.spec.ts
Normal 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 })));
|
||||
});
|
||||
89
web/tests/e2e/ply-web-roundtrip.spec.ts
Normal file
89
web/tests/e2e/ply-web-roundtrip.spec.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
14
web/tests/e2e/script-host-call.spec.ts
Normal file
14
web/tests/e2e/script-host-call.spec.ts
Normal 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" });
|
||||
});
|
||||
18
web/tests/e2e/script-manifest-budgets.spec.ts
Normal file
18
web/tests/e2e/script-manifest-budgets.spec.ts
Normal 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");
|
||||
});
|
||||
12
web/tests/e2e/script-manifest-canonical.spec.ts
Normal file
12
web/tests/e2e/script-manifest-canonical.spec.ts
Normal 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 });
|
||||
});
|
||||
23
web/tests/e2e/script-open-metadata.spec.ts
Normal file
23
web/tests/e2e/script-open-metadata.spec.ts
Normal 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" });
|
||||
});
|
||||
17
web/tests/e2e/script-permission-policy.spec.ts
Normal file
17
web/tests/e2e/script-permission-policy.spec.ts
Normal 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");
|
||||
});
|
||||
14
web/tests/e2e/script-sandbox-budget.spec.ts
Normal file
14
web/tests/e2e/script-sandbox-budget.spec.ts
Normal 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");
|
||||
});
|
||||
27
web/tests/e2e/script-sandbox-cancellation.spec.ts
Normal file
27
web/tests/e2e/script-sandbox-cancellation.spec.ts
Normal 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 });
|
||||
});
|
||||
30
web/tests/e2e/script-sandbox-dispose.spec.ts
Normal file
30
web/tests/e2e/script-sandbox-dispose.spec.ts
Normal 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);
|
||||
});
|
||||
34
web/tests/e2e/script-sandbox-isolation.spec.ts
Normal file
34
web/tests/e2e/script-sandbox-isolation.spec.ts
Normal 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
Reference in New Issue
Block a user