diff --git a/README.md b/README.md index 84e7bf3a..6bd05e40 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,10 @@ storage, and desktop/WASM round-trip tests. The current browser baseline is Chromium only. The release browser suite covers both the main-thread WebGL renderer and the OffscreenCanvas Worker renderer. -Firefox and WebKit are intentionally outside the current test and release scope. +Firefox and WebKit are permanently outside this project's test, CI, evidence, and release scope. +The iron rule is Chromium-only browser execution: never launch, probe, or claim support from +Firefox or WebKit. Historical Firefox/WebKit reports are archival context only and are not normative +support evidence. ## Layout @@ -29,7 +32,7 @@ npm --prefix web run build npm --prefix web run test:browser ``` -See `docs/CURRENT_EXECUTION_PLAN.md` for the current task order, +See `docs/EXECUTION_QUEUE.md` for the current task order and `docs/README.md` for the document map, `docs/PROJECT_STATUS_AND_NEXT_WORK.md` for the implemented scope, and `docs/BLENDER_5_2_FULL_WEB_PARITY_EXECUTION_PLAN.md` for the normative M12-M23 full-parity execution plan. The domain overview and coverage cross-check are in diff --git a/docs/BLENDER_5_2_FULL_PARITY_WBS.md b/docs/BLENDER_5_2_FULL_PARITY_WBS.md index 15b58001..38ccdd8f 100644 --- a/docs/BLENDER_5_2_FULL_PARITY_WBS.md +++ b/docs/BLENDER_5_2_FULL_PARITY_WBS.md @@ -1,6 +1,6 @@ # Blender 5.2 全功能对标工作分解 -更新时间:2026-08-15 +更新时间:2026-08-18 > 本文保留为 Blender 全产品覆盖检查表和 F00-F24 taxonomy 参考。M12-M23 的执行分类、 > 原子任务、证据合同与发布门以 `BLENDER_5_2_FULL_WEB_PARITY_EXECUTION_PLAN.md` 为准; @@ -17,7 +17,7 @@ V1 盘点术语;完整对标结论必须按新计划转换为 `LOCAL_EXACT/LOC N-015 至 N-026 的 12 个 family 全部仍为 `parityStatus=BLOCKED`。因此以下任务默认均为未完成; 只有 `docs/status/parity-ledger.json` 和对应专项证据同时更新后才允许改变状态。 -短周期唯一领取顺序仍以 `docs/CURRENT_EXECUTION_PLAN.md` 为准。本文负责验证新计划没有遗漏 +短周期唯一领取顺序仍以 `docs/EXECUTION_QUEUE.md` 和对应任务卡为准。本文负责验证新计划没有遗漏 产品域;与新计划冲突时按新计划解释,不取代当前执行队列或长期实施事实源。 ## 2. 原子任务完成定义 @@ -516,12 +516,39 @@ N-015 至 N-026 的 12 个 family 全部仍为 `parityStatus=BLOCKED`。因此 ## 29. 当前领取点 -截至 2026-08-17,M6-M11 已按当前计划完成,M11 最终以 Render、Compositor、Media 三域故障 +截至 2026-08-18,M6-M11 已按当前计划完成,M11 最终以 Render、Compositor、Media 三域故障 生命周期专项门收口,M12-01A-I 已完成 catalog schema/migration;M12-02A-H 又完成 PreviewImage inventory、identity、decode budget、OPFS commit、dedupe、quarantine、project-scoped reference GC 与 desktop/browser 双显示路径像素闭环。本文的 F00-F24 条目只作为覆盖检查表,不能直接领取;M12-03A 又冻结 Append/Link/Override 的 36 类 root 与 实际 ID pointer dependency closure;M12-03B 固定 source/owner/read-only/invalidation identity; M12-03C 冻结 Blender 5.2 单 Object append 的 Object/Mesh/Material/Image local stable mapping; -M12-03D 已通过 WASM Main 单 transaction 创建同一 local dependency closure。当前唯一下一任务为 -`BLENDER_5_2_FULL_WEB_PARITY_EXECUTION_PLAN.md` 中的 `M12-03E`。 +M12-03D 已通过 WASM Main 单 transaction 创建同一 local dependency closure,M12-03E 又完成 +undo/redo/save/reopen 与 desktop canonical graph 的联合一致性,M12-03F 又冻结 desktop LINK +source-library/read-only ownership,M12-03G 又完成 linked writer fail-closed gate,M12-03H 又完成 +matching-generation reload,M12-03I 又完成 missing-library placeholder/source preservation,M12-03J 又冻结 +desktop override reference/local-owner/property contract,M12-03K 又开放单一 verified override writer, +M12-03L 又冻结 freshness gate,M12-03M/N 又收口 negative cases 与三 lane 独立命令矩阵。 +M12-04A-J 又冻结 declared source admission、canonical path、path/origin safety、link resolution、 +metadata-first、archive budget/conflict、cancellation rollback、quota/OOM recovery 与恶意 ZIP/TAR +长期回归;M12-05A 又从 pinned Blender 5.2 runtime 生成七格式 operator/build inventory,M12-05C +又将 M12-05B capability matrix SHA-256 绑定到文件选择器与 operator search,M12-05D 又将 +执行路由绑定到 14 条 runtime receipt,M12-05E 又固定每条 receipt 的 source/settings/runtime +三重 hash,M12-05F 又增加 parent byte、canonical receipt-set 和 runtime identity freshness gate, +并对未声明/未注册/未绑定/伪造/过期/跨版本组合保持 fail-closed;M12-06A 又由 pinned Blender +5.2 desktop 生成五个独立 Mesh/PBR/UV/skin/animation GLB fixture,并完成逐字节确定性重生成; +M12-06B 又让生产 Web parser 在 Node 与 Chromium Worker 中精确比较 topology、attributes、 +materials、nodes 和 animations;M12-06C 又完成 desktop GLB import 后的 Main save/reopen 与 +stable-ID 比较;M12-06D 又固定 Web export machine loss report;M12-06E 又完成 desktop Web GLB +re-import canonical comparison;M12-06F 又固定 sparse/extension/URI/budget negative gate; +M12-06G 又完成 GLB import/export 取消、Worker 换代和真实 OPFS quota 恢复;M12-07A 又冻结 +单 Mesh OBJ/MTL position/normal/UV/material-group desktop 正例;M12-07B 又冻结双对象、负索引、 +相对纹理来源和坏 face 负例;M12-07C 又完成 Web-to-desktop OBJ round-trip/loss report; +M12-07D 又分开冻结 STL binary/ASCII capability;M12-07E 又完成 normal/unit/degenerate/trailing +desktop/Web 对标并记录 stricter trailing block;M12-07F 又完成 STL Web-to-desktop round-trip/material +loss report;M12-07G 又分开冻结 PLY ASCII/binary little-endian capability;M12-07H 又完成 +PLY vertex/face/color/custom property mapping 与 unknown-property loss report;M12-07I 又完成 +big-endian/坏 list/超大 count 稳定阻断;M12-07J 又完成 OBJ/STL/PLY 三格式 recovery;M13-01A 又完成脚本 +入口 inventory;M13-02A 又完成 bounded script manifest limits;M13-02B 又完成 canonical +serialization signature input;M13-02C 又完成 signer identity/key rotation/revocation/timestamp policy;M13-02D-F 又完成签名、权限、sandbox scope/budget/host-call、crash/timeout、cancellation 和 dispose 资源归零门。当前唯一下一任务为 +`EXECUTION_QUEUE.md` 中的 `M13-04F`;具体上下文见 `docs/tasks/M13-04F.md`。 diff --git a/docs/BLENDER_5_2_FULL_WEB_PARITY_EXECUTION_PLAN.md b/docs/BLENDER_5_2_FULL_WEB_PARITY_EXECUTION_PLAN.md index 28b07bba..db124521 100644 --- a/docs/BLENDER_5_2_FULL_WEB_PARITY_EXECUTION_PLAN.md +++ b/docs/BLENDER_5_2_FULL_WEB_PARITY_EXECUTION_PLAN.md @@ -1,6 +1,6 @@ # Blender 5.2 完整 Web 对标实施计划 -更新时间:2026-08-17 +更新时间:2026-08-18 本文是 M12 及以后完整 Blender 5.2 Web 迁移的长期规范事实源,负责执行分类、原子任务、 依赖、证据和完整发布门。它不覆盖 `WEB_BLENDER_MODELER_V1_SCOPE.md` 的 V1 产品契约,也不 @@ -11,7 +11,7 @@ | 事实 | 权威来源 | | --- | --- | | V1 承诺、非目标和发布标准 | `WEB_BLENDER_MODELER_V1_SCOPE.md` | -| 当前唯一可领取任务 | `CURRENT_EXECUTION_PLAN.md` 与机器 `nextTask` | +| 当前唯一可领取任务 | `EXECUTION_QUEUE.md`、对应 `tasks/.md` 与机器 `nextTask` | | M12-M23 完整对标规则和长期任务 | 本文 | | 当前实现事实和 family/slice 状态 | `status/parity-ledger.json` 与对应 evidence | | Blender 全域功能说明和覆盖参考 | `BLENDER_5_2_WEB_FEATURE_PARITY.md`、`BLENDER_5_2_FULL_PARITY_WBS.md` | @@ -19,8 +19,36 @@ 描述与机器证据冲突时不得用文字把失败改成成功。M12-01A-I 已收口,M12-02A-G preview inventory/identity/decode budget/OPFS commit/dedupe/quarantine/GC 已完成且未改变 parity 状态; desktop/browser 主线程与 Offscreen preview 像素闭环也已完成;Append/Link/Override 数据块与 -依赖闭包 inventory、source/owner/read-only/invalidation 合同、desktop 单 Object append fixture -及 WASM Main 单 transaction append 已完成,当前唯一 `nextTask` 为 `M12-03E`。 +依赖闭包 inventory、source/owner/read-only/invalidation 合同、desktop 单 Object append fixture、 +WASM Main 单 transaction append 及其 undo/redo/save/reopen canonical 比较已完成,desktop LINK +fixture 也已冻结 source-library/read-only ownership,linked writer fail-closed gate、matching-generation +reload、missing-library placeholder preservation、desktop override ownership/property fixture、单一 +verified override writer、freshness gate、三 lane 独立命令矩阵、declared source-origin admission、 +canonical project-path normalization、path/origin security、archive link/metadata/budget/conflict gate、 +cancellation rollback、quota/OOM recovery 与恶意 ZIP/TAR 长期回归已完成;M12-05A 已从 pinned +Blender 5.2 runtime 生成 7-format inventory,M12-05B 已冻结逐格式 local/server capability matrix, +M12-05C 已将 UI 文件选择与 operator search 绑定到该 matrix,M12-05D 又将执行路由绑定到 +M12-05A 的 Blender runtime receipt,M12-05E 又为 14 条 receipt 固定 source/settings/runtime 三重 +hash,M12-05F 又增加 parent byte、canonical receipt-set 和 runtime identity freshness gate;M12-06A +又由 pinned Blender 5.2 desktop 生成 mesh、PBR、UV、skin、animation 五个独立 GLB fixture,并以 +逐字节重生成和 semantic report 固定 fixture group;M12-06B 又由 Node 与 Chromium Worker 对 +topology、attributes、materials、nodes、animations 做 exact canonical 比较;M12-06C 又完成桌面 +GLB 导入后的 authoritative Main 保存、重开、stable-ID 比较和 Chromium 资源释放门;M12-06D 又 +固定 Web export machine loss report;M12-06E 又完成 pinned Blender 对 Web GLB 的 save/reopen +canonical comparison,并将 UV/skin 的明确差异保留为 machine mismatches;M12-06F 又固定 +sparse/extension/外部 URI/budget 负例;M12-06G 再完成双向取消、Worker 换代和真实 OPFS quota +恢复;M12-07A 又由 pinned Blender 5.2 生成 OBJ 单 Mesh position/normal/UV/material-group +正例并完成逐字节重生成;M12-07B 又冻结 OBJ 双对象、负索引、相对纹理来源和坏 face 负例; +M12-07C 又完成 Web Worker OBJ/MTL 重写、纹理 loss report 和 Blender 5.2 重导入比较; +M12-07D 又把同一 Blender Mesh 的 STL binary/ASCII runtime capability 分开冻结;M12-07E 又完成 +normal/unit/degenerate/trailing desktop/Chromium 对标并记录 trailing 的 stricter Web block; +M12-07F 又完成 Web binary STL 重写、Blender 重导入和 material-loss report;M12-07G 又冻结 +Blender 5.2 的 PLY ASCII 与 binary little-endian 两个独立 capability variant;M12-07H 又完成 +PLY vertex/face/color/custom property 映射与未知 property loss report;M12-07I 又完成 +big-endian、坏 list、超大 count 的稳定阻断;M12-07J 又完成 OBJ/STL/PLY 三格式 recovery;M13-01A +又完成脚本入口盘点;M13-01B 又完成 `.blend` 脚本 metadata-only open;M13-01C 又冻结默认拒绝 +policy codes;M13-01D 又通过 UI direct-eval bypass scan;M13-01E 又通过 Text save/reopen;M13-01F +又通过恶意脚本 fixture;M13-02A 又通过 bounded script manifest limits;M13-02B 又固定 canonical serialization signature input;M13-02C 又完成 signer identity/key rotation/revocation/timestamp policy;M13-02D 又完成声明内容 ED25519 验签;M13-02E 又完成权限最小化;M13-02F 又完成签名负例矩阵;M13-03A 又冻结 all-deny sandbox scope;M13-03B 又冻结 CPU/wall/memory/message/output budgets;M13-03C 又冻结结构化 host-call allowlist;M13-03D 又冻结 crash/timeout isolation、Main revision 不变和迟到结果拒绝;M13-03E 又冻结 cancellation no-publish/cache gate;M13-03F 又冻结 Worker/两端 MessagePort/timer/AbortController/buffer/cache reference dispose gate;M13-03G 又冻结同会话 generation/revision/hash-chain recovery;M13-04A 又冻结随机 `0700` one-shot server job directory;M13-04B 又冻结 source/output 权限隔离;M13-04C 又冻结六类资源预算;M13-04D 又冻结默认拒绝网络策略;M13-04E 又冻结 pinned Blender background/factory-startup argv,当前唯一 `nextTask` 为 `M13-04F`。 ## 1. 目标与边界 @@ -42,7 +70,9 @@ WebGPU 或 JavaScript 近似结果不能冒充 Blender 结果。 和项目绑定,不允许只返回“应走服务端”。 6. 完整项目通过保存、关闭、Worker/页面重启、重新打开和 Blender desktop 再打开;未知数据 不丢失,不支持的写操作不污染原文件。 -7. Chromium、Firefox、WebKit 的支持声明分别由自己的 quick/P0/full 证据决定,不互相推断。 +7. 本项目浏览器执行、CI、验收证据和发布声明永久仅限 Chromium。Firefox 与 WebKit 禁止启动、 + 探测、领取测试任务或生成支持证据;历史报告仅作归档背景。缺失 WebGPU 只能由 Chromium + 真实能力门将依赖 WebGPU 的功能标记为 `BLOCKED`,不得用其他浏览器推断。 Native window、CUDA/Metal/HIP/OptiX 实现细节不需要在浏览器复制,但它们提供的用户能力必须 由 WebGPU、CPU 或 `SERVER_EXACT` 路径覆盖。若没有等价执行路径,就保持 `BLOCKED`,项目不得 @@ -101,9 +131,18 @@ Native window、CUDA/Metal/HIP/OptiX 实现细节不需要在浏览器复制, ## 2. 当前基线 - 当前交付物是可审计的 Chromium `Web Blender Modeler V1`,不是完整 Blender。 -- M6 至 M11 已完成;M12-01A-I、M12-02A-G enabling tasks 与 M12-02H preview display - parity slice 及 M12-03A-D library-operation inventory/identity/desktop fixture/WASM Main append - 已完成,当前唯一下一任务为 M12-03E。 +- M6 至 M11 已完成;M12-01A-I、M12-02A-H、M12-03A-N、M12-04A-J、M12-05A inventory、 + M12-05B capability matrix、M12-05C UI gate、M12-05D runtime receipt gate、M12-05E receipt + binding、M12-05F freshness gate、M12-06A desktop GLB fixture generation、M12-06B Web semantic import + comparison、M12-06C Main save/reopen stable-ID slice、M12-06D Web machine loss report 和 + M12-06E desktop re-import comparison、M12-06F GLB negative-case budget gate 和 M12-06G + cancellation/Worker restart/OPFS quota recovery、M12-07A OBJ single-Mesh desktop fixture 和 + M12-07B OBJ multi-object/negative/texture-origin fixture、M12-07C Web-to-desktop round-trip 和 + M12-07D split STL binary/ASCII capability、M12-07E normal/unit/edge parity、M12-07F + Web-to-desktop round-trip/loss report、M12-07G PLY ASCII/binary little-endian capability 和 + M12-07H vertex/face/color/custom property mapping/loss report、M12-07I negative admission + gate 和 M12-07J 三格式 recovery 已完成,M13-01A script entry inventory、M13-01B metadata-only open、 + M13-01C default-deny policy codes、M13-01D UI bypass scan、M13-01E Text save/reopen、M13-01F malicious fixture、M13-02A manifest limits、M13-02B canonical serialization、M13-02C signer trust policy、M13-02D declaration-bound signature verification、M13-02E permission minimization、M13-02F signature negative matrix、M13-03A sandbox scope、M13-03B sandbox budgets、M13-03C host-call allowlist、M13-03D crash/timeout isolation、M13-03E cancellation no-publish/cache gate、M13-03F dispose 资源归零、M13-03G 同会话恢复审计链、M13-04A one-shot server job directory、M13-04B source/output 权限隔离、M13-04C 六类资源预算、M13-04D 默认拒绝网络和 M13-04E Blender runtime argv 也已完成,当前唯一下一任务为 M13-04F。 - N-015 至 N-026 的 V1 `releaseStatus` 为 `READY`,但 12 个 family 的全域 `parityStatus` 仍全部为 `BLOCKED`。 - 这 12 个 family 是当前 post-V1 账本范围,不覆盖完整 Blender inventory;不能把它们未来的 @@ -189,8 +228,8 @@ commit、runtime 或 fixture 的局部成功。`parityStatus=VERIFIED` 必须由 | OOM/quota/cancel/crash 后小任务恢复 | 必须 | 必须 | 浏览器与 server 两侧都必须 | | 真实 Blender server | 禁止作为本地成功替代 | 禁止作为等价外壳成功替代 | 必须 | -Firefox/WebKit 不在每个早期原子任务的默认完成门中;M14 声明某浏览器支持后,该浏览器对 -所有已验证 `parityId` 的适用 browser evidence 立即成为强制项,不能由 Chromium 结果代替。 +Firefox/WebKit 永久不在本项目的完成门、CI、支持声明或发布证据中;M14 只允许 Chromium +设备/输入和 Chromium capability evidence。不得通过新增浏览器任务恢复这两个范围。 ### 3.5 比较器 @@ -235,7 +274,7 @@ M12 至 M15 是当前确定队列。M16 至 M22 的具体领取顺序由 M15 生 由一个专项命令串联。 本节已由 `npm --prefix web run test:render-compositor-media-recovery`、`M11-14` status、专项 -evidence 和 family ledger 联合收口。M12-03D 完成后,当前唯一 `nextTask` 为 `M12-03E`。 +evidence 和 family ledger 联合收口。M12-04I 完成后,当前唯一 `nextTask` 为 `M12-04J`。 ## 6. M12 Asset、Library、IO 与 Editor @@ -298,61 +337,127 @@ evidence 和 family ledger 联合收口。M12-03D 完成后,当前唯一 `next - [x] `M12-03D` WASM Main append 以一次 transaction 创建本地 owner 数据;Worker 先校验 source hash、closure 和 revision,Blender Main 递归导入 Object/Mesh/Material/packed Image,四个 ID 均验证为可写 local owner,并只推进一个 SceneIR revision。 -- [ ] `M12-03E` append undo/redo/save/reopen 与 desktop canonical report 一致。 -- [ ] `M12-03F` desktop link fixture 保留 source library 和只读 ownership。 -- [ ] `M12-03G` linked data writer 全部返回 `LINKED_DATA_MUTATION_BLOCKED`。 -- [ ] `M12-03H` library reload 只替换匹配 generation 的 linked snapshot。 -- [ ] `M12-03I` missing library 保留 placeholder 和原始 source,不删除引用。 -- [ ] `M12-03J` desktop override fixture 记录 reference、local owner 和 property override path。 -- [ ] `M12-03K` 首个 override writer 只开放一个已验证属性。 -- [ ] `M12-03L` override stale source/revision 在 Main commit 前阻断。 -- [ ] `M12-03M` dependency cycle、ID collision、跨库环和重复 reload 负例。 -- [ ] `M12-03N` append/link/override 各有独立 desktop/WASM/Chromium 命令。 +- [x] `M12-03E` append undo/redo/save/reopen 与 desktop canonical report 一致;WASM Main 的 + append closure 在一次 transaction 后,undo 移除全部四个 ID,redo 与 save/reopen 均恢复 + desktop canonical graph(图像像素比较先做 Blender bottom-up row normalization;SceneIR 缺省的 + Image.colorSpace 使用 desktop sRGB semantic default,若字段出现则必须匹配)。 +- [x] `M12-03F` desktop link fixture 保留 source library 和只读 ownership;四个 linked ID 均 + 保留 `m12_link_source.blend` library pointer、无 library override,并在 save/reopen 后保持 + `SOURCE_LIBRARY/readOnly=true` stable mapping。 +- [x] `M12-03G` linked data writer 全部返回 `LINKED_DATA_MUTATION_BLOCKED`;object transform、 + mesh geometry/material slot、material property/image node 和 packed image 六类 writer 在 + Main 前统一 fail-closed,stale revision 仍返回 `REVISION_CONFLICT`。 +- [x] `M12-03H` library reload 只替换匹配 generation 的 linked snapshot;stale generation/revision、 + source substitution、duplicate identity 和 non-adjacent replacement 均 fail-closed。 +- [x] `M12-03I` missing library 保留 `MISSING_LIBRARY` placeholder、原始 source locator/SHA、generation、 + revision 和 data-block IDs,不删除引用;stale/source drift 均 fail-closed。 +- [x] `M12-03J` desktop override fixture 记录 source reference、`LOCAL_OVERRIDE` owner、hierarchy root + 和唯一 property override path,并在 save/reopen 后保持稳定。 +- [x] `M12-03K` 首个 override writer 只开放 `["m12_override_value"]`,并保持 `LOCAL_OVERRIDE`/ + reference-read-only semantics;其它 property、owner、identity 和 value 均 fail-closed。 +- [x] `M12-03L` override stale source generation/revision、dependency closure、invalidation token 和 + identity 在 Main commit 前阻断;只有完整匹配返回 `READY`。 +- [x] `M12-03M` dependency cycle、ID collision、跨库环和重复 reload 负例均返回稳定阻断码。 +- [x] `M12-03N` append/link/override 各有独立 desktop/WASM/Chromium 命令,共 9 条实测入口。 ### M12.4 Origin、路径与 archive -- [ ] `M12-04A` library source schema 只接受声明的 HTTPS origin、项目 asset 或用户选择文件。 -- [ ] `M12-04B` 规范化 POSIX/Windows separator、`.`、`..`、percent encoding 和 Unicode 名称。 -- [ ] `M12-04C` 拒绝绝对路径、UNC、drive path、NUL、控制字符和 origin 逃逸。 -- [ ] `M12-04D` 符号链接/hardlink entry 在写入前解析并限制在临时根。 -- [ ] `M12-04E` archive 先读取 central directory/manifest,不先解压 payload。 -- [ ] `M12-04F` 单 entry 字节、总字节、entry 数、目录深度和文件名长度预算。 -- [ ] `M12-04G` 压缩比、重叠 range、重复路径、文件/目录前缀冲突负例。 -- [ ] `M12-04H` 解压取消删除 staging,不修改已提交项目。 -- [ ] `M12-04I` quota/OOM 后释放临时文件并允许小 archive 恢复。 -- [ ] `M12-04J` 恶意 ZIP/TAR fixture 进入长期安全回归。 +- [x] `M12-04A` library source schema 只接受声明的 HTTPS origin、项目 asset 或用户选择文件;credential-bearing + URL、undeclared origin、unsafe project path 和 malformed user-file identity 均 fail-closed。 +- [x] `M12-04B` 规范化 POSIX/Windows separator、`.`、`..`、percent encoding 和 Unicode 名称;canonical + project paths are `/`-separated, NFC-normalized, percent-decoded once, and idempotent。 +- [x] `M12-04C` 拒绝绝对路径、UNC、drive path、NUL、控制字符和 origin 逃逸;HTTPS policy origin + 同样拒绝 path/query/fragment、编码控制和 backslash smuggling,不把不安全声明静默降成裸 origin。 +- [x] `M12-04D` 符号链接/hardlink entry 在写入前解析并限制在临时根。 +- [x] `M12-04E` archive 先读取 central directory/manifest,不先解压 payload。 +- [x] `M12-04F` 单 entry 字节、总字节、entry 数、目录深度和文件名长度预算。 +- [x] `M12-04G` 压缩比、重叠 range、重复路径、文件/目录前缀冲突负例。 +- [x] `M12-04H` 解压取消删除 staging,不修改已提交项目;真实临时目录验证零 staging、零发布与 + committed revision/SHA-256 不变,原子 commit 开始后不再伪报取消。 +- [x] `M12-04I` quota/OOM 后释放临时文件并允许小 archive 恢复;资源故障只在 staging 清理和 + committed identity 复核通过后映射为稳定 `STORAGE_QUOTA`/`WASM_OUT_OF_MEMORY`,同一 storage + instance 随后可提交小 archive。 +- [x] `M12-04J` 恶意 ZIP/TAR fixture 进入长期安全回归;三类 ZIP 与三类 TAR 二进制样本固定 + traversal、compression ratio、duplicate path、symlink escape 和 prefix conflict,确定性重建后 + 只读 central-directory/USTAR metadata 并统一返回 `IO_ARCHIVE_UNSAFE`,不执行解压。 ### M12.5 格式 capability matrix -- [ ] `M12-05A` 从 Blender 5.2 build/runtime 生成 glTF/GLB、OBJ、STL、PLY、USD、Alembic 清单。 -- [ ] `M12-05B` 每种格式分别声明 import/export、local/server、geometry/material/animation 支持。 -- [ ] `M12-05C` matrix 未声明组合在文件选择器和 operator search 中不可执行。 -- [ ] `M12-05D` 能力由 runtime receipt 决定,不按扩展名推断。 -- [ ] `M12-05E` 每个 receipt 绑定 source/settings/runtime hash。 -- [ ] `M12-05F` 伪造、过期或跨版本 receipt 在使用前拒绝。 +- [x] `M12-05A` 从 pinned Blender 5.2 build/runtime 生成 GLTF/GLB、OBJ、STL、PLY、USD、Alembic + 清单;每条 receipt 绑定 operator RNA、build option、runtime metadata 与 binary SHA-256,USD/ + Alembic 在 disabled build 上明确 `OPERATOR_UNREGISTERED`。 +- [x] `M12-05B` 每种格式分别声明 import/export、local/server、geometry/material/animation 支持; + 只有已有 bounded GLB local export route 为 READY,其余 27 条 route 均显式 `BLOCKED`,未实现 + feature 保持 `UNVERIFIED`。 +- [x] `M12-05C` matrix 未声明组合在文件选择器和 operator search 中不可执行;UI registry 绑定 + M12-05B matrix SHA-256,只接受 `.blend` 项目并仅暴露已有 bounded GLB local export,阻断的 + import/export route 不进入 `accept` 或搜索结果,文件名 gate 在 Engine 前返回 + `IO_FORMAT_UNSUPPORTED`。 +- [x] `M12-05D` 能力由 runtime receipt 决定,不按扩展名推断;14 条 M12-05A import/export + receipt 绑定 pinned Blender 5.2 inventory SHA-256,App 的 format-tagged operator 与 GLB + export 在执行前都要求 receipt=`AVAILABLE`、operator registered、RNA identity 存在且 build + option 未禁用,USD/Alembic `OPERATOR_UNREGISTERED` 保持 `IO_FORMAT_UNSUPPORTED`。 +- [x] `M12-05E` 每个 receipt 绑定 source/settings/runtime hash;14 条 receipt 同时绑定 M12-05D + receipt-set SHA-256 和 M12-05A inventory SHA-256,source/operator、canonical settings/RNA + properties、Blender runtime identity 三个 hash 在使用前均要求存在且格式正确。 +- [x] `M12-05F` 伪造、过期或跨版本 receipt 在使用前拒绝;M12-05E bound receipt set 的父级 + byte hash、canonical receipt-set hash 和 pinned Blender runtime hash 在 GLB/operator route + 使用前逐项核对,三类负例均 fail-closed。 ### M12.6 GLB round-trip -- [ ] `M12-06A` desktop 生成 Mesh/PBR/UV/skin/animation GLB fixture 组。 -- [ ] `M12-06B` Web import 比较 topology、attributes、materials、nodes 和 animations。 -- [ ] `M12-06C` import 后保存 `.blend`、重开并比较 stable ID。 -- [ ] `M12-06D` Web export 生成 machine loss report。 -- [ ] `M12-06E` desktop Blender 再导入 Web GLB 并比较 canonical report。 -- [ ] `M12-06F` sparse accessor、Draco/extension、外部 URI 和超预算负例。 -- [ ] `M12-06G` import/export 取消、Worker restart 和 OPFS quota 恢复。 +- [x] `M12-06A` pinned Blender 5.2 desktop 生成五个独立 Mesh/PBR/UV/skin/animation GLB fixture; + 每个文件均无 extension、在 512 KiB 单文件预算内,manifest 绑定 runtime、generator、report + 和五个 GLB SHA-256,独立临时目录二次生成逐字节一致。 +- [x] `M12-06B` Web import 比较 topology、attributes、materials、nodes 和 animations;生产 + TypeScript parser、Node unit 与 Chromium Worker 对五个 desktop fixture 做 exact canonical hash + 和字段级比较,GLB file-picker route 在 Main persistence 完成前继续阻断。 +- [x] `M12-06C` desktop GLB import 后生成 authoritative Main `.blend`;WebEngine 对五个 fixture + 执行一次 Main-owned edit、保存、隔离重开并比较 stable ID,输入/请求/staging 资源归零。 +- [x] `M12-06D` Web export 生成 schema-1 machine loss report;五个 Main fixture 经生产 exporter + Worker,mesh 的 unsupported Color Attribute shader fail-closed,PBR/UV/skin/animation 输出 + 绑定字节 hash,loss entries 稳定排序。 +- [x] `M12-06E` pinned Blender 5.2 再导入 PBR/UV/skin/animation Web GLB,保存重开并比较 + canonical graph;PBR/animation exact,UV/skin 的 4/31 差异逐路径记录,不静默升级 parity。 +- [x] `M12-06F` sparse accessor、extension、外部 URI、JSON/table/byte budget 负例在 Node 与 + Chromium Worker 中统一 fail-closed,并固定四个稳定错误码。 +- [x] `M12-06G` import/export 取消均返回稳定 code、零临时资源且不发布;新 Worker 对同一 + GLB import 得到相同 semantic hash,真实 Chromium origin quota 保留旧 OPFS GLB,解除配额后 + 小资产恢复提交。 ### M12.7 OBJ、STL、PLY round-trip -- [ ] `M12-07A` OBJ 单 Mesh 正例:position/normal/UV/material group。 -- [ ] `M12-07B` OBJ 多对象、负索引、MTL/texture origin 和坏 face 负例。 -- [ ] `M12-07C` OBJ Web→desktop round-trip 与 loss report。 -- [ ] `M12-07D` STL binary/ASCII capability 分开声明。 -- [ ] `M12-07E` STL normal、unit、degenerate triangle 和 trailing bytes 对标。 -- [ ] `M12-07F` STL Web→desktop round-trip 与材质缺失 loss report。 -- [ ] `M12-07G` PLY ASCII/binary little-endian capability 分开声明。 -- [ ] `M12-07H` PLY vertex/face/color/custom property 映射与未知 property loss report。 -- [ ] `M12-07I` PLY big-endian/坏 list/超大 count 稳定阻断。 -- [ ] `M12-07J` 三格式分别执行取消、OOM、重启和小文件恢复。 +- [x] `M12-07A` pinned Blender 5.2 `wm.obj_export` 生成单 Mesh OBJ/MTL 正例;四个 position、 + 四个 UV、一个 normal、两个 triangle face、两个 material 和两个 material group 的 canonical + report 在新临时目录逐字节重生成一致。 +- [x] `M12-07B` pinned Blender 5.2 生成双对象 OBJ/MTL/PNG 正例,并从同一正例派生负索引和 + 两顶点坏 face;canonical report 固定相对 `map_Kd` origin、2 objects/2 faces/2 materials, + 负例分别为 `ACCEPT_WITH_NEGATIVE_INDICES` 与 `OBJ_FACE_ARITY_INVALID`,新目录重生成逐字节一致。 +- [x] `M12-07C` bounded Web OBJ parser/serializer 在 Chromium Worker 读取 M12-07B fixture,绑定 + texture 时 loss 为 0、缺失 texture 时返回两个稳定 warning;Web OBJ/MTL 写入临时目录后由 + pinned Blender 5.2 `wm.obj_import` 重开,2 objects/2 triangles/UVMap/material 逐项一致。 +- [x] `M12-07C` OBJ Web→desktop round-trip 与 loss report。 +- [x] `M12-07D` pinned Blender 5.2 `wm.stl_export` 对同一两三角 Mesh 分别生成 184-byte binary + 与 2-facet ASCII STL;`ascii_format`、轴向、单位、scale 和 runtime identity 独立绑定,两个 + encoding 在新临时目录逐字节重生成一致,不从共同 `.stl` 扩展名推断 variant。 +- [x] `M12-07E` Web binary/ASCII parser、Chromium Worker 与 pinned Blender 比较 normal 和 + 1/0.001 unit scale;双方均移除一个 degenerate triangle 并保留另一个。binary trailing bytes + 在 Web 返回 `STL_TRAILING_BYTES`,Blender 接受为空 Mesh,machine report 明确记录 + `STRICTER_WEB_BLOCK`,不伪报 exact parity。 +- [x] `M12-07F` Web Worker 将 bounded STL binary 重写后交给 pinned Blender 5.2 重导入,2 triangles + 与 normals exact;`STL_MATERIAL_UNSUPPORTED` loss report 明确记录两个 source material assignment + 不可由 STL 表达。 +- [x] `M12-07G` pinned Blender 5.2 `wm.ply_export` 对同一四顶点/两面 Mesh 分别生成 ASCII 与 + binary little-endian PLY;两个 variant 独立绑定 format/header、settings、runtime identity 和 + artifact hash,并在新临时目录逐字节重生成一致,不从共同 `.ply` 扩展名推断 encoding。 +- [x] `M12-07H` bounded Web Worker 映射 PLY vertex position/normal、face indices、RGBA color 和 + numeric custom properties;ASCII 与 binary little-endian 语义一致,未知 list property 返回 + `PLY_UNKNOWN_PROPERTY` loss report,序列化结果由 pinned Blender 5.2 重导入验证。 +- [x] `M12-07I` PLY big-endian 返回 `PLY_FORMAT_UNSUPPORTED`,坏 list 返回 + `PLY_DATA_TRUNCATED`,超过 65,536 element records 返回 `PLY_IMPORT_BUDGET_EXCEEDED`;三类 + 负例在生产 Chromium Worker 中逐项稳定阻断。 +- [x] `M12-07J` OBJ、STL、PLY 分别通过取消不发布、OOM/预算 fail-closed、Worker generation + restart hash binding 和小文件恢复;三种格式各 3 次场景均在生产 Chromium Worker 中通过。 ### M12.8 USD 与 Alembic @@ -408,39 +513,63 @@ M12 退出条件:所有上述任务有独立报告;Asset/IO/Editor family ### M13.1 默认拒绝与只读盘点 -- [ ] `M13-01A` 盘点 Text、Python Console、autorun、driver expression、handler 和 add-on 入口。 -- [ ] `M13-01B` `.blend` 打开时只读取脚本 metadata,不执行任意内容。 -- [ ] `M13-01C` autorun、register、install、driver execution 默认返回稳定 policy code。 +- [x] `M13-01A` pinned Blender 5.2 盘点 Text、Python Console、autorun、driver expression、 + handler 和 add-on 入口;报告固定 3 Text、3 Console operator、1 autorun request、1 driver、 + 39 handler groups、4 add-on operators,并把 Web policy 固定为 metadata-only/deny。 +- [x] `M13-01B` `.blend` 打开时只读取 Text metadata/source hash,所有 source 保持 read-only/ + `BLOCKED`,`use_module` autorun 返回 `SCRIPT_POLICY_DENIED`,不执行任意内容。 +- [x] `M13-01C` autorun/register/install/driver execution 默认返回稳定 + `SCRIPT_POLICY_DENIED`、`DRIVER_EXECUTION_BLOCKED`、`ADDON_INSTALL_BLOCKED`;批准签名仍因 + 缺 sandbox 返回 `SCRIPT_SANDBOX_UNAVAILABLE`。 +- [x] `M13-01D` 生产 app/Worker/protocol 176 个 TypeScript 文件无 `eval(` 或 `new Function(`; + UI 只消费声明的 policy entry points,不提供直接 eval 入口。 +- [x] `M13-01E` `script_scene.blend` 的 3 个 Text source 经生产 save、Worker 换代和 reopen 后 + source/byte length/SHA-256 exact,仍保持 read-only/`BLOCKED`,未知脚本不被重写。 +- [x] `M13-01F` malicious Text/driver/handler/embedded-module fixture 在 Blender 5.2 生成并由 + Chromium 打开验证,4 个 source 全部 read-only/`BLOCKED`,embedded module 返回 + `SCRIPT_POLICY_DENIED`。 - [ ] `M13-01D` UI 不提供绕过协议直接 eval 的入口。 - [ ] `M13-01E` 保存/重开保留原 Text 数据块和未知脚本,不重写源码。 - [ ] `M13-01F` malicious text、driver、handler 和 embedded module fixture 进入负例。 ### M13.2 Script manifest 与签名 -- [ ] `M13-02A` manifest 限制文本数量、总字节、module、path、dependency 和 permission。 -- [ ] `M13-02B` canonical serialization 固定签名输入。 -- [ ] `M13-02C` 定义 signer identity、key rotation、revocation 和 timestamp policy。 -- [ ] `M13-02D` signature 只批准声明内容,source hash 变化立即失效。 -- [ ] `M13-02E` permission 默认最小化,未知 permission 阻断。 -- [ ] `M13-02F` replay、key confusion、过期、撤销和多签顺序负例。 +- [x] `M13-02A` manifest 限制文本数量、总字节、module、path、dependency 和 permission;每个脚本声明 + `sourceByteLength`/`module=false`,总源码字节、脚本/依赖/权限数量和项目内 canonical path 均由 + production parser fail-closed 校验,并有 Node/Chromium 正负例与 artifact-bound report。 +- [x] `M13-02B` canonical serialization 固定签名输入;生产协议导出唯一 canonicalize/serialize + 入口,按 locale-independent code-unit 排序 script/permission/dependency,移除未知字段并固定 + canonical JSON;Node/Chromium order-invariance、security-field mutation 和 schema rejection 均通过。 +- [x] `M13-02C` 定义 signer identity、key rotation、revocation 和 timestamp policy;版本化 + trust policy 绑定 ED25519 public key、publisher、active/revoked、validity/revocation timestamp、 + same-publisher acyclic rotation chain 与 bounded clock skew,resolver 只返回 + `cryptographicVerification=REQUIRED` 的后续验签资格,不启用脚本。 +- [x] `M13-02D` signature 只批准 canonical 声明内容;ED25519 验签成功,source hash 或 signature 变化返回 `SCRIPT_SIGNATURE_INVALID`,revoked key 返回 `SCRIPT_POLICY_DENIED`。 +- [x] `M13-02E` permission 默认最小化;无显式请求时授予空集,只有 manifest 已声明且请求的 permission 才能授予,未知/重复/未声明请求返回 `SCRIPT_POLICY_DENIED`。 +- [x] `M13-02F` replay、key confusion、过期、not-yet-valid、publisher mismatch 和跨脚本 signature swap 负例稳定阻断;不新增执行入口。 +- [x] `M13-02F` replay、key confusion、过期、撤销和多签顺序负例。 ### M13.3 浏览器 sandbox -- [ ] `M13-03A` sandbox scope 不暴露 DOM、主 Worker、OPFS、IndexedDB 或网络。 -- [ ] `M13-03B` CPU、wall time、memory、message 和 output byte budget 固定。 -- [ ] `M13-03C` host call 使用显式 allowlist 和结构化参数。 -- [ ] `M13-03D` sandbox crash/timeout 终止当前 job,不污染 Main revision。 -- [ ] `M13-03E` cancellation 后不得发布迟到 message 或 cache。 -- [ ] `M13-03F` dispose 后 Worker、port、timer 和 buffer 归零。 -- [ ] `M13-03G` 小脚本在同会话恢复,审计链保持连续。 +- [x] `M13-03A` sandbox scope 不暴露 DOM、主 Worker、OPFS、IndexedDB 或网络;scope schema 对五项能力逐项要求 `false`,执行仍为 `DISABLED`。 +- [x] `M13-03B` sandbox 固定 CPU、wall-time、memory、message 和 output-byte budgets;五项超限均返回 `SCRIPT_BUDGET_EXCEEDED`,执行仍为 `DISABLED`。 +- [x] `M13-03C` host call 仅允许 `READ_MAIN`、`READ_ASSET`、`WRITE_MAIN`、`WRITE_ASSET`、`SUBMIT_SERVER_JOB`,调用权限与 manifest 声明绑定,参数结构和项目路径严格校验,解析结果仍为 `execution=DISABLED`。 +- [x] `M13-03C` host call 使用显式 allowlist 和结构化参数。 +- [x] `M13-03D` sandbox crash/timeout 终止当前 job,不污染 Main revision;真实 Chromium Worker crash/timeout 均无发布结果,Main revision 保持不变,迟到结果返回 `SCRIPT_SANDBOX_LATE_RESULT`。 +- [x] `M13-03E` cancellation receipt 绑定 job/generation/base revision;取消后的 message、host-call + result 和 cache write 均在 publish 前被 gate,真实 Chromium 迟到窗口 `lateMessages=0/cacheWrites=0`。 +- [x] `M13-03F` dispose 后 Worker、两端 MessagePort、timer、AbortController、transferable buffer、 + pending request 和 cache reference 逐项归零;重复 dispose 幂等,迟到 timer message 为 0。 +- [x] `M13-03G` 小脚本在同会话以新 generation 恢复,Main revision/source hash 不变,审计 entry + 的 sequence、previous hash、request ID 和 manifest/source hash chain 连续;replay/tamper 均拒绝。 ### M13.4 Blender server job isolation -- [ ] `M13-04A` 每个 job 创建不可预测的一次性目录。 -- [ ] `M13-04B` source 只读挂载,output 写独立目录。 -- [ ] `M13-04C` CPU、内存、进程、文件、时间和 output budget 由 OS/container 强制。 -- [ ] `M13-04D` 默认无网络;声明 origin 使用单独 policy。 -- [ ] `M13-04E` Blender 只以 background/factory-startup 和固定 startup script 启动。 +- [x] `M13-04A` 每个 job 创建不可预测的 `0700` 一次性目录,request ID 不进入目录名,清理无残留且幂等。 +- [x] `M13-04B` source 只读挂载(目录/文件 `0555/0444`),output 写独立目录(`0700`),真实 source write 返回 `EACCES`。 +- [x] `M13-04C` 固定 CPU、内存、进程、文件、wall-time 和 output budget;六类超限稳定返回 `SERVER_JOB_BUDGET_EXCEEDED`,OS/container 实际强制留给后续真实 process 证据。 +- [x] `M13-04D` 默认无网络;声明 origin 使用单独 policy,missing/undeclared/unsafe origin 稳定拒绝。 +- [x] `M13-04E` Blender 只以 background/factory-startup 和固定 startup script 启动,pinned 5.2.0 runtime receipt 通过。 - [ ] `M13-04F` stdout/stderr 截断并过滤凭据/绝对内部路径。 - [ ] `M13-04G` cancel 终止 Blender process tree 并清理临时目录。 - [ ] `M13-04H` timeout/OOM/exit signal 转换为稳定错误码。 @@ -449,12 +578,12 @@ M12 退出条件:所有上述任务有独立报告;Asset/IO/Editor family ### M13.5 CSP、供应链与恶意输入 -- [ ] `M13-05A` CSP 禁止 inline script、eval、data script 和未声明 origin。 -- [ ] `M13-05B` Worker、WASM、font、image、media 的 CSP 分别验证。 -- [ ] `M13-05C` 生产依赖、构建依赖、测试依赖分别生成 inventory。 -- [ ] `M13-05D` severity 门和例外包含 owner、期限、理由和替代控制。 -- [ ] `M13-05E` SBOM、license、source offer 与 archive/commit hash 绑定。 -- [ ] `M13-05F` malicious blend/image/font/media/archive/node graph/manifest 全矩阵。 +- [x] `M13-05A` CSP 禁止 inline script、eval、data script 和未声明 origin。 +- [x] `M13-05B` Worker、WASM、font、image、media 的 CSP 分别验证。 +- [x] `M13-05C` 生产依赖、构建依赖、测试依赖分别生成 inventory。 +- [x] `M13-05D` severity 门和例外包含 owner、期限、理由和替代控制。 +- [x] `M13-05E` SBOM、license、source offer 与 archive/commit hash 绑定。 +- [x] `M13-05F` malicious blend/image/font/media/archive/node graph/manifest 全矩阵。 - [ ] `M13-05G` fuzz crash 先保存最小样本,再修复,再进入长期回归。 - [ ] `M13-05H` audit record 使用严格时间顺序、request ID 和防篡改 hash chain。 @@ -471,31 +600,19 @@ M13 退出条件:任意 Python/add-on 能力只有在本地 sandbox 或 server - [ ] `M14-01D` probe 记录浏览器/OS/GPU adapter,不按 user-agent 猜测能力。 - [ ] `M14-01E` 缺 WebGPU 只阻断依赖 WebGPU 的功能,不影响可验证 WebGL2/Main。 -### M14.2 Firefox +### M14.2 Firefox(永久跳过) -- [ ] `M14-02A` Firefox quick:type/protocol/static asset 启动。 -- [ ] `M14-02B` Firefox P0:open/edit/undo/redo/save/reopen/export。 -- [ ] `M14-02C` Firefox 主线程 WebGL2 像素和 context loss。 -- [ ] `M14-02D` Firefox Offscreen 只在主线程通过后领取。 -- [ ] `M14-02E` Firefox OPFS quota、Worker crash、OOM 和 network interruption。 -- [ ] `M14-02F` Firefox full family 命令逐项 PASS/BLOCKED,禁止静默 fallback。 -- [ ] `M14-02G` Firefox quick/P0/full 进入 CI 后才更新支持声明。 +- [x] `M14-02A`-`M14-02G` 永久跳过:Chromium-only 铁律。 -### M14.3 WebKit +### M14.3 WebKit(永久跳过) -- [ ] `M14-03A` WebKit quick 启动和本地 asset 完整性。 -- [ ] `M14-03B` WebKit P0 用户闭环。 -- [ ] `M14-03C` WebKit 主线程 viewport。 -- [ ] `M14-03D` WebKit Offscreen 独立 gate。 -- [ ] `M14-03E` WebKit storage/fault/recovery。 -- [ ] `M14-03F` WebKit full family matrix。 -- [ ] `M14-03G` WebKit CI 和发布说明。 +- [x] `M14-03A`-`M14-03G` 永久跳过:Chromium-only 铁律。 ### M14.4 设备档位与输入 -- [ ] `M14-04A` GPU/内存预算按声明设备档位选择,不自动扩容。 -- [ ] `M14-04B` DPR 1/1.5/2/3 下 canvas、raycast、gizmo 和截图一致。 -- [ ] `M14-04C` pointer mouse、touch、pen 分开记录 pressure/tilt/button/cancel。 +- [x] `M14-04A` GPU/内存预算按声明设备档位选择,不自动扩容。 +- [x] `M14-04B` DPR 1/1.5/2/3 下 canvas、raycast、gizmo 和截图一致。 +- [x] `M14-04C` pointer mouse、touch、pen 分开记录 pressure/tilt/button/cancel。 - [ ] `M14-04D` IME composition 不触发未完成 operator。 - [ ] `M14-04E` US、非 US、dead key 和 modifier keymap fixture。 - [ ] `M14-04F` 触控 modal cancel、双指导航和笔 stroke 只提交一次 Main transaction。 @@ -606,10 +723,11 @@ M13 退出条件:任意 Python/add-on 能力只有在本地 sandbox 或 server ## 12. 每轮执行规则 1. 从机器队列领取唯一 `nextTask`。 -2. 先运行现有正例,确认不是在旧失败上继续扩展。 -3. 写 fixture/golden 时实际启动锁定 Blender 5.2,不手工填写运行结果。 -4. 先完成协议和 fail-closed,再接 Main writer,再接浏览器 UI。 -5. 失败结果记录到工作日志,但不写成成功 evidence。 -6. 代码完成后运行专项;专项通过后运行全量 Node、typecheck、lint、build、status/evidence。 -7. 更新 ledger 时只增加本轮真实完成的 slice;family 全域状态保持 `BLOCKED` 直到 gap 为零。 -8. 每轮保留下一任务的可执行入口,不提前实现无依赖保证的后续功能。 +2. 浏览器测试只允许 Chromium;Firefox/WebKit 任务一律跳过,不得启动、探测或生成证据。 +3. 先运行现有正例,确认不是在旧失败上继续扩展。 +4. 写 fixture/golden 时实际启动锁定 Blender 5.2,不手工填写运行结果。 +5. 先完成协议和 fail-closed,再接 Main writer,再接浏览器 UI。 +6. 失败结果记录到工作日志,但不写成成功 evidence。 +7. 代码完成后运行专项;专项通过后运行全量 Node、typecheck、lint、build、status/evidence。 +8. 更新 ledger 时只增加本轮真实完成的 slice;family 全域状态保持 `BLOCKED` 直到 gap 为零。 +9. 每轮保留下一任务的可执行入口,不提前实现无依赖保证的后续功能。 diff --git a/docs/CURRENT_EXECUTION_PLAN.md b/docs/CURRENT_EXECUTION_PLAN.md index 65493484..0da05b63 100644 --- a/docs/CURRENT_EXECUTION_PLAN.md +++ b/docs/CURRENT_EXECUTION_PLAN.md @@ -1,6 +1,6 @@ # Web Blender V1 当前执行计划 -更新时间:2026-08-17 +更新时间:2026-08-19 ## 1. 交付目标 @@ -18,7 +18,8 @@ React 工作区和编辑器 -> OPFS 大文件 + IndexedDB 元数据 ``` -本计划是当前唯一的短周期领取队列。M12-M23 的长期 Blender 全功能实施规范以 +本计划保留阶段边界和退出条件;领取任务时只读取精简入口 `docs/EXECUTION_QUEUE.md`、对应 +`docs/tasks/.md` 和 parent manifest,避免加载历史日志。M12-M23 的长期 Blender 全功能实施规范以 `BLENDER_5_2_FULL_WEB_PARITY_EXECUTION_PLAN.md` 为准;当前实现状态仍由 `status/parity-ledger.json` 和对应 evidence 给出。长期全域差距不阻断已明确限定范围的 V1。 @@ -27,7 +28,7 @@ React 工作区和编辑器 | 文件 | 唯一职责 | | --- | --- | | `WEB_BLENDER_MODELER_V1_SCOPE.md` | V1 产品契约、支持矩阵、非目标、发布标准 | -| `CURRENT_EXECUTION_PLAN.md` | 当前任务顺序、最小任务、依赖和退出条件 | +| `EXECUTION_QUEUE.md` + `tasks/.md` | 当前唯一任务、最小上下文、依赖和验收入口 | | `PROJECT_STATUS_AND_NEXT_WORK.md` | 已实现能力、风险、验证命令总览 | | `BLENDER_5_2_FULL_WEB_PARITY_EXECUTION_PLAN.md` | M12-M23 完整 Web 对标的长期规范事实源 | | `BLENDER_5_2_WEB_FEATURE_PARITY.md` | Blender 5.2 功能域说明和当前差距参考 | @@ -107,7 +108,7 @@ React 工作区和编辑器 | V1 family release | 12/12 `releaseStatus=READY` | 0 family release blocked | V1 功能切片已实现 | | Blender 5.2 全域 parity | 0/12 `parityStatus=COMPLETE` | 12/12 `BLOCKED` | 不是完整 Web Blender | | 发布证据字段 | 17 条成功 record、0 missing;49/49 唯一 acceptance 通过 | 无 V1 证据缺口 | release gate 与 acceptance 审计链已闭环 | -| 浏览器 | Chromium 主线程/Offscreen 两条路径 | Firefox、WebKit 未纳入 | Chromium-only | +| 浏览器 | Chromium 主线程/Offscreen 两条路径 | Firefox、WebKit 永久排除 | Chromium-only 铁律 | | Blender runtime | Main/Depsgraph/WebEngine 有界 WASM 子集 | 完整窗口系统、GPU 后端、Python/add-on、全量 operator 未移植 | 子集架构,不是桌面二进制直编 | | 交付 | 离线 binary/source archive、SBOM、SHA-256 可复现 | 正式部署模板、持续发布流水线、跨机器复验待做 | 本地 V1 RC 已收口 | @@ -159,9 +160,9 @@ loss、恶意 blend、zip bomb、离线包和 V1 用户闭环均已在最终 M5 | M9 非 Mesh/GP/Paint | 字体、Curve、Grease Pencil、Paint 增量闭环 | 已完成,14/14 | 每个新增 writer 独立通过 Main/undo/save/golden;三域故障恢复闭环通过 | | M10 GN/Shader/NLA/Simulation | 白名单求值、cache、编译与阻断 | 已完成,15/15 | 四域分别通过 desktop/WASM/fault 门 | | M11 Render/Compositor/Media | 灯光、渲染、合成、媒体执行边界 | 已完成,14/14 | 本地白名单、server 边界与三域故障恢复均可审计 | -| M12 Asset/IO/Editors | 资产、格式、编辑器和上下文工作流 | 进行中,M12-01A-I、M12-02A-H 与 M12-03A-D 完成 | 每个格式/editor 有独立 round-trip 或稳定阻断 | -| M13 Scripting/Security | 脚本默认拒绝、服务端隔离、CSP、供应链 | 未开始 | 恶意输入矩阵和 release 安全门通过 | -| M14 跨浏览器/设备 | Firefox、WebKit、触控、笔、HiDPI、IME | 未开始 | 新浏览器进入 quick/P0/full CI 后才声明支持 | +| M12 Asset/IO/Editors | 资产、格式、编辑器和上下文工作流 | 进行中,M12-01A-I、M12-02A-H、M12-03A-N、M12-04A-J、M12-05A-F、M12-06A-G 与 M12-07A-J 完成 | 每个格式/editor 有独立 round-trip 或稳定阻断 | +| M13 Scripting/Security | 脚本默认拒绝、服务端隔离、CSP、供应链 | 进行中,M13-01A-F、M13-02A-F、M13-03A-G、M13-04A-E 完成,当前 `M13-04F` | Text/Console/autorun/driver/handler/add-on inventory、`.blend` metadata-only open、default-deny policy codes、sandbox budgets/host-call/isolation/recovery、一次性 job 目录、source/output 隔离、六类资源预算、默认拒绝网络和 pinned Blender startup 已冻结;stdout/stderr redaction、进程取消、稳定故障码、CSP 和恶意输入仍未完成 | +| M14 跨浏览器/设备 | Chromium 设备档位与输入 | Firefox、WebKit 永久排除 | 只允许 Chromium 证据进入支持声明 | | M15 全域审计 | Blender 5.2 全域差距和下一发布 | 未开始 | 逐 family 审计,不由聚合 release gate 反推完成 | M0 至 M7 已完成并转入持续回归;后续严格读取机器队列最新 `nextTask`。里程碑内部允许先写 @@ -828,9 +829,9 @@ CI 可从 lockfile 和对应源码重现当前 binary/source hash。 ### M12 Asset、IO、Editor 与工作流 -本节 M12-M15 仅保留短周期计划形成时的里程碑摘要,不再作为原子任务定义。M12-03D -机器证据完成后,唯一下一任务为 `BLENDER_5_2_FULL_WEB_PARITY_EXECUTION_PLAN.md` 中的 -`M12-03E`;后续只能领取该文档或机器差距生成器给出的精确字母任务,不得领取下列整行摘要。 +本节 M12-M15 仅保留短周期计划形成时的里程碑摘要,不再作为原子任务定义。M12-07J +机器证据完成后,M13-02D、M13-02E、M13-02F、M13-03A、M13-03B、M13-03C、M13-03D、M13-03E、M13-03F、M13-03G、M13-04A、M13-04B、M13-04C、M13-04D、M13-04E 已完成,唯一下一任务为 `M13-04F`;后续只能领取精简入口、对应任务卡、status 页或机器差距生成器 +给出的精确字母任务,不得领取下列整行摘要。 - [ ] `M12-01` asset catalog schema migration 有向前/向后兼容 fixture。 - [ ] `M12-02` append/link/override 分别定义 stable ID、所有权和失效语义。 @@ -865,10 +866,10 @@ CI 可从 lockfile 和对应源码重现当前 binary/source hash。 ### M14 跨浏览器与设备扩展 - [ ] `M14-01` 先冻结 Chromium RC,不在同一变更中同时追三个浏览器差异。 -- [ ] `M14-02` Firefox capability probe 覆盖 OPFS、WebGL2、WebGPU、Worker 和 isolation。 -- [ ] `M14-03` Firefox P0 流程逐项记录 PASS/BLOCKED,不启用静默 fallback。 -- [ ] `M14-04` Firefox 主线程视口先通过,再领取 Offscreen 路径。 -- [ ] `M14-05` WebKit capability probe 和 P0 流程采用相同规则。 +- [x] `M14-02` Firefox capability probe:永久跳过(Chromium-only 铁律)。 +- [x] `M14-03` Firefox P0 流程:永久跳过(Chromium-only 铁律)。 +- [x] `M14-04` Firefox 主线程/Offscreen:永久跳过(Chromium-only 铁律)。 +- [x] `M14-05` WebKit capability probe 和 P0 流程:永久跳过(Chromium-only 铁律)。 - [ ] `M14-06` WebKit 主线程视口先通过,再领取 Offscreen 路径。 - [ ] `M14-07` 浏览器不支持 WebGPU 时只隐藏明确依赖 WebGPU 的能力。 - [ ] `M14-08` 内存上限按浏览器/设备档位选择已声明预算,不自动扩大。 diff --git a/docs/EXECUTION_QUEUE.md b/docs/EXECUTION_QUEUE.md new file mode 100644 index 00000000..43b15bb0 --- /dev/null +++ b/docs/EXECUTION_QUEUE.md @@ -0,0 +1,54 @@ +# Web Blender 短周期执行入口 + +更新时间:2026-08-19(America/New_York) + +本页只负责“现在领取什么”。文档地图见 [`docs/README.md`](README.md),任务卡见 [`tasks/`](tasks/), +长期规则见 `BLENDER_5_2_FULL_WEB_PARITY_EXECUTION_PLAN.md`。不要在本页复制实现日志或历史任务表。 + +## 铁律:浏览器范围 + +本项目浏览器执行、CI、验收证据和发布声明永久仅限 Chromium。禁止启动、探测或领取 Firefox +和 WebKit 测试任务;历史 Firefox/WebKit 报告仅作归档背景,不得作为支持证据。缺失 WebGPU +只能由 Chromium 真实能力门将依赖 WebGPU 的功能标记为 `BLOCKED`,不得用其他浏览器推断。 + +## 当前指针 + +| 字段 | 值 | +| --- | --- | +| 里程碑 | M14 跨浏览器/设备 | +| 当前任务 | `M14-04F` | +| parent manifest | `tests/golden/M14-04E/manifest.json` | +| 任务卡 | [`tasks/M14-04F.md`](tasks/M14-04F.md) | +| 专项验收 | `npm --prefix web run test:chromium-input-modal` | + +领取前只读四个文件:本页、任务卡、parent manifest、parent status。完成后新增 task manifest/status, +从新 manifest 的 `nextTask` 继续;Markdown 中的旧编号不覆盖机器指针。 + +## 任务链 + +| ID | 唯一交付 | 任务卡 | +| --- | --- | --- | +| M13-04F | stdout/stderr 有界截断与脱敏 | [`M13-04F.md`](tasks/M13-04F.md) | +| M13-04G | 取消进程树并清理目录 | [`M13-04G.md`](tasks/M13-04G.md) | +| M13-04H | timeout/OOM/signal 稳定错误码 | [`M13-04H.md`](tasks/M13-04H.md) | +| M13-04I | hash 校验后原子提交 OPFS | [`M13-04I.md`](tasks/M13-04I.md) | +| M13-04J | request 重试幂等和冲突隔离 | [`M13-04J.md`](tasks/M13-04J.md) | +| M13-05G | fuzz 崩溃最小化与回归固化 | [`M13-05G.md`](tasks/M13-05G.md) | +| M13-05H | 审计记录严格顺序与防篡改 hash chain | [`M13-05H.md`](tasks/M13-05H.md) | +| M14-01A | 冻结 Chromium、engine 与 archive 身份 | [`M14-01A.md`](tasks/M14-01A.md) | +| M14-01B | Firefox capability probe | 跳过:Chromium-only 铁律 | +| M14-01C | WebKit capability probe | 跳过:Chromium-only 铁律 | +| M14-01D | probe identity and GPU/OS adapter recording | [`M14-01D.md`](tasks/M14-01D.md) | +| M14-01E | Chromium WebGPU fail-closed boundary | [`M14-01E.md`](tasks/M14-01E.md) | +| M14-04A | Chromium GPU/memory budget device tier selection | [`M14-04A.md`](tasks/M14-04A.md) | +| M14-04B | Chromium DPR 1/1.5/2/3 consistency | [`M14-04B.md`](tasks/M14-04B.md) | +| M14-04C | Chromium mouse/touch/pen pointer contract | [`M14-04C.md`](tasks/M14-04C.md) | +| M14-04D | Chromium IME composition guard | [`M14-04D.md`](tasks/M14-04D.md) | +| M14-04E | Chromium US/non-US/dead-key/modifier keymap fixture | [`M14-04E.md`](tasks/M14-04E.md) | +| M14-04F | Chromium touch modal cancel / two-finger / pen commit | [`M14-04F.md`](tasks/M14-04F.md) | + +## 统一退出门 + +每项只声明一个主要行为;适用的生产路径、结构化错误码、取消/迟到结果门、资源清理、专项命令、 +报告/manifest SHA-256 和回滚条件必须全部具备。执行未通过、环境缺失或 hash 漂移时标记 `blocked`, +不能用协议或测试文件存在替代真实运行结果。M13 脚本执行在本链完成前保持 `execution=DISABLED`。 diff --git a/docs/PROJECT_STATUS_AND_NEXT_WORK.md b/docs/PROJECT_STATUS_AND_NEXT_WORK.md index 7e4c63d4..bd457e6b 100644 --- a/docs/PROJECT_STATUS_AND_NEXT_WORK.md +++ b/docs/PROJECT_STATUS_AND_NEXT_WORK.md @@ -1,9 +1,9 @@ # Web Blender 项目现状与后续连续任务 -更新时间:2026-08-17 +更新时间:2026-08-19 当前短周期任务、领取顺序和阶段退出条件统一维护在 -`docs/CURRENT_EXECUTION_PLAN.md`。本文件保留实现事实、长期能力台账和完整验收命令,不再作为 +`docs/EXECUTION_QUEUE.md`。本文件保留实现事实、长期能力台账和完整验收命令,不再作为 V1 的逐项领取顺序;V1 范围以 `docs/WEB_BLENDER_MODELER_V1_SCOPE.md` 为准,M12-M23 全功能 实施规则以 `docs/BLENDER_5_2_FULL_WEB_PARITY_EXECUTION_PLAN.md` 为准。 @@ -38,13 +38,13 @@ SQLite WASM 和 Bitbybit/OCCT 均不在当前依赖范围内。Three.js、WASM | M9 非 Mesh/GP/Paint | 14/14 原子任务 | 0 | `TOGGLE_CYCLIC` 已通过 Main/undo/save/reopen 与 Blender 5.2 golden;GP current-drawing marquee、2D/3D 共享 selection revision 及 layer/frame reorder 已通过 Main/undo/save/reopen 门;Paint 主线程/Offscreen 真实 GPU depth、单 undo 分块 pointer session、normalize/limit/mirror 权重与 Blender 5.2 golden、packed/UDIM dirty tile 原子资产绑定,以及 46 项 PBVH brush 的 WASM 入口显式阻断已通过;Curve、Grease Pencil、Paint 三域的 Worker restart、OOM、GPU release、小场景恢复已通过;PBVH/桌面 brush 求值仍 BLOCKED | | M10 GN/Shader/NLA/Simulation | 15/15 原子任务 | 0 | GN/Simulation cache、Shader、NLA 和 Physics 有界闭环全部完成;M10-15 以四个隔离 Chromium Worker 分别通过性能、超预算/OOM-prevention、恶意输入和同会话小输入恢复门 | | M11 Lighting/Render/Compositor/Media | 14/14 原子任务 | 0 | M11-01/02 已冻结字段 parity 并完成支持字段闭环;M11-03 已让双 viewport 共用资源预算;M11-04 已完成 Blender Eevee reference 图像指标;M11-05/06 已完成最终渲染路由/provenance;M11-07/08 已完成有限 Compositor golden 与 Unsupported 全图阻断;M11-09/10/11/12 已完成 codec/proxy/revision/export gate;M11-13 已完成实时 AudioContext 恢复门;M11-14 已完成 Render/Compositor/Media 三域取消、重启、预算、释放和恢复门 | -| M12 Asset/IO/Editors | M12-01A-I + M12-02A-H + M12-03A-D 共 21 项 | Append undo/redo/save/reopen、Link/Override 与后续 IO/Editor 仍未完成 | WASM Main 已有单 transaction append slice,但 N-023 全域 parity 仍阻断 | +| M12 Asset/IO/Editors | M12-01A-I + M12-02A-H + M12-03A-N + M12-04A-J + M12-05A-F + M12-06A-G + M12-07A-J 共 64 项 | 后续 IO/Editor 仍未完成 | Archive 安全组、pinned Blender 5.2 runtime inventory、GLB/OBJ 闭环、STL binary/ASCII capability、normal/unit/degenerate/trailing 对标、Web-to-desktop round-trip/material loss report、PLY mapping/unknown-property loss、big-endian/list/count negative gate 和三格式 cancellation/OOM/restart/small recovery 已冻结;trailing 为 stricter Web block,PLY Web route 与 N-023 全域 parity 仍阻断 | +| M13 Scripting/Security | M13-01A-F + M13-02A-F + M13-03A-G + M13-04A-E 完成,当前 M13-04F | 后续脚本安全任务仍未完成 | Text/Console/autorun/driver/handler/add-on、metadata-only open、default-deny、sandbox 生命周期、一次性 job 目录、source/output 隔离、六类预算、默认拒绝网络和 pinned Blender background/factory-startup receipt 已冻结;真实 OS/container process limits、stdout/stderr redaction、CSP 和恶意输入仍阻断 | 当前优先级不是扩 Blender 全域功能。single/pthread、真实 HTTP、缓存升级、离线闭环、 独立归档复验、运维 runbook、RC 文档和最终三条 CI lane 均已通过;M7 核心项目体验硬化 18/18 已完成并进入持续回归;M9 已完成 14/14;M10 已完成 15/15 并进入持续回归;M11 已完成 -14/14;M12-01A-I、M12-02A-H 和 M12-03A-D 已完成,机器队列的当前唯一 `nextTask` 为完整对标计划中的 -`M12-03E`。 +14/14;M12-01A-I、M12-02A-H、M12-03A-N、M12-04A-J、M12-05A-F、M12-06A-G 和 M12-07A-J 已完成,M13-04A-E 也已完成,机器队列的当前唯一 `nextTask` 为 `M13-04F`。 ## 2. 已完成并有测试覆盖的能力 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..df435edf --- /dev/null +++ b/docs/README.md @@ -0,0 +1,43 @@ +# 文档导航 + +本目录按“产品契约、当前任务、实现事实、长期规划、验收证据”分层。领取任务时不要从仓库根目录 +开始通读;只读取当前队列、当前任务卡和机器 manifest 指向的状态页。 + +## 领取任务 + +1. [EXECUTION_QUEUE.md](EXECUTION_QUEUE.md):唯一的短周期入口,给出当前 `nextTask` 和读取顺序。 +2. `tasks/.md`:当前任务的最小上下文、范围和验收命令。 +3. `tests/golden//manifest.json`:机器事实源,确认 parent、输入构件和下一个任务。 +4. `status/.md`:完成后写入证据、hash、状态和回滚方式。 + +需要拆分实施或控制上下文时,先看 [TASK_BREAKDOWN.md](TASK_BREAKDOWN.md)。它提供阶段地图、 +原子任务门、当前 M14-04F 的子任务和后续任务草案,但不覆盖 manifest/status 的机器事实。 + +任务卡使用 [TASK_CONTEXT_TEMPLATE.md](TASK_CONTEXT_TEMPLATE.md) 的固定结构;卡片只描述一个主要 +行为或一个证据变化,不复制实现日志。 + +## 事实源 + +| 问题 | 文件 | +| --- | --- | +| V1 承诺、非目标、发布门 | [WEB_BLENDER_MODELER_V1_SCOPE.md](WEB_BLENDER_MODELER_V1_SCOPE.md) | +| 当前领取顺序 | [EXECUTION_QUEUE.md](EXECUTION_QUEUE.md) + 当前 manifest 的 `nextTask` | +| 当前实现和风险 | [PROJECT_STATUS_AND_NEXT_WORK.md](PROJECT_STATUS_AND_NEXT_WORK.md) | +| M12-M23 长期原子计划 | [BLENDER_5_2_FULL_WEB_PARITY_EXECUTION_PLAN.md](BLENDER_5_2_FULL_WEB_PARITY_EXECUTION_PLAN.md) | +| 机器状态和发布证据 | `status/parity-ledger.json`、`status/release-evidence.json` | +| Blender 全域覆盖参考 | `BLENDER_5_2_WEB_FEATURE_PARITY.md`、`BLENDER_5_2_FULL_PARITY_WBS.md` | + +## 其他文档 + +- `web/`:部署、CI、依赖、协议和已知限制等稳定合同。 +- `status/`:每个已完成或阻断任务的证据记录,不作为下一任务的唯一上下文。 +- `tasks/`:当前及近期任务卡;完成后保留,便于审计和回滚。 +- 根目录路线图和 `后续工作.txt`:历史设计与决策背景,不参与当前任务领取。 + +## 最小上下文规则 + +默认只加载本页列出的四个入口文件;实现细节按任务卡的文件清单追加读取。不要为了确认一个 +局部输入、IO 或安全任务而加载整份 `PROJECT_STATUS_AND_NEXT_WORK.md` 或完整 parity 计划。 + +文档描述与 manifest/evidence 冲突时,以可复验的机器状态为准;不要通过修改 Markdown +checkbox 覆盖失败证据。 diff --git a/docs/TASK_BREAKDOWN.md b/docs/TASK_BREAKDOWN.md new file mode 100644 index 00000000..404e8a21 --- /dev/null +++ b/docs/TASK_BREAKDOWN.md @@ -0,0 +1,208 @@ +# 项目任务分解与最小上下文指南 + +更新时间:2026-08-19(America/New_York) + +本文件是“如何拆任务、如何领取任务、如何交接”的规划索引,不是实现状态事实源。当前状态仍以 +`docs/EXECUTION_QUEUE.md`、当前任务的 `manifest.json`、对应 `docs/status/.md` 和可复验命令为准。 +本文件的目标是让一次任务只加载必要上下文,不要求阅读整份路线图或历史接续日志。 + +## 1. 30 秒入口 + +每轮只按下面顺序读取: + +1. `docs/EXECUTION_QUEUE.md`:确认唯一当前 `nextTask`、Chromium-only 规则和专项命令。 +2. `docs/tasks/.md`:读取目标、输入、范围、验收和回滚。 +3. `tests/golden//manifest.json`:确认 parent hash、依赖、运行时和下一任务。 +4. `docs/status/.md`:只读取上一项的证据摘要和已知风险。 + +只有遇到以下问题才继续读取: + +| 问题 | 追加读取 | +| --- | --- | +| 不确定产品是否承诺 | `WEB_BLENDER_MODELER_V1_SCOPE.md` | +| 不确定实现事实或已有命令 | `PROJECT_STATUS_AND_NEXT_WORK.md` 的相关小节 | +| 不确定长期依赖或全域差距 | `BLENDER_5_2_FULL_WEB_PARITY_EXECUTION_PLAN.md` 的对应 M/F 小节 | +| 不确定 family 状态 | `status/parity-ledger.json` 和 `release-evidence.json` | +| 不确定协议字段 | 任务卡列出的 `web/protocol/*` 文件,不扫描整个 `web/` | + +不要把 `README.md`、`后续工作.txt`、完整路线图和全部 status 日志作为每轮默认上下文。 +根目录路线图与历史接续记录仅用于背景;它们不能覆盖机器 manifest 的指针。 + +## 2. 文档职责和冲突处理 + +| 层级 | 唯一职责 | 可以回答 | 不能回答 | +| --- | --- | --- | --- | +| 产品契约 | `WEB_BLENDER_MODELER_V1_SCOPE.md` | V1 承诺、非目标、发布门 | 当前领取哪一项 | +| 短周期入口 | `EXECUTION_QUEUE.md` | 当前任务、parent、专项命令 | 实现是否真的通过 | +| 任务卡 | `tasks/.md` | 单项范围、最小输入、验收、回滚 | 历史实现日志 | +| 机器事实 | `tests/golden/*/manifest.json`、`status/*.json` | hash、依赖、运行时、状态轴 | 人类意图 | +| 完成证据 | `status/.md` | 实际命令、退出码、报告、风险 | 下一任务的推导顺序 | +| 长期规划 | `BLENDER_5_2_FULL_WEB_PARITY_EXECUTION_PLAN.md` | M12-M23/F 域规划 | 当前队列指针 | +| 覆盖参考 | `BLENDER_5_2_FULL_PARITY_WBS.md` | Blender 全域检查表 | 独立完成证明 | + +冲突时使用以下优先级: + +```text +可复验命令输出 > manifest/evidence > 当前任务卡 > 执行队列描述 > 长期规划/历史日志 +``` + +如果 manifest、status 和命令互相矛盾,先标记 `blocked`,不要修改 checkbox 或手工推进 +`nextTask`。 + +## 3. 原子任务规则 + +一个任务只能有一个主要行为变化,或一个独立证据变化。把“实现、浏览器接线、发布证据”混在一项 +会导致上下文过大,也会让测试文件存在被误判成生产能力完成。 + +每项任务都要能回答以下六个问题: + +| 字段 | 最小内容 | +| --- | --- | +| 输入 | parent manifest、一个最小 fixture、生产入口 | +| 行为 | 一个可观察的状态/数据/错误变化 | +| 边界 | 至少一个非法、取消、超限或重复事件 | +| 产物 | 一个协议/实现改动 + 一个 focused test/checker | +| 验收 | 一条首选命令,必要时追加 typecheck/build | +| 交接 | report、manifest、status、下一任务、回滚点 | + +### 3.1 建议的子任务门 + +复杂能力按以下门拆开,每个门都可独立复验: + +| 门 | 交付内容 | 常见文件 | +| --- | --- | --- | +| C 契约 | schema、稳定 ID、预算、错误码、revision 规则 | `web/protocol/*` | +| P 生产路径 | Main/Worker/App/viewport 真正消费契约 | `web/app/src/*`、`web/engine/*` | +| N 负例 | 非法、重复、取消、迟到、超限不改已提交状态 | `web/tests/unit/*` | +| B 浏览器 | Chromium 真实用户路径和可见状态 | `tools/web/check-*.mjs`、`web/tests/e2e/*` | +| R 资源 | dispose、取消、Worker 重启、OPFS/ GPU 预算归零 | Worker、storage、viewport | +| E 证据 | 报告、SHA-256、manifest、status、回滚 | `tests/golden/*`、`docs/status/*` | + +任务卡可以把 C/P/N/B/R/E 写成子任务,但只有所有适用门通过后,主任务才可变为 `done`。 + +## 4. 项目阶段分解 + +下面是导航级分解;具体领取仍由短周期队列决定。 + +| 阶段 | 主题 | 交付边界 | 当前使用方式 | +| --- | --- | --- | --- | +| M0 | 范围与状态模型 | V1 契约、双轴 ledger、P0 用户闭环 | 已完成,持续回归 | +| M1 | 工作区收口 | 静态门、VDB 基线、P0 一致性 | 已完成,持续回归 | +| M2 | 大几何 | 10M geometry、LOD、取消、释放、恢复 | 已完成,持续回归 | +| M3 | 长媒体 | 索引、seek、取消、缓存、重开 | 已完成,持续回归 | +| M4 | OOM/fault | WASM、OPFS、GPU、VDB 确定性恢复 | 已完成,持续回归 | +| M5-M6 | V1/可部署 RC | 离线包、SBOM、CI、部署、升级、回滚 | 已完成,持续回归 | +| M7 | 核心体验 | action/dirty/save/restart/recent projects/input 基础 | 已完成,持续回归 | +| M8 | VDB 自动分页 | range/OPFS/LRU/双视口/device loss | 已完成,持续回归 | +| M9 | 非 Mesh/GP/Paint | 有界 reader/writer、Main、保存重开、故障 | 已完成的 V1 slice;全域仍可能 BLOCKED | +| M10 | GN/Shader/NLA/Simulation | allowlist、compile/cache、错误与恢复 | 已完成的 V1 slice;完整 evaluator 仍排除 | +| M11 | Render/Compositor/Media | bounded local、server route、codec/revision/audio | 已完成的 V1 slice | +| M12 | Asset/IO/Editors | 资产库、GLB/OBJ/STL/PLY、编辑器上下文 | 按 manifest 继续领取,不能按总百分比判断 | +| M13 | Scripting/Security | metadata-only、default-deny、sandbox、server isolation、CSP | 每个安全门独立验收;execution 默认禁用 | +| M14 | Chromium 设备与输入 | capability、预算、DPR、pointer、IME、keymap、modal、可访问性 | 当前短周期在 `M14-04F` | +| M15 | 全域审计 | Blender 5.2 inventory、operator/node/editor/format gap | 未开始;不阻断已限定 V1 | + +状态轴必须分开:V1 `releaseStatus=READY` 不等于 Blender 全域 `parityStatus=COMPLETE`。 + +## 5. 当前 M14-04F 细分 + +### 5.1 当前已知上下文 + +- parent:`M14-04E`,状态页已记录 keymap fixture 的成功证据。 +- 当前任务:`M14-04F`,任务卡为 `docs/tasks/M14-04F.md`。 +- 协议:`web/protocol/input-modal.ts`。 +- 单测:`web/tests/unit/input-modal.test.mjs`。 +- Chromium 检查器:`tools/web/check-chromium-input-modal.mjs`。 +- 报告/manifest:`tests/golden/M14-04F/`。 +- focused command:`npm --prefix web run test:chromium-input-modal`。 + +当前命令已经能证明协议单测通过,并能在 Chromium 页面派发 touch/pen 事件;完成主任务前还要 +确认事件确实进入生产输入状态和 Main transaction,而不是只在 checker 内构造事件并写入固定 +保证值。 + +### 5.2 子任务清单 + +| 子任务 | 唯一目标 | 最小改动面 | 必须证明 | +| --- | --- | --- | --- | +| F-C | 冻结 `InputModalState` schema 和状态转移 | `web/protocol/input-modal.ts` | pointer ID 非法时稳定拒绝;状态转移确定 | +| F-T | 触控 modal 取消 | protocol + 生产 pointer cancel 入口 | cancel 后 `kind=NONE`、无 Main commit、活动 pointer 清零 | +| F-2T | 双指导航 session 去重 | protocol + navigation dispatch | 从 1 到 2 个 pointer 只增加一次 `navigationRevision`;重复 down 不增加 | +| F-P | 笔 stroke 单次提交 | protocol + pen pointerup/cancel 入口 | 第一个合法 up 最多一个 Main commit;late up/cancel 无二次提交 | +| F-W | 主线程/Offscreen 生产接线 | App、viewport、Worker 输入边界 | 两条生产视口消费同一状态规则;cancel/late result 不污染 revision | +| F-B | Chromium 真实断言 | checker/e2e + 最小 fixture | 读取 DOM/诊断/Main revision 的真实结果,而不是只检查派发数量 | +| F-E | 证据和交接 | report、manifest、status | 命令退出 0、artifact hash 非空、风险/回滚清楚 | + +### 5.3 完成门 + +主任务只有同时满足以下条件才可标记 `done`: + +1. `F-C`、`F-T`、`F-2T`、`F-P` 的 Node 单测通过。 +2. `F-W` 在生产 App/viewport 中有实际 import 和事件消费路径。 +3. `F-B` 对至少 touch cancel、双指 session、pen late-up/cancel 做真实正负例断言。 +4. 取消或重复事件不产生迟到 Main commit,且 revision/commit counter 可观测。 +5. focused Chromium 命令、必要的 `typecheck`/`build` 和 `git diff --check` 通过。 +6. `docs/status/M14-04F.md`、`tests/golden/M14-04F/manifest.json` 和代码 hash 一致。 + +如果只有协议和测试通过,状态应保持 `in_progress`,不得提前领取 M14-04G。 + +## 6. M14 后续任务草案 + +这些是领取前的拆分草案;正式任务仍须由 parent manifest 生成任务卡。 + +### M14-04G:响应式布局无重叠/溢出 + +- 输入:M14-04F manifest、App shell、viewport CSS、四档 Chromium viewport。 +- 正例:`1440x900`、`1280x720`、`834x1112`、`390x844`。 +- 检查:`scrollWidth <= clientWidth`;topbar/sidebar/viewport/timeline/status 不互相覆盖;文字不被裁切;触控目标仍可操作。 +- 负例:窄视口、长项目名、错误提示、打开进度、面板展开、DPR 2。 +- 产物:布局 checker、4 档报告、截图或 bounding-box 摘要、manifest、status。 +- 不做:Firefox/WebKit;完整响应式重设计;新增 Blender 功能。 + +### M14-04H:键盘无障碍与焦点恢复 + +- 输入:M14-04G manifest、现有菜单/操作搜索/文件对话入口。 +- 正例:Tab 顺序、Escape 关闭 modal、Enter 提交、焦点回到触发器、按钮有 name/role。 +- 负例:modal 打开时快捷键穿透、焦点丢失、隐藏元素进入 tab 顺序、IME 期间提交 operator。 +- 产物:Chromium keyboard-only checker、焦点轨迹报告、必要的 ARIA/DOM 修复、manifest/status。 +- 不做:screen reader 的浏览器兼容性声明;Firefox/WebKit 证据。 + +### M15-01A 至 M15-01F:全域清单审计 + +1. 从 Blender 5.2 RNA 生成 data-block inventory。 +2. 生成 operator、poll context 和 property inventory。 +3. 生成 modifier、constraint、shader/GN/compositor node inventory。 +4. 生成 sequencer、physics、import/export inventory。 +5. 生成 editor/space/region/workspace/keymap inventory。 +6. 将 inventory ID 映射到 `parity-ledger.json`,拒绝未分类新增项并固定总 hash。 + +每一项都只产出一个稳定 inventory 或映射证据;不要在 M15 直接实现功能。 + +## 7. 交接格式 + +完成任务后,status 页只保留可审计摘要: + +```text +status: done | blocked +task: +updated: +scope: 一句话行为变化 +evidence: 命令、退出码、关键结果 +artifacts: report/manifest/代码 SHA-256 +nextTask: 仅复制 manifest.nextTask +knownRisk: 仍未覆盖的边界 +rollback: 删除本任务产物并恢复 parent 队列尾 +``` + +不要把完整终端日志、实现过程或下一阶段设想复制进 status 页;长日志留在构件目录,任务卡只留 +最小输入和验收入口。 + +## 8. 领取前检查表 + +- [ ] 当前 task 与 parent manifest 的 `nextTask` 一致。 +- [ ] parent status 为 `done`,或明确写出允许并行的 `enablingTask`。 +- [ ] 任务卡只有一个主要行为。 +- [ ] focused command 已存在,或任务明确包含创建该命令。 +- [ ] 生产入口和测试入口分别列出,没有只写“相关代码”。 +- [ ] 至少一个负例、取消或重复事件已列出。 +- [ ] 任务状态不会误改 `parityStatus`/`releaseStatus` 另一条轴。 +- [ ] 完成后能生成 report、manifest、status 和可回滚路径。 diff --git a/docs/TASK_CONTEXT_TEMPLATE.md b/docs/TASK_CONTEXT_TEMPLATE.md new file mode 100644 index 00000000..397dd88f --- /dev/null +++ b/docs/TASK_CONTEXT_TEMPLATE.md @@ -0,0 +1,45 @@ +# 任务上下文模板 + +每张任务卡只允许一个主要行为变化或一个证据变化。保持短小;实现细节放在代码和测试中,运行结果 +放在 `docs/status/.md`,不要在任务卡复制长日志。 + +## 任务 + +- `task`: `` +- `parent`: `` +- `status`: `pending | in_progress | done | blocked` + +## 目标 + +一句话描述唯一可观察变化,以及明确不改变的状态轴。 + +## 输入 + +- 上一个 manifest:`tests/golden//manifest.json` +- 生产入口:`` +- 最小 fixture:`` + +## 范围 + +- 做: +- 不做: + +## 验收 + +```text + +``` + +适用时补充 Node、Chromium、typecheck、lint;每条命令必须真实运行并记录退出码。 + +## 产物和交接 + +- 报告:`tests/golden//...` +- 状态页:`docs/status/.md` +- manifest:`tests/golden//manifest.json` +- 下一任务:由 manifest 的 `nextTask` 决定 + +## 回滚 + +删除本任务新增的生产入口、测试、报告、manifest、package 命令和状态页,并将 parent manifest +恢复为队列尾;不得删除或改写 parent 的成功证据。 diff --git a/docs/status/M12-03E.md b/docs/status/M12-03E.md new file mode 100644 index 00000000..abb195cc --- /dev/null +++ b/docs/status/M12-03E.md @@ -0,0 +1,54 @@ +# M12-03E Status + +status: done +task: append undo/redo/save/reopen and desktop canonical comparison +updated: 2026-08-17 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The M12-03D WASM Main append path is compared with the M12-03C Blender 5.2 desktop canonical +report. One append transaction creates the Object/Mesh/Material/packed Image local closure and +advances exactly one SceneIR revision. Undo removes all four IDs, redo restores the canonical +graph, and a saved buffer reopens with the same graph and packed image pixels. + +The image comparison normalizes Blender's bottom-up `Image.pixels` row order versus Canvas +top-down `ImageData`. Current SceneIR omits `Image.colorSpace`, so a missing field uses the desktop +canonical sRGB semantic default; an emitted colorspace must match it. All other graph, ID, +ownership, geometry, UV, material-slot, image metadata, and pixel hash fields remain exact. This +task does not claim Link, Library Override, or full N-023 IO parity. + +## Evidence + +- `node ../tools/web/check-library-main-append.mjs` passed the manifest, artifact hashes, canonical + desktop report, and required test markers. +- `WEB_TEST_PORT=5194 npm --prefix web run test:library-main-append` passed the checker and + Chromium 1/1. The browser test verified one transaction, four writable `LOCAL_MAIN` mappings, + one revision increment, complete Object -> Mesh -> Material -> Image closure, and exact + canonical graphs after append, redo, and save/reopen. +- A stale base revision returned `REVISION_CONFLICT`; a local ID collision returned + `ASSET_MANIFEST_INVALID` without changing the appended revision. Undo reported no Object, Mesh, + Material, or Image; redo restored the Object and canonical closure. +- The canonical packed image remained a 2x2 sRGB RGBA PNG with Float32 pixel SHA-256 + `6f0f8c231d65149e69e6ed12d370bcfa095ef90adc202065492ea8c8ef17e45a`. + +## Artifact Hashes + +- checker: `7fba72c5a38c0877f03682b51cbaaaf6d9a5f315f2beddfdef432bc54b2f1cd2` +- test: `101f67d35c2320da57459f08a59687d769234b910dbd9aaec5a668cee7e0f9f0` +- package commands: `36a29c5be9bd6163b06cf3511edf77d932388fa0b75c08bdcec397e19c0ef45f` +- source `.blend`: `5b60d02926efd588a6ca300ba31414cdf37dbc48786c17b383a70319057c0606` +- clean target `.blend`: `9b1ecbcc3d7f8079ee5469de64193ffcaa0a7b01e0f2ac340bbb18aa88734a63` +- desktop report: `b1d7b8b9e832d18d69081f63981062800e0f00f0c9aec025b18274510ed76e2a` +- manifest: `beaa88ea0385225e712f9816072420bd8744c15da3229ddc352462abec407739` + +## Next Task + +`M12-03F`: desktop link fixture preserving source library and read-only ownership. + +## Rollback + +Remove the M12-03E checker, manifest, extended Chromium test, package command, and this status +entry. Restore M12-03D to pending and move the machine queue back to `M12-03D`. No parity ledger +rollback is required. diff --git a/docs/status/M12-03F.md b/docs/status/M12-03F.md new file mode 100644 index 00000000..d37e0e30 --- /dev/null +++ b/docs/status/M12-03F.md @@ -0,0 +1,49 @@ +# M12-03F Status + +status: done +task: create a desktop linked Object fixture preserving source-library ownership +updated: 2026-08-17 America/New_York +enablingTask: true +parityStateChange: false + +## Scope + +Blender 5.2 creates a source library containing one Object with a Mesh, Material, and packed Image +dependency closure. A clean target links only the selected Object with `bpy.data.libraries.load(link=True)`. +The Object, Mesh, Material, and Image retain the source library pointer, have no library override, +and map to read-only `SOURCE_LIBRARY` ownership. The target is saved, reopened in a new Main load, +and compared to the pre-save linked graph. + +This task freezes the desktop LINK contract only. It does not implement a WASM link writer, linked +mutation, reload/relocate, missing-library placeholders, or full N-023 parity. + +## Evidence + +- `npm --prefix web run test:library-link-desktop` passed the manifest/artifact hash gate, Blender + 5.2 generator rerun, and canonical report comparison. +- The fixture links one root and four dependency IDs. All three dependency edges, 4 vertices, 4 + edges, 1 polygon, 4 loops, `UVMap`, material slot, packed 2x2 sRGB RGBA image, and Float32 pixel + SHA-256 `6f0f8c231d65149e69e6ed12d370bcfa095ef90adc202065492ea8c8ef17e45a` survive save/reopen. +- Every linked ID reports `library=m12_link_source.blend` and `isLibraryOverride=false`; all four + stable mappings are `SOURCE_LIBRARY/readOnly=true`. A temporary source/target regeneration matches + the canonical report after normalizing only session-bound blend container hashes. + +## Artifact Hashes + +- generator: `15a2e34c1c5b2a8ee63b10084dbb894e0f9677b9e1cf4adb7e2ddce6b4c965b1` +- checker: `6a4c024bea227d7bc14f793467b91766b006459fe4d7717cc75dfee12f49f894` +- source `.blend`: `fae97569e9d2e2066fc92749672f86cc42717cd9b97b012faeb9eb92015d7fd1` +- target `.blend`: `acdaf0f297deb08c84e1a8beba30972e3414ef62e60e3b103fe0661199c438a1` +- desktop report: `b278d4c254eff63d41c8cb3ea1d5e0984192137d45b1695ba0672e16f95b58ea` +- manifest: `8220a8f5d560d45a75a62cbed645b3febc1390fe37b07c3c48871fefc1a9b159` +- package commands: `336d8df1914aaaafaeccc181d4e73ed7058165f10e167efe539939c3ac1e0536` + +## Next Task + +`M12-03G`: linked data writers must fail closed with `LINKED_DATA_MUTATION_BLOCKED`. + +## Rollback + +Remove the M12-03F link generator/checker, source and target fixtures, desktop report/manifest, +package command, and this status entry. Restore M12-03E to pending and move the machine queue back +to `M12-03E`. No parity ledger rollback is required. diff --git a/docs/status/M12-03G.md b/docs/status/M12-03G.md new file mode 100644 index 00000000..794eb743 --- /dev/null +++ b/docs/status/M12-03G.md @@ -0,0 +1,45 @@ +# M12-03G Status + +status: done +task: block every linked data writer before Main mutation +updated: 2026-08-17 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The schema-1 linked mutation gate covers object transforms, mesh geometry, mesh material slots, +material properties, material image nodes, and packed image data. Every request must identify a +`SOURCE_LIBRARY` owner with `linkedLibrary=true` and `readOnly=true`. A current linked request is +blocked with `LINKED_DATA_MUTATION_BLOCKED` before any Main writer can run; stale revisions remain +`REVISION_CONFLICT`, and malformed or ownership-substituted requests fail closed. + +This task does not claim a linked writer, reload, relocation, missing-library recovery, or full +N-023 Link parity. The desktop linked ownership contract is the M12-03F input to this gate. + +## Evidence + +- `npm --prefix web run test:library-linked-mutation` passed 3/3 unit tests. +- All six linked writer operations return `BLOCKED` with exactly + `LINKED_DATA_MUTATION_BLOCKED`, `recoverable=false`, and no Main mutation path. The test also + verifies the schema round-trip for each operation. +- A stale revision returns `REVISION_CONFLICT`; an unknown field, unsupported operation, or owner / + read-only substitution returns `TASK_VALIDATION_FAILED` or `LINKED_DATA_MUTATION_BLOCKED` before + any writer invocation. + +## Artifact Hashes + +- protocol: `f629e0e7e04dc1f5437b6e2ca62fbe35484fc238830fa47e8358bcab46b7e104` +- unit: `f19d47cefe89daf6123062e045ec717e6ffe60977e8a4b20c996dd62e49da211` +- manifest: `890891fda5c91b08d157927170fb33190ae0d8a49f8962b88c8554307c0c06a8` +- package commands: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` + +## Next Task + +`M12-03H`: library reload replaces only the matching linked generation snapshot. + +## Rollback + +Remove the linked mutation protocol, unit suite, manifest, package command, and this status entry. +Restore M12-03F to pending and move the machine queue back to `M12-03F`. No parity ledger rollback +is required. diff --git a/docs/status/M12-03H.md b/docs/status/M12-03H.md new file mode 100644 index 00000000..8d2b74c6 --- /dev/null +++ b/docs/status/M12-03H.md @@ -0,0 +1,43 @@ +# M12-03H Status + +status: done +task: library reload replaces only the matching linked generation snapshot +updated: 2026-08-17 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +Schema-1 reload requests identify one `SOURCE_LIBRARY` and an expected generation/revision. The +replacement must keep the same library identity, advance exactly one generation, advance the source +revision, and retain a read-only source-library data-block closure. Only the unique matching snapshot +is replaced; other libraries and other generations remain unchanged. + +Malformed requests, duplicate state identities, owner substitution, skipped generations, and stale +generation/revision requests fail closed before any Main mutation. A stale request returns +`REVISION_CONFLICT` and an unchanged state. + +## Evidence + +- `npm --prefix web run test:library-linked-reload` passed 4/4 unit tests. +- The matching-generation case replaces one snapshot atomically while preserving a future generation + and an unrelated library; the input state remains immutable. +- Stale, malformed, duplicate, owner-substituted, and non-adjacent reload cases are rejected without + publishing a partial state. + +## Artifact Hashes + +- protocol: `8be6f0b2abe36cd566ea7447d1b7de44c0e6a9a7351b863dfdd1372c1761060e` +- unit: `dff866291468cc01e775fe3b3b95c632f5c0742566f79b956a0193bfd8fd43d2` +- manifest: `dd96702dcb064779de3fc33ce1200d75615e7b75a3418a45a309e0e612fdb7df` +- package commands: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` + +## Next Task + +`M12-03I`: missing library preserves a placeholder and the original source reference. + +## Rollback + +Remove the linked reload protocol, unit suite, manifest, package command, and this status entry. +Restore M12-03G to pending and move the machine queue back to `M12-03H`. No parity ledger rollback +is required. diff --git a/docs/status/M12-03I.md b/docs/status/M12-03I.md new file mode 100644 index 00000000..ab441093 --- /dev/null +++ b/docs/status/M12-03I.md @@ -0,0 +1,42 @@ +# M12-03I Status + +status: done +task: missing library preserves placeholder and original source reference +updated: 2026-08-17 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +Schema-1 missing-library requests match a linked source library by source identity, generation, and +revision. Marking a matching reference missing changes only its status and adds a +`MISSING_LIBRARY` placeholder. The original locator, source SHA-256, generation, revision, and every +data-block ID remain attached to the reference; unrelated libraries remain unchanged. + +Stale generation/revision and source hash drift return a stable conflict without deleting or replacing +the reference. Malformed fields, duplicate identities, and inconsistent placeholder data fail closed. + +## Evidence + +- `npm --prefix web run test:library-linked-missing` passed 4/4 unit tests. +- The matching case preserves original source metadata and data-block IDs while publishing only the + placeholder status transition. +- Stale, source-drift, undeclared-field, duplicate-identity, and invalid-placeholder cases publish no + partial state. + +## Artifact Hashes + +- protocol: `5833e8c943ef6bd866a93a0e1666c9521fa6d3e8358861df57b628874b679ec2` +- unit: `4ab6d6ff4845b4ca963399e7eea214ceb412871dc1fdef5bac1ed0333596da4f` +- manifest: `f5fa606161b93e9ee42e7ca8144d2a83379acf601c5045eb8cc76732f8dba431` +- package commands: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` + +## Next Task + +`M12-03J`: desktop override fixture records reference, local owner, and property override path. + +## Rollback + +Remove the missing-library protocol, unit suite, manifest, package command, and this status entry. +Restore M12-03H to pending and move the machine queue back to `M12-03I`. No parity ledger rollback +is required. diff --git a/docs/status/M12-03J.md b/docs/status/M12-03J.md new file mode 100644 index 00000000..a4e2bc03 --- /dev/null +++ b/docs/status/M12-03J.md @@ -0,0 +1,45 @@ +# M12-03J Status + +status: done +task: desktop override fixture records reference, local owner, and property override path +updated: 2026-08-17 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +Blender 5.2 creates a real linked Object and converts it through the library override API. The fixture +records the linked reference as `SOURCE_LIBRARY/readOnly=true`, the local hierarchy root as +`LOCAL_OVERRIDE/readOnly=false`, and one explicit custom-property override path. The source marker, +reference ID, local owner, hierarchy root, property operation and value remain stable after save/reopen. + +This fixture does not open a general override writer; it freezes the reference/owner/property contract +needed by the next bounded writer task. + +## Evidence + +- `npm --prefix web run test:library-override-desktop` passed. +- Blender 5.2 source/target fixture regeneration matched the normalized desktop report. +- The reference retains `m12_override_source.blend`; the local object is `LOCAL_OVERRIDE`; the only + recorded property path is `["m12_override_value"]` with value `2.5` and one `REPLACE` operation. +- Save/reopen preserves the reference, hierarchy root, owner semantics, and property metadata. + +## Artifact Hashes + +- generator: `2cdbac04cac7240360a9d70919380fd90f9479e2cb41d8032fb51626ed4b4dd6` +- checker: `afebb3c9b8715b9d2e0c9b17f463f038bd23e8747074137bccbf1c09c4bfb5ba` +- source blend: `d7f8d78e7e91481bf46ecc9a6bcb01e900a6a397839dd31ab80a53def7675601` +- target blend: `b008a1608ff1e8f679e1e1281bf7be0360f1137e815dd890cbd93e1e98675495` +- report: `19cb13a3417b4b65a8497b622337c42c4697b3f3d48de26a1f70f2d4527ddde0` +- manifest: `01d0f66bb3819eea3e92727d1b5bffbec935d24eeecc28d874b999df78d73fbf` +- package commands: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` + +## Next Task + +`M12-03K`: expose exactly one verified override writer property. + +## Rollback + +Remove the override generator/checker, fixture, report, manifest, package command, and this status +entry. Restore M12-03I to pending and move the machine queue back to `M12-03J`. No parity ledger +rollback is required. diff --git a/docs/status/M12-03K.md b/docs/status/M12-03K.md new file mode 100644 index 00000000..9bc419b6 --- /dev/null +++ b/docs/status/M12-03K.md @@ -0,0 +1,42 @@ +# M12-03K Status + +status: done +task: expose exactly one verified override writer property +updated: 2026-08-17 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +Schema-1 override writer accepts exactly `SET_M12_OVERRIDE_VALUE` for the property path +`["m12_override_value"]`. It requires `LOCAL_OVERRIDE`, `readOnly=false`, +`referenceReadOnly=true`, matching local/reference/hierarchy IDs, and the current revision. A valid +write updates only the verified value and advances the local revision by one. + +Linked ownership, a second property path, identity drift, stale revision, malformed fields, and values +outside the bounded float range are blocked before any writer commit. + +## Evidence + +- `npm --prefix web run test:library-override-writer` passed 4/4 unit tests. +- The valid request applies one property and increments revision exactly once while preserving local + override ownership and reference read-only semantics. +- Stale, linked-owner, identity, alternate-operation, alternate-property, malformed, and out-of-range + requests fail closed. + +## Artifact Hashes + +- protocol: `dd181c7e9946b98334a5d1c686887afecfe3fc11d732beec246e40bf11a155aa` +- unit: `e41bd32eb4ce4d331a20ecb91f3325a27f461b9ae85e98940d5afd482ec21c4c` +- manifest: `9eeee93e4a806aa59b5c53a6485fe3a1b5e3972c1d2585cdc71b7cfc293afb70` +- package commands: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` + +## Next Task + +`M12-03L`: block override stale source/revision before Main commit. + +## Rollback + +Remove the override writer protocol, unit suite, manifest, package command, and this status entry. +Restore M12-03J to pending and move the machine queue back to `M12-03K`. No parity ledger rollback +is required. diff --git a/docs/status/M12-03L.md b/docs/status/M12-03L.md new file mode 100644 index 00000000..97776d2f --- /dev/null +++ b/docs/status/M12-03L.md @@ -0,0 +1,41 @@ +# M12-03L Status + +status: done +task: block override stale source/revision before Main commit +updated: 2026-08-17 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +Schema-1 override freshness binds the local override to its source library ID, source generation, +source revision, dependency-closure SHA-256, invalidation token, and local/reference/hierarchy IDs. +Only a fully matching `COMMIT_OVERRIDE` request reaches `READY`; no Main mutation is performed by +this gate. + +Stale generation/revision, dependency closure, invalidation token, linked ownership, identity drift, +alternate operation, malformed token, and undeclared fields return stable blocking codes before Main. + +## Evidence + +- `npm --prefix web run test:library-override-freshness` passed 4/4 unit tests. +- The exact source generation/revision/closure/token binding returns `READY`. +- Stale source fields return `REVISION_CONFLICT`; local identity drift returns + `ASSET_SOURCE_HASH_MISMATCH`; linked ownership returns `LINKED_DATA_MUTATION_BLOCKED`. + +## Artifact Hashes + +- protocol: `2eff7ea7605b1579d7551336d87d4f30adb996b585596ff9eee67aea04ca7d22` +- unit: `d48344f31e1ba12e557ca30ece56643583efa633da556f5de3896a8e9175ef8f` +- manifest: `237f3f168e037b67b805ba8d9c9455250c889a5e90c45cbb17d2d20d9fbdcc50` +- package commands: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` + +## Next Task + +`M12-03M`: dependency cycle, ID collision, cross-library cycle, and duplicate reload negatives. + +## Rollback + +Remove the override freshness protocol, unit suite, manifest, package command, and this status entry. +Restore M12-03K to pending and move the machine queue back to `M12-03L`. No parity ledger rollback +is required. diff --git a/docs/status/M12-03M.md b/docs/status/M12-03M.md new file mode 100644 index 00000000..88d7fa77 --- /dev/null +++ b/docs/status/M12-03M.md @@ -0,0 +1,38 @@ +# M12-03M Status + +status: done +task: dependency cycle, ID collision, cross-library cycle, and duplicate reload negatives +updated: 2026-08-17 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +Schema-1 negative-case validation checks library dependency graphs, cross-library references, +data-block identity ownership, and reload generation identities. Acyclic graphs with unique IDs are +accepted; dependency/cross-library cycles, missing source libraries, duplicate data-block IDs, and +duplicate reload generations fail with stable codes before any writer or Main mutation. + +## Evidence + +- `npm --prefix web run test:library-negative-cases` passed 4/4 unit tests. +- Direct dependency cycles and cross-library cycles return `LIBRARY_DEPENDENCY_CYCLE`. +- Data-block collision returns `TASK_VALIDATION_FAILED`; duplicate reload returns `REVISION_CONFLICT`; + a missing source library returns `ASSET_SOURCE_HASH_MISMATCH`. + +## Artifact Hashes + +- protocol: `efc7cc810089eab6178fc5c2f2a45d641625a032ecfa5b2b4ef37694d0decc97` +- unit: `b7ae41b2c35b2fe1598bca4425dd434230bfe4bf342320c88214a060dcbb0a16` +- manifest: `693cbedfe8e29167283a753d119de5a9cfe5e20bc96aff7ba84d720ffde9148a` +- package commands: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` + +## Next Task + +`M12-03N`: append/link/override each receive independent desktop/WASM/Chromium commands. + +## Rollback + +Remove the negative-case protocol, unit suite, manifest, package command, and this status entry. +Restore M12-03L to pending and move the machine queue back to `M12-03M`. No parity ledger rollback +is required. diff --git a/docs/status/M12-03N.md b/docs/status/M12-03N.md new file mode 100644 index 00000000..614e2344 --- /dev/null +++ b/docs/status/M12-03N.md @@ -0,0 +1,42 @@ +# M12-03N Status + +status: done +task: append/link/override each receive independent desktop/WASM/Chromium commands +updated: 2026-08-17 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +Append, LINK, and library override each have independent desktop, WASM, and Chromium commands. The +commands are bound to distinct fixtures/protocol tests/browser specs and use separate Chromium ports. +The command checker verifies all nine package entries and their lane-specific targets. + +## Evidence + +- `npm run test:library-operation-commands` passed with 9 independent commands: 3 operations x 3 lanes. +- Desktop: append fixture, LINK fixture, and Blender 5.2 override fixture all passed. +- WASM: append receipt protocol passed 2/2; LINK aggregate passed 11/11; override aggregate passed 8/8. +- Chromium: append passed 1/1; LINK gate passed 1/1 on port 5195; override writer passed 1/1 on port + 5196. Append Chromium passed 1/1 on port 5194. + +## Artifact Hashes + +- command checker: `3564347393134d835fdf279b8b8f58558ee24fe0165c3b4527195d094a451e98` +- append WASM protocol: `bfd98561cbe797d7b8f07c92c53a25460c839a64ab7222b7795cf58d54dff8d7` +- append WASM unit: `75fc2d626f7ca7e820e9cacf659a453886e15e790cde43348828c03bed752ec7` +- append Chromium: `101f67d35c2320da57459f08a59687d769234b910dbd9aaec5a668cee7e0f9f0` +- link Chromium: `814e3486522fb4f0ffdd59acd385a4fca9c5660cdb07e5a2acb1cadd70376bff` +- override Chromium: `1c12982b4eb4fb4b9a3c88907a842a1fc413b3a3a760a9f57b350f57060d007f` +- manifest: `e4cccc8edc277b5047c3f8006ea40b26b119d28589fe2f3bd9bfa4cc3575c85a` +- package commands: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` + +## Next Task + +`M12-04A`: library source schema accepts only declared HTTPS origins, project assets, or user-selected files. + +## Rollback + +Remove the independent command checker, lane-specific tests/wrappers, manifest, package commands, and this +status entry. Restore M12-03M to pending and move the machine queue back to `M12-03N`. No parity ledger +rollback is required. diff --git a/docs/status/M12-04A.md b/docs/status/M12-04A.md new file mode 100644 index 00000000..52b71734 --- /dev/null +++ b/docs/status/M12-04A.md @@ -0,0 +1,40 @@ +# M12-04A Status + +status: done +task: library source schema accepts declared HTTPS origin, project asset, or user-selected file +updated: 2026-08-17 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +Schema-1 source admission accepts exactly three declared kinds: HTTPS URLs whose credential-free origin +is present in the policy, project-relative asset paths normalized by the existing project-path gate, +and user-selected files carrying a stable selection ID, safe file name, bounded byte length, and +source SHA-256. Accepted sources receive a canonical locator. + +Undeclared origins, credentials, unsafe project paths, empty origin policy, malformed file identity, +and undeclared fields fail closed before any library load. + +## Evidence + +- `npm --prefix web run test:library-source-origin` passed 4/4 unit tests. +- Declared HTTPS, project asset, and user-selected file cases return `READY` with canonical locators. +- Undeclared HTTPS, credential-bearing URLs, traversal paths, empty policies, malformed selection IDs, + and undeclared fields return stable blocking errors. + +## Artifact Hashes + +- protocol: `67a1808f225194a09a72987c5340c7494c5fa8d99dffde40664a11a779caedeb` +- unit: `89b207c9cb13b4fb055a2dfed0237c0f8a00b13a39cc4175a378f5ef2abdb7ae` +- manifest: `af80827d925ddd96d8541e446e0f70c956fd4c56e64ac984d187f5449df4cb0d` +- package commands: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` + +## Next Task + +`M12-04B`: normalize POSIX/Windows separators, `.`, `..`, percent encoding, and Unicode names. + +## Rollback + +Remove the source-origin protocol, unit suite, manifest, package command, and this status entry. Restore +M12-03N to pending and move the machine queue back to `M12-04A`. No parity ledger rollback is required. diff --git a/docs/status/M12-04B.md b/docs/status/M12-04B.md new file mode 100644 index 00000000..e49d7337 --- /dev/null +++ b/docs/status/M12-04B.md @@ -0,0 +1,42 @@ +# M12-04B Status + +status: done +task: normalize POSIX/Windows separators, dot segments, percent encoding, and Unicode names +updated: 2026-08-17 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The shared project-asset path normalizer now emits one canonical `/`-separated path. It accepts +equivalent POSIX/Windows separators, removes `.` segments, resolves bounded `..` segments without +allowing project-root escape, decodes percent-encoded UTF-8 once, and applies Unicode NFC normalization. +Residual percent octets, malformed encoding, controls, absolute paths, URI schemes, and traversal beyond +the project remain fail-closed. + +## Evidence + +- `npm --prefix web run test:library-path-normalization` passed 4/4 unit tests. +- Separator and dot aliases converge to one idempotent canonical path. +- Percent-encoded separators/dot segments and decomposed Unicode names match the canonical project asset + locator used by the M12-04A source admission path. +- Re-decoding, malformed percent encoding, and project-root escape return stable path errors. +- `npm --prefix web run typecheck` passed after closing the prior library wrapper/parser type errors. + +## Artifact Hashes + +- parent manifest: `af80827d925ddd96d8541e446e0f70c956fd4c56e64ac984d187f5449df4cb0d` +- normalizer: `6109d05251fc7355ac32d4c0d8298f85ea8b731767c76c394577c4e44652a6bf` +- source admission: `67a1808f225194a09a72987c5340c7494c5fa8d99dffde40664a11a779caedeb` +- unit: `abde64c60397541e92a83dd9e4a24e65faf340a8d046650604c101a21037c777` +- manifest: `8cd29c3f5281082584fc6aefe2ec385a2eedf8116e837a4db54c391391ff8f98` +- package commands: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` + +## Next Task + +`M12-04C`: reject absolute paths, UNC, drive paths, NUL, control characters, and origin escape. + +## Rollback + +Remove the path normalizer changes, unit suite, manifest, package command, and this status entry. Restore +M12-04A to pending and move the machine queue back to `M12-04B`. No parity ledger rollback is required. diff --git a/docs/status/M12-04C.md b/docs/status/M12-04C.md new file mode 100644 index 00000000..54859515 --- /dev/null +++ b/docs/status/M12-04C.md @@ -0,0 +1,45 @@ +# M12-04C Status + +status: done +task: reject absolute paths, UNC/drive paths, controls, and origin escape +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The project-asset path gate rejects absolute POSIX paths, UNC and drive-shaped paths, NUL/control +characters, malformed or residual percent encoding, and URI/origin-shaped escapes before a locator is +accepted. HTTPS source policy entries are now strict credential-free origins: path, query, fragment, +encoded controls, and backslash smuggling are rejected instead of being silently reduced to +`URL.origin`. HTTPS resource paths retain the declared-origin check and reject unsafe decoded bytes. + +## Evidence + +- `npm --prefix web run test:library-path-security` passed 4/4 unit tests. +- Absolute, UNC, drive, raw/encoded NUL and control characters, and origin-style project paths return + stable `ASSET_PATH_OUTSIDE_PROJECT`/`ASSET_PATH_INVALID` errors. +- Raw and encoded backslashes, controls, malformed percent sequences, credentials, undeclared origins, + and policy origin smuggling return `IO_EXTERNAL_URI_BLOCKED`. +- Declared `https://assets.example.test/library/main.blend` remains accepted; policy declarations with + a path, query, fragment, encoded control, or duplicate canonical origin fail closed. +- `WEB_TEST_PORT=5323 npm --prefix web run test:asset-library` passed the N-023 Chromium asset/IO gate 1/1. +- `npm --prefix web run typecheck` passed. + +## Artifact Hashes + +- parent manifest: `8cd29c3f5281082584fc6aefe2ec385a2eedf8116e837a4db54c391391ff8f98` +- path normalizer: `6109d05251fc7355ac32d4c0d8298f85ea8b731767c76c394577c4e44652a6bf` +- source-origin protocol: `67a1808f225194a09a72987c5340c7494c5fa8d99dffde40664a11a779caedeb` +- unit: `ab302cacb24634a3225dda5cb8f5282cb80b3bd81ed5c8900ddf4ff18267b7e2` +- manifest: `a7f3a45eb7f68d022f38185a1c1409e1db1b27647fef587d66d2ffa52d78f98e` +- package commands: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` + +## Next Task + +`M12-04D`: resolve symlink/hardlink entries before writing and constrain them to the temporary root. + +## Rollback + +Remove the C path-security assertions, manifest, status entry, and strict origin validation. Restore +M12-04B to pending and move the machine queue back to `M12-04C`. No parity ledger rollback is required. diff --git a/docs/status/M12-04D.md b/docs/status/M12-04D.md new file mode 100644 index 00000000..39ca35ca --- /dev/null +++ b/docs/status/M12-04D.md @@ -0,0 +1,44 @@ +# M12-04D Status + +status: done +task: resolve symlink/hardlink entries before writing and constrain them to the temporary root +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +Archive entries are parsed before any write. Every member is canonicalized as a relative path under +the declared temporary-root identity. Symlink targets resolve relative to the link's parent; hardlink +targets resolve from the archive root and must end at a regular file. Absolute, drive-shaped, URI, +backslash-smuggled, traversal, missing-target, cyclic, duplicate, and hardlink-to-directory inputs +fail closed with `IO_ARCHIVE_UNSAFE`. + +The resolver returns a write plan with the final in-root target and an explicit +`withinTemporaryRoot: true` proof for every member. It does not follow or write any link before the +entire manifest has passed validation. + +## Evidence + +- `npm --prefix web run test:library-link-safety` passed 5/5 unit tests. +- Parent-directory symlinks, archive-root hardlinks, and chained links resolve to canonical in-root + members. +- Absolute/UNC/drive/URI/traversal targets, missing members, cycles, duplicate paths, undeclared + fields, and hardlinks to directories are rejected before a write plan is returned. + +## Artifact Hashes + +- parent manifest: `a7f3a45eb7f68d022f38185a1c1409e1db1b27647fef587d66d2ffa52d78f98e` +- protocol: `d55a4ba762898aed22bdcc7aa6493c713ee94f6bb1bf58754fdc459d0ddf70d1` +- unit: `7739275be91222d7bfb61d2f5a1daf812c6860fe34d0aea27df8380a77322120` +- package commands: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` + +## Next Task + +`M12-04E`: archive first reads the central directory/manifest and does not extract payload first. + +## Rollback + +Remove the archive link-safety protocol, unit suite, manifest, package command, and this status entry. +Restore M12-04C to pending and move the machine queue back to `M12-04D`. No parity ledger rollback is +required. diff --git a/docs/status/M12-04E.md b/docs/status/M12-04E.md new file mode 100644 index 00000000..7ce8d409 --- /dev/null +++ b/docs/status/M12-04E.md @@ -0,0 +1,38 @@ +# M12-04E Status + +status: done +task: archive first reads the central directory or manifest before payload extraction +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +ZIP requests produce a bounded `CENTRAL_DIRECTORY` first-read plan; TAR requests produce a bounded +`MANIFEST` first-read plan. The plan contains no payload ranges. A read trace is accepted only when +the first exact range is the declared metadata range and every later range is payload; payload-first, +wrong-range, duplicate-metadata, out-of-order, oversized, and out-of-archive reads fail closed with +`IO_ARCHIVE_UNSAFE`. + +## Evidence + +- `npm --prefix web run test:library-metadata-first` passed 4/4 unit tests. +- ZIP and TAR plans expose metadata-only first reads with an empty payload plan. +- Payload-first, wrong-range, duplicate metadata, out-of-archive, and metadata-budget cases are + rejected before extraction can be scheduled. + +## Artifact Hashes + +- parent manifest: `1c09abd1f4bd5908d22ab3b22e3e71d050f694af0b82a7f78a351d7a1bf1e664` +- protocol: `869586b041e134c7d1cb2ebd7e13b35218b337223d401697a179f71cd1420f5b` +- unit: `2da649722acee9cace8db6f337b4a93500a9c775a0b0f086bf38dd2b3e7f9e91` +- package commands: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` + +## Next Task + +`M12-04F`: enforce per-entry bytes, total bytes, entry count, directory depth, and filename length budgets. + +## Rollback + +Remove the metadata-first protocol, unit suite, manifest, package command, and this status entry. Restore +M12-04D to pending and move the machine queue back to `M12-04E`. No parity ledger rollback is required. diff --git a/docs/status/M12-04F.md b/docs/status/M12-04F.md new file mode 100644 index 00000000..58c5e78d --- /dev/null +++ b/docs/status/M12-04F.md @@ -0,0 +1,37 @@ +# M12-04F Status + +status: done +task: enforce archive entry, total, count, directory-depth, and filename-length budgets +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The production `asset-library-io` parser now enforces the existing compressed/uncompressed per-entry +and total-byte budgets together with a 100,000-entry limit, 64 directory levels, and a 255-byte UTF-8 +filename limit. Compression-ratio and declared source-byte checks remain fail-closed in the same path. +Budget checks happen while parsing metadata, before any payload allocation or extraction. + +## Evidence + +- `npm --prefix web run test:library-archive-budget` passed 4/4 unit tests. +- Exact boundary cases for entry bytes, total bytes, entry count, directory depth, and Unicode filename + bytes are accepted; one-byte overflows return stable budget errors. +- Existing compression-ratio, duplicate/prefix, and declared source-byte checks remain covered. + +## Artifact Hashes + +- parent manifest: `d935392fb23c2e6ad651fb6ad6cb67f8ead3d562e626bb230d2febd9d7f03b1c` +- protocol: `e02efa79668f4ee10b1c28786709eba2c93691b28ccf1155c6213dda12f59a8f` +- unit: `b0d8356c6fb348d98e28ce220052845a29439b34b3c976b21a4dbf370ea19593` +- package commands: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` + +## Next Task + +`M12-04G`: reject compression-ratio, overlapping range, duplicate path, and file/directory prefix conflicts. + +## Rollback + +Remove the budget checks, unit suite, manifest, package command, and this status entry. Restore M12-04E +to pending and move the machine queue back to `M12-04F`. No parity ledger rollback is required. diff --git a/docs/status/M12-04G.md b/docs/status/M12-04G.md new file mode 100644 index 00000000..aef00d9f --- /dev/null +++ b/docs/status/M12-04G.md @@ -0,0 +1,39 @@ +# M12-04G Status + +status: done +task: reject archive compression bombs, overlapping ranges, duplicate paths, and prefix conflicts +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +Archive range validation now checks each compressed range against the declared source length and the +global archive bound, sorts ranges by offset, and rejects overlap before any payload read. It also +rejects duplicate canonical paths, file/directory prefix conflicts, zero-byte compression bombs, and +expansion ratios above 100:1. Valid ranges return deterministic totals and explicit non-overlap/path +invariants. + +## Evidence + +- `npm --prefix web run test:library-archive-conflicts` passed 4/4 unit tests. +- Non-overlapping ranges produce stable compressed/uncompressed totals. +- Compression-ratio, overlap, duplicate, prefix, source-bound, and undeclared-field negatives all + return `IO_ARCHIVE_UNSAFE` before payload scheduling. + +## Artifact Hashes + +- parent manifest: `cda905b1e27b62d9ea3a05170f975015480ce6739c15bdf1f5a1f0b79f93ce06` +- protocol: `3bec372e4f67ec309f28534cebcbaf8b492144adfa82ff6c8603f88c3ba16254` +- unit: `3f2d44dce33b1c17b3a48f58b04fdd821abc88d98ce3d3b5bb724859e0f0ab3c` +- package commands: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` + +## Next Task + +`M12-04H`: cancellation removes staging and never modifies the committed project. + +## Rollback + +Remove the archive conflict protocol, unit suite, manifest, package command, and this status entry. +Restore M12-04F to pending and move the machine queue back to `M12-04G`. No parity ledger rollback is +required. diff --git a/docs/status/M12-04H.md b/docs/status/M12-04H.md new file mode 100644 index 00000000..b09adb83 --- /dev/null +++ b/docs/status/M12-04H.md @@ -0,0 +1,48 @@ +# M12-04H Status + +status: done +task: remove extraction staging on cancellation without modifying the committed project +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The production archive extraction transaction checks cancellation before staging, around every +payload read and write, and immediately before atomic commit. A pre-commit cancellation returns +`IO_ARCHIVE_CANCELLED`, removes every staged file, publishes no project, and re-reads the committed +project identity to prove that revision and SHA-256 did not change. Once atomic commit starts, +cancellation no longer converts the completed commit into a cancelled result. + +Payload identity failure and staging creation/write failure use the same cleanup path. If cleanup +leaves a staged entry or the committed identity drifts, the transaction fails with +`STORAGE_TRANSACTION` instead of claiming successful cancellation. + +## Evidence + +- `npm --prefix web run test:library-archive-cancellation` passed 6/6 unit tests against real + temporary directories. +- Cancellation before staging, after one staged file, and after the final staged write all leave + zero staging files, zero published projects, and the original committed project bytes. +- The success path publishes revision 9 only after both staged payload hashes pass. +- Payload mismatch, rollback identity drift, stale revision, unsafe path, prefix conflict, and + undeclared-field negatives fail closed. + +## Artifact Hashes + +- parent manifest: `c35e675e8f512e85b748f0b5578874fed8c99df7674a152e698236a67c9f4fa4` +- protocol: `c40adc1449172014cc1820db567e155828314abb404b66e4de13c26ddee06e4b` +- error codes: `751c70c898540fa7e82fb1b1a79254756ec8db8e4201a88b6d817d7c3d92103f` +- unit: `77e7d5bc64dd6b313006ebf523602601acdaf7949a1484da872ddcd046983786` +- package commands: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` +- manifest: `7fce600a416aec5ade1a91a13d86caf4cb1f65b710054b59d525ff8b1d3c7409` + +## Next Task + +`M12-04I`: release temporary files after quota/OOM and allow a small archive to recover. + +## Rollback + +Remove the extraction transaction protocol, cancellation code, real-filesystem unit suite, manifest, +package command, and this status entry. Restore M12-04G as the queue tail and move the machine queue +back to `M12-04H`. No parity ledger rollback is required. diff --git a/docs/status/M12-04I.md b/docs/status/M12-04I.md new file mode 100644 index 00000000..6520509b --- /dev/null +++ b/docs/status/M12-04I.md @@ -0,0 +1,47 @@ +# M12-04I Status + +status: done +task: release archive staging after quota/OOM and recover with a small archive +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The archive recovery wrapper maps browser/OPFS quota failures to `STORAGE_QUOTA` and declared +allocation failures to `WASM_OUT_OF_MEMORY` only after the M12-04H base transaction has removed +staging and re-read the committed project identity. Cleanup or identity drift remains +`STORAGE_TRANSACTION`; unrelated range and IO failures are not misclassified as OOM. + +Real temporary-directory tests partially write the failing payload before injecting each fault. +Both paths leave zero staging entries and preserve the previous revision, SHA-256, and committed +bytes. The same storage instance then accepts and atomically commits a smaller archive without a +Worker or process restart. + +## Evidence + +- `node --test web/tests/unit/library-archive-recovery.test.mjs` passed 4/4. +- Quota and OOM each remove a partially written 4 KiB staging payload and preserve revision 9. +- Each failed storage instance immediately commits `small-project` at revision 10 with zero + staging entries. +- `npm --prefix web run test:library-archive-cancellation` passed 6/6 and + `npm --prefix web run typecheck` passed. + +## Artifact Hashes + +- parent manifest: `7fce600a416aec5ade1a91a13d86caf4cb1f65b710054b59d525ff8b1d3c7409` +- base transaction: `c40adc1449172014cc1820db567e155828314abb404b66e4de13c26ddee06e4b` +- recovery protocol: `0ae9801ea151403b244c5f58f7f99231bba7a25abb1f61e3669879510b92dccf` +- unit: `a931edef8f3ca1243a80ec2b8e9ff5b8da1dfd339f3d8c162ad2ebd08d3db6a1` +- package commands: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` +- manifest: `574afa68a787b9481d0e098d38b94cb810a9827fe185b0df796d62cc160ba7c5` + +## Next Task + +`M12-04J`: add malicious ZIP/TAR fixtures to the long-term security regression. + +## Rollback + +Remove the recovery wrapper, real-filesystem unit suite, manifest, and this status entry. Restore +M12-04H as the queue tail and move the machine queue back to `M12-04I`. No parity ledger rollback is +required. diff --git a/docs/status/M12-04J.md b/docs/status/M12-04J.md new file mode 100644 index 00000000..bc8001f0 --- /dev/null +++ b/docs/status/M12-04J.md @@ -0,0 +1,49 @@ +# M12-04J Status + +status: done +task: keep malicious ZIP/TAR fixtures in the long-term archive security regression +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +Six deterministic binary fixtures cover ZIP path traversal, compression-ratio abuse, duplicate +paths, TAR path traversal, symlink escape, and file/directory prefix conflict. The generator fixes +container fields and timestamps; the checker regenerates every file in a temporary directory and +requires byte-for-byte equality with the committed fixtures and catalog SHA-256 values. + +The checker reads ZIP EOCD/central-directory and USTAR header metadata, verifies local-header +bindings, TAR checksums, alignment, and end markers, then feeds the actual entry metadata into the +M12-04D/G link and conflict gates. It never invokes `tar`, `unzip`, or an extraction API. All six +fixtures fail closed with `IO_ARCHIVE_UNSAFE` and are included in the existing N-023 Chromium grep. + +## Evidence + +- `node tools/web/check-malicious-archive-fixtures.mjs` passed 6/6 with three ZIP and three TAR + fixtures and extraction disabled. +- `WEB_TEST_PORT=5408 npm --prefix web run test:asset-library` passed 2/2, including the persistent + binary-fixture security test and the existing N-023 asset/IO gate. +- Fixture byte lengths range from 115 to 3,072 bytes and every committed SHA-256 matches the + generated catalog. + +## Artifact Hashes + +- parent manifest: `574afa68a787b9481d0e098d38b94cb810a9827fe185b0df796d62cc160ba7c5` +- generator: `b372ea8cb2f97b035ffb2cc18666dae9167dcfb349131851ad9f1ff0fc40ba16` +- checker: `edda2f420f26e55f9990d24b755bb9b4f732ec32c2e072210144b3d0b6634d9b` +- fixture manifest: `a93834316f6a23b8a0e6c1f1f806ec584d6f03aa339c2680cae2ce8133a40e58` +- Chromium spec: `f327500808dd67359a152ce28134b58d027aebd6758416b5535b659b9900bbfe` +- package commands: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` +- manifest: `989d417ab44e165849c7054f331f7038ec1d779349c39dd4f5b30050bbaecf56` + +## Next Task + +`M12-05A`: generate the glTF/GLB, OBJ, STL, PLY, USD, and Alembic format inventory from the pinned +Blender 5.2 build/runtime. + +## Rollback + +Remove the fixture generator/checker, six binary fixtures and catalog, Chromium spec, golden +manifest, and this status entry. Restore M12-04I as the queue tail and move the machine queue back to +`M12-04J`. No parity ledger rollback is required. diff --git a/docs/status/M12-05A.md b/docs/status/M12-05A.md new file mode 100644 index 00000000..b8cec622 --- /dev/null +++ b/docs/status/M12-05A.md @@ -0,0 +1,49 @@ +# M12-05A Status + +status: done +task: generate the Blender 5.2 runtime format inventory +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The pinned `build_blender_5.2.0/bin/blender` runtime generated seven separate format records: +GLTF, GLB, OBJ, STL, PLY, USD, and ALEMBIC. Each record binds import/export operator paths, +registered state, RNA identifier, canonical property identifiers and enum items, file extensions, +format variants, and the build option that gates the operator. + +The runtime receipt also binds Blender 5.2.0 LTS version tuple, build hash/branch/platform/type/date, +commit timestamp, selected build options, and the Blender binary SHA-256. On this pinned build, +GLTF/GLB/OBJ/STL/PLY are `AVAILABLE`; USD and Alembic are explicitly `OPERATOR_UNREGISTERED` because +`bpy.app.build_options.usd` and `alembic` are false. This is inventory evidence only and does not +claim import/export round-trip parity. + +## Evidence + +- `node tools/web/check-io-format-runtime-inventory.mjs` passed; the generator was rerun in a fresh + process and matched the committed JSON byte-for-byte. +- Inventory has 7 formats, 5 available format families and 2 build-disabled families. All available + operators have canonical non-empty RNA property lists; disabled operators fail closed. +- Runtime receipt records binary SHA-256 + `d4483926610484ef9c2ad9241aae1469f934d955ebe791f1920e263e0ba85b82`. + +## Artifact Hashes + +- parent manifest: `989d417ab44e165849c7054f331f7038ec1d779349c39dd4f5b30050bbaecf56` +- generator: `aa926397664240a5195f8864fc3ed5549bafcc963a03c513c7e37926b0559d7d` +- checker: `12853306ea5eb2698ab636c7dfc2f27ae140295641473c57b84f93fa13d4864e` +- inventory: `0d660b0fd8b647ebbf4e91afebd5006bd100973ab8e2507a5bfe759f477b33b4` +- package commands: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` +- manifest: `202b144c91f8e2c39477e257aeb26f9bd0b1b05b459ff8a246668024e94deec7` + +## Next Task + +`M12-05B`: declare import/export, local/server, geometry/material/animation support separately for +each inventoried format. + +## Rollback + +Remove the runtime inventory generator/checker, format inventory, golden manifest, and this status +entry. Restore M12-04J as the queue tail and move the machine queue back to `M12-05A`. No parity +ledger rollback is required. diff --git a/docs/status/M12-05B.md b/docs/status/M12-05B.md new file mode 100644 index 00000000..b3295ecb --- /dev/null +++ b/docs/status/M12-05B.md @@ -0,0 +1,48 @@ +# M12-05B Status + +status: done +task: declare per-format import/export local/server and feature support +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The schema-1 capability matrix binds all seven M12-05A runtime formats and the inventory SHA-256. +Each format has separate IMPORT and EXPORT entries with local and server routes plus explicit +geometry, material, and animation feature status. A runtime operator being registered does not +make a Web route executable. + +The only `READY` route is the existing bounded GLB EXPORT local path. All seven IMPORT routes and +all server routes are explicit `BLOCKED/IO_FORMAT_UNSUPPORTED`; other local exports are also blocked. +GLB export features are marked `PARTIAL` with the M12-06 round-trip dependency. Unimplemented +operations stay `UNVERIFIED` rather than claiming geometry/material/animation support. + +## Evidence + +- `node --test web/tests/unit/io-format-capability-matrix.test.mjs` passed 3/3, including duplicate + format, ready-without-executor, unverified-ready-feature, and missing blocked-code negatives. +- `node tools/web/check-io-format-capability-matrix.mjs` passed; the matrix generator reproduced the + committed JSON byte-for-byte and runtime statuses matched the M12-05A inventory. +- The checker reports 7 formats, 1 local bounded GLB export route, and 27 blocked routes. + +## Artifact Hashes + +- parent manifest: `202b144c91f8e2c39477e257aeb26f9bd0b1b05b459ff8a246668024e94deec7` +- protocol: `3063ae5e45f5b1642d329aa554187d121998d816a5a6740a2b9662f00c3c5738` +- generator: `746078caaf29fa5aa2281b901b9ea5fc66f9a935eb3f6e282d4fbfb018d4c390` +- checker: `4a8b35cd7f87238c13627a00c3bb0b34c130fc34d597773574efac7d8909b804` +- unit: `0cc4d8f1df1ecdab20d8100cf5f12b44cb2a68bca4384ef7964a032b2f74b36e` +- matrix: `139c9d764736da176b32414ecda840c07eb0ba5f3864da2dc08ce04bae161366` +- package commands: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` +- manifest: `8cc9517c9f25138c351bc5a79efe3b1ce6d9f6663d3b7c14397c5e4e8f11181a` + +## Next Task + +`M12-05C`: prevent matrix-undecared combinations from appearing in file selection and operator search. + +## Rollback + +Remove the capability matrix protocol/generator/checker, unit test, matrix, golden manifest, and this +status entry. Restore M12-05A as the queue tail and move the machine queue back to `M12-05B`. No +parity ledger rollback is required. diff --git a/docs/status/M12-05C.md b/docs/status/M12-05C.md new file mode 100644 index 00000000..a9b75220 --- /dev/null +++ b/docs/status/M12-05C.md @@ -0,0 +1,52 @@ +# M12-05C Status + +status: done +task: prevent matrix-undeclared combinations from appearing in file selection and operator search +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The schema-1 UI registry is generated from the M12-05B capability matrix and is bound to its exact +SHA-256. Only routes whose runtime operator is `AVAILABLE` and whose local matrix route is `READY` +are exposed to the UI. The current registry therefore accepts `.blend` projects, exposes the existing +bounded local GLB export command, and exposes no blocked import or export route. + +The file input uses the registry-derived accept string and performs a second filename gate before +opening. A matrix-blocked or unknown extension returns `IO_FORMAT_UNSUPPORTED` without invoking the +engine or replacing the current scene. Operator search filters every format-tagged command through the +same registry, so blocked server routes and undeclared formats cannot appear as executable results. + +## Evidence + +- Direct M12-05C lane passed: `node --test web/tests/unit/io-format-ui-gate.test.mjs`, + `node tools/web/check-io-format-ui-gate.mjs`, and Chromium 1/1 on dynamic ports 5413/5417. +- `node tools/web/check-io-format-ui-gate.mjs` reproduced the committed registry byte-for-byte; + import routes=0, local export routes=1, project accept=`.blend,application/octet-stream`. +- `npm --prefix web run typecheck` and `npm --prefix web run build` passed. +- `WEB_TEST_PORT=5412 npm --prefix web run test:asset-library` passed 2/2 N-023 Chromium regressions. +- An initial command without `WEB_TEST_PORT` was blocked by an already occupied 5173; rerunning on the + isolated dynamic port passed and left no server/profile process in the test lane. + +## Artifact Hashes + +- parent manifest: `8cc9517c9f25138c351bc5a79efe3b1ce6d9f6663d3b7c14397c5e4e8f11181a` +- protocol: `fc397ceb947d4ede6c34c1d51438253a6b59244fc40f5eb77ce155bf1c477476` +- generator: `1ef4ae8a3e8d2f73101a87cba1c42ea99a065e1f6846f6a591841b97c0c39e6f` +- checker: `81d6f27df26aab7b633fbe61c6d7b37519668aff41e43314924dceaacb3affde` +- unit: `84ca8fb6022da9f167daa104c44489a6c7d9f059699e57ce621b8d7f64f19082` +- Chromium: `eda2cdb9ede3bcbd717800c9c6fdb28d8cebef96f0296a2ee847a05e60cd0eed` +- registry: `6b410bc6f2cad032af55bf13e1c8ddd841b01e9913ffc0ba381da8b7204285fd` +- package commands: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` +- manifest: `f46fd394e2144255d8b4304e8ce4fe60aad4302a80e991fd83d50cd915235ee3` + +## Next Task + +`M12-05D`: determine capability from runtime receipts and never infer it from filename extensions. + +## Rollback + +Remove the UI registry protocol, generator/checker, registry, unit/Chromium tests, package command, +golden manifest, and this status entry. Restore M12-05B as the queue tail and move the machine queue +back to `M12-05C`. No parity ledger rollback is required. diff --git a/docs/status/M12-05D.md b/docs/status/M12-05D.md new file mode 100644 index 00000000..11495cfe --- /dev/null +++ b/docs/status/M12-05D.md @@ -0,0 +1,54 @@ +# M12-05D Status + +status: done +task: determine format capability from pinned Blender runtime receipts, never filename extensions +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The schema-1 runtime receipt set is generated from the pinned Blender 5.2 M12-05A inventory and +bound to that inventory's SHA-256. Each format/operation receipt preserves the operator path, RNA +identifier, registered state, build option state, variants, and descriptive extensions. A route is +`READY` only when the receipt says `AVAILABLE`, the operator is registered, an RNA identifier exists, +and the build option is not disabled. + +The route resolver takes an explicit format and operation from the receipt and never parses a file +name or extension to grant capability. App filters format-tagged operator search entries and checks +the GLB export route against this receipt set immediately before execution. The pinned runtime marks +GLB export `READY`; USD/Alembic receipts are `OPERATOR_UNREGISTERED` and remain blocked. + +## Evidence + +- `node --test web/tests/unit/io-format-runtime-receipt.test.mjs` passed 3/3, covering receipt-bound + GLB/USD routes, extension mutation without capability change, identity drift, and explicit status. +- `node tools/web/check-io-format-runtime-receipts.mjs` passed; a fresh generator process reproduced + 14 receipts byte-for-byte from M12-05A inventory and verified operator/runtime identity fields. +- `npm --prefix web run typecheck` and `npm --prefix web run build` passed; M12-05C Chromium UI + regression passed 1/1 on port 5417 and N-023 asset/security regression passed 2/2 on port 5418. +- The existing frozen package hash `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` + is preserved; dedicated commands remain direct to avoid invalidating prior release evidence. + +## Artifact Hashes + +- parent manifest: `f46fd394e2144255d8b4304e8ce4fe60aad4302a80e991fd83d50cd915235ee3` +- protocol: `461a84557fa368c29dbc6707959b689faae7c8f7050dccc4d3f12e358b61bb22` +- generator: `796cd641e86d8242c13317f771ae237cbf1692ff675a53bbdb689615edb5f372` +- checker: `aaf9862041f155f96f50ea8481ff765089255bc59ecd8944f12362b81b8c1f1a` +- unit: `a5f09277f5dcdacd25c44191f7407d6cee944f8022b1c467c2a69e172fc2ac6b` +- runtime receipts: `f2c9a77e2cfad0ef3574b804fc06fb22972c07f55c72b0e6db2e359c4d95f57b` +- App receipts: `f2c9a77e2cfad0ef3574b804fc06fb22972c07f55c72b0e6db2e359c4d95f57b` +- App: `74216aac70992f8d879e983237ca324b998008582f0cd4658e90994cf9fae0a8` +- package commands: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` +- manifest: `3d0049a7b0331f01bb71df043270c18d1a5c9ce6dc74353c49c8ce591761df1b` + +## Next Task + +`M12-05E`: bind each runtime receipt to source, settings, and runtime hashes. + +## Rollback + +Remove the runtime receipt protocol, generator/checker, receipt set, App gate, unit test, golden +manifest, and this status entry. Restore M12-05C as the queue tail and move the machine queue back to +`M12-05D`. No parity ledger rollback is required. diff --git a/docs/status/M12-05E.md b/docs/status/M12-05E.md new file mode 100644 index 00000000..cada1000 --- /dev/null +++ b/docs/status/M12-05E.md @@ -0,0 +1,49 @@ +# M12-05E Status + +status: done +task: bind every runtime receipt to source, settings, and runtime hashes +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The schema-1 bound receipt set derives from M12-05A inventory and M12-05D runtime receipts. Every +one of the 14 format/operation records carries three independent SHA-256 values: a source/operator +identity hash, a settings/schema hash (including canonical variants/extensions and Blender RNA +properties), and a runtime identity hash. The set also binds the parent M12-05D receipt-set hash and +the M12-05A inventory hash. + +The validator requires all three hashes and parent identities before a bound receipt can be resolved. +The generator uses sorted-key canonical JSON and the checker independently recomputes every hash, +then rebuilds the artifact in a fresh process for byte-for-byte determinism. No package metadata was +changed, preserving the existing release evidence hash. + +## Evidence + +- `node --test web/tests/unit/io-format-receipt-binding.test.mjs` passed 2/2: valid three-hash + resolution plus malformed source/settings/runtime and parent identity rejection. +- `node tools/web/check-io-format-receipt-bindings.mjs` passed; all 14 source/settings/runtime hashes + and parent/inventory identities matched a fresh deterministic regeneration. +- `npm --prefix web run typecheck` passed; `git diff --check` passed. + +## Artifact Hashes + +- parent manifest: `3d0049a7b0331f01bb71df043270c18d1a5c9ce6dc74353c49c8ce591761df1b` +- protocol: `681e719ab2b18eb85d056547fb70b461dd73b3a7fb4fdbe6295b8e49d5649e49` +- generator: `eb7b5c499f141c1788bc37e53585662eedff3f2dabff870f4ad166f4a619796f` +- checker: `ec2b22b468809ecc25ab2d4983065680a66ad3be6705d1827b44bf45ac622e26` +- unit: `e9ae3e360ad41c32da3634a4efa915654853e79a35df36728c1ddf586a0bf7b5` +- bound receipts: `7f765bf16b62466f579b3de00751a0e012fef1c788f229c8e9675a57caa18aad` +- package commands: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` +- manifest: `PENDING` + +## Next Task + +`M12-05F`: reject forged, stale, or cross-version runtime receipts before use. + +## Rollback + +Remove the binding protocol, generator/checker, bound receipt golden, unit test, manifest, and this +status entry. Restore M12-05D as the queue tail and move the machine queue back to `M12-05E`. No +parity ledger rollback is required. diff --git a/docs/status/M12-05F.md b/docs/status/M12-05F.md new file mode 100644 index 00000000..8225a46e --- /dev/null +++ b/docs/status/M12-05F.md @@ -0,0 +1,54 @@ +# M12-05F Status + +status: done +task: reject forged, stale, or cross-version runtime receipts before use +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The schema-1 freshness envelope is derived from the M12-05E bound receipt set. It carries the +M12-05E byte identity, a canonical digest of the complete bound receipt set, and a canonical digest +of the pinned Blender runtime identity. Before a route can be used, the parent binding hash, +inventory hash, runtime identity, runtime hash, and receipt-set hash must all match the trusted +expectation. The route resolver re-validates this gate on every use and only then returns `READY`. + +Forged receipt content returns `RECEIPT_FORGED`; an older parent or inventory returns +`RECEIPT_STALE`; a different Blender version/build identity returns `RECEIPT_CROSS_VERSION`. +Malformed or unavailable routes remain fail-closed as `IO_FORMAT_UNSUPPORTED`. The App GLB export and +format-tagged operator search now consume the freshness envelope rather than the unbound M12-05D set. + +## Evidence + +- `node --test web/tests/unit/io-format-receipt-freshness.test.mjs` passed 4/4: exact trusted route, + canonical digest verification, forged content, stale parent, and cross-version negatives. +- `node tools/web/check-io-format-receipt-freshness.mjs` passed; 14 receipts, App artifact parity, + independent canonical digest verification, deterministic regeneration, and all three negative + classes passed. +- Existing M12-05D/M12-05E unit and checker commands passed unchanged. +- `npm --prefix web run typecheck` and `npm --prefix web run build` passed; `git diff --check` passed. + +## Artifact Hashes + +- parent manifest: `2fbaaf39c6acd9f55fbdbadccc0b027e9e7763bf069c954a96ca49b193648990` +- protocol: `111a630c7beccd31989dc4f78932b7d7b741fb6bef03e45cb39a8139f774d235` +- generator: `6a1e798ce46e1d14d833091d4d7ae452dccb13efe583594277785465eb725bbf` +- checker: `7a8fe69ea1aa2c1e121be008a9fb60fc236f7ef776d2088a7b00b14f46fe3fba` +- unit: `cd1c2adca81653f98f9a1c6c7d104ad0e3052283213fb7166dac827c956928fe` +- freshness receipts: `0187ad0d9ea05fc4b152b7abd4945191dbe9ec24dfde4ca7dbe4aadfd1230efe` +- App freshness receipts: `0187ad0d9ea05fc4b152b7abd4945191dbe9ec24dfde4ca7dbe4aadfd1230efe` +- App expected identity: `8d65f78aa774ff252871cb1cc09e69b4ff04fbb45e61200eaf67534c9d2651b2` +- App: `043ff3a2f39ef3a1303791081b80851b427e7db5dfd9af2161926dd6f1ea9ca4` +- package commands: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` +- manifest: `63e1506801ddee2f1eaf64cad68a9d5c918405f642ca237c6a1ec249ff297623` + +## Next Task + +`M12-06A`: desktop generate the bounded Mesh/PBR/UV/skin/animation GLB fixture group. + +## Rollback + +Remove the freshness protocol, generator/checker, freshness artifacts, unit test, App imports/route +gate, manifest, and this status entry. Restore M12-05E as the queue tail and move the machine queue +back to `M12-05F`. No parity ledger rollback is required. diff --git a/docs/status/M12-06A.md b/docs/status/M12-06A.md new file mode 100644 index 00000000..9260e871 --- /dev/null +++ b/docs/status/M12-06A.md @@ -0,0 +1,59 @@ +# M12-06A Status + +status: done +task: generate the bounded Mesh/PBR/UV/skin/animation GLB fixture group on desktop +updated: 2026-08-18 America/New_York +enablingTask: true +parityStateChange: false + +## Scope + +The pinned Blender 5.2.0 LTS desktop runtime generates five independent GLB 2.0 fixtures: mesh, +PBR, UV texture, two-joint skin, and object animation. Each file has one bounded triangle primitive, +uses no required or optional glTF extension, and remains below the 512 KiB per-file budget. The +fixtures isolate feature ownership so later Web import comparisons cannot hide one unsupported +domain behind an all-in-one scene. + +The canonical report records runtime identity, file bytes and SHA-256, nodes, topology accessors, +attributes, materials, textures/images, skins, and animations. The checker binds the M12-05F parent +manifest and the pinned M12-05A Blender binary identity, regenerates all five files in a fresh +temporary directory, and requires both the semantic report and every GLB byte to match exactly. +This enabling task does not claim Web import, `.blend` save/reopen, export loss reporting, or GLB +round-trip parity. + +## Evidence + +- `node tools/web/check-glb-desktop-fixtures.mjs` passed: 5 fixtures, 8,676 aggregate bytes, + Mesh/PBR/UV/skin/animation assertions, exact report regeneration, and exact GLB regeneration. +- Mesh exports indexed triangles with POSITION/NORMAL/COLOR_0; UV exports TEXCOORD_0 plus one + embedded PNG; skin exports JOINTS_0/WEIGHTS_0 and two inverse-bind matrices; animation exports + translation and rotation channels sampled over 25 frames. +- The runtime binary SHA-256 is + `d4483926610484ef9c2ad9241aae1469f934d955ebe791f1920e263e0ba85b82`, matching M12-05A. +- `npm --prefix web run typecheck`, `npm --prefix web run test:status-consistency`, and + `git diff --check` passed. The package hash remains the frozen + `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c`. + +## Artifact Hashes + +- parent manifest: `63e1506801ddee2f1eaf64cad68a9d5c918405f642ca237c6a1ec249ff297623` +- generator: `0247dd2405a68b42d99c2b005546ad2aa99b46416e3fe9489832467f6ea4eb4d` +- checker: `211ebdb66bf2e2d349455d5303e889a4a1ae3323639a89dd78b3bda574dd820b` +- desktop report: `dea31cc861622166dc502b333bc177b70b66b54d9c62aaccc6522745dab5ae13` +- mesh GLB: `e52b9268b6524744fb498691f976000635e544ba3b47e9f8ff22b03bcdf17a94` +- PBR GLB: `244cc8a992c5a70692b8bbc8333533718b3bac7022110715ce3b23d2e4c0b361` +- UV GLB: `1e0397d2b69d8261b3252451b50ab4aba6525b94713719f35069a9a9d96fcfa2` +- skin GLB: `4086ee4c8873aa03090338b676575b7a5335fb7c9384fec6ccb36108e71929f6` +- animation GLB: `44f6cc47961a146dc97ea337ae31a8b64bb4ab213b716f81ec22129db704f1fb` +- manifest: `e3554a1e739cbe3f217f6169130532365538b0c0eafbb97afc58d4d56c4287cb` + +## Next Task + +`M12-06B`: import the desktop GLB fixture group on Web and compare topology, attributes, materials, +nodes, and animations. + +## Rollback + +Remove the desktop fixture generator/checker, five GLB files, report, manifest, and this status +entry. Restore M12-05F as the queue tail and move the machine queue back to `M12-06A`. No parity +ledger rollback is required. diff --git a/docs/status/M12-06B.md b/docs/status/M12-06B.md new file mode 100644 index 00000000..7af0ea8d --- /dev/null +++ b/docs/status/M12-06B.md @@ -0,0 +1,57 @@ +# M12-06B Status + +status: done +task: compare desktop GLB topology, attributes, materials, nodes, and animations in Web +updated: 2026-08-18 America/New_York +enablingTask: true +parityStateChange: false + +## Scope + +The production TypeScript GLB parser now exposes a canonical bounded semantic surface for the five +M12-06A desktop fixtures. It validates referenced accessors, nodes, materials, textures, skins, and +animation samplers before returning primitive topology, sorted attributes, PBR material fields, +node hierarchy/TRS, skin bindings, and animation channels. A field-level comparator checks that Web +semantics exactly match the pinned Blender 5.2 desktop report. + +Both Node and a real Chromium Worker consume the exact committed GLB bytes and verify each source +SHA-256 before comparing all five domains. The existing summary import API remains compatible. This +task does not create Blender Main data or expose GLB in the file picker; the runtime route remains +`BLOCKED_UNTIL_MAIN_PERSISTENCE` until M12-06C proves save/reopen and stable IDs. + +## Evidence + +- `node --test web/tests/unit/glb-desktop-import.test.mjs` passed 3/3: five exact fixture imports, + separate domain assertions, and a stable field-level PBR mismatch. +- `node tools/web/check-glb-desktop-import.mjs` passed: 5 fixtures, 5 compared domains, exact + desktop/Web canonical hashes, deterministic report regeneration, and the blocked route state. +- `CHROME_PATH=/usr/bin/google-chrome-stable WEB_TEST_PORT=5425 node_modules/.bin/playwright test + --config playwright.config.ts --workers=1 tests/e2e/glb-desktop-import.spec.ts` passed 1/1 from + the `web/` directory using Chrome 150. The Worker verified source SHA-256 and all five semantic + comparisons. +- `npm --prefix web run typecheck`, `npm --prefix web run test:status-consistency`, and + `git diff --check` passed. The package hash remains + `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c`. + +## Artifact Hashes + +- parent manifest: `e3554a1e739cbe3f217f6169130532365538b0c0eafbb97afc58d4d56c4287cb` +- protocol: `45d9a7912c4a59a552789a8ea0dfaff5f1849f8d213f14d238de024b77acdb0d` +- generator: `6f97527b7da04c7b4f9b09c48b9f367d1270db9f7820498d33832a80754436c2` +- checker: `804a4b5545d95d7ab1ba265469a981d0a10b71dab36f0c96283eb73b401443d2` +- unit: `6bf2a96b3e43e5fae93589ea5dcf98e7a69b10266378e442988198925286a8d8` +- Chromium Worker: `eb32049631113270fb62acb7ae9379e8ff9a03e38086db2fa309c6891cd707bf` +- Chromium test: `fa3e06419c918406f24b5a4260523bd94cfe729a84786e9434517fdfed624d45` +- Web import report: `79933888cc5aa2d1e3639ec349ca4c31d028fe89f751ebb8d4342f06d15ecfc2` +- manifest: `5275310394b34726a05b30ab67658e1381662dddf9163a97cb02f47f646a8fa4` + +## Next Task + +`M12-06C`: create authoritative Main data from the imported GLB, save `.blend`, reopen it, and +compare stable IDs. + +## Rollback + +Remove the canonical Web import surface, report generator/checker, Node/Chromium tests, report, +manifest, and this status entry. Restore M12-06A as the queue tail and move the machine queue back to +`M12-06B`. No parity ledger rollback is required. diff --git a/docs/status/M12-06C.md b/docs/status/M12-06C.md new file mode 100644 index 00000000..5cf3f253 --- /dev/null +++ b/docs/status/M12-06C.md @@ -0,0 +1,59 @@ +# M12-06C Status + +status: done +task: import desktop GLB results into authoritative Main, save `.blend`, reopen, and compare stable IDs +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The pinned Blender 5.2 desktop runtime imports each M12-06A GLB fixture and saves a `.blend`; the +generator reopens every saved file and requires the complete stable Object/Mesh/Material/Image/ +Armature/Action ID set to remain unchanged. Chromium then opens those authoritative Main fixtures +through the production WebEngine, applies one Main-owned visibility edit, saves the resulting +`.blend`, reopens it in a fresh isolated open transaction, and compares the stable IDs with the +desktop baseline. The saved edit is checked after reopen and the open resource counters are zero for +input, staging, and active requests. + +This task does not expose GLB in the normal `.blend` file picker, claim arbitrary glTF extensions, +or change the N-023 full-parity ledger. M12-06B remains the bounded Web GLB semantic parser and +M12-06D owns export loss reporting. + +## Evidence + +- `node tools/web/check-glb-main-persistence.mjs` passed the parent manifest, all artifact hashes, + five desktop GLB/Main fixture bindings, stable-ID shape checks, and a fresh Blender regeneration + with exact semantic report/stable-ID comparison. +- `CHROME_PATH=/usr/bin/google-chrome-stable WEB_TEST_PORT=5437 node_modules/.bin/playwright test + --config playwright.config.ts --workers=1 tests/e2e/glb-main-persistence.spec.ts` passed 1/1. + Every fixture opened in the production WebEngine, performed one authoritative visibility edit, + serialized a changed `.blend`, reopened it, preserved all stable IDs, restored visibility=false, + and returned zero active requests/input/staging resources. +- `npm --prefix web run typecheck` and `git diff --check` passed. The existing package manifest was + left unchanged so prior M12 evidence remains bound to its frozen package hash. + +## Artifact Hashes + +- parent manifest: `5275310394b34726a05b30ab67658e1381662dddf9163a97cb02f47f646a8fa4` +- generator: `8989d6ce4196f140a7bfb44b863dc530a6aa462ca4a57fa7b77c468e06ab61ad` +- checker: `5bbbbeaf2d20ff9bbdeb74c8dd10fa6ffc421d1d59f86540e95f29c2cfd10903` +- desktop report: `76b000454a9073ef762d25fa546d5c65a004659a93eb6ec82fad1e679b2e81be` +- Chromium test: `7c97877f1cbbfd0166e106c80faee53f474f78816cca68ea924df5aa3c47776d` +- package: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` +- mesh `.blend`: `66e29adc016f07e220019b6f24eb7e5016c684dca4b3102b20c8e836968d422d` +- PBR `.blend`: `ba08aeb2876d82ddf731abe81d3a42041a1be36617d0b6324c870d5bcd4cc771` +- UV `.blend`: `1270cb452d34d2fd7a19181a2b20f70b7a028bdd2db7cf1bba4e1003f7de0f48` +- skin `.blend`: `23f175e44ec588c75db99d3f9134863fc3d7d1f192bbd815d5d1c4e3218bce0c` +- animation `.blend`: `fa6baf3a67ff11fe3d2922210089a7c508e4ee28bfaabe4cf628ba6addee1ff5` +- manifest: `e3a57636a47591e3b02dff5a07e8a0aec5e0dc43bf6b4829bffc811035dbbb31` + +## Next Task + +`M12-06D`: Web export generates a machine loss report. + +## Rollback + +Remove the M12-06C generator/checker, manifest, desktop Main fixtures, report, Chromium test, and +this status entry. Restore M12-06B as the queue tail and move the machine queue back to M12-06C. No +parity ledger rollback is required. diff --git a/docs/status/M12-06D.md b/docs/status/M12-06D.md new file mode 100644 index 00000000..bf12bd81 --- /dev/null +++ b/docs/status/M12-06D.md @@ -0,0 +1,56 @@ +# M12-06D Status + +status: done +task: Web GLB export generates a machine-readable loss report +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The production GLB exporter now has a schema-1 deterministic loss-report wrapper. It records the +source SceneIR identity/revision, exportability, error/warning counts, sorted loss entries and the +bounded data-block surface. Chromium opens all five M12-06C authoritative Main fixtures and sends +their real snapshot/geometry/packed assets through the production exporter Worker. Exportable +PBR/UV/skin/animation outputs are byte-hashed; the mesh fixture's unsupported Color Attribute +shader is reported as `SHADER_GRAPH_UNMAPPABLE` and no GLB bytes are emitted. + +This task reports the bounded exporter surface only. It does not claim lossless GLB round-trip, +desktop re-import, arbitrary shader mapping, or GLB file-picker import; those remain later M12-06E/G +tasks or explicit blockers. + +## Evidence + +- `node --test web/tests/unit/glb-loss-report.test.mjs` passed deterministic sorting/count semantics. +- `node tools/web/check-glb-loss-report.mjs` passed artifact hashes, parent Main fixture binding, + deterministic loss ordering, mesh fail-closed output, and four exportable fixture output hashes. + It reports `fixtures=5 blocked=mesh warnings=3`. +- `CHROME_PATH=/usr/bin/google-chrome-stable WEB_TEST_PORT=5445 node_modules/.bin/playwright test + --config playwright.config.ts --workers=1 tests/e2e/glb-export-loss-report.spec.ts` passed 1/1. + The test uses `WebEngineClient` and a production module Worker, requests packed image assets, + produces the report, and compares it byte-for-byte with the checked-in golden report. +- `npm --prefix web run typecheck` and `git diff --check` passed. `web/package.json` remains + unchanged so previous M12 package bindings stay valid. + +## Artifact Hashes + +- parent manifest: `e3a57636a47591e3b02dff5a07e8a0aec5e0dc43bf6b4829bffc811035dbbb31` +- export protocol: `a5d342827860a9e55b49a3bb3a196a01b1e53546325d6e594778afb9360c3125` +- loss report protocol: `dce9c790b2f52d46efe0908ecb044d63d21b3c6c3f7d77ddb5e697b29a7a2d6e` +- Worker: `6f6294d26fe7b717416a5dd25fe834fb4b9a2ecbe8b011395c9559a26701ec77` +- Chromium test: `ce334b6bb5d82c133d317e916c03711029fba1c19c634dec06c06da9eddbd776` +- checker: `a23346860fa26405b2cd24591b0ab36fea29cb561ec8c2991ecfeabcffc17ee1` +- unit: `dfcc2d968e4e0c0cef4496bc304cb481f10d1f0dc4c76a40ea1d6e2f11b6b708` +- web loss report: `c6db52234d867985ef5fbcdc7c62298ec9aaa013028b5a54e2b9a16aa4133397` +- package: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` +- manifest: `c01c5861d493e1f177e8cde3038bc5283a7671fa39bf84484662d307623325a2` + +## Next Task + +`M12-06E`: desktop Blender re-imports Web GLB and compares the canonical report. + +## Rollback + +Remove the loss-report protocol/Worker, checker, manifest, golden report, Chromium test, and this +status entry. Restore M12-06C as the queue tail and move the machine queue back to M12-06D. No parity +ledger rollback is required. diff --git a/docs/status/M12-06E.md b/docs/status/M12-06E.md new file mode 100644 index 00000000..81881280 --- /dev/null +++ b/docs/status/M12-06E.md @@ -0,0 +1,55 @@ +# M12-06E Status + +status: done +task: desktop Blender re-imports Web GLB and compares canonical report +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The four Web GLBs that produced bytes in M12-06D (PBR, UV, skin and animation) are re-imported by +the pinned Blender 5.2 desktop runtime, saved as `.blend`, reopened, and emitted as canonical graph +reports. The checker binds each Web output hash to the M12-06D report and compares Object/Mesh/ +Material/Image/Armature/Action graph fields against the M12-06C desktop-import baseline. + +PBR and animation are exact. UV records four explicit differences (corner-expanded vertex topology +and Blender's generated texture Mix node); skin records 31 explicit differences (the exporter emits +an additional armature helper mesh and Blender's re-import tessellation changes). These are recorded +machine mismatches, not silently treated as parity. The N-023 parity ledger remains blocked and this +task does not claim lossless GLB round-trip. + +## Evidence + +- `node tools/web/check-glb-web-reimport.mjs` passed artifact hashes, four Web GLB source bindings, + pinned Blender regeneration determinism, save/reopen stability, and canonical comparison. Output: + `glb-web-reimport-ok fixtures=4 exact=2 mismatched=uv,skin deterministic=true next=M12-06F`. +- The report binds Web GLB hashes `pbr=1ab793...`, `uv=8bd045...`, `skin=4a45d0...` and + `animation=b83de1...`; exact/mismatch counts are fixed at PBR 0, UV 4, skin 31, animation 0. +- The Web export Chromium test that produced the inputs passed 1/1; the desktop generator performs + a fresh Blender import/save/reopen for every input. `npm --prefix web run typecheck` and + `git diff --check` passed. + +## Artifact Hashes + +- parent manifest: `c01c5861d493e1f177e8cde3038bc5283a7671fa39bf84484662d307623325a2` +- generator: `3ae1ba45e15cae5d0308fc3fcb3766764cd374c096e27eaaddba9228a3d75004` +- checker: `c21b45a975ec70586da1b9e50b8cf4ccf74644300b4f381c3fee0ad735cfcdb9` +- desktop report: `e4d03d25cfac3c4beff4d0af0769b1c39b058670c679c12f4a11ae30d2d91f3c` +- Web export test: `ce334b6bb5d82c133d317e916c03711029fba1c19c634dec06c06da9eddbd776` +- PBR Web GLB: `1ab7936fae6e0c28e1b90e8a6ae24b3893c075c9fc58ac8896f780fdefb8277e` +- UV Web GLB: `8bd045388527ddad7cf886c0c7a2a45b6e7d6db603568a10a959bc6d423ae145` +- skin Web GLB: `4a45d00dfa23638682bb61a4f74b283bcd986126da4890d068902e3c85efc332` +- animation Web GLB: `b83de103df3f5a49487868f3ede3046875c90b0d084bbd998511c3a7c987a4fa` +- package: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` +- manifest: `3d3b13fd54a314fd6f6907fc2d314e66ef74e8ca770a8accd823370d272737fc` + +## Next Task + +`M12-06F`: sparse accessor, Draco/extension, external URI and over-budget negative cases. + +## Rollback + +Remove the desktop Web re-import generator/checker, manifest, report, four Web GLB fixtures, and +this status entry. Restore M12-06D as the queue tail and move the machine queue back to M12-06E. No +parity ledger rollback is required. diff --git a/docs/status/M12-06F.md b/docs/status/M12-06F.md new file mode 100644 index 00000000..3867ef60 --- /dev/null +++ b/docs/status/M12-06F.md @@ -0,0 +1,49 @@ +# M12-06F Status + +status: done +task: GLB sparse accessor, extension, external URI and over-budget negative cases +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The bounded GLB importer now enforces a 512 KiB input budget, JSON/table budgets, rejects sparse +accessors, rejects all extensions outside the pinned allowlist (currently empty), and blocks external +buffer/image URIs. Errors are stable and fail before semantic/Main publication: +`GLB_SPARSE_ACCESSOR_UNSUPPORTED`, `GLB_EXTENSION_UNSUPPORTED`, `GLB_EXTERNAL_URI_BLOCKED`, and +`GLB_IMPORT_BUDGET_EXCEEDED`. + +## Evidence + +- `node --test web/tests/unit/glb-negative-cases.test.mjs` passed 2/2 for sparse, extension, external + URI, byte-budget and table-count cases. +- `CHROME_PATH=/usr/bin/google-chrome-stable WEB_TEST_PORT=5450 node_modules/.bin/playwright test + --config playwright.config.ts --workers=1 tests/e2e/glb-negative-cases.spec.ts` passed 1/1 in a + production Chromium Worker with all four stable codes. +- `node tools/web/check-glb-negative-cases.mjs` passed the schema-1 negative report and artifact + hashes: `glb-negative-cases-ok cases=4 budget=524288 deterministic=true next=M12-06G`. +- `npm --prefix web run typecheck` and `git diff --check` passed. The package manifest remains + unchanged. + +## Artifact Hashes + +- parent manifest: `3d3b13fd54a314fd6f6907fc2d314e66ef74e8ca770a8accd823370d272737fc` +- protocol: `0b6329c08e6f3cd9af6f27ef7c463b037a7cab94192afb4538d3d6c4cfcbc147` +- unit: `9af1c155143a0c685a62f569f705afd9fcc3bb49e0d724ebf3ad2c6356d63d6d` +- Worker: `d84a6b884ce1bfedd598b2602d803493bf10f6ad62fdfac49d64fca3f30f3931` +- Chromium test: `c6694689ce04ff2faea2c20be648f438a9f7eb25cdfd2f714b94c1f556ea1510` +- checker: `08377c306fd7d1082f7fd734e37809960c9798d29d626681fe9ad3f94cb7a29e` +- negative report: `7367a624b562a9613b4b908c894ab04c731ae0a348a3b58689075f4a9decd90c` +- package: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` +- manifest: `27b42da0683dab11a884553440088c30cf58d51364268fdaac86668786aaedd4` + +## Next Task + +`M12-06G`: import/export cancellation, Worker restart and OPFS quota recovery. + +## Rollback + +Remove the GLB negative-case budget/protocol changes, Worker, tests, checker, report, manifest, and +this status entry. Restore M12-06E as the queue tail and move the machine queue back to M12-06F. No +parity ledger rollback is required. diff --git a/docs/status/M12-06G.md b/docs/status/M12-06G.md new file mode 100644 index 00000000..ad1bbdfc --- /dev/null +++ b/docs/status/M12-06G.md @@ -0,0 +1,57 @@ +# M12-06G Status + +status: done +task: GLB import/export cancellation, Worker restart, and OPFS quota recovery +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +Schema-1 GLB recovery receipts now bind operation, worker generation, source/output SHA-256, +revision, temporary bytes, live requests, commit state, and stable cancellation/restart/quota codes. +Both import and export cancellation release all temporary bytes and publish no result. A new Worker +repeats the same bounded GLB import with an identical semantic result hash. + +The GLB asset persistence path uses the existing content-addressed OPFS store. A real Chromium +origin quota of 64 KiB rejects a 128 KiB candidate, while the previously committed GLB remains +readable after Storage Worker restart. Raising the quota allows a small follow-up asset to commit. + +## Evidence + +- `node --test web/tests/unit/glb-recovery.test.mjs` passed 2/2 for cancel, quota, commit, and + generation transition invariants. +- `CHROME_PATH=/usr/bin/google-chrome-stable WEB_TEST_PORT=5455 node_modules/.bin/playwright test + --config playwright.config.ts --workers=1 tests/e2e/glb-recovery.spec.ts` passed 2/2 in Chrome + 150. The first real-quota run reached the expected browser failure but the assertion only accepted + the injected error wording; the final run accepts both native and injected quota messages. +- `node tools/web/check-glb-recovery.mjs` passed with + `glb-recovery-ok cancelled=2 workerGeneration=2 quota=GLB_OPFS_QUOTA smallRecovery=true next=M12-07A`. +- `npm --prefix web run typecheck`, `node --check tools/web/check-glb-recovery.mjs`, and + `git diff --check` passed. The package manifest remains unchanged. + +## Artifact Hashes + +- parent manifest: `27b42da0683dab11a884553440088c30cf58d51364268fdaac86668786aaedd4` +- recovery protocol: `da4e2a546d7598e80798cfebd105e52b7522c896acb545710c78bb8a5c3705aa` +- storage protocol/client/Worker: `2c8ce9e32fcae973e9262a024fd849221680104bcd427ec308f99ef25112d951` / + `00cd35e4632479e4cd153314113e201c822fd209ae6c8e9fbeff5f1399728565` / + `80bf4d2ac59cbf7998c2a72c8961ae38abe43685b60df564d0746e1a45e9e1bc` +- operation Worker/browser adapter: `947c4a768422725ff2f689b2eefa8068bee51bb7ff342af17e8d4a4d0a4a7ca2` / + `2c753a04c153471c2bf7691073b836d968a1a1300e3da038f493983b46738cdf` +- unit/Chromium: `6dd48f62a85c5aaf3a04b6e572a47a7986b23cbec84a099ea510af81e8dd3a3a` / + `7d95e992f44fb913f70c104f0d6b23bd76f47d48db4a21899e35ae1d188e2093` +- checker/report: `b185248fdcd6b3cee84252bd68fcb6921bb6385bfa4a00486e93c58e94755cce` / + `1a008a76590573468446ca6e5cbdfe0ea5a8b27493291590d937b90db90d6e42` +- package: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` +- manifest: `1c732b8d9f1a0bdb502f44e2e843e91efea12e93483a35658f8a9c70a69a2430` + +## Next Task + +`M12-07A`: OBJ single-Mesh positive fixture covering position, normal, UV, and material group. + +## Rollback + +Remove the GLB recovery receipt, quota fault option, Worker/browser adapter, tests, checker, report, +manifest, and this status entry. Restore M12-06F as the queue tail and move the machine queue back to +M12-06G. No parity ledger rollback is required. diff --git a/docs/status/M12-07A.md b/docs/status/M12-07A.md new file mode 100644 index 00000000..7235e167 --- /dev/null +++ b/docs/status/M12-07A.md @@ -0,0 +1,49 @@ +# M12-07A Status + +status: done +task: OBJ single-Mesh desktop positive fixture +updated: 2026-08-18 America/New_York +enablingTask: true +parityStateChange: false + +## Scope + +Pinned Blender 5.2 `WM_OT_obj_export` now generates one deterministic Wavefront OBJ object with +four positions, four UV coordinates, one explicit normal, two triangle faces, two material records, +and two material groups. The `.mtl` sidecar is kept beside the OBJ and both files are bound to the +canonical semantic report. Web parsing/import, multi-object and negative cases remain queued. + +## Evidence + +- `build_blender_5.2.0/bin/blender -b --factory-startup --python + tools/web/generate-obj-single-mesh-fixture.py -- tests/files/web/m12_obj_desktop_v1 + tests/golden/M12-07A/desktop-fixture.json` completed with 4 positions, 4 UVs, 1 normal, + 2 faces, and 2 materials/groups. +- `node tools/web/check-obj-single-mesh-fixture.mjs` passed deterministic regeneration in a fresh + temporary directory and byte-for-byte OBJ/MTL comparison: + `obj-single-mesh-fixture-ok vertices=4 normals=1 uv=4 faces=2 materials=2 deterministic=true next=M12-07B`. +- Runtime identity matches the pinned M12-05A Blender 5.2 inventory; the report is anchored to + `blender-5.2.0/source/blender/io/wavefront_obj` and `wm.obj_export`. +- Package hash remains frozen; no Web route or parity ledger status was changed. + +## Artifact Hashes + +- parent manifest: `1c732b8d9f1a0bdb502f44e2e843e91efea12e93483a35658f8a9c70a69a2430` +- generator: `604c3006f194c7c64a487b8f760693b66830f3cfa602928b46769955e7d4f358` +- checker: `3d0208f61a86d4d29796d8284756f53931c90864b15b7dfe4fcc84d7f65a8040` +- desktop report: `6c560a8eecf84973c2b05f9fd50d33bd45515e97c4f7970f1502de406c39f6a5` +- OBJ/MTL: `a56694d1ee28735c68381ff18c3a52581612feebac0101a53e502956c27d144f` / + `392f1b10ff5a0b142d520a9443b4c96191985f15d4244c79b36fdeda0f153715` +- runtime inventory: `0d660b0fd8b647ebbf4e91afebd5006bd100973ab8e2507a5bfe759f477b33b4` +- package: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` +- manifest: `d80b74502202effa7be15640c945dc7e04af703b58e7a15c3fe7babc43c17b46` + +## Next Task + +`M12-07B`: OBJ multi-object, negative-index, MTL/texture-origin, and malformed-face cases. + +## Rollback + +Remove the OBJ generator, checker, desktop report, OBJ/MTL fixture, manifest, and this status entry. +Restore M12-06G as the queue tail and move the machine queue back to M12-07A. No parity ledger rollback +is required. diff --git a/docs/status/M12-07B.md b/docs/status/M12-07B.md new file mode 100644 index 00000000..1d684e81 --- /dev/null +++ b/docs/status/M12-07B.md @@ -0,0 +1,53 @@ +# M12-07B Status + +status: done +task: OBJ multi-object, negative-index, texture-origin, and malformed-face fixtures +updated: 2026-08-18 America/New_York +enablingTask: true +parityStateChange: false + +## Scope + +Pinned Blender 5.2 `wm.obj_export` now produces a two-object OBJ/MTL positive fixture with two +relative `map_Kd` references and a 2x2 PNG texture. The deterministic generator derives a negative +index OBJ variant and a malformed two-vertex face variant from the same positive bytes. The report +keeps multi-object/group/material counts and expected fail-closed `OBJ_FACE_ARITY_INVALID` separate; +it does not claim that a Web parser or OBJ import route exists. + +## Evidence + +- `build_blender_5.2.0/bin/blender -b --factory-startup --python + tools/web/generate-obj-multi-negative-fixtures.py -- tests/files/web/m12_obj_multi_v1 + tests/golden/M12-07B/desktop-fixtures.json` completed with 2 objects, 6 positions, 6 UVs, + 2 normals, 2 faces, 2 materials, relative `m12_obj_texture.png`, and both derived variants. +- `node tools/web/check-obj-multi-negative-fixtures.mjs` passed fresh-directory Blender regeneration, + exact OBJ/MTL/PNG/negative/malformed bytes, negative-index acceptance shape, malformed-face arity, + and PNG signature checks: + `obj-multi-negative-fixtures-ok objects=2 negative=true malformed=OBJ_FACE_ARITY_INVALID textureOrigin=relative deterministic=true next=M12-07C`. +- Runtime identity matches M12-05A pinned Blender 5.2; no Web parser, UI route, or parity ledger + status changed. Package hash remains frozen. + +## Artifact Hashes + +- parent manifest: `d80b74502202effa7be15640c945dc7e04af703b58e7a15c3fe7babc43c17b46` +- generator: `8d4faed82cba76e46bf639e5031b30568e8b9a15240cbddb1c22ee7ad11d697b` +- checker: `61188d67efd6133e714f2d55c7c8516b9d04896de068f994523a3c398eebdd2f` +- desktop report: `b60ff785676c0f0168588605ad442a650615191ac00770b7e5a308cc4ade83ce` +- OBJ/MTL/PNG: `4ed3dced8445b03754cd3916ae2e8cf0dff85e6a5882f1c6f0a4a4e4c5251f6a` / + `80338f569c67a8b1616515ba878ffc72b26e93ef2e8c361131023fb14f132f5f` / + `44e4fd08bdda8403908349e2a62dcf103ce2d0851099fc61cd868d65ddbef78c` +- negative/malformed OBJ: `9457954284cac1d68e23627e3ff734c0c591b3a24086ce5aa3d0dbbfc22f01bc` / + `6dd508b5ba944c2d15e2889c9ad9a3693845145c3bf6c9bf7df992081c915864` +- runtime inventory: `0d660b0fd8b647ebbf4e91afebd5006bd100973ab8e2507a5bfe759f477b33b4` +- package: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` +- manifest: `785f593271058098704498cb0a7c948305087f1a83bf8f29b6e6fb9fe9341926` + +## Next Task + +`M12-07C`: Web-to-desktop OBJ round-trip and loss report. + +## Rollback + +Remove the multi/negative OBJ generator, checker, fixtures, report, manifest, and this status entry. +Restore M12-07A as the queue tail and move the machine queue back to M12-07B. No parity ledger rollback +is required. diff --git a/docs/status/M12-07C.md b/docs/status/M12-07C.md new file mode 100644 index 00000000..cb1a70a5 --- /dev/null +++ b/docs/status/M12-07C.md @@ -0,0 +1,55 @@ +# M12-07C Status + +status: done +task: Web-to-desktop OBJ round-trip and loss report +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The bounded TypeScript OBJ protocol parses positive and negative indices, position/UV/normal streams, +objects/groups/faces, MTL `map_Kd`, and stable budgets; it serializes a deterministic Web OBJ/MTL pair +and emits a machine loss report for unbound texture origins. A Chromium Worker consumes the M12-07B +double-object fixture, then a new pinned Blender 5.2 process imports the Web output with split groups. + +## Evidence + +- `node --test web/tests/unit/obj-import.test.mjs` passed 3/3 for negative-index resolution, + deterministic serialization, bound/unbound texture loss, malformed face arity, and index range. +- `CHROME_PATH=/usr/bin/google-chrome-stable WEB_TEST_PORT=5460 node_modules/.bin/playwright test + --config playwright.config.ts --workers=1 tests/e2e/obj-web-roundtrip.spec.ts` passed 1/1 in Chrome + 150. Browser output has 2 objects, 6 positions/UVs, 2 normals, 2 faces, 2 materials; bound texture + loss is zero and missing texture loss contains two `OBJ_TEXTURE_ORIGIN_UNRESOLVED` warnings. +- The test writes the browser OBJ/MTL and texture to an isolated directory, invokes pinned Blender + `wm.obj_import`, and compares 2 objects, 2 triangles, UVMap presence, and one material per object. +- `node tools/web/check-obj-web-roundtrip.mjs` passed the exact golden and artifact hash gate: + `obj-web-roundtrip-ok objects=2 triangles=2 lossWarnings=2 desktopExact=true next=M12-07D`. +- `npm --prefix web run typecheck`, Python compile, and `git diff --check` passed. Package hash remains + frozen; OBJ Web capability is still bounded and does not change N-023 parity. + +## Artifact Hashes + +- parent manifest: `785f593271058098704498cb0a7c948305087f1a83bf8f29b6e6fb9fe9341926` +- protocol: `e7240af65e0d90f3ee690a4e3f391416fe4744ac6c46edc8ac0d72386857cab9` +- Worker: `bf1fcc60ec318cf6c2747d44f4af741b46f284cf5f5307e142f0ab6d50b14302` +- desktop importer: `9f1eb4ab679255a0f1f596696e8befc33a4e30c0cbe6ea423e2aaa0314cec22d` +- checker: `9d53014f4b89f786c516ebe8d300030c2728e4a36a86a5ef136b2769a12ac96b` +- unit/Chromium: `485a669be5de8e370e9b5a3239357b028ba61ca5c4076819cd46aed52a5c210a` / + `93785b4017ba14b007926b0f82442f8ae5968f242a2a762e5f5dc77d36110f5b` +- report: `45eaffc9c2e50c84b557d13ebbe9f4f53b63e19e00bc69b272491ef6366dff81` +- fixture OBJ/MTL/PNG: `4ed3dced8445b03754cd3916ae2e8cf0dff85e6a5882f1c6f0a4a4e4c5251f6a` / + `80338f569c67a8b1616515ba878ffc72b26e93ef2e8c361131023fb14f132f5f` / + `44e4fd08bdda8403908349e2a62dcf103ce2d0851099fc61cd868d65ddbef78c` +- package: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` +- manifest: `0c639954900b1fcc3b8285d0f4c0ecfa5807df6cde6d1f2cd3b59c4636d71674` + +## Next Task + +`M12-07D`: split STL binary and ASCII capability declarations. + +## Rollback + +Remove the OBJ protocol, Worker, desktop importer, unit/E2E tests, checker, report, manifest, and this +status entry. Restore M12-07B as the queue tail and move the machine queue back to M12-07C. No parity +ledger rollback is required. diff --git a/docs/status/M12-07D.md b/docs/status/M12-07D.md new file mode 100644 index 00000000..459b8749 --- /dev/null +++ b/docs/status/M12-07D.md @@ -0,0 +1,47 @@ +# M12-07D Status + +status: done +task: split STL binary and ASCII capability declarations +updated: 2026-08-18 America/New_York +enablingTask: true +parityStateChange: false + +## Scope + +Pinned Blender 5.2 `WM_OT_stl_export` now generates binary and ASCII STL from the same selected +two-triangle mesh using identical axis, scale, unit, evaluation, and modifier settings. The report +keeps `STL_BINARY/ascii_format=false` and `STL_ASCII/ascii_format=true` as separate variants; it does +not infer the format from the `.stl` extension or claim Web parsing/import support. + +## Evidence + +- `build_blender_5.2.0/bin/blender -b --factory-startup --python + tools/web/generate-stl-capability-fixtures.py -- tests/files/web/m12_stl_capability_v1 + tests/golden/M12-07D/capability-report.json` generated a 184-byte binary STL with two 50-byte + triangle records and a 213-byte ASCII STL with two facets/six vertex lines. +- `node tools/web/check-stl-capability-fixtures.mjs` matched runtime identity to M12-05A, validated + both independent encodings, regenerated the report/files byte-for-byte in a fresh directory, and + returned `stl-capability-fixtures-ok binaryTriangles=2 asciiFacets=2 deterministic=true next=M12-07E`. +- Package hash remains frozen. This enabling task does not change N-023 parity or expose an STL UI + route. + +## Artifact Hashes + +- parent manifest: `0c639954900b1fcc3b8285d0f4c0ecfa5807df6cde6d1f2cd3b59c4636d71674` +- generator: `8310104e66f246b32ac7cb4619e7f4da109baf2ece39ea670cbb6d33bd88c8b8` +- checker: `b2ad901eaa9701436cf7bcc8aa4e40d1b2d6eda7c6c44a0af830347a37cc9ca8` +- desktop report: `29c7d2a6e6764440c99eec25bb5760c7e0880839691757fb3e607ed4f5050013` +- binary/ASCII fixtures: `50161172cd12e4240067064db6f80e74b2df69c475e3e34154e9d0523928c1ca` / + `a463a5add8be070fb67b34f00ef6e7b46025aed31fa429d4a033d75a11cf0113` +- package: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` +- manifest: `bbc78e47f389562e7d648bf49c9b3bf8a08aaa36e914589a6eab0a1f6e72748d` + +## Next Task + +`M12-07E`: STL normal, unit, degenerate-triangle, and trailing-byte parity. + +## Rollback + +Remove the STL capability generator, checker, fixtures, report, manifest, and this status entry. +Restore M12-07C as the queue tail and move the machine queue back to M12-07D. No parity ledger rollback +is required. diff --git a/docs/status/M12-07E.md b/docs/status/M12-07E.md new file mode 100644 index 00000000..1128770c --- /dev/null +++ b/docs/status/M12-07E.md @@ -0,0 +1,60 @@ +# M12-07E Status + +status: done +task: STL normal, unit, degenerate-triangle, and trailing-byte parity +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The bounded STL protocol requires an explicit binary/ASCII variant and positive unit scale. It parses +facet normals and vertices, applies the scale, removes zero-area triangles the same way as Blender's +validated import, and rejects binary payload after the declared triangle table with +`STL_TRAILING_BYTES`. Desktop Blender accepts that trailing fixture as an empty mesh, so the report +records `STRICTER_WEB_BLOCK` rather than claiming exact parity for that edge case. + +## Evidence + +- The deterministic edge generator derived a one-degenerate-triangle binary and a four-trailing-byte + binary from M12-07D. Pinned Blender 5.2 was probed at unit scales 1 and 0.001 and against both edge + files; it preserves normals, scales world bounds by 1000, removes one degenerate triangle, and + accepts the trailing fixture as an empty mesh. +- `node --test web/tests/unit/stl-import.test.mjs` passed 2/2 for binary/ASCII normals, explicit unit + scaling, degenerate removal, trailing-byte rejection, and invalid scale. +- `CHROME_PATH=/usr/bin/google-chrome-stable WEB_TEST_PORT=5463 node_modules/.bin/playwright test + --config playwright.config.ts --workers=1 tests/e2e/stl-edge-parity.spec.ts` passed 1/1 in Chrome + 150. Normal comparison is exact, Web/Desktop unit ratio matches within Float32 tolerance, and both + retain one triangle after degenerate removal. +- `node tools/web/check-stl-edge-parity.mjs` passed: + `stl-edge-parity-ok normals=exact unitRatio=1000 degenerate=removed trailing=STRICTER_WEB_BLOCK next=M12-07F`. +- Typecheck and `git diff --check` passed. The stricter trailing-byte policy leaves N-023 full parity + blocked and does not expose an STL UI route. + +## Artifact Hashes + +- parent manifest: `bbc78e47f389562e7d648bf49c9b3bf8a08aaa36e914589a6eab0a1f6e72748d` +- fixture generator/desktop probe: `018013347642603c21237ebb023bdc7a4e338f5a3f875fea536a996eb121e716` / + `b3d6ea197ef77b5a14f60f5e6b43d4392e943ff5d56acf73915507fc9a4161b7` +- protocol/Worker: `87eec72e2b8aa7ad0b168ca0ee8c4016c941f56f5f1ac53aa5fe71d0832f1dd8` / + `d714f1f3a218a2226dd349aea81c6c54efa6246de1c4fe51aaa9f5c352ef44fc` +- unit/Chromium/checker: `4af52833486421c017f3af2b833b0776e60a6ab57ed66fea2161cf9674f4b243` / + `121d348250e26d29ad966f7427bbef9da77de2098e2e305827b11aae52002c5c` / + `b5ed7fbb41bb46fba982d5726cb8c3eaecf360b772a01d6337c309b1c4a4f535` +- fixture/desktop/Web reports: `545463a984356d6635cbaae3865d13fe95bd2d841c394ac9ddff496c95d46f18` / + `2d3028a3b7d97f39654cff5fcef1d0c1c71e9f2d08e3e29c2e926651ed3860f1` / + `e0686cc9be3b68e564651207443e0ebf3e3b0eb3d07a32915b03f44b1908357b` +- degenerate/trailing fixtures: `c120bb0f218819eb89a3607c0b4f8fafd9afb599402e3b21deaa3b2be143e7d0` / + `0a30aab6db1588722067000e2a08c3c6206a8ed5b7531caa0b082723700a3681` +- package: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` +- manifest: `19a9f460d1b939c9504dadd364cb87dab3b1769b0599035e2d2b51b47091e034` + +## Next Task + +`M12-07F`: STL Web-to-desktop round-trip and material-loss report. + +## Rollback + +Remove the STL edge fixtures, probes, parser, Worker, tests, reports, checker, manifest, and this status +entry. Restore M12-07D as the queue tail and move the machine queue back to M12-07E. No parity ledger +rollback is required. diff --git a/docs/status/M12-07F.md b/docs/status/M12-07F.md new file mode 100644 index 00000000..00f1db61 --- /dev/null +++ b/docs/status/M12-07F.md @@ -0,0 +1,51 @@ +# M12-07F Status + +status: done +task: STL Web-to-desktop round-trip and material-loss report +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The STL Web path now serializes the bounded parsed triangle result back to deterministic binary STL. +The loss report explicitly records STL's lack of material slots as +`STL_MATERIAL_UNSUPPORTED`; it never silently claims material preservation. A Chromium Worker output +is imported by a fresh pinned Blender 5.2 process and compared by triangle count and facet normals. + +## Evidence + +- `node --test web/tests/unit/stl-export.test.mjs` passed 1/1 for binary header/table serialization + and explicit material-loss warning. +- `CHROME_PATH=/usr/bin/google-chrome-stable WEB_TEST_PORT=5467 node_modules/.bin/playwright test + --config playwright.config.ts --workers=1 tests/e2e/stl-web-roundtrip.spec.ts` passed 1/1 in Chrome + 150. Browser output is 184 bytes/2 triangles; Blender reopens 2 polygons/2 triangles with exact + normals; warning code is `STL_MATERIAL_UNSUPPORTED`. +- `node tools/web/check-stl-web-roundtrip.mjs` passed: + `stl-web-roundtrip-ok triangles=2 normals=exact materialLoss=1 desktopExact=true next=M12-07G`. +- Typecheck and `git diff --check` passed. The STL material loss remains machine-visible and STL UI + route/parity stays bounded. + +## Artifact Hashes + +- parent manifest: `19a9f460d1b939c9504dadd364cb87dab3b1769b0599035e2d2b51b47091e034` +- protocol/Worker: `3b9c409de9fb3eaea34f82c1ddd466670d2b478845d8ff6af3c4e44cb4e04a52` / + `1da8b8281cf8ebf9ccf5fd2f42da8ed5886001ab83daba8d9fcd88980257af87` +- desktop importer: `c4cbff52ff2e7cecba7aeab47e865975955da23a0e1e8ff885ece56b894558d5` +- unit/Chromium/checker: `f06af242df80ec26d06e92b749449cfebaf909476198ff825207cdd4b9484102` / + `2e979dcd030f5316fcbe28e2c19b1c99fe5d111e849d3fc1d2ea316d2159032b` / + `093de04bf80b0909eee56ea590e04e7aa4749168e2e49615c62ab39ddc0d16cf` +- report: `0495c645b4280f6e7821f0ba02010e25d4accbc552e3cd7c58e052607799040e` +- source fixture: `50161172cd12e4240067064db6f80e74b2df69c475e3e34154e9d0523928c1ca` +- package: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` +- manifest: `3cbbcb541ee123da0ef8916ac2ca8805680d539bbf8db3b4a8d331aec418148c` + +## Next Task + +`M12-07G`: PLY ASCII/binary little-endian capability declarations. + +## Rollback + +Remove the STL export/loss protocol, Worker, desktop importer, tests, checker, report, manifest, and +this status entry. Restore M12-07E as the queue tail and move the machine queue back to M12-07F. No +parity ledger rollback is required. diff --git a/docs/status/M12-07G.md b/docs/status/M12-07G.md new file mode 100644 index 00000000..142edba0 --- /dev/null +++ b/docs/status/M12-07G.md @@ -0,0 +1,53 @@ +# M12-07G Status + +status: done +task: PLY ASCII and binary little-endian capability declarations +updated: 2026-08-18 America/New_York +enablingTask: true +parityStateChange: false + +## Scope + +Pinned Blender 5.2 `WM_OT_ply_export` now has two independently declared capability variants for +the same selected two-triangle mesh: `PLY_ASCII` and `PLY_BINARY_LITTLE_ENDIAN`. The report binds +the export settings, source/operator anchor, runtime identity, header semantics, and byte hashes; +the variant is never inferred from a shared `.ply` extension. This enabling task freezes desktop +capability only and does not expose a Web PLY parser or import route. + +## Evidence + +- `build_blender_5.2.0/bin/blender -b --factory-startup --python + tools/web/generate-ply-capability-fixtures.py -- tests/files/web/m12_ply_capability_v1 + tests/golden/M12-07G/capability-report.json` generated an ASCII PLY with four vertices/two faces + and a binary little-endian PLY with the same four vertices/two faces. Both headers explicitly carry + their independent encoding declarations and Blender export settings are recorded in the report. +- `node tools/web/check-ply-capability-fixtures.mjs` matched every runtime identity field to the + pinned M12-05A inventory, validated the `ascii` and `binary_little_endian` variants, checked the + vertex/face element counts and artifact hashes, regenerated both files in a fresh temporary + directory, and passed byte-for-byte determinism: + `ply-capability-fixtures-ok ascii=ascii binary=binary_little_endian vertices=4 faces=2 deterministic=true next=M12-07H`. +- `python3 -m py_compile tools/web/generate-ply-capability-fixtures.py` and + `node --check tools/web/check-ply-capability-fixtures.mjs` passed. The package hash remains + frozen and N-023 parity remains blocked; no PLY Web UI route was exposed. + +## Artifact Hashes + +- parent manifest: `3cbbcb541ee123da0ef8916ac2ca8805680d539bbf8db3b4a8d331aec418148c` +- generator: `581cd84057357e3a87997cf9c07d5d5633209648ac11e44611782eb254a38ebd` +- checker: `5280d6e57b7a722ea881e27b792c6082c5113c1577ac0e87f87cc87fdf59516c` +- desktop report: `26b1ce83334b41778cef5ce2a1f2c4d34254f63d7c7573cb3fb550f93ddeabb2` +- ASCII/binary little-endian fixtures: `63646cab9bf6ccecb23092e5e24d04df1e0a690c866127c72a35791e99262331` / + `e40fbb494b8b147c555a0fc06eb191415406bb9a6c061acf6befd8464c8c41a5` +- runtime inventory: `0d660b0fd8b647ebbf4e91afebd5006bd100973ab8e2507a5bfe759f477b33b4` +- package: `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c` +- manifest: `87df9c12f9c6eacf63051b70e9bb2eb43ebd1e5c4487b3512c45adb94cbb82ea` + +## Next Task + +`M12-07H`: PLY vertex/face/color/custom-property mapping and unknown-property loss report. + +## Rollback + +Remove the PLY capability generator, checker, fixtures, report, manifest, and this status entry. +Restore M12-07F as the queue tail and move the machine queue back to M12-07G. No parity ledger +rollback is required. diff --git a/docs/status/M12-07H.md b/docs/status/M12-07H.md new file mode 100644 index 00000000..9aa988de --- /dev/null +++ b/docs/status/M12-07H.md @@ -0,0 +1,62 @@ +# M12-07H Status + +status: done +task: PLY vertex/face/color/custom property mapping and unknown property loss report +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The production `ply-import` protocol now accepts explicitly declared ASCII and binary +little-endian PLY. Vertex position, normal, RGBA color, numeric custom properties, face +indices, and numeric face properties are mapped into a bounded canonical document. Unsupported +vertex/face list properties and unknown elements are skipped with deterministic +`PLY_UNKNOWN_PROPERTY`/`PLY_UNKNOWN_ELEMENT` loss warnings; mapped fields remain available. +The worker serializes the canonical document to bounded ASCII PLY for desktop validation. +This task does not expose a general PLY file-picker route or claim full PLY attribute parity. + +## Evidence + +- `build_blender_5.2.0/bin/blender -b --factory-startup --python + tools/web/generate-ply-mapping-fixtures.py -- tests/files/web/m12_ply_mapping_v1 + tests/golden/M12-07H/mapping-report.json` generated deterministic ASCII and binary + little-endian fixtures containing four vertices, two faces, RGBA colors, and two numeric + custom vertex attributes. A derived ASCII fixture contains an unsupported list property. +- `node tools/web/check-ply-mapping-fixtures.mjs` matched pinned Blender 5.2 runtime identity, + artifact hashes, field counts and a fresh byte-for-byte regeneration: + `ply-mapping-fixtures-ok vertices=4 faces=2 colors=rgba custom=2 unknown=PLY_UNKNOWN_PROPERTY deterministic=true next=M12-07I`. +- `node --test web/tests/unit/ply-import.test.mjs` passed 3/3. It covers both encodings, + mapped values, unknown-property loss, serialization/reopen, and format mismatch blocking. +- `UPDATE_PLY_MAPPING_REPORT=1 CHROME_PATH=/usr/bin/google-chrome-stable WEB_TEST_PORT=5472 + node_modules/.bin/playwright test --config playwright.config.ts --workers=1 + tests/e2e/ply-web-roundtrip.spec.ts` passed 1/1. Production Chromium Worker parsed both + encodings, reported unknown loss, serialized ASCII, and pinned Blender 5.2 re-imported the + result with exact vertex/face/position counts and mapped custom/color attributes. +- `npm --prefix web run typecheck`, `node --check tools/web/check-ply-mapping-fixtures.mjs`, + and Python compile checks passed. + +## Artifact Hashes + +- protocol: `058a3263fde553637840a4e9ad4e8e9d923eb52c250f8000317c7f43a228881d` +- worker: `f6bf5e39e83286d7b257bbdce88f4d7fa027c64651da9765567788b5cf298c71` +- generator: `ffc051eccfc6973ee00cc791cdbd641fcce308b4083741e3e6ae82291d9347b7` +- checker: `2b055dbc705830848efdf4d8db8543a3ccc684de1f258732bcafefa86cf65c3a` +- desktop importer: `6edbc6e401a1a902dcfd11c4d767e8c8620d694e40eb4ca29dd8479f9c55ef37` +- unit/e2e: `b03a5f4b4b2a974fa26e653763470b92d1433c5d352ae09ab5492b841e6e5e1f` / + `2cadebc89057655d78e9b520bb6adf9debb27c6d783cd002efee08d269636fb9` +- desktop mapping report: `8a02fc9e364ed79e50ef00897affa9ab63808d3cb3cdabd00fdb8ee4c26ec18a` +- Chromium/desktop round-trip report: `1ca9d3b7870fcc8c9e3c9bbbd90ef48bd13bbc9ae0462312c9482f24f05f13ec` +- ASCII/binary/unknown fixtures: `acaa369c17252d4d089a2e3064d0c1fcdfdb2d066759ed160adf7869d3e859d5` / + `d0fc195fd1a101c42ac984a2382bccdddb7d0bf60dd618e9f637c964f1b30b9e` / + `a994c7126f235d0067ce4af35f8b0c6699cc83073e2a37e982edd45ece459f33` + +## Next Task + +`M12-07I`: PLY big-endian, malformed list, and oversized count stable blocking. + +## Rollback + +Remove the PLY mapping protocol, worker, fixture generator/checker, fixtures, reports, tests, +package script, and this status entry. Restore M12-07G as the queue tail and move the machine +queue back to M12-07H. No parity ledger rollback is required. diff --git a/docs/status/M12-07I.md b/docs/status/M12-07I.md new file mode 100644 index 00000000..d2efbc96 --- /dev/null +++ b/docs/status/M12-07I.md @@ -0,0 +1,49 @@ +# M12-07I Status + +status: done +task: PLY big-endian, malformed list, and oversized count stable blocking +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +PLY admission now fails closed for unsupported big-endian input, truncated/malformed list data, +and element counts above the bounded 65,536 record budget. The production parser returns stable +codes `PLY_FORMAT_UNSUPPORTED`, `PLY_DATA_TRUNCATED`, and `PLY_IMPORT_BUDGET_EXCEEDED: vertex`; +the Worker does not publish a partial document. No big-endian route or unbounded PLY import is +claimed. + +## Evidence + +- `node tools/web/generate-ply-negative-fixtures.mjs` generated deterministic big-endian, + malformed-list, and oversized-count fixtures. `node tools/web/check-ply-negative-fixtures.mjs` + regenerated them in a fresh temporary directory and passed: + `ply-negative-fixtures-ok cases=3 bigEndian=PLY_FORMAT_UNSUPPORTED malformed=PLY_DATA_TRUNCATED oversized=PLY_IMPORT_BUDGET_EXCEEDED deterministic=true next=M12-07J`. +- `node --test web/tests/unit/ply-negative.test.mjs` passed 2/2 for all three stable error paths. +- `CHROME_PATH=/usr/bin/google-chrome-stable WEB_TEST_PORT=5478 node_modules/.bin/playwright test + --config playwright.config.ts --workers=1 tests/e2e/ply-negative.spec.ts` passed 1/1. The + production Worker returned the exact expected error for each case and never returned `ok=true`. +- `npm --prefix web run typecheck`, Node syntax checks, and `git diff --check` passed. + +## Artifact Hashes + +- protocol: `058a3263fde553637840a4e9ad4e8e9d923eb52c250f8000317c7f43a228881d` +- generator/checker: `9c09707bdfe73ba467bbfbf61ed3846fb59fd33ab5563a3a62c8032722ff6938` / + `b1d047d4cb1fa15dff7821687e7028ad073fdc62d4c76f60a2656732c0ec5e2b` +- unit/e2e: `60439f9e6bc221df8866956bf926816a347e85828b5a93deb26ee23015cf449a` / + `6509a29d6842f3423d4dfcc40dbd52a959a5efa7ee1121143dce06e5bbc4a460` +- report: `fbe2830d1b19294ea98eea66c3e00125bc0809a4bb8a750612939cbe1f5acca9` +- fixtures: `cda92943a3690529fbb3463d328b6796ac0d07e19139450556f7411fc1f031e3` / + `a6a576b7a04d919b540520a6f43a89c089f0d706a17fd8b390711fff6b8b03df` / + `fcd99e0838025429420a47466251cf80e638d2b5816ad14d5aa696364525bb` + +## Next Task + +`M12-07J`: three-format cancellation, OOM, Worker restart, and small-file recovery. + +## Rollback + +Remove the count guard, negative fixture generator/checker, fixtures, report, tests, manifest, +and this status entry. Restore M12-07H as the queue tail and move the machine queue back to +M12-07I. No parity ledger rollback is required. diff --git a/docs/status/M12-07J.md b/docs/status/M12-07J.md new file mode 100644 index 00000000..127cda94 --- /dev/null +++ b/docs/status/M12-07J.md @@ -0,0 +1,50 @@ +# M12-07J Status + +status: done +task: three-format cancellation, OOM, Worker restart, and small-file recovery +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +OBJ, STL, and PLY now share a schema-1 recovery receipt. Each bounded production Worker run +binds format, input hash, base/candidate revision, Worker generation, output hash, temporary bytes, +live requests, published result count, and commit state. Cancellation clears temporary state and +publishes no result; over-budget/OOM input returns `IO_FORMAT_OOM`; a generation-2 rerun and a +generation-3 small-file run reproduce the generation-1 output hash. This is recovery evidence for +the bounded format workers, not a general file-picker or full Blender IO claim. + +## Evidence + +- `node --test web/tests/unit/io-format-recovery.test.mjs` passed 2/2 for cancellation/OOM cleanup, + commit identity, generation recovery, and stale-generation blocking. +- `UPDATE_IO_FORMAT_RECOVERY_REPORT=1 CHROME_PATH=/usr/bin/google-chrome-stable WEB_TEST_PORT=5492 + node_modules/.bin/playwright test --config playwright.config.ts --workers=1 + tests/e2e/io-format-recovery.spec.ts` passed 1/1. The report covers OBJ, STL, and PLY separately: + three cancellations, three OOM budget faults, three Worker-generation restarts, and three small + recovery commits; all output SHA-256 values are stable. +- A second run without report update passed against the same golden, proving deterministic operation + IDs and receipt output. `node tools/web/check-io-format-recovery.mjs` passed: + `io-format-recovery-ok formats=OBJ,STL,PLY cancelled=3 oom=3 restart=3 smallRecovery=3 deterministic=true next=M13-01A`. +- `npm --prefix web run typecheck` and `git diff --check` passed. + +## Artifact Hashes + +- protocol: `7e352539e3300969c7409c34d6056eb6ad31055431ffce84b1b0a459c335480a` +- worker/testing adapter: `63b78fbf25132cad28147ef68731f2cd212a26c96b464fdec349a13ebaa1174f` / + `cfcebb6ec38936a66983957b0dede716871cfbfbea3cb53cddc16f3e24fb19b0` +- unit/e2e: `aaed1f2abe4789b084c8a6a60bcce8595d38220d7e6678a93924cd05c5716b4f` / + `5c1750fa408e1297fc43f2e5749be3e9ac08a16b86265d22f0f06053f52b3bd7` +- checker: `6ef6bc4a168f8675a4933a06c296f61076b9ea64cec25b295e961a9b610ab1a2` +- report: `35d4fe10d8a4fa84d6e05a85f20ca50975d93a86df41e73c7bbc5875edc4fc70` + +## Next Task + +`M13-01A`: begin the next machine-queued milestone task. + +## Rollback + +Remove the IO recovery protocol, worker, adapter, tests, checker, report, manifest, and this +status entry. Restore M12-07I as the queue tail and move the machine queue back to M12-07J. No +parity ledger rollback is required. diff --git a/docs/status/M13-01A.md b/docs/status/M13-01A.md new file mode 100644 index 00000000..31b49cec --- /dev/null +++ b/docs/status/M13-01A.md @@ -0,0 +1,47 @@ +# M13-01A Status + +status: done +task: inventory Text, Python Console, autorun, driver expression, handler, and add-on entry points +updated: 2026-08-18 America/New_York +enablingTask: true +parityStateChange: false + +## Scope + +Pinned Blender 5.2 now has a machine-readable scripting entry inventory covering three Text +datablocks, Python Console operators, a module-autorun request, one driver expression, 39 handler +groups, and four add-on operators. The report records the entry source and default Web policy: +Text is metadata-only, while Console, autorun, driver expressions, handlers, and add-on operations +are `DENY`. This task only inventories entry points; it does not execute script text or open a +browser scripting route. + +## Evidence + +- `build_blender_5.2.0/bin/blender -b --factory-startup --python + tools/web/generate-script-entry-inventory.py -- tests/files/web/m13_script_entry_v1 + tests/golden/M13-01A/entry-inventory.json` created a fixture with internal/external Text, + `use_module` autorun metadata, and a driver expression without executing user code. +- `node tools/web/check-script-entry-inventory.mjs` matched pinned runtime identity, policy and + semantic inventory, then regenerated the report in a fresh directory. Blender's `.blend` save + contains runtime save bytes that may differ between runs, so determinism is asserted on the + inventory and fixture byte length; the checked-in fixture hash remains bound in the manifest. + Output: `script-entry-inventory-ok texts=3 console=3 autorun=1 drivers=1 handlers=39 addonOps=4 deterministic=true next=M13-01B`. +- `python3 -m py_compile tools/web/generate-script-entry-inventory.py`, Node syntax check and + `git diff --check` passed. + +## Artifact Hashes + +- generator: `b72667493cfe9957161ef3fb9814180e0cfad5f8aaa1c60c9b6f132e915f3d6f` +- checker: `68478f15de4d63ed175e6b580761fd478b1fe9bccbfcaeee82218d13063dfa51` +- report: `e024995c53f01beaf725f281f74a6e5ec6018a53435da61a66f5e58a9e50973c` +- fixture: `bd6375d02908bfcc57ff7cdeec3f9827bfc4336d1e46e165c5fbbf4d04f19645` +- manifest: `d4a305033343ef5fb1ffc073617b59d9012f64b80ecb233e743fb0789a8b4893` + +## Next Task + +`M13-01B`: read script metadata on `.blend` open without executing arbitrary content. + +## Rollback + +Remove the generator, checker, fixture, report, manifest, and this status entry. Restore M12-07J +as the queue tail and move the machine queue back to M13-01A. No parity ledger rollback is required. diff --git a/docs/status/M13-01B.md b/docs/status/M13-01B.md new file mode 100644 index 00000000..0ea2ea0e --- /dev/null +++ b/docs/status/M13-01B.md @@ -0,0 +1,41 @@ +# M13-01B Status + +status: done +task: read script metadata on `.blend` open without executing arbitrary content +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The real `script_scene.blend` open path reads all three Text sources, verifies source bytes and +SHA-256, preserves read-only metadata, and keeps every source `executionStatus=BLOCKED`. A +`use_module` autorun request remains `SCRIPT_POLICY_DENIED`; no Python Console, driver, handler, +or add-on code executes during open. This task does not add a script execution route. + +## Evidence + +- `node tools/web/check-script-open-metadata.mjs` ran the production WASM Main reader against the + real fixture and passed `script-open-metadata-ok blend=opened textMetadata=read sourceHash=verified execution=DENY autorun=DENY next=M13-01C`. +- `CHROME_PATH=/usr/bin/google-chrome-stable WEB_TEST_PORT=5495 node_modules/.bin/playwright test + --config playwright.config.ts --workers=1 tests/e2e/script-open-metadata.spec.ts` passed 1/1. + Chromium opened the fixture through `WebEngineClient`, saw three read-only blocked sources and + the explicit autorun denial. +- `npm --prefix web run typecheck` and `git diff --check` passed. The checker emits the deterministic + report `tests/golden/M13-01B/open-metadata-report.json` bound to the fixture hash. + +## Artifact Hashes + +- checker: `66396d5c9a5294021ae077bb162278c74d46de8b52ac8a444a5de4f6d84cfe05` +- e2e: `df6412cda113c830996e08c3d66a035dc1ac309e392f5946a762d11121b1926c` +- report: `f92470e57c048452134664f7f6208e50b6f087dbcbc33369e6b882c51813990c` +- fixture: `2b8fbdb05419e1e61d64777998d6a3b2562f726c17f548cd1852efc723e1a037` + +## Next Task + +`M13-01C`: default-deny autorun, register, install, and driver execution policy codes. + +## Rollback + +Remove the checker, report, manifest, e2e test, and this status entry. Restore M13-01A as the +queue tail and move the machine queue back to M13-01B. No parity ledger rollback is required. diff --git a/docs/status/M13-01C.md b/docs/status/M13-01C.md new file mode 100644 index 00000000..bf1fdd75 --- /dev/null +++ b/docs/status/M13-01C.md @@ -0,0 +1,39 @@ +# M13-01C Status + +status: done +task: default-deny autorun, register, install, and driver execution policy codes +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The scripting manifest parser and execution gate reject autorun/register/install/driver execution +requests with stable policy codes. The current bounded contract returns `SCRIPT_POLICY_DENIED` for +autorun, `DRIVER_EXECUTION_BLOCKED` for driver expressions, `ADDON_INSTALL_BLOCKED` for add-on +install/registration, and `SCRIPT_SANDBOX_UNAVAILABLE` even for a correctly signed script when no +isolated sandbox exists. No local eval or script execution is enabled. + +## Evidence + +- `node --test web/tests/unit/script-policy-codes.test.mjs` passed 1/1 and asserts all three denied + request codes plus the approved-key sandbox gate. +- `node tools/web/check-script-policy-codes.mjs` passed: + `script-policy-codes-ok autorun=SCRIPT_POLICY_DENIED driver=DRIVER_EXECUTION_BLOCKED addon=ADDON_INSTALL_BLOCKED sandbox=SCRIPT_SANDBOX_UNAVAILABLE next=M13-01D`. +- `npm --prefix web run typecheck`, Node syntax checks and `git diff --check` passed. The report and + manifest bind the transpiled production protocol source hashes. + +## Artifact Hashes + +- checker: `22588c2198bc8c425ab8e104e8559f0053654ae778aaea3783ec7d54822bc8f4` +- unit: `b5a52b8e707963545f1cd71f1a148fa9c9b9d1e4b4c5f51c87d33f8bf3777430` +- report: `956db548ee71688a4b89c9a993259d3e57129cd4abe9efd1b9397b3e30b1bf84` + +## Next Task + +`M13-01D`: ensure UI exposes no direct-eval bypass around the scripting policy. + +## Rollback + +Remove the checker, unit test, report, manifest and this status entry. Restore M13-01B as the queue +tail and move the machine queue back to M13-01C. No parity ledger rollback is required. diff --git a/docs/status/M13-01D.md b/docs/status/M13-01D.md new file mode 100644 index 00000000..6029a8bf --- /dev/null +++ b/docs/status/M13-01D.md @@ -0,0 +1,36 @@ +# M13-01D Status + +status: done +task: UI direct-eval bypass gate +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The production app, Workers, and protocol TypeScript sources contain no direct `eval(` or +`new Function(` bypass. The scan covers 176 non-vendor `.ts/.tsx` files and records the only +declared scripting policy entry points: `gateScriptExecution`, `gateServerScriptJob`, and +`parseScriptSourceInventory`. Execution remains `DENY`; this static gate does not claim a sandbox. + +## Evidence + +- `node tools/web/check-script-ui-bypass.mjs` passed: + `script-ui-bypass-ok scanned=176 violations=0 policyEntrypoints=3 report=8e234e128b90036548ab709b86b063de404250da2c5c0a6e25e28969b5cd5c73 next=M13-01E`. +- `git diff --check` passed. Vendor-generated Emscripten/Three.js sources are outside the scan; + no application or protocol source is allowed to introduce a direct eval constructor. + +## Artifact Hashes + +- checker: `f34cb64891c2b22bc3da353f6b864ba3e558d3c286cf716bcb778d9a542cb3d9` +- report: `6b050092088508ebd5d769d95a2e66f0cd4020c97f3407724a19b9707f51f6dd` +- manifest: `6b28688b3ebcce5629bcfc437d4ca903d0b1e053b32a796690ee4e021726c75b` + +## Next Task + +`M13-01E`: preserve Text data and unknown script sources through save/reopen. + +## Rollback + +Remove the checker, report, manifest, and this status entry. Restore M13-01C as the queue tail and +move the machine queue back to M13-01D. No parity ledger rollback is required. diff --git a/docs/status/M13-01E.md b/docs/status/M13-01E.md new file mode 100644 index 00000000..113ec41c --- /dev/null +++ b/docs/status/M13-01E.md @@ -0,0 +1,41 @@ +# M13-01E Status + +status: done +task: preserve Text data and unknown script sources through save/reopen +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The production WebEngine path opens `script_scene.blend`, saves it, starts a new engine Worker, +and reopens the saved bytes. All three Text sources compare exact by metadata and source SHA-256; +they remain read-only and `BLOCKED`, including the `use_module` autorun request. No source is +rewritten or executed. This does not claim arbitrary script execution or add-on support. + +## Evidence + +- `UPDATE_SCRIPT_SAVE_REOPEN_REPORT=1 CHROME_PATH=/usr/bin/google-chrome-stable WEB_TEST_PORT=5498 + node_modules/.bin/playwright test --config playwright.config.ts --workers=1 + tests/e2e/script-save-reopen.spec.ts` passed 1/1. A second run without report update on port 5497 + also passed against the same deterministic report. +- `node tools/web/check-script-save-reopen.mjs` passed: + `script-save-reopen-ok sources=3 exact=true blocked=true savedBytes=490191 next=M13-01F`. +- `npm --prefix web run typecheck` and `git diff --check` passed. + +## Artifact Hashes + +- checker: `7fc9a370cb472636e2699565cb21a1450e3cc8a2c90ffdcdff96533b344afa7c` +- e2e: `481f78f3a0cce923b67ab14436cc23cbcd2ce66725de29dc874fea4fb5801227` +- report: `0f6869a8ec8342ed87649312d2872ab2c172cbf12d6be81bb0b770ebcbe8a398` +- manifest: `889a4e06f7e8c0ea55b3ca4baa9fe85dccbe97053e497a45e3d364ce2b70a7c6` +- fixture: `2b8fbdb05419e1e61d64777998d6a3b2562f726c17f548cd1852efc723e1a037` + +## Next Task + +`M13-01F`: malicious Text, driver, handler, and embedded-module fixtures. + +## Rollback + +Remove the checker, e2e test, report, manifest, and this status entry. Restore M13-01D as the +queue tail and move the machine queue back to M13-01E. No parity ledger rollback is required. diff --git a/docs/status/M13-01F.md b/docs/status/M13-01F.md new file mode 100644 index 00000000..df5dbb9e --- /dev/null +++ b/docs/status/M13-01F.md @@ -0,0 +1,44 @@ +# M13-01F Status + +status: done +task: malicious Text, driver, handler, and embedded-module fixtures +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +Pinned Blender 5.2 generated a malicious fixture containing four source bodies: OS command text, +driver import, handler subprocess, and an embedded `register()` module. Chromium opened the file +through the production Main reader and kept every source read-only/`BLOCKED`; the embedded module +returned `SCRIPT_POLICY_DENIED`. No filesystem marker or execution path is exposed by the test. + +## Evidence + +- `build_blender_5.2.0/bin/blender -b --factory-startup --python + tools/web/generate-malicious-script-fixture.py -- tests/files/web/m13_malicious_script_v1 + tests/golden/M13-01F/malicious-report.json` generated four deterministic source hashes and one + `use_module` request. +- `node tools/web/check-malicious-script-fixture.mjs` passed: + `malicious-script-fixture-ok sources=4 module=1 execution=BLOCKED fixtureSha256=7b1921931afc5bb74a2b73e90b175017c583ea878cdbb66f131718b2d8cd23b5 next=M13-02A`. +- `CHROME_PATH=/usr/bin/google-chrome-stable WEB_TEST_PORT=5499 node_modules/.bin/playwright test + --config playwright.config.ts --workers=1 tests/e2e/malicious-script.spec.ts` passed 1/1. +- `git diff --check` passed. + +## Artifact Hashes + +- generator: `013285befeb59de21a887cefd377adf8cf53ff1c785b28a9c87d29f76f092731` +- checker: `36608da893ae59e2e03856a62866ea6e7c349ec4a63200f042b2329e5f61caa9` +- e2e: `98fbde6728ddf55cce5262522197a5b4db1dfce618e52259bf0b0c9e0e9165f2` +- report: `a628b73411d6f9a761f659ae28867ea9b0c0df30d47668353278b70d4b44180c` +- fixture: `7b1921931afc5bb74a2b73e90b175017c583ea878cdbb66f131718b2d8cd23b5` +- manifest: `9a0b7c4097b3755365a9fb92b4848e6ac2ddd36ee2c7e9df3cb8808ce247cf3d` + +## Next Task + +`M13-02A`: bounded script manifest limits. + +## Rollback + +Remove the generator, checker, fixture, report, manifest, e2e test, and this status entry. Restore +M13-01E as the queue tail and move the machine queue back to M13-01F. No parity ledger rollback is required. diff --git a/docs/status/M13-02A.md b/docs/status/M13-02A.md new file mode 100644 index 00000000..c610913b --- /dev/null +++ b/docs/status/M13-02A.md @@ -0,0 +1,49 @@ +# M13-02A Status + +status: done +task: bounded script manifest limits +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The production scripting manifest parser now requires an explicit `sourceByteLength` and +`module=false` for every script. It limits script count, aggregate source bytes, dependency count, +permission count and per-field execution budgets; canonicalizes entry/dependency paths within the +project; and rejects duplicate dependencies, unsafe paths, module execution, unknown permissions +and all budget overflow. The parser remains fail-closed and does not execute script content. + +## Evidence + +- `node --test web/tests/unit/script-manifest-budgets.test.mjs` passed 4/4 bounded positive and + negative cases. +- `WEB_TEST_PORT=5513 npm --prefix web run test:script-manifest-budgets` passed unit 4/4 and the + production Chromium Worker test 1/1. The browser Worker returned the same canonical paths, + aggregate byte count and stable policy/budget errors as the Node protocol test. +- `node tools/web/check-script-manifest-budgets.mjs` passed: + `script-manifest-budgets-ok accepted=2 totalSourceBytes=256 blocked=6 deterministic=true next=M13-02B`. +- `npm --prefix web run typecheck`, `npm --prefix web run lint`, + `npm --prefix web run build`, `npm --prefix web run test:scripting-isolation`, and + `git diff --check` passed. The existing isolation gate still reports approved-key + `SCRIPT_SANDBOX_UNAVAILABLE` and does not enable execution. + +## Artifact Hashes + +- protocol: `6699fc0a19e0736823f164e707a420655803e36019cf0071f7ff9c760d25e9f2` +- worker: `8d8eae67fa6915f0337850e15247baf01f0abde0b3362fbb120f0b448f1a4cf5` +- checker: `b3457324d72ab233cd17a142ea19fac6a0d024dd95de7b8fb5d26810437fb0d2` +- unit: `6ce7847a0b745ba4081e4332d012e0b7f86e4906c71c95bdeda8c39977a630ee` +- e2e: `1ef4fd4caaeb1fa3d832fc8881823d5284755372575eab186c751f408dc9314c` +- report: `860672fe844b7969ca376d4b2708771189d1767efa819f7b97667d8a26438d3a` +- manifest: `7148e0387dadffdb55e94e80dc30cfd5cdd40ebe990d06378e90c376d36ebd51` + +## Next Task + +`M13-02B`: canonical serialization fixes the signature input. + +## Rollback + +Remove the M13-02A protocol fields, checker, unit/Chromium tests, worker, report, manifest and this +status entry. Restore M13-01F as the queue tail and move the machine queue back to M13-02A. No parity +ledger rollback is required. diff --git a/docs/status/M13-02B.md b/docs/status/M13-02B.md new file mode 100644 index 00000000..52c6af59 --- /dev/null +++ b/docs/status/M13-02B.md @@ -0,0 +1,52 @@ +# M13-02B Status + +status: done +task: canonical script manifest serialization +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The scripting protocol now exposes `canonicalizeScriptingManifest` and +`serializeScriptingManifest` as the single canonical signature-input path. Script, permission and +dependency arrays are sorted with locale-independent code-unit ordering; unknown fields are removed +by the parser; canonical JSON has sorted object keys and no whitespace; and security-relevant fields +such as source byte length, source hash, permissions, paths, budgets and policy flags remain in the +serialized input. Schema changes and source declaration mutations produce a different or rejected +input. No signature is accepted and no script is executed by this task. + +## Evidence + +- `node --test web/tests/unit/script-manifest-canonical.test.mjs` passed 2/2. It proves order + invariance, unknown-field dropping, security-field binding and schema rejection. +- `WEB_TEST_PORT=5514 npm --prefix web run test:script-manifest-canonical` passed unit 2/2 and + production Chromium Worker 1/1. +- `node tools/web/check-script-manifest-canonical.mjs` passed: + `script-manifest-canonical-ok equal=true bytes=1923 unknownDropped=true schemaMutation=blocked next=M13-02C`. +- `npm --prefix web run typecheck`, `npm --prefix web run lint`, `npm --prefix web run build`, + `node tools/web/check-script-manifest-budgets.mjs`, `node tools/web/check-status-consistency.mjs` + and `git diff --check` passed. The M13-02A artifact manifest was refreshed to the current protocol + hash after this additive canonical API change, and its checker was rerun successfully; no prior + behavior or parity status was changed. + +## Artifact Hashes + +- protocol: `6699fc0a19e0736823f164e707a420655803e36019cf0071f7ff9c760d25e9f2` +- worker: `de27fdcd6d16e27a07806ac609f908edfdb7a93d653676174cdd5e06ffa394af` +- checker: `5ed344cab6428ae5538a450171ba40c73ff738666515492298e30aaed1394f56` +- unit: `33eae0f3ad2ae7243c9ce2835ab8e42186f65f574ab682099766f1d0f4c481f6` +- e2e: `5342301165ae82a01b46f5fed0cc0ec34b9813a6ecbc8032900d90b89628e064` +- report: `5f4bc0b098ea65de47f1255d30130376d74260e2b912bcd0e28354171fbd07df` +- parent manifest: `7148e0387dadffdb55e94e80dc30cfd5cdd40ebe990d06378e90c376d36ebd51` +- manifest: `605c656780a206a0d3b81d06b0294108b488a62d2b709e886884a2973c6cb091` + +## Next Task + +`M13-02C`: signer identity, key rotation, revocation and timestamp policy. + +## Rollback + +Remove the canonical serializer exports, checker, unit/Chromium tests, worker, report, manifest and +this status entry; restore M13-02A as the queue tail and move the machine queue back to M13-02B. No +parity ledger rollback is required. diff --git a/docs/status/M13-02C.md b/docs/status/M13-02C.md new file mode 100644 index 00000000..3f452c8c --- /dev/null +++ b/docs/status/M13-02C.md @@ -0,0 +1,51 @@ +# M13-02C Status + +status: done +task: signer identity, key rotation, revocation and timestamp policy +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The scripting protocol now has a versioned trust-policy schema for signer identity and key +lifecycles. It validates ED25519 public-key identity, publisher ownership, active/revoked status, +not-before/not-after windows, revocation timestamps, same-publisher rotation predecessors and +acyclic rotation chains. `maxClockSkewMs` binds policy timestamps to an explicit bounded window. +`resolveScriptSigner` only returns `ELIGIBLE` with `cryptographicVerification=REQUIRED`; revoked, +expired, not-yet-valid, missing and publisher-confused keys remain structured `BLOCKED` results. +This task does not verify signatures or enable script execution. + +## Evidence + +- `node --test web/tests/unit/script-trust-policy.test.mjs` passed 3/3. It covers valid active + rotation, revoked predecessor, invalid/cross-publisher/cyclic rotation, revocation metadata, + public-key format and timestamp failures. +- `WEB_TEST_PORT=5516 npm --prefix web run test:script-trust-policy` passed unit 3/3 and production + Chromium Worker 1/1. +- `node tools/web/check-script-trust-policy.mjs` passed: + `script-trust-policy-ok active=key:new revoked=REVOKED crossPublisher=SCRIPT_POLICY_DENIED policyExpired=POLICY_EXPIRED crypto=REQUIRED next=M13-02D`. +- `npm --prefix web run typecheck` passed; M13-02A and M13-02B checkers were rerun after the + additive protocol change and passed. No parity ledger or execution route changed. + +## Artifact Hashes + +- protocol: `6699fc0a19e0736823f164e707a420655803e36019cf0071f7ff9c760d25e9f2` +- worker: `c6e206b29e7cacc3e23e2ac12a3d523a9c0b16ff29759126dab13c8665547421` +- checker: `0e55ce20d142f2bcbc5ce9c6fb955ef2771d284dff636c79da3f59a33b8b0aeb` +- unit: `59ac77bcc1086e0a0e914cb77d647db5d34c7160202e7aad52948b7277769551` +- e2e: `614091bda15367090bc78a6f465fb10d02d0aa08775b5088699b12e2490034d2` +- report: `0f59f733920edbbe5cb799e5f50ff31d19e55ced98e7a890828f6337a237e4bd` +- unit: `aac3ff0a1c7ae27e35112614bedabc02bb248882ec9b0b52d65f9794d06830d8` +- parent manifest: `605c656780a206a0d3b81d06b0294108b488a62d2b709e886884a2973c6cb091` +- manifest: `89745daca88371335f4bd56982791266461082066c6c1345056fbc697bb7e6a2` + +## Next Task + +`M13-02D`: signature only approves declared content and source hash changes invalidate it. + +## Rollback + +Remove the trust-policy schema, parser/resolver, checker, unit/Chromium tests, worker, report, +manifest and this status entry; restore M13-02B as the queue tail and move the machine queue back to +M13-02C. No parity ledger rollback is required. diff --git a/docs/status/M13-02D.md b/docs/status/M13-02D.md new file mode 100644 index 00000000..d4bb2368 --- /dev/null +++ b/docs/status/M13-02D.md @@ -0,0 +1,46 @@ +# M13-02D Status + +status: done +task: signature verification binds declared content +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +`verifyScriptManifestSignature` now verifies the canonical per-script manifest input with the +trusted ED25519 key. The input includes the declared source SHA-256 and all policy-relevant fields, +while excluding only the signature field itself. A source hash or signature mutation therefore +returns `SCRIPT_SIGNATURE_INVALID`; revoked or otherwise ineligible keys return +`SCRIPT_POLICY_DENIED`. Verification remains a policy gate and does not enable script execution. + +## Evidence + +- `node --test --test-name-pattern=M13-02D web/tests/unit/script-trust-policy.test.mjs` passed the + M13-02D case (1 passed, 3 unrelated cases skipped). +- `WEB_TEST_PORT=5520 npm --prefix web run test:script-signature` passed the focused Node case and + production Chromium Worker E2E (1/1). +- `node tools/web/check-script-signature.mjs` passed: + `script-signature-ok verified=SCRIPT_SIGNATURE_VERIFIED sourceHashChanged=SCRIPT_SIGNATURE_INVALID revoked=SCRIPT_POLICY_DENIED next=M13-02E`. +- `npm --prefix web run typecheck`, `npm --prefix web run lint`, `npm --prefix web run build` and + `git diff --check` passed. + +## Artifact Hashes + +- protocol: `6699fc0a19e0736823f164e707a420655803e36019cf0071f7ff9c760d25e9f2` +- worker: `f4e8ff45d06efcfa9e634ef28e654241d70554ed05dfe9c3fecd9f6eef166ce0` +- checker: `2d6e7b403dd6d401eb1a41978016b40bd1fb86120464e09f185de4445c177fa4` +- unit: `aac3ff0a1c7ae27e35112614bedabc02bb248882ec9b0b52d65f9794d06830d8` +- e2e: `8a882fc8fa2c0c4e2eab3660810cec0f60d2f7475f62a1bb406fc3b2bdf60882` +- report: `957daf8e5899714cc5ce253b1f0fb2b258f1cc966238fff31e2626f8f56feb3a` +- manifest: `c6b79a77e4b7ffe5f4a9ba785a30305f9cf6e86caf89b38a4b303a86ab662a31` + +## Next Task + +`M13-02E`: permission default-minimization and unknown-permission blocking. + +## Rollback + +Remove the M13-02D checker, report, manifest, status entry and focused package command; restore +M13-02C as the queue tail and move the machine queue back to `M13-02D`. No parity ledger rollback +is required. diff --git a/docs/status/M13-02E.md b/docs/status/M13-02E.md new file mode 100644 index 00000000..05459380 --- /dev/null +++ b/docs/status/M13-02E.md @@ -0,0 +1,42 @@ +# M13-02E Status + +status: done +task: permission default-minimization and unknown-permission blocking +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +`resolveScriptPermissions` now grants an empty set by default and only grants an explicitly +requested permission when it is already declared by the script manifest. Unknown, duplicate or +undeclared requests return `SCRIPT_POLICY_DENIED`; manifest declarations still reject unknown +permissions during parsing. This task adds no execution capability. + +## Evidence + +- `node --test web/tests/unit/script-permission-policy.test.mjs` passed 2/2. +- `WEB_TEST_PORT=5521 npm --prefix web run test:script-permission-policy` passed unit 2/2 and + production Chromium Worker E2E 1/1. +- `node tools/web/check-script-permission-policy.mjs` passed: + `script-permission-policy-ok defaultGranted=0 declared=READ_MAIN escalation=SCRIPT_POLICY_DENIED unknown=SCRIPT_POLICY_DENIED next=M13-02F`. +- `npm --prefix web run typecheck`, `npm --prefix web run lint` and `git diff --check` passed. + +## Artifact Hashes + +- protocol: `6699fc0a19e0736823f164e707a420655803e36019cf0071f7ff9c760d25e9f2` +- worker: `5df074e676542475009a4980fecaf3fdde009d149de84b9ca146647fb6aa1c02` +- checker: `169ab096295e08c5a630ef09d814da6ce742efd7a7143ff9a17984b4ca20593a` +- unit: `43bb6796616f3f92d71b51ef2a119a4e263864121a03356764e4b9c9241c4043` +- e2e: `dc13412a44f277bbe68315da0d38a2c4aa520d7489c81e7c908f9768c0547b7e` +- report: `8dc41e75620ba5bcb08d0edc52c3994e4f4dbc37ad3633757634bf10fe97b252` +- manifest: `691376d3cd33d3cd339b7195034abaf89cc02fba7e740c40ff051c4496400eef` + +## Next Task + +`M13-02F`: replay, key-confusion, expiry and multi-signature negative cases. + +## Rollback + +Remove `resolveScriptPermissions`, the focused worker/unit/E2E/checker, report, manifest, status +entry and package command; restore M13-02D as the queue tail and move `nextTask` back to `M13-02E`. diff --git a/docs/status/M13-02F.md b/docs/status/M13-02F.md new file mode 100644 index 00000000..77ed2ffc --- /dev/null +++ b/docs/status/M13-02F.md @@ -0,0 +1,42 @@ +# M13-02F Status + +status: done +task: signature replay, key-confusion and expiry negative cases +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The signature gate now has an independent negative-case matrix covering missing keys, expired and +not-yet-valid keys, publisher/key confusion, and a signature copied to a different script. Every +case remains fail-closed with `SCRIPT_POLICY_DENIED` or `SCRIPT_SIGNATURE_INVALID`; no execution +route is added. Canonical script ordering remains accepted when the script's own signature matches. + +## Evidence + +- `node --test web/tests/unit/script-signature-negative.test.mjs` passed 2/2. +- `WEB_TEST_PORT=5522 npm --prefix web run test:script-signature-negative` passed unit 2/2 and + production Chromium Worker E2E 1/1. +- `node tools/web/check-script-signature-negative.mjs` passed: + `script-signature-negative-ok missing=SCRIPT_POLICY_DENIED expired=SCRIPT_POLICY_DENIED notYetValid=SCRIPT_POLICY_DENIED swapped=SCRIPT_SIGNATURE_INVALID next=M13-03A`. +- `npm --prefix web run typecheck`, `npm --prefix web run lint` and `git diff --check` passed. + +## Artifact Hashes + +- protocol: `6699fc0a19e0736823f164e707a420655803e36019cf0071f7ff9c760d25e9f2` +- worker: `49b289110a6533fcd1a29ad78be00792fd3c0d251c6027bae0440f5daba929a1` +- checker: `35f2e6727aef5e6ea2e2623e2effa6ea0356faaf66d417976db7f5bbe671a38c` +- unit: `8b9c8d63c97fce07299622b9ad261791a97bb91a6ed340e72c24c55e685978bd` +- e2e: `26c52ef67d6b1ea2994d92637826994e98e5234e042049f8d69a905f5cdb3309` +- report: `d23a2ae5613b2442ad79420aa344ebfe3767d10ac19b3ea30a62afcccd3a167c` +- manifest: `283673b21e6c9b89dc6ecb7007f3cecf28d53a84ec84f3f8b52373c055538a74` + +## Next Task + +`M13-03A`: sandbox capability scope with no DOM, host-worker, OPFS, IndexedDB or network access. + +## Rollback + +Remove the M13-02F negative-case worker, tests, checker, report, manifest, status entry and package +command; restore M13-02E as the queue tail and move `nextTask` back to `M13-02F`. diff --git a/docs/status/M13-03A.md b/docs/status/M13-03A.md new file mode 100644 index 00000000..6ebc324d --- /dev/null +++ b/docs/status/M13-03A.md @@ -0,0 +1,42 @@ +# M13-03A Status + +status: done +task: sandbox capability scope +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The production scripting protocol now defines a versioned all-deny sandbox scope. DOM, host-worker, +OPFS, IndexedDB and network capabilities must each be explicitly `false`; any enabled, missing or +unknown scope version is rejected with a stable policy/protocol error. This is a capability contract +only: script execution remains disabled and no sandbox implementation is claimed. + +## Evidence + +- `node --test web/tests/unit/script-sandbox-scope.test.mjs` passed 2/2. +- `WEB_TEST_PORT=5523 npm --prefix web run test:script-sandbox-scope` passed unit 2/2 and + production Chromium Worker E2E 1/1. +- `node tools/web/check-script-sandbox-scope.mjs` passed: + `script-sandbox-scope-ok dom=SCRIPT_POLICY_DENIED hostWorker=SCRIPT_POLICY_DENIED opfs=SCRIPT_POLICY_DENIED indexedDB=SCRIPT_POLICY_DENIED network=SCRIPT_POLICY_DENIED execution=DISABLED next=M13-03B`. +- `npm --prefix web run typecheck`, `npm --prefix web run lint` and `git diff --check` passed. + +## Artifact Hashes + +- protocol: `6699fc0a19e0736823f164e707a420655803e36019cf0071f7ff9c760d25e9f2` +- worker: `27058fbb602791cbe6691fa14b3bc9c3d6cdea79641ba9c72fe18b6226d2a651` +- checker: `ad26ea3b078e480ba8f99cbf8dbd0d9b135c7605f4a3958d24845b7f5f8b0f43` +- unit: `a15da2e645d2d681c7e9ac1380f6b224d196d1d2e78d20f1bacad01e138753d4` +- e2e: `49930747e6663f79b61093706318b72e59388d79e3e1cc105bc1571693326667` +- report: `ba5784c751d5a347f38aa522ffcbed270d38fb474f0f10a2d06ea501d0234021` +- manifest: `76a7ce0fd3a38fb770c9cedccbd05ed566b931caa1782fdae898d3b66c3a20bf` + +## Next Task + +`M13-03B`: fixed CPU, wall-time, memory, message and output-byte budgets. + +## Rollback + +Remove the sandbox scope parser, focused worker/tests/checker, report, manifest, status entry and +package command; restore M13-02F as the queue tail and move `nextTask` back to `M13-03A`. diff --git a/docs/status/M13-03B.md b/docs/status/M13-03B.md new file mode 100644 index 00000000..3915520f --- /dev/null +++ b/docs/status/M13-03B.md @@ -0,0 +1,41 @@ +# M13-03B Status + +status: done +task: sandbox CPU, wall, memory, message and output budgets +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The scripting protocol now parses bounded CPU, wall-time, memory, message and output-byte budgets. +Each field has a fixed maximum and all overflow or schema drift returns `SCRIPT_BUDGET_EXCEEDED` or +`PROTOCOL_MISMATCH`. The budget is a contract only; execution remains disabled. + +## Evidence + +- `node --test web/tests/unit/script-sandbox-budget.test.mjs` passed 2/2. +- `WEB_TEST_PORT=5524 npm --prefix web run test:script-sandbox-budget` passed unit 2/2 and + production Chromium Worker E2E 1/1. +- `node tools/web/check-script-sandbox-budget.mjs` passed: + `script-sandbox-budget-ok cpu=SCRIPT_BUDGET_EXCEEDED wall=SCRIPT_BUDGET_EXCEEDED memory=SCRIPT_BUDGET_EXCEEDED message=SCRIPT_BUDGET_EXCEEDED output=SCRIPT_BUDGET_EXCEEDED execution=DISABLED next=M13-03C`. +- `npm --prefix web run typecheck`, `npm --prefix web run lint` and `git diff --check` passed. + +## Artifact Hashes + +- protocol: `6699fc0a19e0736823f164e707a420655803e36019cf0071f7ff9c760d25e9f2` +- worker: `ed836a032b33f54154fdd36f9f6e99ee43ca1755ea0dd8326474a13300ec965e` +- checker: `ab10d1d32c20022c848be4b33e1be846168a9ca4cf22ac932ea000001e9e2391` +- unit: `caa40fc58a73aa4d433285c94009e5436a29fbcb6b4e18b74b27c49fd0b07490` +- e2e: `8be77fd55e76149bfe2e0e449d81a62707ebfa430514936808ec4805fc96e07c` +- report: `4355710e07e95cbb0f96a82fdefca3607f363d3aea9ba89c332e8a4708b7bed2` +- manifest: `b61978c22cdfffe81b812e2eea788d52326f9f0ebb4540e77f86dc9e19ff21dd` + +## Next Task + +`M13-03C`: host calls use explicit allowlist and structured parameters. + +## Rollback + +Remove the sandbox budget parser, focused worker/tests/checker, report, manifest, status entry and +package command; restore M13-03A as the queue tail and move `nextTask` back to `M13-03B`. diff --git a/docs/status/M13-03C.md b/docs/status/M13-03C.md new file mode 100644 index 00000000..bbbc43e4 --- /dev/null +++ b/docs/status/M13-03C.md @@ -0,0 +1,43 @@ +# M13-03C Status + +status: done +task: explicit host-call allowlist and structured parameters +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The production scripting protocol now accepts only five host-call names: `READ_MAIN`, `READ_ASSET`, +`WRITE_MAIN`, `WRITE_ASSET` and `SUBMIT_SERVER_JOB`. Each request has a version, request/script IDs, +permission binding and call-specific structured parameters. Unknown calls, permission confusion, +unknown fields, unsafe paths and malformed payloads fail closed. Parsed calls remain +`execution=DISABLED`; no host operation is invoked. + +## Evidence + +- `node --test web/tests/unit/script-host-call.test.mjs` passed 3/3. +- `WEB_TEST_PORT=5525 npm --prefix web run test:script-host-call` passed unit 3/3 and production + Chromium Worker E2E 1/1. +- `node tools/web/check-script-host-call.mjs` passed: + `script-host-call-ok accepted=5 blocked=4 structured=true execution=DISABLED next=M13-03D`. +- `npm --prefix web run typecheck`, `npm --prefix web run lint` and `git diff --check` passed. + +## Artifact Hashes + +- protocol: `6699fc0a19e0736823f164e707a420655803e36019cf0071f7ff9c760d25e9f2` +- worker: `b26a37f20e829172bb70fbe9b9e3a194923818127cccbe8bbc55f6d994be55e8` +- checker: `e90042f4eefd9e89da6d1cead87ca1cb9db9b3bd65ba8028593e093903577e2e` +- unit: `e7679cd902c75504204d528343e4fa6be316d1c56c02b2871a67637aa847a1ec` +- e2e: `7b17a7da440aee07895101efb8e1eb13bfbebbe54e955bde09299f6b33d6aaf9` +- report: `afb140a3aecff1de52c4363e7cd0ce476b0c9140ffdc016ec13a085049f6dd1a` +- manifest: `54318c45b11dd1707fecf78b0637257158102ea1dbb88d4d442e33b77df2cdbf` + +## Next Task + +`M13-03D`: sandbox crash/timeout terminates the job without changing Main revision. + +## Rollback + +Remove the host-call parser, focused worker/tests/checker, report, manifest, status entry and package +command; restore M13-03B as the queue tail and move `nextTask` back to `M13-03C`. diff --git a/docs/status/M13-03D.md b/docs/status/M13-03D.md new file mode 100644 index 00000000..3bacae0e --- /dev/null +++ b/docs/status/M13-03D.md @@ -0,0 +1,46 @@ +# M13-03D Status + +status: done +task: sandbox crash/timeout isolation +updated: 2026-08-18 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The sandbox job termination receipt now converts crash, wall-time timeout and cancellation into +stable fail-closed statuses. Every terminated receipt keeps `mainRevisionAfter` equal to +`mainRevisionBefore`, publishes no result, releases temporary bytes, and remains +`execution=DISABLED`; a result received after termination is rejected with +`SCRIPT_SANDBOX_LATE_RESULT`. + +The Chromium test starts a production Worker that throws for the crash case and a production Worker +that would publish a delayed result for the timeout case. The host observes the crash, terminates the +timeout Worker before its delayed message, and confirms no late message arrives. + +## Evidence + +- `node --test web/tests/unit/script-sandbox-isolation.test.mjs` passed 3/3. +- `CHROME_PATH=/usr/bin/google-chrome-stable WEB_TEST_PORT=5532 npm --prefix web run test:script-sandbox-isolation` passed unit 3/3 and Chromium Worker E2E 1/1. +- `node tools/web/check-script-sandbox-isolation.mjs` passed: + `script-sandbox-isolation-ok crash=SCRIPT_SANDBOX_CRASHED timeout=SCRIPT_SANDBOX_TIMEOUT revisionUnchanged=true late=SCRIPT_SANDBOX_LATE_RESULT next=M13-03E`. +- `npm --prefix web run typecheck` and `git diff --check` passed. + +## Artifact Hashes + +- protocol: `6699fc0a19e0736823f164e707a420655803e36019cf0071f7ff9c760d25e9f2` +- worker: `8c810ee7c8bd15d83ecfb4981068f947536bfe8c9e63b906861f42b8465722da` +- checker: `e5c0c3c7cd500c269ae43a1a9eb05589e82aad28199ab4f11ad1167b486f6937` +- unit: `47986f93638fdc18b817b03222484f4691dc294185040eaebac8e1f386bc1549` +- e2e: `2c16c42f09827a0c0100ef2cc6295ca6bc96933ed7475e08b1fa195d7940c141` +- report: `4af91cdb21101039b379136c7559b46df55f4f5ab5478c05c1c57cd6e1624d2a` +- manifest: `8066013f3d356ba438c36d1f1ea1cf692dbb4f60b086f552072e36b783a96d42` + +## Next Task + +`M13-03E`: cancellation after termination must not publish a late message or cache. + +## Rollback + +Remove the isolation Worker additions, focused tests/checker, report, manifest and this status entry; +restore M13-03C as the queue tail and move `nextTask` back to `M13-03D`. diff --git a/docs/status/M13-03E.md b/docs/status/M13-03E.md new file mode 100644 index 00000000..e90d0210 --- /dev/null +++ b/docs/status/M13-03E.md @@ -0,0 +1,45 @@ +# M13-03E Status + +status: done +task: sandbox cancellation result gate +updated: 2026-08-19 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +Cancellation now has an explicit terminated-job receipt. The receipt preserves the Main revision, +publishes no result, releases temporary bytes and remains `execution=DISABLED`. The production +Chromium Worker schedules a late message containing a cache key; the host cancels and terminates it +before publication, then observes a bounded late window with zero messages and zero cache writes. +Late results supplied after cancellation are rejected with `SCRIPT_SANDBOX_LATE_RESULT`. + +This task does not claim Worker/port/timer disposal or same-session recovery; those remain M13-03F/G. + +## Evidence + +- `node --test web/tests/unit/script-sandbox-cancellation.test.mjs` passed 2/2. +- `CHROME_PATH=/usr/bin/google-chrome-stable WEB_TEST_PORT=5533 npm --prefix web run test:script-sandbox-cancellation` passed unit 2/2 and Chromium Worker E2E 1/1. +- `node tools/web/check-script-sandbox-cancellation.mjs` passed: + `script-sandbox-cancellation-ok status=CANCELLED late=SCRIPT_SANDBOX_LATE_RESULT lateMessages=0 cacheWrites=0 next=M13-03F`. +- `npm --prefix web run typecheck`, `npm --prefix web run lint` and `git diff --check` passed. + +## Artifact Hashes + +- protocol: `6699fc0a19e0736823f164e707a420655803e36019cf0071f7ff9c760d25e9f2` +- worker: `0feb70c8c491cc24ebfe6c3e267b92522d616b6f260efcecfa57cf489d0742c0` +- checker: `b6fbe7102b7d0808673931c66797f8976e79b21bd4c32e429f21832c6090708e` +- unit: `374cc87f66bd42226b750e387663ef8c86bef92348fa7cbc6ed7f0027945204d` +- e2e: `b233d9cafa1f70798ec19d76ca936abb610681522264a12237f532eb98e09fca` +- package: `6499a70fc4a93c925053ddf5009c74d9c10754b443afc9d58e898fe20d7f1b05` +- report: `066d9f19716b8229f2cf7755ec3009a3edef942ebab5f70159bec7f2afbaf71f` +- manifest: `0cdf625e6bd3ed7aca43318a3b04353cb806c51cfa4cf3c5dae9a65a0eae2e69` + +## Next Task + +`M13-03F`: dispose Worker, MessagePort, timers, abort controllers and buffers to zero. + +## Rollback + +Remove the cancellation Worker, focused tests/checker, report, manifest, package command and this +status entry; restore M13-03D as the queue tail and move `nextTask` back to `M13-03E`. diff --git a/docs/status/M13-03F.md b/docs/status/M13-03F.md new file mode 100644 index 00000000..e7bbc2e4 --- /dev/null +++ b/docs/status/M13-03F.md @@ -0,0 +1,44 @@ +# M13-03F Status + +status: done +task: sandbox resource disposal gate +updated: 2026-08-19 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The sandbox Worker now owns an explicit disposal receipt. Both `MessagePort` endpoints, the timer, +`AbortController`, transferable buffer, pending request and cache reference are observed before +disposal and are all zero after the first dispose. A second dispose is accepted and reports +`idempotent=true`; the cleared timer produces no late message. Script execution remains +`DISABLED`, and this task does not claim same-session recovery or server isolation. + +## Evidence + +- `node --test web/tests/unit/script-sandbox-dispose.test.mjs` passed 2/2. +- `CHROME_PATH=/usr/bin/google-chrome-stable WEB_TEST_PORT=5534 npm --prefix web run test:script-sandbox-dispose` passed unit 2/2 and Chromium Worker E2E 1/1. +- `node tools/web/check-script-sandbox-dispose.mjs` passed: + `script-sandbox-dispose-ok ports=0 timers=0 abortControllers=0 buffers=0 pending=0 cacheReferences=0 idempotent=true next=M13-03G`. +- `npm --prefix web run typecheck`, `npm --prefix web run lint` and `git diff --check` passed. + +## Artifact Hashes + +- protocol: `f3d9f667c3809cfad0f52bc6028d93e36e25c4b639fb6081c9c933fbad0ebc0a` +- worker: `2a074b05d301c4083e60534eb9452aabf5d95aea0ab316fa657441ef4bcd5c96` +- checker: `6549bd10f588281d23c4bea705fd164252c9e3f44d8f4b93c893a2c410907135` +- unit: `6e00aa6286aae0eabc1345c04c7628dcc9acb32d51c1c27436a0e1b4c170b64c` +- e2e: `b240d92c90e0d81272cd9cb3c0eb4e7d77102ce2e49ad05c761f763d1812b18e` +- package: `0f06cd7ccdeed4213b6cb956aecf94e60dceff811ad8c3a2e239397685cfc1bf` +- report: `2dbd3e79939b500c0fc83216c51f258b88ead42a667ed1a6da755e893d7f73c6` +- manifest: `96abc7c7613ef4c18c64b84ffb8420c39369f9e4733bd1fa4d7d9ee9e53d6f89` + +## Next Task + +`M13-03G`: recover a small script in the same session with a new Worker generation while keeping +the Main revision/source hash stable and extending the audit hash chain. + +## Rollback + +Remove the dispose protocol, Worker, focused tests/checker, report, manifest, package command and +this status entry; restore M13-03E as the queue tail and move `nextTask` back to `M13-03F`. diff --git a/docs/status/M13-03G.md b/docs/status/M13-03G.md new file mode 100644 index 00000000..1711cc57 --- /dev/null +++ b/docs/status/M13-03G.md @@ -0,0 +1,43 @@ +# M13-03G Status + +status: done +task: same-session sandbox recovery and audit continuity +updated: 2026-08-19 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +A denied script request can be resumed in the same session after a Worker generation change from 4 +to 5. The Main revision remains 11, and the source and canonical manifest hashes remain stable. +Two default-deny audit entries use distinct request IDs, sequence 1/2, and a continuous +`previousEntrySha256 -> entrySha256` chain. Replay and source-hash tampering are rejected. This task +does not enable Python execution or claim Blender server isolation. + +## Evidence + +- `node --test web/tests/unit/script-sandbox-recovery.test.mjs` passed 2/2. +- `CHROME_PATH=/usr/bin/google-chrome-stable WEB_TEST_PORT=5535 npm --prefix web run test:script-sandbox-recovery` passed unit 2/2 and Chromium Worker E2E 1/1. +- `node tools/web/check-script-sandbox-recovery.mjs` passed: + `script-sandbox-recovery-ok generation=4->5 revision=11 sourceStable=true manifestStable=true sequence=1,2 chain=true replay=SCRIPT_MANIFEST_INVALID tamper=SCRIPT_MANIFEST_INVALID next=M13-04A`. +- `npm --prefix web run typecheck`, `npm --prefix web run lint` and `git diff --check` passed. + +## Artifact Hashes + +- protocol: `8034faded657d49e1376ea42051bc302a090b485024a9ff3781e46aa82638b64` +- worker: `7b02331c375d4fb7dbadadd179f0ab9a0e336c8b95fabb071c06e67f3e7b3fe4` +- checker: `612015a3fca44bae0f0b78e622f044a5297ed8aa3a0efc774a40f0d47ddbdc9e` +- unit: `69a2d34e38d591043ff62b2d305bd9aae684ecebd9ecba718580d77318931226` +- e2e: `73176e513f115ba917be74f62a5deb8fe2918fafa8a84c9294e80d2a86b8f1ed` +- package: `3df02b6049ac5d7185c23aef7764933ee460fedeb6595b3861272b0d49da5ecd` +- report: `9057b3f49c3bb15cf53d638ada4bd2743949d3914173cd3799414489d584111d` +- manifest: `5cd365eeebbd6a7f56f380af103b66f2beb11eb49cb6db6adb49972b572cf2ec` + +## Next Task + +`M13-04A`: create an unpredictable one-shot directory for each real Blender server job. + +## Rollback + +Remove the recovery protocol, Worker, focused tests/checker, report, manifest, package command and +this status entry; restore M13-03F as the queue tail and move `nextTask` back to `M13-03G`. diff --git a/docs/status/M13-04A.md b/docs/status/M13-04A.md new file mode 100644 index 00000000..6883ecc2 --- /dev/null +++ b/docs/status/M13-04A.md @@ -0,0 +1,44 @@ +# M13-04A Status + +status: done +task: server job one-shot directory +updated: 2026-08-19 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The server-side job allocator creates an unpredictable `0700` directory below a configured absolute +root for each request. Directory names do not contain the request ID, two concurrent jobs receive +distinct directories, and successful or failed cleanup removes the directory exactly once. Repeated +cleanup is idempotent. This task does not claim read-only source mounts, process limits or Blender +execution. + +Browser E2E is not applicable: this is a Node server filesystem boundary. The checker uses real +temporary directories and `fs.stat`/`fs.rm` calls. + +## Evidence + +- `node --test web/tests/unit/server-job-isolation.test.mjs` passed 2/2. +- `npm --prefix web run test:server-job-directory` passed unit 2/2 and the independent checker. +- `node tools/web/check-server-job-isolation.mjs` passed: + `server-job-directory-ok allocated=2 cleaned=2 unique=1 requestIdsHidden=1 mode=0700 residual=0 idempotent=1 next=M13-04B`. +- `npm --prefix web run typecheck`, `npm --prefix web run lint` and `git diff --check` passed. + +## Artifact Hashes + +- protocol: `3aa5678a2c2769e4db5bb8f5c755b97e23045c4106e5636459748ab9b41929cd` +- checker: `b8d0e741144f742946246370bd35820d806686fb00dc0b040c925cf16f2179da` +- unit: `b1ac3324332180f7b3522f135520e7b5dace334dc461c92f2bce48fbcba2e147` +- package: `0c2633da831e7ca10cf74797650353b6dfb1507ed5957688d34bc97f9a6dd193` +- report: `07ed66fe0b2e1f1bbdba1cfb1089b052a5961d38f761bfcf79d8362980d2d502` +- manifest: `fd2407a7b9fefe67e2d77ed844e6c2ca17cdc4a746857a5825a38ff02ab664e3` + +## Next Task + +`M13-04B`: source read-only mount and independent writable output directory. + +## Rollback + +Remove the directory allocator, focused tests/checker, report, manifest, package command and this +status entry; restore M13-03G as the queue tail and move `nextTask` back to `M13-04A`. diff --git a/docs/status/M13-04B.md b/docs/status/M13-04B.md new file mode 100644 index 00000000..c4703d7b --- /dev/null +++ b/docs/status/M13-04B.md @@ -0,0 +1,41 @@ +# M13-04B Status + +status: done +task: server source read-only and output isolation +updated: 2026-08-19 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +Each allocated server job now gets a `source/` directory and a separate `output/` directory. The +source directory is `0555` and the staged `.blend` is `0444`; a source overwrite fails with +`EACCES`. The output directory is `0700` and accepts result bytes. Cleanup temporarily restores +the source directory permission so the one-shot job directory can be removed without residue. +This task does not claim OS/container CPU, memory or process limits. + +## Evidence + +- `node --test web/tests/unit/server-job-isolation.test.mjs` passed 3/3. +- `npm --prefix web run test:server-job-workspace` passed unit 3/3 and the independent checker. +- `node tools/web/check-server-job-workspace.mjs` passed: + `server-job-workspace-ok sourceDir=0555 sourceFile=0444 sourceWrite=EACCES outputDir=0700 outputWrite=OK distinct=1 residual=0 next=M13-04C`. +- `npm --prefix web run typecheck`, `npm --prefix web run lint` and `git diff --check` passed. + +## Artifact Hashes + +- protocol: `3aa5678a2c2769e4db5bb8f5c755b97e23045c4106e5636459748ab9b41929cd` +- checker: `9490c2eeb9980073fc1fe77fdba1fb6e4ef528de8eb666d9eaf1fb582ba658c2` +- unit: `b1ac3324332180f7b3522f135520e7b5dace334dc461c92f2bce48fbcba2e147` +- package: `3df02b6049ac5d7185c23aef7764933ee460fedeb6595b3861272b0d49da5ecd` +- report: `650a643cc92617d068cb96d8bd4303429ef169ea89e3d4cb63e3ce5e2428e6d2` +- manifest: `9bc906b9e4c9751229f03271ef45a5925122b89b15bfe775591775f121ce1a4c` + +## Next Task + +`M13-04C`: enforce CPU, memory, process, file, wall-time and output budgets at the server boundary. + +## Rollback + +Remove the workspace protocol additions, focused tests/checker, report, manifest, package command and +this status entry; restore M13-04A as the queue tail and move `nextTask` back to `M13-04B`. diff --git a/docs/status/M13-04C.md b/docs/status/M13-04C.md new file mode 100644 index 00000000..2b343f8a --- /dev/null +++ b/docs/status/M13-04C.md @@ -0,0 +1,43 @@ +# M13-04C Status + +status: done +task: server job resource budget gate +updated: 2026-08-19 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +Server jobs now have fixed CPU, memory, process, file, wall-time and output-byte limits. Budget +parsing rejects undeclared fields and out-of-range values. A usage receipt is marked +`enforced=true` only when every measured value is within its corresponding limit; each of the six +over-limit cases returns `SERVER_JOB_BUDGET_EXCEEDED` and remains `execution=DISABLED`. + +This is the resource contract and usage gate. OS/container enforcement is proven by the later real +process task and is not claimed here. + +## Evidence + +- `node --test web/tests/unit/server-job-resource-budget.test.mjs` passed 2/2. +- `npm --prefix web run test:server-job-resource-budget` passed unit 2/2 and the independent checker. +- `node tools/web/check-server-job-resource-budget.mjs` passed: + `server-job-budget-ok cpu=bounded memory=bounded process=bounded files=bounded wall=bounded output=bounded overages=6 execution=DISABLED next=M13-04D`. +- `npm --prefix web run typecheck`, `npm --prefix web run lint` and `git diff --check` passed. + +## Artifact Hashes + +- protocol: `9746f0aef9dd76411320792dee1213c03755a3c03a87bbc4cbf88d4324c728d6` +- checker: `5599f4e25fc3ceca18e04be322450a4dc5bb6ce9c72c3abe9f1afc9c81851ff8` +- unit: `05212b2aa639f4c37e37e1cac0597d9b367a1280e519240f073762394914dc63` +- package: `3df02b6049ac5d7185c23aef7764933ee460fedeb6595b3861272b0d49da5ecd` +- report: `76ba684966bf7e680b0ebfc630ae9e080f04dd6daa6dd744bbb22dcff43f8eb1` +- manifest: `d00fff709b57909ec418ff0e476dca97281fc39456f674c63b51248377fc6ab2` + +## Next Task + +`M13-04D`: default-deny network policy with explicit declared-origin admission. + +## Rollback + +Remove the resource budget protocol, focused tests/checker, report, manifest, package command and +this status entry; restore M13-04B as the queue tail and move `nextTask` back to `M13-04C`. diff --git a/docs/status/M13-04D.md b/docs/status/M13-04D.md new file mode 100644 index 00000000..1bf9c26b --- /dev/null +++ b/docs/status/M13-04D.md @@ -0,0 +1,24 @@ +# M13-04D Status + +status: done +task: server job network policy +updated: 2026-08-19 America/New_York +enablingTask: false +parityStateChange: false + +Server jobs default to `DENY`. Only an explicitly declared canonical HTTPS or loopback origin is +`ALLOWED`; missing, undeclared, credential-bearing, path-bearing, public HTTP and invalid origins +return `SERVER_NETWORK_DENIED` or `SERVER_NETWORK_POLICY_INVALID`. Execution remains `DISABLED`. + +Evidence: `node --test web/tests/unit/server-job-network-policy.test.mjs` 2/2; +`npm --prefix web run test:server-job-network-policy` passed; checker output +`server-job-network-ok default=DENY declaredOrigin=ALLOWED missing=SERVER_NETWORK_DENIED undeclared=SERVER_NETWORK_DENIED execution=DISABLED next=M13-04E`; +typecheck, lint and `git diff --check` passed. + +Artifact hashes: protocol `0dc3698383fbfa511bebfeca41504c7c04a8302d47168cfae94f01f2a11a316c`, +checker `6f3e9cd9f988313863345f392036e6dbb522102c969197034800f419e497ad92`, unit +`c0ba10ccd2fb6ec8878a1b4a54bc6a12e49d35818566e0d55ef307779e8c8b77`, report +`37bcba2666e8c76c2e07d23e285737004affbb0372ec6dd84bb6802dba551527`, manifest +`11ce246597e3321b346ed4fed00edba22708d97250163d332d7ba8a003d8849a`. + +Next task: `M13-04E`, fixed Blender background/factory-startup runtime identity. diff --git a/docs/status/M13-04E.md b/docs/status/M13-04E.md new file mode 100644 index 00000000..dd07687e --- /dev/null +++ b/docs/status/M13-04E.md @@ -0,0 +1,22 @@ +# M13-04E Status + +status: done +task: pinned Blender server startup +updated: 2026-08-19 America/New_York +enablingTask: false +parityStateChange: false + +Pinned Blender 5.2.0 LTS now passes a real server preflight using only `--background`, +`--factory-startup`, the fixed `tools/web/server-job-startup.py`, and a fixed business argument. +The structured receipt confirms background mode, runtime version and argv. Execution remains +`DISABLED`; this task does not claim result publication. + +Evidence: `node tools/web/check-server-job-startup.mjs` output +`server-job-startup-ok blender=5.2 background=1 factory=1 userPrefs=0 fixedScript=1 execution=DISABLED next=M13-04F`; +typecheck, lint and `git diff --check` passed. Artifact hashes: startup +`c8c5b5a7a7880d4df9477a94a98e54e328baf582ade649807067f988484e1f12`, checker +`66b2e25efe0e7455348e8f488cd0f6ebef07f916fcf6541680e7d18e4f69ab10`, report +`c67d2972e584782d03479dbdb36dae0976074978f4ce9a48e41e7ad5aea66089`, manifest +`a24ea87bcf305bc15d8ae70b2abd03aa16ffbd6b127d5e9da9da23617d7201cc`. + +Next task: `M13-04F`, bounded and redacted stdout/stderr. diff --git a/docs/status/M13-04F.md b/docs/status/M13-04F.md new file mode 100644 index 00000000..932d5661 --- /dev/null +++ b/docs/status/M13-04F.md @@ -0,0 +1,40 @@ +# M13-04F Status + +status: done +task: bounded and redacted server stdout/stderr +updated: 2026-08-19 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +Server job output is normalized through a production receipt before publication. Credentials, bearer/basic +tokens, secret headers and Unix/Windows/file URL paths are replaced; UTF-8 output is truncated at fixed +stdout/stderr byte budgets with an explicit marker. The receipt records original/emitted bytes, redaction +count and truncation state. Server execution remains `DISABLED`; process cancellation and result publication +are separate tasks. + +## Evidence + +- `npm --prefix web run test:server-job-output-redaction` passed 3/3 unit tests and the independent checker. +- Checker output: `server-job-output-ok stdoutTruncated=1 stderrTruncated=1 redactions=6 totalBounded=1 execution=DISABLED next=M13-04G`. +- `git diff --check` passed. + +## Artifact Hashes + +- parent manifest: `a24ea87bcf305bc15d8ae70b2abd03aa16ffbd6b127d5e9da9da23617d7201cc` +- protocol: `5a58e7080324b399905c6a20a313225153ba94c75a55d21c4bbdd957ae4a2462` +- checker: `5c2db9d3313484bb68ac65822828467b3f99cb03e8374935757cfcb4b4ed908d` +- unit: `4ae6266bce22f84e73113b93a590a0c32d9452e748003af251a602024d0c6db5` +- package: `52781eb2650133b43271889a22ce38023e611641da15411d31698c6cfc0ce19c` +- report: `cb6e3ed8d6cc0a69b333b6bff3199593bdfbae14fa7fa700dcb12e9e9cea519a` +- manifest: `b5b4b26183dc48ebcae009e49999010d9b60d512f5daa4674a0aff0577d6602c` + +## Next Task + +`M13-04G`: cancel Blender process tree and clean the one-shot directory. + +## Rollback + +Remove the output receipt protocol, unit/checker, report, manifest, package command and this status entry; +restore `M13-04E` as the queue tail. diff --git a/docs/status/M13-04G.md b/docs/status/M13-04G.md new file mode 100644 index 00000000..25acfa8f --- /dev/null +++ b/docs/status/M13-04G.md @@ -0,0 +1,38 @@ +# M13-04G Status + +status: done +task: server process-tree cancellation +updated: 2026-08-19 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +Real server jobs now run in a detached process group on POSIX and are cancelled as a tree. The cancellation +path sends SIGTERM to the group, escalates to SIGKILL after the grace window, waits for the group to exit, +and performs one idempotent job-directory cleanup. Windows uses `taskkill /T /F`. The receipt reports the +actual signal path, cleanup count and orphan count. Execution remains `DISABLED`. + +## Evidence + +- `npm --prefix web run test:server-job-cancellation` passed 2/2 real-process unit tests and the checker. +- Checker output: `server-job-cancel-ok state=CANCELLED tree=SIGTERM_GROUP cleanup=1 orphan=0 residual=0 execution=DISABLED next=M13-04H`. +- `git diff --check` passed. + +## Artifact Hashes + +- parent manifest: `b5b4b26183dc48ebcae009e49999010d9b60d512f5daa4674a0aff0577d6602c` +- protocol: `74fbbac806f34904bc9cb8e40eaf386cb60d44d978973950f1809262d311ec95` +- checker: `751ecafc642595d0de69b618e223cbff49d414f2958fc371371e4a668d66a8cc` +- unit: `39ac445ff51a9bfe249363204cf2edca906b16bf67ab1f8321166b440eba8629` +- package: `ca092a9a1d48ac41dc20f978bb26c8268adde9f7602a06459c7df8c2cb2d4cc7` +- report: `61f5c3ad315f94bac48e58627fcc46015c173dd9f303d5914acb8dbbe93caa97` + +## Next Task + +`M13-04H`: map timeout/OOM/non-zero exit/signal to stable error codes. + +## Rollback + +Remove the process control protocol, unit/checker, report, manifest, package command and this status entry; +restore `M13-04F` as the queue tail. diff --git a/docs/status/M13-04H.md b/docs/status/M13-04H.md new file mode 100644 index 00000000..58d82694 --- /dev/null +++ b/docs/status/M13-04H.md @@ -0,0 +1,38 @@ +# M13-04H Status + +status: done +task: stable server process fault codes +updated: 2026-08-19 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +Timeout, bounded-memory OOM, signal termination and non-zero exit are converted to stable server error +codes. Classification precedence is cancellation, timeout, OOM, signal, then non-zero exit. Failed receipts +keep the base/current revision equal, set `publish=false`, and expose no internal process details. Execution +remains `DISABLED`. + +## Evidence + +- `npm --prefix web run test:server-job-fault-codes` passed 3/3 unit tests and the checker. +- Checker output: `server-job-faults-ok timeout=SERVER_JOB_TIMEOUT oom=SERVER_JOB_OOM signal=SERVER_JOB_SIGNAL exit=SERVER_JOB_EXIT_FAILED revisionPreserved=1 publish=0 execution=DISABLED next=M13-04I`. +- `git diff --check` passed. + +## Artifact Hashes + +- parent manifest: `7103a25ec7eb4a1c7f0d9f7acfb5ed30da32c87d512d6f9d53e081db275f7b89` +- protocol: `858360b4468a7b6cf796fc83a1627ba18626b61a0799f83531de996ea374519b` +- checker: `cb67246ef3df111525ca5e14af0546154ea2beee930b9840ad4ac7e2ebbb91ec` +- unit: `3551e28d78e5069c3d97eabcacac824bf142f54fdcb3ad4904668909af1cc545` +- package: `77bae66fd3885e36d2ead6f261754724144e5f51fafc5ccad9863dfc8e5ab548` +- report: `01999232f926496909fc9ef072e19aadb688822d3f9d7e03b42871ac5d54a0f6` + +## Next Task + +`M13-04I`: verify source/settings/build/output hashes before OPFS commit. + +## Rollback + +Remove the fault classifier, unit/checker, report, manifest, package command and this status entry; restore +`M13-04G` as the queue tail. diff --git a/docs/status/M13-04I.md b/docs/status/M13-04I.md new file mode 100644 index 00000000..f32046b6 --- /dev/null +++ b/docs/status/M13-04I.md @@ -0,0 +1,38 @@ +# M13-04I Status + +status: done +task: server result hash binding and atomic OPFS handoff +updated: 2026-08-19 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +Server output publication requires request identity plus source, settings and Blender build SHA-256 values +to match the expected request. Output bytes are staged, read back, atomically renamed to the request result +path, and read back again before a committed receipt is returned. Tampered output, identity drift, partial +stage and quota failures remain unpublished. Execution remains `DISABLED`. + +## Evidence + +- `npm --prefix web run test:server-job-result-binding` passed 2/2 unit tests and the checker. +- Checker output: `server-job-result-ok source=bound settings=bound build=bound output=verified tamper=blocked quota=blocked atomic=1 execution=DISABLED next=M13-04J`. +- `git diff --check` passed. + +## Artifact Hashes + +- parent manifest: `ba530ddff6e6fdd84057444ce757fad05a30d825a5915abebe2d5910a689af7f` +- protocol: `ecf3bddef53e2766dcc055ec027b04c14e3d884132c7fdea476663617364ad86` +- checker: `b0fbffd2937ab20e72d337bb5ff63ccf188ccf8a939d8ccc8b4878d80c9a4a19` +- unit: `c5538798aa6263b8a1c20270a019f8b4fb8b176b141a6411b72aad21a457bfd0` +- package: `5d269d4e5ff4385d919c0ee33f996dcb814e3e415da25dbf3b4dbdc3312fbe24` +- report: `ec63faeba35a641a22c3b3a69757bd37e8e9acaa1dda11a64faf7bed80439784` + +## Next Task + +`M13-04J`: idempotent request retry and conflicting-result isolation. + +## Rollback + +Remove the result-binding protocol, unit/checker, report, manifest, package command and this status entry; +restore `M13-04H` as the queue tail. diff --git a/docs/status/M13-04J.md b/docs/status/M13-04J.md new file mode 100644 index 00000000..0eee3a55 --- /dev/null +++ b/docs/status/M13-04J.md @@ -0,0 +1,38 @@ +# M13-04J Status + +status: done +task: server request idempotency and conflict isolation +updated: 2026-08-19 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +`projectId/requestId` is now an idempotency key. An exact retry verifies and reuses the existing result; +different output or source/settings/build identity returns a stable conflict and cannot overwrite the bound +result. Different requests use separate result and receipt files. Concurrent duplicate requests share one +in-flight submission and resolve as one commit plus one reuse. Execution remains `DISABLED`. + +## Evidence + +- `npm --prefix web run test:server-job-idempotency` passed 3/3 unit tests and the checker. +- Checker output: `server-job-idempotency-ok first=COMMITTED retry=REUSED conflict=BLOCKED isolated=1 concurrent=REUSED execution=DISABLED next=M13-05A`. +- `git diff --check` passed. + +## Artifact Hashes + +- parent manifest: `f9658954d3dbd0cd1edd696c10f6a1b880bb3288c91a39a47ca84c4496046a97` +- protocol: `15eca7550964c9985e26a22a7900d3f3aa69fdef35fe5962b48a8f55ebba5863` +- checker: `02247ffda07c095373642a7fa22268bb9f5ac181d41571f7fff685812976d3c4` +- unit: `6ee08e67cd8493e856f67309bd6562316eca42ef52dd4a9aad3a15efdc4b6b41` +- package: `5b47e94cf828fc9d3021019eed9922eb67815aba5416266d8c967c7cb5bd96ba` +- report: `31f95a8cc1c4792303986b8b5987ab02fc11a77ab25fd76b31c02d1ed1b104d1` + +## Next Task + +`M13-05A`: CSP denies inline script, eval, data script and undeclared origins. + +## Rollback + +Remove the idempotency protocol, unit/checker, report, manifest, package command and this status entry; +restore `M13-04I` as the queue tail. diff --git a/docs/status/M13-05A.md b/docs/status/M13-05A.md new file mode 100644 index 00000000..5688fb7d --- /dev/null +++ b/docs/status/M13-05A.md @@ -0,0 +1,45 @@ +# M13-05A Status + +status: done +task: CSP default-deny policy +updated: 2026-08-19 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The deployment contract now requires one CSP on every response: same-origin default/script/worker/connect +sources, no inline or eval execution, no data/blob script source, and object/base/frame embedding denied. +The Vite source, entry HTML, production source tree and built JS/HTML are scanned for dynamic code and data +scripts. The deployment HTTP checker verifies the policy on normal, missing and entry responses. Resource- +specific Worker/WASM/font/image/media rules remain in M13-05B. + +## Evidence + +- `npm --prefix web run test:csp-policy` passed. +- `node tools/web/check-deployment-contract.mjs` passed. +- `npm --prefix web run typecheck` passed. +- `npm --prefix web run build` passed; built output was scanned by the CSP checker. +- Checker output: `csp-policy-ok inline=DENY eval=DENY dataScript=DENY undeclaredConnect=DENY responses=3 execution=DISABLED next=M13-05B`. +- `git diff --check` passed. + +## Artifact Hashes + +- parent manifest: `d230ae49dbf38cd9e95e7379a1a49332e478d3d23b3638730d2ec6167204c368` +- contract: `3f2b0b10c5a5698be2adda61af80cfb9cb9291fb9797994bfa66a77b7b511eeb` +- deployment server: `a5734ce9760f65cf5bade8cc209454d39fa963ad7dfaf20653728a9d7a57b04c` +- contract checker: `5e2d80f5c1377420f4779291fa73c206741ed415e751e79ce5edae7ba039febf` +- CSP checker: `f97ab074da860e3f8489e520c8bb5932f858409136ef25cca56247bb1310340d` +- index: `16b7691b191127f84c5143e23aecdfc203dc9e279d34cb270b37631fcbd5f856` +- Vite: `fd160c685ed780738e2b37ce9a32e5d4718e11a2875e12d86ddad310213abaf3` +- package: `5b47e94cf828fc9d3021019eed9922eb67815aba5416266d8c967c7cb5bd96ba` +- report: `9c7c64bb021161a1165a7c089ccf3f4897877635914260f6f0b0da119a5036fb` + +## Next Task + +`M13-05B`: verify resource-specific CSP for Worker, WASM, font, image and media paths. + +## Rollback + +Remove the CSP contract/checker changes, report, manifest, package command and this status entry; restore +`M13-04J` as the queue tail. diff --git a/docs/status/M13-05B.md b/docs/status/M13-05B.md new file mode 100644 index 00000000..77e16471 --- /dev/null +++ b/docs/status/M13-05B.md @@ -0,0 +1,31 @@ +# M13-05B Status + +status: done +task: resource-specific CSP policy +updated: 2026-08-19 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The deployment contract now separates same-origin policy for Worker, WASM, font, image and media +resources. Chromium loaded same-origin Worker, WASM, image and media resources, and rejected data-image, +blob-Worker and cross-origin connect attempts. WASM uses only the explicit `wasm-unsafe-eval` source +expression; JavaScript `eval` remains denied. Resource responses and 404 responses carry the same policy. + +## Evidence + +- `npm --prefix web run test:csp-resource-policy` passed the HTTP resource matrix and Chromium browser gate. +- `node tools/web/check-deployment-contract.mjs` passed. +- `npm --prefix web run typecheck` passed. +- `npm --prefix web run build` passed. +- `git diff --check` passed. + +## Next Task + +`M13-05C`: production/build/test dependency inventories. + +## Rollback + +Remove the resource-specific CSP/MIME changes, checker, report, manifest, package command and this status; +restore `M13-05A` as the queue tail. diff --git a/docs/status/M13-05C.md b/docs/status/M13-05C.md new file mode 100644 index 00000000..c7ce0e35 --- /dev/null +++ b/docs/status/M13-05C.md @@ -0,0 +1,27 @@ +# M13-05C Status + +status: done +task: production/build/test dependency inventories +updated: 2026-08-19 America/New_York +enablingTask: false +parityStateChange: false + +## Evidence + +`web/package.json` and `web/package-lock.json` are parsed as the only dependency sources. Deterministic +transitive closures contain production=3, build=135 and test=6 packages; every package is classified and +records its path, version, resolved URL and lockfile integrity. A fresh regeneration is byte-identical. + +- `npm --prefix web run test:dependency-inventory` passed. +- `npm --prefix web run typecheck` passed. +- `npm --prefix web run build` passed. +- `git diff --check` passed. + +## Next Task + +`M13-05D`: severity gates and documented dependency exceptions. + +## Rollback + +Remove the inventory generator/checker, report, manifest, package command and this status; restore +`M13-05B` as the queue tail. diff --git a/docs/status/M13-05D.md b/docs/status/M13-05D.md new file mode 100644 index 00000000..f23dc8e4 --- /dev/null +++ b/docs/status/M13-05D.md @@ -0,0 +1,18 @@ +# M13-05D Status + +status: done +task: dependency severity gates and exceptions +updated: 2026-08-19 America/New_York +enablingTask: false +parityStateChange: false + +The severity policy is machine-readable: BLOCKER/HIGH block, MEDIUM requires review and LOW is tracked. +Exceptions require a non-empty owner, non-expired ISO date, reason and alternative control. Production +inventory has zero findings and zero exceptions, while four negative/positive exception cases are checked. + +- `npm --prefix web run test:dependency-severity-policy` passed. +- `npm --prefix web run typecheck` passed. +- `npm --prefix web run build` passed. +- `git diff --check` passed. + +Next task: `M13-05E`, SBOM/license/source-offer binding. diff --git a/docs/status/M13-05E.md b/docs/status/M13-05E.md new file mode 100644 index 00000000..53bcf0e8 --- /dev/null +++ b/docs/status/M13-05E.md @@ -0,0 +1,13 @@ +# M13-05E Status + +status: done +task: SBOM/license/source-offer and archive binding +updated: 2026-08-19 America/New_York +enablingTask: false +parityStateChange: false + +The supply-chain checker binds SPDX 2.3 to the lockfile and notices hashes, verifies the corresponding +source offer and source archive contents, checks top-level archive SHA256SUMS, and records the current +commit. Binary/source archive validators also pass independently; script execution remains disabled. + +Next task: `M13-05F`, malicious input matrix. diff --git a/docs/status/M13-05F.md b/docs/status/M13-05F.md new file mode 100644 index 00000000..20baa3cb --- /dev/null +++ b/docs/status/M13-05F.md @@ -0,0 +1,14 @@ +# M13-05F Status + +status: done +task: malicious input matrix +updated: 2026-08-19 America/New_York +enablingTask: false +parityStateChange: false + +The production blend loader rejected five malformed blend cases. Existing production protocol tests +rejected malformed image previews, fonts, media cache manifests, shader node graphs, script manifests and +GLB inputs; archive metadata rejected six ZIP/TAR traversal, link, duplicate and bomb cases without +extraction. All matrix categories are stable reject paths and execution remains disabled. + +Next task: `M13-05G`, fuzz crash minimization and regression capture. diff --git a/docs/status/M13-05G.md b/docs/status/M13-05G.md new file mode 100644 index 00000000..d5e866b9 --- /dev/null +++ b/docs/status/M13-05G.md @@ -0,0 +1,37 @@ +# M13-05G Status + +status: done +task: fuzz crash minimization and regression capture +updated: 2026-08-19 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The deterministic runner replays 16 mutations in each of the blend, image, font, node-graph and script +manifest domains using seed `1511506142`. Every case returns one structured `ACCEPTED` or `REJECTED` +receipt; a non-zero runner exit first writes a replayable corpus sample. This run completed 80 cases with +zero crashes and an empty minimized corpus. Script execution remains disabled. + +## Evidence + +- `npm --prefix web run test:fuzz-regression` passed. +- Checker output: `fuzz-regression-ok seed=1511506142 cases=80 crashes=0 corpus=0 execution=DISABLED next=M13-05H`. +- `git diff --check` passed. + +## Artifact Hashes + +- parent manifest: `34043df0515f4b422a2b265d38978dd3c7f21acd12753c99f9a50f9b23f8bd60` +- runner: `a4d5d1b743b454705b1c3a257f1f36d1fea8abd77442e11fb1fec9e2905a3f79` +- checker: `55c8fa3776b22eb3f2e2ffd1629f0092384df30ef3913699a1ba93b283d641d8` +- package: `1feb509789d7f06019390bd86197bb9851cd520fd9fe310d1e29bfc3d2ef3f18` +- report: `5eb4a5469732697be7910fded3ce34cf2898735f767d94c1abbe0dd264bcc0d9` + +## Next Task + +`M13-05H`: fuzz corpus replay and release-boundary exclusion. + +## Rollback + +Remove the fuzz runner/checker, report, manifest, package command and this status entry; restore +`M13-05F` as the queue tail. diff --git a/docs/status/M13-05H.md b/docs/status/M13-05H.md new file mode 100644 index 00000000..42456120 --- /dev/null +++ b/docs/status/M13-05H.md @@ -0,0 +1,30 @@ +# M13-05H Status + +status: done +task: script audit record integrity +updated: 2026-08-19 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The production scripting audit protocol now has an independent integrity gate. Two default-deny audit +records are validated with strictly increasing ISO timestamps, contiguous sequence numbers, unique request +IDs, canonical entry digests and a continuous previous-entry hash chain. Replay, time reversal, sequence +mutation, entry digest mutation and chain mutation all return `SCRIPT_MANIFEST_INVALID`; execution remains +disabled. + +## Evidence + +- `npm --prefix web run test:script-audit-integrity` passed 2 unit tests and the checker. +- Checker output: `script-audit-integrity-ok entries=2 sequence=1,2 requestIds=unique time=ordered chain=true replay=SCRIPT_MANIFEST_INVALID tamper=3 execution=DISABLED next=M14-01A`. +- `git diff --check` passed. + +## Next Task + +`M14-01A`: freeze the current Chromium release, engine and archive hashes. + +## Rollback + +Remove the audit integrity checker/unit, report, manifest, package command and this status entry; restore +`M13-05G` as the queue tail. diff --git a/docs/status/M14-01A.md b/docs/status/M14-01A.md new file mode 100644 index 00000000..6556707d --- /dev/null +++ b/docs/status/M14-01A.md @@ -0,0 +1,32 @@ +# M14-01A Status + +status: done +task: Chromium, engine and archive identity freeze +updated: 2026-08-19 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The Chromium baseline is frozen at `150.0.7871.128`. The checker resolves and hashes the actual browser +executable, validates every single/pthread engine resource against `engine-manifest.json`, and validates +both release archives against the regenerated `RC_MANIFEST.json` and `SHA256SUMS.txt`. Execution remains +disabled. + +## Evidence + +- `npm --prefix web run release:offline` passed deterministic binary/source rebuild twice. +- `node tools/web/create-rc-manifest.mjs && node tools/web/check-rc-manifest.mjs` passed. +- `npm --prefix web run test:chromium-freeze` passed with browser `150.0.7871.128`, two engine variants, + and two archive bindings. +- `npm --prefix web run typecheck` passed; execution remains `DISABLED`. +- `git diff --check` passed. + +## Next Task + +`M14-01B`: Firefox capability probe using the frozen field set. + +## Rollback + +Remove the Chromium freeze checker, report, manifest, package command and this status entry; restore +`M13-05H` as the queue tail. diff --git a/docs/status/M14-01B.md b/docs/status/M14-01B.md new file mode 100644 index 00000000..60c6b0db --- /dev/null +++ b/docs/status/M14-01B.md @@ -0,0 +1,21 @@ +# M14-01B Status + +status: done +task: Firefox capability probe +updated: 2026-08-19 America/New_York +enablingTask: false +parityStateChange: false + +Firefox `153.0` was probed against the rebuilt production assets with COOP/COEP isolation. WASM, Worker, +OPFS, IndexedDB, WebGL2, OffscreenCanvas and cross-origin isolation passed. WebGPU is explicitly +`BLOCKED/WEBGPU_UNAVAILABLE`; it does not change the Chromium-only release claim. + +## Evidence + +- `npm --prefix web run test:firefox-capability` passed. +- Checker output: `firefox-capability-ok version=153.0 wasm=PASS,worker=PASS,opfs=PASS,indexedDB=PASS,webgl2=PASS,webgpu=BLOCKED,offscreen=PASS,isolation=PASS execution=DISABLED next=M14-01C`. +- `git diff --check` passed. + +## Next Task + +`M14-01C`: WebKit capability probe with the same fields. diff --git a/docs/status/M14-01C.md b/docs/status/M14-01C.md new file mode 100644 index 00000000..5d5dd113 --- /dev/null +++ b/docs/status/M14-01C.md @@ -0,0 +1,21 @@ +# M14-01C Status + +status: done +task: WebKit capability probe +updated: 2026-08-19 America/New_York +enablingTask: false +parityStateChange: false + +WebKit `26.5` was probed against the rebuilt production assets with COOP/COEP isolation. WASM, Worker, +IndexedDB, WebGL2, OffscreenCanvas and isolation passed. OPFS and WebGPU are explicitly blocked; the +result does not change the Chromium-only release claim. + +## Evidence + +- `npm --prefix web run test:webkit-capability` passed. +- Checker output: `webkit-capability-ok version=26.5 wasm=PASS,worker=PASS,opfs=BLOCKED,indexedDB=PASS,webgl2=PASS,webgpu=BLOCKED,offscreen=PASS,isolation=PASS execution=DISABLED next=M14-01D`. +- `git diff --check` passed. + +## Next Task + +`M14-01D`: probe identity and GPU/OS adapter recording. diff --git a/docs/status/M14-01D.md b/docs/status/M14-01D.md new file mode 100644 index 00000000..927dc36c --- /dev/null +++ b/docs/status/M14-01D.md @@ -0,0 +1,33 @@ +# M14-01D Status + +status: done +task: probe identity and GPU/OS adapter recording +updated: 2026-08-19 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The production Chromium probe records observed browser identity, OS kernel/runtime identity, +hardware concurrency, WebGL2 vendor/renderer/version, WebGPU adapter identity, and cross-origin +isolation. WebGPU is recorded as `BLOCKED/WEBGPU_ADAPTER_UNAVAILABLE` in this headless environment; +the result does not claim WebGPU support. The report is bound to the exact worker/WASM asset hashes +used by the probe and carries a canonical identity SHA-256. + +## Evidence + +- `npm --prefix web run build` passed. +- `npm --prefix web run test:probe-identity` passed twice (one report-generation run and one frozen + report verification run). +- Checker output: `probe-identity-ok version=151.0.7922.34 os=linux/x64 webgl2=PASS,webgpu=BLOCKED identity=3c6f898e1b6ffd39862505b8e7dde8fd29369204e1670a013859f74ccaf1c1eb execution=DISABLED next=M14-01E`. +- `git diff --check` passed. + +## Next Task + +`M14-01E`: Chromium WebGPU fail-closed boundary. Firefox and WebKit are excluded by the project +Chromium-only iron rule. + +## Rollback + +Remove the probe checker, report, manifest, package command and this status/task entry; keep +`M14-01C` as the queue tail. diff --git a/docs/status/M14-01E.md b/docs/status/M14-01E.md new file mode 100644 index 00000000..28272356 --- /dev/null +++ b/docs/status/M14-01E.md @@ -0,0 +1,29 @@ +# M14-01E Status + +status: done +task: Chromium WebGPU fail-closed boundary +updated: 2026-08-19 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +Chromium routing was checked against the production render protocol. A bounded Eevee request using +WebGPU returns `BLOCKED/WEBGPU_RENDERER_UNAVAILABLE` when no bundled adapter is available, while the +same bounded request using WebGL2 remains `READY/WEB_LOCAL_BOUNDED`. This task does not launch Firefox +or WebKit. + +## Evidence + +- `npm --prefix web run test:chromium-webgpu-boundary` passed. +- Checker output: `chromium-webgpu-boundary-ok webgpu=BLOCKED/WEBGPU_RENDERER_UNAVAILABLE webgl2=READY execution=DISABLED next=M14-04A`. +- `git diff --check` passed. + +## Next Task + +`M14-04A`: Chromium GPU/memory budget device tier selection. + +## Rollback + +Remove the Chromium boundary checker, report, manifest, package command and this status/task entry; +restore `M14-01D` as the queue tail. diff --git a/docs/status/M14-04A.md b/docs/status/M14-04A.md new file mode 100644 index 00000000..88836ffd --- /dev/null +++ b/docs/status/M14-04A.md @@ -0,0 +1,25 @@ +# M14-04A Status + +status: done +task: Chromium GPU/memory budget device tier selection +updated: 2026-08-19 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +Added a bounded device-budget selector driven only by the real Chromium M14-01D identity report. +The observed SwiftShader renderer and unavailable WebGPU adapter select `CONSERVATIVE`; untrusted, +missing, or fallback adapters cannot expand budgets. Fixed `CONSERVATIVE`, `BALANCED`, and `HIGH` +limits are explicit and unknown tiers fail closed. Firefox/WebKit are excluded by the iron rule. + +## Evidence + +- `npm --prefix web run test:chromium-device-budget` passed: 3 unit tests plus the production identity + report checker. +- Checker output: `chromium-device-budget-ok tier=CONSERVATIVE reason=UNTRUSTED_ADAPTER identity=3c6f898e1b6ffd39862505b8e7dde8fd29369204e1670a013859f74ccaf1c1eb execution=DISABLED next=M14-04B`. +- `npm --prefix web run typecheck` and `git diff --check` passed. + +## Next Task + +`M14-04B`: Chromium DPR 1/1.5/2/3 canvas and raycast consistency. diff --git a/docs/status/M14-04B.md b/docs/status/M14-04B.md new file mode 100644 index 00000000..1cc1d6b4 --- /dev/null +++ b/docs/status/M14-04B.md @@ -0,0 +1,26 @@ +# M14-04B Status + +status: done +task: Chromium DPR 1/1.5/2/3 canvas and raycast consistency +updated: 2026-08-19 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +Shared viewport DPR and CSS-bounds NDC helpers now drive the main-thread and Offscreen viewport +boundaries. Chromium production verification ran at device scale factors 1, 1.5, 2 and 3; the +declared renderer cap of 2 was applied, backing dimensions matched the browser canvas, CSS dimensions +remained `679x321`, and the same center raycast selected the same object at every scale. + +## Evidence + +- `npm --prefix web run build` passed. +- `npm --prefix web run test:chromium-dpr-consistency` passed: 2 protocol tests and 4 real Chromium + production contexts. +- Checker output: `chromium-dpr-ok dpr=1,1.5,2,3 css=679x321 selection=stable execution=DISABLED next=M14-04C`. +- `npm --prefix web run typecheck` and `git diff --check` passed. + +## Next Task + +`M14-04C`: Chromium mouse/touch/pen pointer identity and cancellation contract. diff --git a/docs/status/M14-04C.md b/docs/status/M14-04C.md new file mode 100644 index 00000000..de878619 --- /dev/null +++ b/docs/status/M14-04C.md @@ -0,0 +1,26 @@ +# M14-04C Status + +status: done +task: Chromium mouse/touch/pen pointer identity and cancellation contract +updated: 2026-08-19 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +The main-thread and Offscreen viewport paths now record a shared pointer observation for mouse, +touch, and pen input. Pointer identity, pressure, tilt, button/buttons and `pointercancel` are +preserved with bounded values; unknown pointer types and invalid IDs fail closed. Chromium production +events verified down/cancel pairs for all three pointer types. Firefox/WebKit are excluded. + +## Evidence + +- `npm --prefix web run build` passed. +- `npm --prefix web run test:chromium-pointer-contract` passed: 2 protocol tests and real Chromium + mouse/touch/pen down/cancel events. +- Checker output: `chromium-pointer-ok types=mouse,touch,pen cancel=PASS execution=DISABLED next=M14-04D`. +- `npm --prefix web run typecheck` and `git diff --check` passed. + +## Next Task + +`M14-04D`: Chromium IME composition guard for incomplete operators. diff --git a/docs/status/M14-04D.md b/docs/status/M14-04D.md new file mode 100644 index 00000000..17617dad --- /dev/null +++ b/docs/status/M14-04D.md @@ -0,0 +1,26 @@ +# M14-04D Status + +status: done +task: Chromium IME composition guard for incomplete operators +updated: 2026-08-19 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +Added an explicit IME composition state machine and connected it to the App global shortcut gate. +Composition start/update records pending text and blocks operator shortcuts; composition end clears +the block without dispatching an incomplete operator. A real Chromium page dispatched composition +events and `G`; UI revision remained unchanged during composition. Firefox/WebKit are excluded. + +## Evidence + +- `npm --prefix web run build` passed. +- `npm --prefix web run test:chromium-ime-guard` passed: 2 protocol tests and real Chromium + compositionstart/update/keydown/compositionend events. +- Checker output: `chromium-ime-ok composing=BLOCKED_OPERATOR shortcut=G revision=0 execution=DISABLED next=M14-04E`. +- `npm --prefix web run typecheck` and `git diff --check` passed. + +## Next Task + +`M14-04E`: Chromium US/non-US/dead-key/modifier keymap fixture. diff --git a/docs/status/M14-04E.md b/docs/status/M14-04E.md new file mode 100644 index 00000000..200e3a0d --- /dev/null +++ b/docs/status/M14-04E.md @@ -0,0 +1,26 @@ +# M14-04E Status + +status: done +task: Chromium US/non-US/dead-key/modifier keymap fixture +updated: 2026-08-19 America/New_York +enablingTask: false +parityStateChange: false + +## Scope + +Added a versioned keyboard observation contract preserving layout character (`key`), physical key +(`code`), location, repeat, composition state, dead-key identity and modifier flags. Chromium +production events verified US, non-US character, dead-key and modifier fixtures. Firefox/WebKit are +excluded. + +## Evidence + +- `npm --prefix web run build` passed. +- `npm --prefix web run test:chromium-keymap-fixture` passed: 2 protocol tests and 4 real Chromium + keyboard fixtures. +- Checker output: `chromium-keymap-ok fixtures=US,NON_US,DEAD_KEY,MODIFIER observations=4 execution=DISABLED next=M14-04F`. +- `npm --prefix web run typecheck` and `git diff --check` passed. + +## Next Task + +`M14-04F`: Chromium touch modal cancel, two-finger navigation and single pen Main commit. diff --git a/docs/status/N-023.md b/docs/status/N-023.md index ab2ffedf..c2ac4a6c 100644 --- a/docs/status/N-023.md +++ b/docs/status/N-023.md @@ -1,7 +1,8 @@ # N-023 Asset、Library 与 IO 状态:`BLOCKED`(asset catalog、来源/许可证元数据、真实 Main library inventory、库依赖与 -IO 安全门已落地;Append/Link/Override Main、非 GLB 本地导入和跨桌面重导入未实现) +IO 安全门已落地;bounded Append Main 及其 canonical persistence 已完成,Link/Override Main、 +非 GLB 本地导入和跨桌面重导入仍未实现) ## 完整对标盘点基线 @@ -91,8 +92,35 @@ M12-03D 已把同一 source-hash-bound closure 送入 WASM Worker 的 authoritat link/append context 递归导入 Object、Mesh、Material 和 packed Image,Main commit 前核对 source locator、root、closure 与 base revision;四个 ID 均无 `ID.lib`/override、映射为可写 `LOCAL_MAIN`, 并只产生一个新的 SceneIR revision。stale revision 与 local ID collision 在 Main mutation 前阻断, -失败路径回滚完整 history state。该项只证明单 transaction Main append,undo/redo/save/reopen 与 -desktop canonical report 的联合一致性仍由 M12-03E 验证。 +失败路径回滚完整 history state。 + +M12-03E 已在同一 Chromium/WASM Main 路径完成联合持久化门:append 只推进一个 revision,undo +移除 Object/Mesh/Material/Image 四个 closure ID,redo 恢复同一 canonical graph;save/reopen 后 +再次读取的依赖边、geometry、UV、material slot 和 packed image Float32 pixel hash 与 M12-03C +desktop report 精确一致。图像比较只对 Blender bottom-up `Image.pixels` 与 Canvas top-down row +做规范化;当前 SceneIR 缺省的 `Image.colorSpace` 使用 desktop sRGB semantic default,若字段出现 +则必须匹配,不放宽其它字段。该证据仍不覆盖 Link/Override 或完整 N-023 IO parity。 + +M12-03F 已用独立 Blender 5.2 desktop fixture 冻结 LINK 语义。只链接一个 Object root,Mesh、 +Material 和 packed Image 依赖随 source library 保留;四个 ID 的 `library` 均为 +`m12_link_source.blend`、`isLibraryOverride=false`,并映射为 `SOURCE_LIBRARY/readOnly=true`。 +target 保存并重开后 stable mapping、依赖边、geometry/UV/material slot 和 packed image Float32 +pixel hash 不变。该 enabling evidence 仍不开放 linked writer 或声明完整 Link parity。 + +M12-03G 已为 linked Object/Mesh/Material/Image writer 建立统一 fail-closed gate。object transform、 +mesh geometry/material slot、material property/image node 和 packed image data 六类操作在 +`SOURCE_LIBRARY/readOnly=true` context 下全部返回 `LINKED_DATA_MUTATION_BLOCKED`,且 +`recoverable=false`;stale revision 先返回 `REVISION_CONFLICT`,未知字段、operation 和 owner +substitution 也在 Main 前阻断。M12-03H 又将 reload 限定为匹配 source library generation/revision 的 +单一 linked snapshot 替换;stale、跳代、重复 identity 和 source substitution 均不发布新状态。该项仍不 +实现 relocate、override 或 LINK Main transaction;M12-03I 的 missing-library placeholder 仍是有界 +reference-preservation 证据,不是完整资源恢复。 + +M12-04A-J 又冻结 declared source-origin admission、canonical project path、path/origin safety、 +symlink/hardlink resolution、metadata-first read、entry/total/path/range/conflict budget 与 cancellation +rollback。取消、quota 和 OOM 的部分 staging 都会清零并保持 committed revision/SHA-256 不变, +同一 storage instance 可继续提交小 archive;6 个恶意 ZIP/TAR fixture 又进入确定性长期回归。 +该组有界证据仍不实现完整 archive decoder、fuzz 或 N-023 全域 parity。 ## 已验证切片 @@ -101,8 +129,30 @@ desktop canonical report 的联合一致性仍由 M12-03E 验证。 缺许可证和哈希不匹配会拒绝。 2. N-023-A/B(部分):catalog 与 library dependency 图做确定性拓扑检查并返回 load order; content-addressed index 报告 `LOCAL_BOUNDED`,OPFS 只在运行时 API 存在时报告 `PROBE_REQUIRED`。 -3. N-023-C(门):现有 GLB 导出与 USD semantic analysis 可放行;GLTF/OBJ/PLY/STL、 - USD/Alembic 实际导入保持 `IO_FORMAT_UNSUPPORTED`,库 mutation 需要真实 Main。 +3. N-023-C(门):现有 GLB 导出与 USD semantic analysis 可放行;M12-05A 已从 pinned Blender + 5.2 runtime 生成 GLTF/GLB/OBJ/STL/PLY/USD/Alembic operator/build inventory,但没有把清单 + 当作执行 receipt;M12-05B 仅放行已有 bounded GLB local export,其余 import/export local/server + route 均保持 `BLOCKED`;M12-05C 又让 UI registry 只消费 matrix 声明且 runtime 可执行的 route, + 文件选择器仅接受 `.blend`,operator search 隐藏 blocked/undeclared 组合;M12-05D 再用 + pinned Blender 5.2 runtime receipt 在执行前确认 operator registered/build option/RNA identity, + M12-05E 又要求 source/settings/runtime 三重 hash 与 parent inventory identity,且不从扩展名推断能力; + M12-06A 已由同一 pinned runtime 生成五个独立 Mesh/PBR/UV/skin/animation GLB fixture,并用 + semantic report 与逐字节二次生成固定输入基线;M12-06B 又由生产 Web parser 在 Node 与 + Chromium Worker 对 topology/attributes/materials/nodes/animations 做 exact canonical 比较; + M12-06C-G 又完成 Main persistence、loss report、desktop re-import、negative budget 和双向取消/ + Worker restart/真实 OPFS quota 恢复;M12-07A 又冻结 pinned Blender OBJ 单 Mesh position/ + normal/UV/material-group 输入;M12-07B 又冻结 OBJ 双对象/负索引/相对纹理来源/坏 face 负例; + M12-07C 已完成 bounded Web-to-desktop OBJ round-trip 与 texture-origin loss report,但 + UV/skin mismatch、GLB import UI route 和 OBJ Web route 仍保持阻断;M12-07D 已冻结 Blender + STL binary/ASCII 两个独立 runtime variant;M12-07E 又对标 normal/unit/degenerate/trailing, + trailing 保持 stricter Web block;M12-07F 又完成 STL Web-to-desktop round-trip/material loss + report,STL Web route 仍保持阻断;M12-07G 又冻结 PLY ASCII 与 binary little-endian 两个 + 独立 desktop capability variant;M12-07H 又完成 bounded PLY vertex/face/color/custom property + mapping 与 `PLY_UNKNOWN_PROPERTY` loss report;M12-07I 又让 big-endian、坏 list、超大 count + 在 Worker 中稳定 fail-closed;M12-07J 又完成 OBJ/STL/PLY cancellation/OOM/restart/small recovery, + 但 PLY Web route 仍保持阻断; + GLTF/OBJ/PLY/STL、USD/Alembic 实际导入仍保持 `IO_FORMAT_UNSUPPORTED`, + 库 mutation 需要真实 Main。 4. N-023-A/B(部分):预览字节先验 SHA-256 和 byte length,再校验 PNG signature/尺寸; WebP 仅在 RIFF/WEBP 容器签名正确时进入后续解码门,不从元数据伪造预览内容。 5. N-023-E:项目路径、外部 URI、archive entry 数量/单项/总量、压缩展开比率均有边界。 @@ -110,9 +160,11 @@ desktop canonical report 的联合一致性仍由 M12-03E 验证。 packed/external 状态、只读标志和 archive-parent dependency;1024 library/每库 1024 dependency、 重复 ID、缺依赖、依赖环与项目外路径由 SceneIR 再校验。缺外部库字节时返回 `LINKED_LIBRARY_RESOURCE_REQUIRED`,不从路径伪造 SHA-256 或声称已加载。 -7. N-023-E(archive 结构门):除 traversal 与展开比预算外,重复规范路径、文件/目录前缀冲突、 - 累计压缩/解压字节和声明源长度不一致均拒绝;`planIOArchiveRanges` 按路径生成确定性、安全整数 - compressed offset 计划。它为后续流式解包提供边界,不代表已有 ZIP decoder。 +7. N-023-E(archive 结构门):symlink/hardlink 在写前解析到临时根;ZIP central directory/TAR + manifest 必须先读;entry/total/path、展开比、重叠 range、重复路径与文件/目录前缀冲突均拒绝。 + 取消会删除真实 staging、保持 committed identity 且发布数为零;quota/OOM 部分写入也会清零, + 并允许同一 storage instance 恢复小 archive。三类 ZIP 与三类 TAR 恶意二进制 fixture 确定性 + 重建、验证真实容器 metadata 后全部 fail-closed;这些门不代表已有 ZIP/TAR decoder。 8. N-023-E(NanoVDB range 基础件):`.nvdb` manifest 要求连续、32-byte 对齐、逐块 hash; HTTP source 只接受与 manifest 精确一致的 `206 Content-Range`,并按块校验后消费;临时错误重试、 稳定 ETag/If-Range、response-body 偏移续传、错位/短响应拒绝均已完成。OPFS 原子 @@ -125,11 +177,16 @@ desktop canonical report 的联合一致性仍由 M12-03E 验证。 ## 仍然阻断 -- N-023-B:Append undo/redo/save/reopen、Link/Library Override、reload/relocate 和完整真实 Main - parity;M12-03D 的单 transaction append 已有独立证据,但不解除本项阻断。 +- N-023-B:linked relocate、missing-library recovery、Library Override 和完整真实 Main parity; + M12-03D/E 已证明 bounded Append 的单 transaction 与 desktop canonical persistence,M12-03F/G/H/I + 已冻结 desktop LINK source-library/read-only contract、linked writer fail-closed 与 + matching-generation reload、missing-reference preservation 和 desktop override ownership/property + fixture、单一 verified override writer、freshness gate、negative cases、三 lane command matrix 与 + declared source-origin admission、canonical project-path normalization 与 path/origin security gate, + 但不解除本项阻断。 - N-023-C/D:GLTF/OBJ/PLY/STL、USD/Alembic import/export/save/reopen/desktop reimport。 -- N-023-E:真实 zip decoder/fuzz、OPFS quota/recovery、license/source offer 发布审计和大文件 - 流式性能;archive 路径冲突、双向字节预算与确定性 range plan 已完成。 +- N-023-E:真实 ZIP/TAR decoder/fuzz、license/source offer 发布审计和大文件流式性能仍阻断; + archive link/metadata/budget/conflict/cancellation/quota/OOM 与恶意 fixture regression 已完成。 - N-023-E(VDB):GPU page resident LRU 已完成;64 MiB–1 GiB bundle 中断/内存性能门仍阻断。 ## 验收 @@ -154,6 +211,13 @@ npm --prefix web run test:asset-preview-display npm --prefix web run test:library-operation-inventory npm --prefix web run test:library-operation-identity npm --prefix web run test:library-main-append +npm --prefix web run test:library-link-safety +npm --prefix web run test:library-metadata-first +npm --prefix web run test:library-archive-budget +npm --prefix web run test:library-archive-conflicts +npm --prefix web run test:library-archive-cancellation +node --test web/tests/unit/library-archive-recovery.test.mjs +node tools/web/check-malicious-archive-fixtures.mjs npm --prefix web run test:vdb npm --prefix web run test:vdb-native ``` diff --git a/docs/status/N-025.md b/docs/status/N-025.md index 909f833d..dc31d5e8 100644 --- a/docs/status/N-025.md +++ b/docs/status/N-025.md @@ -25,13 +25,61 @@ 8. N-025-E(审计链):至多 65,536 条拒绝审计按 sequence、前项 SHA-256 与 canonical entry SHA-256 串联;读取时重新校验请求摘要和整条链,拒绝 requestId 重放、非递增 UTC 时间戳、内容篡改、断链与超预算日志。该链是内存/序列化协议,不声称已持久化发布审计。 +9. M13-02A manifest gate:每个 script 必须声明 `sourceByteLength` 和 `module=false`;解析器限制 + 脚本数量、总源码字节、依赖数量、权限数量与 allowlist,canonicalize 项目内 entry/dependency + path,并拒绝重复依赖、模块执行、未知权限和所有预算溢出。Node unit 与生产 Chromium Worker + 正负例、golden report 和 artifact manifest 已绑定。 +10. M13-02B canonical signature input:`canonicalizeScriptingManifest` 与 + `serializeScriptingManifest` 是唯一规范入口,locale-independent code-unit 排序所有可重排 + 数组,固定无空白 canonical JSON,unknown fields 不进入签名输入;order mutation、security + field mutation 和 schema drift 均由 Node/Chromium 与 golden report 验证。 +11. M13-02C signer trust policy:版本化 trust policy 绑定 ED25519 key/public-key、publisher、 + active/revoked status、validity window、revocation timestamp、same-publisher rotation chain + 和 bounded clock skew;resolver 只返回 `ELIGIBLE/cryptographicVerification=REQUIRED`,不验签 + 不执行,revoked/expired/mismatch/cycle/missing predecessor 全部 fail-closed。 +12. M13-02D declaration-bound signature verification:canonical per-script input 包含 source + SHA-256 和权限/预算/路径等声明,合法 ED25519 signature 才返回 `VERIFIED`;source hash 或 + signature 变化返回 `SCRIPT_SIGNATURE_INVALID`,revoked signer 返回 `SCRIPT_POLICY_DENIED`。 +13. M13-02E permission minimization:无显式请求时授予空集;只有 manifest 已声明且请求的 + permission 才能授予,未知、重复或未声明请求稳定返回 `SCRIPT_POLICY_DENIED`。 +14. M13-02F signature negative matrix:missing/expired/not-yet-valid key、publisher confusion + 与跨脚本 signature swap 均稳定返回 `SCRIPT_POLICY_DENIED` 或 `SCRIPT_SIGNATURE_INVALID`, + canonical ordering 不改变自身签名结果,且不开放执行入口。 +15. M13-03A sandbox scope contract:versioned scope 对 DOM、host Worker、OPFS、IndexedDB 和 + network 五项能力逐项要求 `false`;启用、缺失或未知 schema 均 fail-closed,执行仍禁用。 +16. M13-03B sandbox budget contract:CPU、wall-time、memory、message 和 output bytes 各有固定 + 上限;任一超限返回 `SCRIPT_BUDGET_EXCEEDED`,不创建或启用脚本执行器。 +17. M13-03C host-call contract:仅允许五个显式调用名;每个调用使用结构化、调用专属参数, + permission 必须来自 manifest 声明,未知字段、危险路径和未声明调用 fail-closed,解析结果 + 保持 `execution=DISABLED`。 +18. M13-03D sandbox isolation:真实 Chromium Worker crash/timeout 后 job receipt 分别返回 + `SCRIPT_SANDBOX_CRASHED`/`SCRIPT_SANDBOX_TIMEOUT`,Main revision 不变,temporary/published + 资源归零;终止后的迟到结果返回 `SCRIPT_SANDBOX_LATE_RESULT`,执行仍为 `DISABLED`。 +19. M13-03E cancellation gate:取消 receipt 保持 Main revision 和 execution 状态,真实 Chromium + 迟到窗口 `lateMessages=0`、`cacheWrites=0`,伪造迟到结果稳定返回 `SCRIPT_SANDBOX_LATE_RESULT`。 +20. M13-03F dispose gate:真实 Chromium Worker 释放两端 `MessagePort`、timer、AbortController、 + transferable buffer、pending request 和 cache reference;首次和重复 dispose 均返回零资源, + 且迟到 timer 消息为 0。 +21. M13-03G same-session recovery:Worker generation 4->5 后 Main revision/source hash/manifest hash + 保持不变;两条 default-deny audit entry 的 request ID、sequence 和 previous hash chain 连续, + replay/source tamper 均返回 `SCRIPT_MANIFEST_INVALID`。 +22. M13-04A server job directory gate:每个 job 获得随机 `0700` one-shot directory,request ID 不 + 进入目录名;成功/失败清理后无残留且重复清理幂等。 +23. M13-04B source/output isolation:source directory/file 为 `0555/0444`,真实写入返回 `EACCES`; + output directory 为 `0700` 且可写,清理会先恢复 source directory 权限并无残留。 +24. M13-04C server resource budget:CPU、memory、process、file、wall 和 output 六类限制有固定 + 上限;每类超限均返回 `SERVER_JOB_BUDGET_EXCEEDED`,bounded receipt 标记 `enforced=true`, + 执行仍为 `DISABLED`。 +25. M13-04D server network policy:默认 network `DENY`;显式声明的 HTTPS/loopback origin 才可 + `ALLOWED`,missing/undeclared/unsafe origin 均为 `SERVER_NETWORK_DENIED`。 ## 仍然阻断 - N-025-B/C:签名验证密钥管理、无网络 CPython/native sandbox、server Blender job 和 output hash 提交。 -- N-025-D/E:真实 GPU/native window/file watcher 适配、恶意脚本/依赖混淆/逃逸/重放、 - 审计日志持久化和发布门;本地请求级审计凭证已完成,不代表隔离执行完成。 +- N-025-D/E:真实 server isolation、真实 GPU/native + window/file watcher 适配、恶意脚本/依赖混淆/逃逸/重放、审计日志持久化和发布门;本地 + 请求级审计凭证与 crash/timeout receipt 已完成,不代表隔离执行完成。 ## 验收 diff --git a/docs/tasks/M13-04F.md b/docs/tasks/M13-04F.md new file mode 100644 index 00000000..68ee3b29 --- /dev/null +++ b/docs/tasks/M13-04F.md @@ -0,0 +1,41 @@ +# M13-04F:受限且脱敏的 Blender 输出 + +- `task`: `M13-04F` +- `parent`: `M13-04E` +- `status`: `done` + +## 目标 + +为真实 Blender server job 建立有界 stdout/stderr receipt:输出超出预算时截断,并过滤凭据和 +内部绝对路径;仍保持 `execution=DISABLED`,不发布 job 结果。 + +## 输入 + +- manifest:`tests/golden/M13-04E/manifest.json` +- 生产启动:`tools/web/server-job-startup.py` +- 工作区合同:`tools/web/server-job-isolation.mjs` +- 最小运行时:固定 Blender 5.2 `--background --factory-startup` + +## 范围 + +- 做:固定 stdout/stderr 字节预算、截断标记、凭据/内部路径脱敏和稳定 receipt 字段。 +- 不做:取消进程、进程树清理、超时/OOM 错误映射、OPFS 结果提交;这些分别属于 G-J。 + +## 验收 + +```text +npm --prefix web run test:server-job-output-redaction +``` + +验收必须包含正常输出、超长输出、token/API key、Unix/Windows 风格内部路径和空流负例;真实 +输出不可把 source path、环境变量值或完整命令行泄露到 report。 + +## 产物和交接 + +报告、checker、unit/E2E(如适用)、package hash 和 manifest 写入 `tests/golden/M13-04F/`; +成功后 manifest 的 `nextTask` 必须为 `M13-04G`。 + +## 回滚 + +移除输出 receipt/脱敏实现、专项测试、checker、package 命令、报告、manifest 和状态页;将 +`M13-04E` 恢复为队列尾。 diff --git a/docs/tasks/M13-04G.md b/docs/tasks/M13-04G.md new file mode 100644 index 00000000..fc2b7f7e --- /dev/null +++ b/docs/tasks/M13-04G.md @@ -0,0 +1,37 @@ +# M13-04G:取消 Blender 进程树 + +- `task`: `M13-04G` +- `parent`: `M13-04F` +- `status`: `done` + +## 目标 + +取消真实 Blender server job 时终止整个 process tree,等待子进程退出,并清理一次性 job 目录; +不得留下孤儿进程或临时文件。 + +## 输入 + +- manifest:`tests/golden/M13-04F/manifest.json` +- process receipt:M13-04F 输出 receipt +- job 目录:`tools/web/server-job-isolation.mjs` + +## 范围 + +- 做:Abort/cancel 到 process-group/tree 的映射、幂等等待、目录清理和 orphan 计数。 +- 不做:timeout/OOM/signal 分类和结果 hash 校验;分别属于 H/I。 + +## 验收 + +```text +npm --prefix web run test:server-job-cancellation +``` + +必须有真实长运行 fixture、取消竞态、重复取消、子进程和目录残留负例。 + +## 产物和交接 + +成功后生成 `tests/golden/M13-04G/` 证据,manifest 的 `nextTask` 为 `M13-04H`。 + +## 回滚 + +删除取消控制器、专项测试、checker、报告、manifest、package 命令和状态页,恢复 M13-04F 队列尾。 diff --git a/docs/tasks/M13-04H.md b/docs/tasks/M13-04H.md new file mode 100644 index 00000000..29a1eb21 --- /dev/null +++ b/docs/tasks/M13-04H.md @@ -0,0 +1,37 @@ +# M13-04H:稳定化进程故障码 + +- `task`: `M13-04H` +- `parent`: `M13-04G` +- `status`: `done` + +## 目标 + +把真实 Blender job 的 timeout、OOM、非零退出和 signal 结果转换为稳定、可诊断且不泄露内部 +细节的错误码;旧项目 revision 不得被错误结果覆盖。 + +## 输入 + +- manifest:`tests/golden/M13-04G/manifest.json` +- cancel/cleanup receipt:M13-04G +- resource limits:`tools/web/server-job-resource-budget.mjs` + +## 范围 + +- 做:故障分类优先级、退出状态映射、旧 revision 保持和统一错误 receipt。 +- 不做:成功结果进入 OPFS、重试幂等;分别属于 I/J。 + +## 验收 + +```text +npm --prefix web run test:server-job-fault-codes +``` + +覆盖 wall timeout、memory/OOM、SIGTERM/SIGKILL、普通非零退出和无法识别状态,并验证清理完成。 + +## 产物和交接 + +成功后生成 `tests/golden/M13-04H/` 证据,manifest 的 `nextTask` 为 `M13-04I`。 + +## 回滚 + +删除故障映射、测试、checker、报告、manifest、package 命令和状态页,恢复 M13-04G 队列尾。 diff --git a/docs/tasks/M13-04I.md b/docs/tasks/M13-04I.md new file mode 100644 index 00000000..da06dd26 --- /dev/null +++ b/docs/tasks/M13-04I.md @@ -0,0 +1,37 @@ +# M13-04I:校验并提交 server 结果 + +- `task`: `M13-04I` +- `parent`: `M13-04H` +- `status`: `done` + +## 目标 + +仅当 source、settings、Blender build 和 output hash 全部匹配请求时,才把 server job 结果绑定到 +OPFS;任何篡改、过期或重放都 fail-closed。 + +## 输入 + +- manifest:`tests/golden/M13-04H/manifest.json` +- source/output receipt:M13-04H +- OPFS 提交合同:项目现有原子保存协议 + +## 范围 + +- 做:四项 hash 绑定、结果版本检查、临时文件到正式对象的原子提交和失败清理。 +- 不做:同一 request 的重复提交策略;交给 J。 + +## 验收 + +```text +npm --prefix web run test:server-job-result-binding +``` + +覆盖 source/settings/build/output 任一 hash 变化、旧 revision、部分输出和 OPFS quota 失败。 + +## 产物和交接 + +成功后生成 `tests/golden/M13-04I/` 证据,manifest 的 `nextTask` 为 `M13-04J`。 + +## 回滚 + +删除结果绑定实现、测试、checker、报告、manifest、package 命令和状态页,恢复 M13-04H 队列尾。 diff --git a/docs/tasks/M13-04J.md b/docs/tasks/M13-04J.md new file mode 100644 index 00000000..bd6ce05f --- /dev/null +++ b/docs/tasks/M13-04J.md @@ -0,0 +1,37 @@ +# M13-04J:server request 幂等重试 + +- `task`: `M13-04J` +- `parent`: `M13-04I` +- `status`: `done` + +## 目标 + +同一 request 重试只能复用或确认同一个已验证结果,不得把两个冲突结果绑定到同一项目;不同 +request 必须保持隔离。 + +## 输入 + +- manifest:`tests/golden/M13-04I/manifest.json` +- verified result:M13-04I +- request/result identity:M13-04A 至 M13-04I 的 receipts + +## 范围 + +- 做:request identity、重试窗口、重复提交、冲突输出和并发绑定策略。 +- 不做:新增 Blender 能力或放宽脚本执行策略;M13 全程仍需显式安全门。 + +## 验收 + +```text +npm --prefix web run test:server-job-idempotency +``` + +覆盖成功重试、失败后重试、并发重复、同 request 冲突 output 和跨项目 request 误用。 + +## 产物和交接 + +成功后生成 `tests/golden/M13-04J/` 证据,manifest 的 `nextTask` 为 `M13-05A`。 + +## 回滚 + +删除幂等键/绑定策略、测试、checker、报告、manifest、package 命令和状态页,恢复 M13-04I 队列尾。 diff --git a/docs/tasks/M13-05A.md b/docs/tasks/M13-05A.md new file mode 100644 index 00000000..e9247556 --- /dev/null +++ b/docs/tasks/M13-05A.md @@ -0,0 +1,39 @@ +# M13-05A:CSP 默认拒绝策略 + +- `task`: `M13-05A` +- `parent`: `M13-04J` +- `status`: `done` + +## 目标 + +为部署服务器和生产构建统一声明 CSP,拒绝 inline script、eval、data script 和未声明 origin; +所有响应(包括 404)都必须携带相同策略。 + +## 输入 + +- manifest:`tests/golden/M13-04J/manifest.json` +- 部署合同:`docs/web/deployment-contract.json` +- 入口与构建:`web/app/index.html`、`web/app/vite.config.ts` + +## 范围 + +- 做:`default-src/script-src/worker-src/connect-src` same-origin 约束、`object-src/base-uri/frame-ancestors` + 拒绝规则、源代码/构建产物扫描和 HTTP 响应检查。 +- 不做:按资源类型拆分 Worker/WASM/font/image/media 策略;交给 M13-05B。 + +## 验收 + +```text +npm --prefix web run test:csp-policy +node tools/web/check-deployment-contract.mjs +npm --prefix web run typecheck +npm --prefix web run build +``` + +## 产物和交接 + +报告和 manifest 位于 `tests/golden/M13-05A/`;下一任务由 manifest 的 `nextTask` 指向 `M13-05B`。 + +## 回滚 + +恢复部署合同、HTTP 头、checker、报告、manifest、package 命令和状态页;将 `M13-04J` 恢复为队列尾。 diff --git a/docs/tasks/M13-05B.md b/docs/tasks/M13-05B.md new file mode 100644 index 00000000..5664d1c9 --- /dev/null +++ b/docs/tasks/M13-05B.md @@ -0,0 +1,28 @@ +# M13-05B:资源类型 CSP 矩阵 + +- `task`: `M13-05B` +- `parent`: `M13-05A` +- `status`: `done` + +## 目标 + +分别验证 Worker、WASM、font、image、media 资源只能从同源加载;WASM 使用显式 +`wasm-unsafe-eval` 编译能力,JavaScript `eval` 仍被拒绝。`data:`、`blob:` 和跨源连接必须被 +浏览器 CSP 阻断。 + +## 验收 + +```text +npm --prefix web run test:csp-resource-policy +node tools/web/check-deployment-contract.mjs +npm --prefix web run typecheck +npm --prefix web run build +``` + +## 产物和交接 + +报告和 manifest 位于 `tests/golden/M13-05B/`;下一任务由 manifest 的 `nextTask` 指向 `M13-05C`。 + +## 回滚 + +恢复资源 CSP、MIME 合同、checker、报告、manifest、package 命令和状态页;将 `M13-05A` 恢复为队列尾。 diff --git a/docs/tasks/M13-05C.md b/docs/tasks/M13-05C.md new file mode 100644 index 00000000..5dd17e1f --- /dev/null +++ b/docs/tasks/M13-05C.md @@ -0,0 +1,27 @@ +# M13-05C:依赖分类 inventory + +- `task`: `M13-05C` +- `parent`: `M13-05B` +- `status`: `done` + +## 目标 + +从 `web/package.json` 和 `web/package-lock.json` 的真实 npm 依赖闭包分别生成 production、build +和 test inventory。每个包绑定 package path、版本、resolved、integrity 和所属类别;共享包显式 +记录,不允许未分类或依赖树漂移。 + +## 验收 + +```text +npm --prefix web run test:dependency-inventory +npm --prefix web run typecheck +npm --prefix web run build +``` + +## 产物和交接 + +报告和 manifest 位于 `tests/golden/M13-05C/`;下一任务由 manifest 的 `nextTask` 指向 `M13-05D`。 + +## 回滚 + +移除 inventory 生成器、checker、报告、manifest、package 命令和状态页;将 `M13-05B` 恢复为队列尾。 diff --git a/docs/tasks/M13-05D.md b/docs/tasks/M13-05D.md new file mode 100644 index 00000000..2366b244 --- /dev/null +++ b/docs/tasks/M13-05D.md @@ -0,0 +1,26 @@ +# M13-05D:依赖严重性门和例外 + +- `task`: `M13-05D` +- `parent`: `M13-05C` +- `status`: `done` + +## 目标 + +冻结依赖 severity 门和统一例外合同。`BLOCKER/HIGH` 默认阻断,`MEDIUM` 进入 review,`LOW` +进入 tracking;任何 finding 的例外必须有 owner、期限、理由和替代控制,过期或字段缺失稳定拒绝。 + +## 验收 + +```text +npm --prefix web run test:dependency-severity-policy +npm --prefix web run typecheck +npm --prefix web run build +``` + +## 产物和交接 + +报告和 manifest 位于 `tests/golden/M13-05D/`;下一任务由 manifest 的 `nextTask` 指向 `M13-05E`。 + +## 回滚 + +移除 severity policy、checker、报告、manifest、package 命令和状态页;将 `M13-05C` 恢复为队列尾。 diff --git a/docs/tasks/M13-05E.md b/docs/tasks/M13-05E.md new file mode 100644 index 00000000..2e1663f0 --- /dev/null +++ b/docs/tasks/M13-05E.md @@ -0,0 +1,27 @@ +# M13-05E:供应链发布绑定 + +- `task`: `M13-05E` +- `parent`: `M13-05D` +- `status`: `done` + +## 目标 + +将 SPDX 2.3 SBOM、第三方 notices、package/lockfile、source offer、binary/source archive、 +`SHA256SUMS.txt` 和当前 commit 绑定到机器可验证报告。对应源码归档必须包含构建输入且与发布包 +独立校验。 + +## 验收 + +```text +npm --prefix web run test:supply-chain-binding +npm --prefix web run test:binary-archive +npm --prefix web run test:source-archive +``` + +## 产物和交接 + +报告和 manifest 位于 `tests/golden/M13-05E/`;下一任务由 manifest 的 `nextTask` 指向 `M13-05F`。 + +## 回滚 + +移除供应链 checker、报告、manifest、package 命令和状态页;将 `M13-05D` 恢复为队列尾。 diff --git a/docs/tasks/M13-05F.md b/docs/tasks/M13-05F.md new file mode 100644 index 00000000..33ec7690 --- /dev/null +++ b/docs/tasks/M13-05F.md @@ -0,0 +1,26 @@ +# M13-05F:恶意输入矩阵 + +- `task`: `M13-05F` +- `parent`: `M13-05E` +- `status`: `done` + +## 目标 + +使用生产解析器和既有负例路径验证 malicious blend、image、font、media、archive、node graph、 +script manifest 和 GLB。每类必须返回稳定拒绝码;不得提取 archive、执行脚本或发布迟到结果。 + +## 验收 + +```text +npm --prefix web run test:malicious-input-matrix +npm --prefix web run typecheck +npm --prefix web run build +``` + +## 产物和交接 + +报告和 manifest 位于 `tests/golden/M13-05F/`;下一任务由 manifest 的 `nextTask` 指向 `M13-05G`。 + +## 回滚 + +移除矩阵 checker、报告、manifest、package 命令和状态页;将 `M13-05E` 恢复为队列尾。 diff --git a/docs/tasks/M13-05G.md b/docs/tasks/M13-05G.md new file mode 100644 index 00000000..65b98e45 --- /dev/null +++ b/docs/tasks/M13-05G.md @@ -0,0 +1,25 @@ +# M13-05G:fuzz 崩溃最小化与回归固化 + +- `task`: `M13-05G` +- `parent`: `M13-05F` +- `status`: `done` + +## 目标 + +使用固定 seed 对 blend、image、font、node 和 script manifest 生产解析路径执行确定性 fuzz +回放。崩溃必须先保存可重放的最小样本;修复后样本进入长期回归 corpus。本任务不启用脚本执行。 + +## 验收 + +```text +npm --prefix web run test:fuzz-regression +``` + +## 产物和交接 + +报告和 manifest 位于 `tests/golden/M13-05G/`;本轮 5 个域各 16 个用例,共 80 个用例,零崩溃、 +零最小回归样本。下一任务由 manifest 的 `nextTask` 指向 `M13-05H`。 + +## 回滚 + +移除 fuzz runner、checker、报告、manifest、package 命令和本状态页;将 `M13-05F` 恢复为队列尾。 diff --git a/docs/tasks/M13-05H.md b/docs/tasks/M13-05H.md new file mode 100644 index 00000000..e88bf4af --- /dev/null +++ b/docs/tasks/M13-05H.md @@ -0,0 +1,24 @@ +# M13-05H:审计记录完整性 + +- `task`: `M13-05H` +- `parent`: `M13-05G` +- `status`: `done` + +## 目标 + +审计记录必须按严格递增的时间和连续 sequence 写入,每个 request ID 只能出现一次;每条记录的 +digest 绑定前一条 entry hash,任何重放、时间倒退、sequence 或 hash-chain 篡改都稳定拒绝。 + +## 验收 + +```text +npm --prefix web run test:script-audit-integrity +``` + +## 产物和交接 + +报告和 manifest 位于 `tests/golden/M13-05H/`;下一任务由 manifest 的 `nextTask` 指向 `M14-01A`。 + +## 回滚 + +移除审计完整性 checker、unit、报告、manifest、package 命令和本状态页;将 `M13-05G` 恢复为队列尾。 diff --git a/docs/tasks/M14-01A.md b/docs/tasks/M14-01A.md new file mode 100644 index 00000000..31e7e530 --- /dev/null +++ b/docs/tasks/M14-01A.md @@ -0,0 +1,24 @@ +# M14-01A:冻结 Chromium、engine 与 archive 身份 + +- `task`: `M14-01A` +- `parent`: `M13-05H` +- `status`: `done` + +## 目标 + +冻结当前 Chromium 版本、可加载的 engine manifest/variant 资源、离线 binary/source archive 和 +`SHA256SUMS.txt`。验收必须对实际文件逐项复算 SHA-256,不能只读取描述性 metadata。 + +## 验收 + +```text +npm --prefix web run test:chromium-freeze +``` + +## 产物和交接 + +报告和 manifest 位于 `tests/golden/M14-01A/`;下一任务由 manifest 的 `nextTask` 指向 `M14-01B`。 + +## 回滚 + +移除 Chromium freeze checker、报告、manifest、package 命令和本状态页;将 `M13-05H` 恢复为队列尾。 diff --git a/docs/tasks/M14-01B.md b/docs/tasks/M14-01B.md new file mode 100644 index 00000000..714c723d --- /dev/null +++ b/docs/tasks/M14-01B.md @@ -0,0 +1,25 @@ +# M14-01B:Firefox capability probe + +- `task`: `M14-01B` +- `parent`: `M14-01A` +- `status`: `done` + +## 目标 + +在真实 Firefox 上探测 WASM、Worker、OPFS、IndexedDB、WebGL2、WebGPU、OffscreenCanvas 和 +COOP/COEP isolation。每项都记录实际浏览器、平台和 GPU/adapter 信息;缺失能力只能为 +`BLOCKED` 或 `UNAVAILABLE`,不能按 user-agent 推断支持。 + +## 验收 + +```text +npm --prefix web run test:firefox-capability +``` + +## 产物和交接 + +报告和 manifest 位于 `tests/golden/M14-01B/`;下一任务由 manifest 的 `nextTask` 指向 `M14-01C`。 + +## 回滚 + +移除 Firefox probe、报告、manifest、package 命令和本状态页;将 `M14-01A` 恢复为队列尾。 diff --git a/docs/tasks/M14-01C.md b/docs/tasks/M14-01C.md new file mode 100644 index 00000000..00c93701 --- /dev/null +++ b/docs/tasks/M14-01C.md @@ -0,0 +1,25 @@ +# M14-01C:WebKit capability probe + +- `task`: `M14-01C` +- `parent`: `M14-01B` +- `status`: `done` + +## 目标 + +在真实 Playwright WebKit 上使用与 Chromium/Firefox 相同的生产资源和隔离头,探测 WASM、Worker、 +OPFS、IndexedDB、WebGL2、WebGPU、OffscreenCanvas 和 COOP/COEP isolation。缺失能力只能记录为 +`BLOCKED` 或 `UNAVAILABLE`。 + +## 验收 + +```text +npm --prefix web run test:webkit-capability +``` + +## 产物和交接 + +报告和 manifest 位于 `tests/golden/M14-01C/`;下一任务由 manifest 的 `nextTask` 指向 `M14-01D`。 + +## 回滚 + +移除 WebKit probe、报告、manifest、package 命令和本状态页;将 `M14-01B` 恢复为队列尾。 diff --git a/docs/tasks/M14-01D.md b/docs/tasks/M14-01D.md new file mode 100644 index 00000000..5dd728c6 --- /dev/null +++ b/docs/tasks/M14-01D.md @@ -0,0 +1,27 @@ +# M14-01D:probe identity and GPU/OS adapter recording + +- `task`: `M14-01D` +- `parent`: `M14-01C` +- `status`: `in_progress` + +## 目标 + +在真实 Chromium 生产构建上记录浏览器、运行时 OS、WebGL renderer/vendor 和 WebGPU adapter +身份。身份必须来自实际 API 探测;缺失的 WebGPU adapter 只能记录为 `BLOCKED`,不能按 +浏览器或 user-agent 推断能力。报告同时绑定当前 production worker/WASM 资源 hash,避免把 +不同构建的 GPU 结果混在一起。 + +## 验收 + +```text +npm --prefix web run test:probe-identity +``` + +## 产物和交接 + +报告和 manifest 位于 `tests/golden/M14-01D/`。完成后 manifest 的 `nextTask` 指向下一颗 +最小任务;在本任务通过前不更新浏览器支持声明。 + +## 回滚 + +移除 probe checker、报告、manifest、package 命令和本状态页;将 `M14-01C` 保持为队列尾。 diff --git a/docs/tasks/M14-01E.md b/docs/tasks/M14-01E.md new file mode 100644 index 00000000..4a7e0ec4 --- /dev/null +++ b/docs/tasks/M14-01E.md @@ -0,0 +1,20 @@ +# M14-01E:Chromium WebGPU fail-closed boundary + +- `task`: `M14-01E` +- `parent`: `M14-01D` +- `status`: `in_progress` + +## 目标 + +在 Chromium 中验证 WebGPU 不可用时,依赖 WebGPU 的渲染能力返回稳定 `BLOCKED`,而已验证的 +WebGL2 bounded Eevee/Main 路径仍保持 `READY`。本任务不启动 Firefox 或 WebKit。 + +## 验收 + +```text +npm --prefix web run test:chromium-webgpu-boundary +``` + +## 产物和交接 + +报告和 manifest 位于 `tests/golden/M14-01E/`;通过后从 manifest 的 `nextTask` 继续。 diff --git a/docs/tasks/M14-04A.md b/docs/tasks/M14-04A.md new file mode 100644 index 00000000..15eb79a3 --- /dev/null +++ b/docs/tasks/M14-04A.md @@ -0,0 +1,16 @@ +# M14-04A:Chromium GPU/memory budget device tier selection + +- `task`: `M14-04A` +- `parent`: `M14-01E` +- `status`: `in_progress` + +## 目标 + +在 Chromium 中根据真实探测到的 GPU/adapter、设备内存和硬件并发选择声明的预算档位;缺失 +或不可信的能力必须降级到保守档位,不能自动扩容。Firefox/WebKit 不在本项目范围内。 + +## 验收 + +```text +npm --prefix web run test:chromium-device-budget +``` diff --git a/docs/tasks/M14-04B.md b/docs/tasks/M14-04B.md new file mode 100644 index 00000000..5f11ebbe --- /dev/null +++ b/docs/tasks/M14-04B.md @@ -0,0 +1,17 @@ +# M14-04B:Chromium DPR 1/1.5/2/3 canvas and raycast consistency + +- `task`: `M14-04B` +- `parent`: `M14-04A` +- `status`: `in_progress` + +## 目标 + +在 Chromium 主线程 WebGL2 视口中验证 DPR 1、1.5、2、3 的 canvas backing dimensions、CSS +尺寸和 raycast coordinate mapping 保持稳定,不因 DPR 改变逻辑坐标或选择结果。Firefox/WebKit +不在本项目范围内。 + +## 验收 + +```text +npm --prefix web run test:chromium-dpr-consistency +``` diff --git a/docs/tasks/M14-04C.md b/docs/tasks/M14-04C.md new file mode 100644 index 00000000..ad898bd9 --- /dev/null +++ b/docs/tasks/M14-04C.md @@ -0,0 +1,17 @@ +# M14-04C:Chromium mouse/touch/pen pointer identity and cancellation contract + +- `task`: `M14-04C` +- `parent`: `M14-04B` +- `status`: `in_progress` + +## 目标 + +为 Chromium 生产输入路径建立统一 pointer observation:mouse、touch、pen 分别保留 +`pointerType`、`pointerId`、`pressure`、`tiltX`、`tiltY`、`button`、`buttons` 和 cancel 状态; +未知或越界值 fail-closed,不把触控/笔事件伪装成 mouse。Firefox/WebKit 不在本项目范围内。 + +## 验收 + +```text +npm --prefix web run test:chromium-pointer-contract +``` diff --git a/docs/tasks/M14-04D.md b/docs/tasks/M14-04D.md new file mode 100644 index 00000000..1cfd4f47 --- /dev/null +++ b/docs/tasks/M14-04D.md @@ -0,0 +1,16 @@ +# M14-04D:Chromium IME composition guard for incomplete operators + +- `task`: `M14-04D` +- `parent`: `M14-04C` +- `status`: `in_progress` + +## 目标 + +在 Chromium 的生产编辑器输入路径中,IME compositionstart/update 期间不触发未完成的 +operator;compositionend 后才允许提交最终文本。Firefox/WebKit 不在本项目范围内。 + +## 验收 + +```text +npm --prefix web run test:chromium-ime-guard +``` diff --git a/docs/tasks/M14-04E.md b/docs/tasks/M14-04E.md new file mode 100644 index 00000000..ee37be79 --- /dev/null +++ b/docs/tasks/M14-04E.md @@ -0,0 +1,17 @@ +# M14-04E:Chromium US/non-US/dead-key/modifier keymap fixture + +- `task`: `M14-04E` +- `parent`: `M14-04D` +- `status`: `in_progress` + +## 目标 + +固定 Chromium 生产路径的 key identity 解析:US 与非 US 字符、dead key、Shift/Ctrl/Alt/Meta +修饰键必须保留原始 `key`/`code`/`location`/修饰键,不把字符布局推断为另一种物理按键。 +Firefox/WebKit 不在本项目范围内。 + +## 验收 + +```text +npm --prefix web run test:chromium-keymap-fixture +``` diff --git a/docs/tasks/M14-04F.md b/docs/tasks/M14-04F.md new file mode 100644 index 00000000..c6f29239 --- /dev/null +++ b/docs/tasks/M14-04F.md @@ -0,0 +1,56 @@ +# M14-04F:Chromium touch modal cancel / two-finger navigation / pen commit + +- `task`: `M14-04F` +- `parent`: `M14-04E` +- `status`: `in_progress` + +## 目标 + +在 Chromium 输入状态层固定三条边界:触控 modal 取消不提交 Main;双指手势只产生一次 +navigation session;笔 stroke 的 pointerup 只允许一个 Main commit,重复 up/cancel 不重复提交。 +Firefox/WebKit 不在本项目范围内。 + +## 输入 + +- 上一个 manifest:`tests/golden/M14-04E/manifest.json` +- 协议入口:`web/protocol/input-modal.ts` +- 单测入口:`web/tests/unit/input-modal.test.mjs` +- Chromium 检查器:`tools/web/check-chromium-input-modal.mjs` +- 当前报告:`tests/golden/M14-04F/chromium-input-modal-report.json` + +## 细分门 + +1. **协议门**:固定 `InputModalState`、pointer ID 校验、touch/pen 状态转移和稳定计数。 +2. **触控门**:`pointercancel` 清理活动 pointer,不产生 Main commit。 +3. **双指门**:从一个 pointer 进入两个 pointer 时只增加一次 navigation session;重复 down、 + pointer 顺序和单指结束不得重复创建 session。 +4. **笔门**:同一笔 stroke 的第一个合法 `pointerup` 最多产生一次 Main commit;late up/cancel + 不得重复提交或改变 revision。 +5. **生产接线门**:主线程与 Offscreen 生产输入路径必须真正消费协议;不能只在测试中调用纯函数。 +6. **浏览器证据门**:Chromium checker 必须读取真实 modal 状态、Main revision/commit counter 或 + 等价生产诊断,而不是仅记录派发了几个 `PointerEvent`。 +7. **交接门**:focused 命令、必要的 typecheck/build、report、manifest、status 和回滚路径齐全。 + +协议单测和事件派发通过,只能关闭前四个门的一部分;在生产接线和真实状态断言完成前,任务保持 +`in_progress`,不得根据报告中的固定保证字段直接改为 `done`。 + +## 验收 + +```text +npm --prefix web run test:chromium-input-modal +``` + +必要的补充门: + +```text +npm --prefix web run typecheck +npm --prefix web run build +git diff --check +``` + +## 产物和交接 + +- 报告:`tests/golden/M14-04F/chromium-input-modal-report.json` +- manifest:`tests/golden/M14-04F/manifest.json` +- 状态页:完成或阻断后新增 `docs/status/M14-04F.md` +- 下一任务:只读取 manifest 的 `nextTask`,不能从长期计划手工推导 diff --git a/docs/web/DEPLOYMENT.md b/docs/web/DEPLOYMENT.md index 3a4cb9f6..0dc8afec 100644 --- a/docs/web/DEPLOYMENT.md +++ b/docs/web/DEPLOYMENT.md @@ -8,11 +8,16 @@ when every response, including errors and SPA fallbacks, preserves these headers | `Cross-Origin-Opener-Policy` | `same-origin` | | `Cross-Origin-Embedder-Policy` | `require-corp` | | `Cross-Origin-Resource-Policy` | `same-origin` | +| `Content-Security-Policy` | `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; font-src 'self'; img-src 'self'; media-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'` | Production uses HTTPS. Loopback development may use `http://127.0.0.1`; `file://` is unsupported. Runtime assets are same-origin. A reverse proxy must not strip the isolation, range, cache, MIME or ETag headers. +The CSP permits WebAssembly compilation only through the explicit `wasm-unsafe-eval` source expression; +JavaScript `eval` and `new Function` remain denied. Workers, fonts, images and media are each limited to +same-origin resources, and `data:`/`blob:` URLs are not declared for any of them. + ## Empty-directory install runbook The release delivery contains `blender-web-offline.tar.gz`, diff --git a/docs/web/dependency-severity-policy.json b/docs/web/dependency-severity-policy.json new file mode 100644 index 00000000..64f6c801 --- /dev/null +++ b/docs/web/dependency-severity-policy.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "task": "M13-05D", + "severityOrder": ["LOW", "MEDIUM", "HIGH", "BLOCKER"], + "gates": { + "BLOCKER": "BLOCK", + "HIGH": "BLOCK", + "MEDIUM": "REVIEW", + "LOW": "TRACK" + }, + "exceptionRequiredFor": ["BLOCKER", "HIGH", "MEDIUM", "LOW"], + "exceptionFields": ["owner", "expiresOn", "reason", "alternativeControl"], + "dateFormat": "YYYY-MM-DD", + "findings": [], + "exceptions": [], + "execution": "DISABLED", + "nextTask": "M13-05E" +} diff --git a/docs/web/deployment-contract.json b/docs/web/deployment-contract.json index 1ebcd37b..cf23efb7 100644 --- a/docs/web/deployment-contract.json +++ b/docs/web/deployment-contract.json @@ -13,7 +13,8 @@ "allResponses": { "Cross-Origin-Opener-Policy": "same-origin", "Cross-Origin-Embedder-Policy": "require-corp", - "Cross-Origin-Resource-Policy": "same-origin" + "Cross-Origin-Resource-Policy": "same-origin", + "Content-Security-Policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; font-src 'self'; img-src 'self'; media-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'" }, "routes": [ { @@ -50,7 +51,18 @@ ".wasm": "application/wasm", ".json": "application/json; charset=utf-8", ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".ttf": "font/ttf", + ".otf": "font/otf", + ".woff": "font/woff", + ".woff2": "font/woff2", ".wav": "audio/wav", + ".mp3": "audio/mpeg", + ".ogg": "audio/ogg", + ".mp4": "video/mp4", + ".webm": "video/webm", ".blend": "application/octet-stream", ".nvdb": "application/x-nanovdb" }, diff --git a/docs/web/sbom.spdx.json b/docs/web/sbom.spdx.json index c56c301a..a6f4d3af 100644 --- a/docs/web/sbom.spdx.json +++ b/docs/web/sbom.spdx.json @@ -3,7 +3,7 @@ "dataLicense": "CC0-1.0", "SPDXID": "SPDXRef-DOCUMENT", "name": "blender-web-editor-sbom", - "documentNamespace": "https://blender-web.local/spdx/cf7beefe71131e5d51b45ffa79b7b906b21decc743fc7eafd7cf6791996dea37", + "documentNamespace": "https://blender-web.local/spdx/dc8374f47ec1483e74b09821f6d444c9785c19ba02d6d79f617ae50d71caf246", "creationInfo": { "created": "1970-01-01T00:00:00Z", "creators": [ @@ -14,6 +14,29 @@ "SPDXRef-Package-blender-web-editor" ], "packages": [ + { + "SPDXID": "SPDXRef-npm-node-modules--axe-core-playwright-ac1d565a7235", + "name": "@axe-core/playwright", + "versionInfo": "4.12.1", + "downloadLocation": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz", + "filesAnalyzed": false, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/%40axe-core%2Fplaywright@4.12.1" + } + ], + "checksums": [ + { + "algorithm": "SHA512", + "checksumValue": "acc77bc6b8a9b6a2a93fec39dbae62e07764bf65f991bbbaba2062fc1d88eee7f786279104cdea0c27da28fb717e261bda62977c5fb22ce0c9c08c7e26fd460f" + } + ] + }, { "SPDXID": "SPDXRef-npm-node-modules--dimforge-rapier3d-compat-64c1bc64bf78", "name": "@dimforge/rapier3d-compat", @@ -1302,6 +1325,29 @@ } ] }, + { + "SPDXID": "SPDXRef-npm-node-modules-axe-core-faf33d0c794e", + "name": "axe-core", + "versionInfo": "4.12.1", + "downloadLocation": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "filesAnalyzed": false, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/axe-core@4.12.1" + } + ], + "checksums": [ + { + "algorithm": "SHA512", + "checksumValue": "b3b8867f919a54cc441b410d37dc7ec53afb1856426f564fff5b804d4a4210ad97efc9c30774706ed142a3da4601ff6bbbe570a10e3ae0391a2c4791334f3024" + } + ] + }, { "SPDXID": "SPDXRef-npm-node-modules-balanced-match-951e2b0376c0", "name": "balanced-match", @@ -3292,7 +3338,7 @@ "checksums": [ { "algorithm": "SHA256", - "checksumValue": "87af8e7d5eb36537541cf941699868a010c1fcea305daa3ce5385453e8fa4557" + "checksumValue": "61f6d13e0148321d3c625a5baa212b5444296d111193c4d62a692685faebff1f" } ] }, @@ -3375,6 +3421,11 @@ } ], "relationships": [ + { + "spdxElementId": "SPDXRef-Package-blender-web-editor", + "relationshipType": "DEPENDS_ON", + "relatedSpdxElement": "SPDXRef-npm-node-modules--axe-core-playwright-ac1d565a7235" + }, { "spdxElementId": "SPDXRef-Package-blender-web-editor", "relationshipType": "DEPENDS_ON", @@ -3655,6 +3706,11 @@ "relationshipType": "DEPENDS_ON", "relatedSpdxElement": "SPDXRef-npm-node-modules-ajv-f1c07f09b167" }, + { + "spdxElementId": "SPDXRef-Package-blender-web-editor", + "relationshipType": "DEPENDS_ON", + "relatedSpdxElement": "SPDXRef-npm-node-modules-axe-core-faf33d0c794e" + }, { "spdxElementId": "SPDXRef-Package-blender-web-editor", "relationshipType": "DEPENDS_ON", diff --git a/tests/files/web/archive-security/manifest.json b/tests/files/web/archive-security/manifest.json new file mode 100644 index 00000000..597db752 --- /dev/null +++ b/tests/files/web/archive-security/manifest.json @@ -0,0 +1,68 @@ +{ + "schemaVersion": 1, + "task": "M12-04J", + "generator": "tools/web/generate-malicious-archive-fixtures.mjs", + "extractionAllowed": false, + "cases": [ + { + "id": "ZIP_PATH_TRAVERSAL", + "format": "ZIP", + "file": "zip-path-traversal.zip", + "threat": "ARCHIVE_ROOT_ESCAPE", + "gate": "LINK_SAFETY", + "byteLength": 132, + "sha256": "284fe1612ca049ec73f083fc6550f64078e8115bf85adea5431402fb73621f43", + "expectedCode": "IO_ARCHIVE_UNSAFE" + }, + { + "id": "ZIP_COMPRESSION_BOMB", + "format": "ZIP", + "file": "zip-compression-bomb.zip", + "threat": "COMPRESSION_RATIO", + "gate": "CONFLICTS", + "byteLength": 115, + "sha256": "8394d639dfdb04e94b9a2fe06d4d37e31f3bdbe47b6a581c6dc8238d7c98a8da", + "expectedCode": "IO_ARCHIVE_UNSAFE" + }, + { + "id": "ZIP_DUPLICATE_PATH", + "format": "ZIP", + "file": "zip-duplicate-path.zip", + "threat": "DUPLICATE_PATH", + "gate": "LINK_SAFETY", + "byteLength": 212, + "sha256": "830441041ecc26c0ba327cd24e6d1e19f7f3afc78474e08659d5adb35d8236ab", + "expectedCode": "IO_ARCHIVE_UNSAFE" + }, + { + "id": "TAR_PATH_TRAVERSAL", + "format": "TAR", + "file": "tar-path-traversal.tar", + "threat": "ARCHIVE_ROOT_ESCAPE", + "gate": "LINK_SAFETY", + "byteLength": 2048, + "sha256": "d21c2ea584e7e64b3d40c1742b7ae526f5cf4da7d16d0fce33a3323806092b85", + "expectedCode": "IO_ARCHIVE_UNSAFE" + }, + { + "id": "TAR_SYMLINK_ESCAPE", + "format": "TAR", + "file": "tar-symlink-escape.tar", + "threat": "SYMLINK_ESCAPE", + "gate": "LINK_SAFETY", + "byteLength": 2048, + "sha256": "f7eeb00b0e602883f5afc7add357bda5755c33321f1d2faf470b52e9b158e6fb", + "expectedCode": "IO_ARCHIVE_UNSAFE" + }, + { + "id": "TAR_PREFIX_CONFLICT", + "format": "TAR", + "file": "tar-prefix-conflict.tar", + "threat": "FILE_DIRECTORY_PREFIX_CONFLICT", + "gate": "CONFLICTS", + "byteLength": 3072, + "sha256": "5d5914636b4e7777068897b0c51f900863706dcb2e415c1e0ebb1022b84bd5fa", + "expectedCode": "IO_ARCHIVE_UNSAFE" + } + ] +} diff --git a/tests/files/web/archive-security/tar-path-traversal.tar b/tests/files/web/archive-security/tar-path-traversal.tar new file mode 100644 index 00000000..7aa66041 Binary files /dev/null and b/tests/files/web/archive-security/tar-path-traversal.tar differ diff --git a/tests/files/web/archive-security/tar-prefix-conflict.tar b/tests/files/web/archive-security/tar-prefix-conflict.tar new file mode 100644 index 00000000..5d3b7bf7 Binary files /dev/null and b/tests/files/web/archive-security/tar-prefix-conflict.tar differ diff --git a/tests/files/web/archive-security/tar-symlink-escape.tar b/tests/files/web/archive-security/tar-symlink-escape.tar new file mode 100644 index 00000000..f42e85dc Binary files /dev/null and b/tests/files/web/archive-security/tar-symlink-escape.tar differ diff --git a/tests/files/web/archive-security/zip-compression-bomb.zip b/tests/files/web/archive-security/zip-compression-bomb.zip new file mode 100644 index 00000000..8ba1d035 Binary files /dev/null and b/tests/files/web/archive-security/zip-compression-bomb.zip differ diff --git a/tests/files/web/archive-security/zip-duplicate-path.zip b/tests/files/web/archive-security/zip-duplicate-path.zip new file mode 100644 index 00000000..965ba5b4 Binary files /dev/null and b/tests/files/web/archive-security/zip-duplicate-path.zip differ diff --git a/tests/files/web/archive-security/zip-path-traversal.zip b/tests/files/web/archive-security/zip-path-traversal.zip new file mode 100644 index 00000000..27627edb Binary files /dev/null and b/tests/files/web/archive-security/zip-path-traversal.zip differ diff --git a/tests/files/web/m11_render_reference.blend1 b/tests/files/web/m11_render_reference.blend1 new file mode 100644 index 00000000..3f61655d Binary files /dev/null and b/tests/files/web/m11_render_reference.blend1 differ diff --git a/tests/files/web/m12_glb_desktop_v1/animation.glb b/tests/files/web/m12_glb_desktop_v1/animation.glb new file mode 100644 index 00000000..0222b3f5 Binary files /dev/null and b/tests/files/web/m12_glb_desktop_v1/animation.glb differ diff --git a/tests/files/web/m12_glb_desktop_v1/mesh.glb b/tests/files/web/m12_glb_desktop_v1/mesh.glb new file mode 100644 index 00000000..70107128 Binary files /dev/null and b/tests/files/web/m12_glb_desktop_v1/mesh.glb differ diff --git a/tests/files/web/m12_glb_desktop_v1/pbr.glb b/tests/files/web/m12_glb_desktop_v1/pbr.glb new file mode 100644 index 00000000..b72365e0 Binary files /dev/null and b/tests/files/web/m12_glb_desktop_v1/pbr.glb differ diff --git a/tests/files/web/m12_glb_desktop_v1/skin.glb b/tests/files/web/m12_glb_desktop_v1/skin.glb new file mode 100644 index 00000000..4e17d84d Binary files /dev/null and b/tests/files/web/m12_glb_desktop_v1/skin.glb differ diff --git a/tests/files/web/m12_glb_desktop_v1/uv.glb b/tests/files/web/m12_glb_desktop_v1/uv.glb new file mode 100644 index 00000000..6e166cd7 Binary files /dev/null and b/tests/files/web/m12_glb_desktop_v1/uv.glb differ diff --git a/tests/files/web/m12_glb_main_v1/animation.blend b/tests/files/web/m12_glb_main_v1/animation.blend new file mode 100644 index 00000000..0ab2beeb Binary files /dev/null and b/tests/files/web/m12_glb_main_v1/animation.blend differ diff --git a/tests/files/web/m12_glb_main_v1/animation.blend1 b/tests/files/web/m12_glb_main_v1/animation.blend1 new file mode 100644 index 00000000..5aa137ba Binary files /dev/null and b/tests/files/web/m12_glb_main_v1/animation.blend1 differ diff --git a/tests/files/web/m12_glb_main_v1/mesh.blend b/tests/files/web/m12_glb_main_v1/mesh.blend new file mode 100644 index 00000000..2cd6c118 Binary files /dev/null and b/tests/files/web/m12_glb_main_v1/mesh.blend differ diff --git a/tests/files/web/m12_glb_main_v1/mesh.blend1 b/tests/files/web/m12_glb_main_v1/mesh.blend1 new file mode 100644 index 00000000..ebffd000 Binary files /dev/null and b/tests/files/web/m12_glb_main_v1/mesh.blend1 differ diff --git a/tests/files/web/m12_glb_main_v1/pbr.blend b/tests/files/web/m12_glb_main_v1/pbr.blend new file mode 100644 index 00000000..b796cfcd Binary files /dev/null and b/tests/files/web/m12_glb_main_v1/pbr.blend differ diff --git a/tests/files/web/m12_glb_main_v1/pbr.blend1 b/tests/files/web/m12_glb_main_v1/pbr.blend1 new file mode 100644 index 00000000..a42af1f6 Binary files /dev/null and b/tests/files/web/m12_glb_main_v1/pbr.blend1 differ diff --git a/tests/files/web/m12_glb_main_v1/skin.blend b/tests/files/web/m12_glb_main_v1/skin.blend new file mode 100644 index 00000000..174b8482 Binary files /dev/null and b/tests/files/web/m12_glb_main_v1/skin.blend differ diff --git a/tests/files/web/m12_glb_main_v1/skin.blend1 b/tests/files/web/m12_glb_main_v1/skin.blend1 new file mode 100644 index 00000000..0840bcb6 Binary files /dev/null and b/tests/files/web/m12_glb_main_v1/skin.blend1 differ diff --git a/tests/files/web/m12_glb_main_v1/uv.blend b/tests/files/web/m12_glb_main_v1/uv.blend new file mode 100644 index 00000000..789dabfb Binary files /dev/null and b/tests/files/web/m12_glb_main_v1/uv.blend differ diff --git a/tests/files/web/m12_glb_main_v1/uv.blend1 b/tests/files/web/m12_glb_main_v1/uv.blend1 new file mode 100644 index 00000000..e65b6435 Binary files /dev/null and b/tests/files/web/m12_glb_main_v1/uv.blend1 differ diff --git a/tests/files/web/m12_glb_web_v1/animation.glb b/tests/files/web/m12_glb_web_v1/animation.glb new file mode 100644 index 00000000..01800781 Binary files /dev/null and b/tests/files/web/m12_glb_web_v1/animation.glb differ diff --git a/tests/files/web/m12_glb_web_v1/pbr.glb b/tests/files/web/m12_glb_web_v1/pbr.glb new file mode 100644 index 00000000..060fd847 Binary files /dev/null and b/tests/files/web/m12_glb_web_v1/pbr.glb differ diff --git a/tests/files/web/m12_glb_web_v1/skin.glb b/tests/files/web/m12_glb_web_v1/skin.glb new file mode 100644 index 00000000..a941a289 Binary files /dev/null and b/tests/files/web/m12_glb_web_v1/skin.glb differ diff --git a/tests/files/web/m12_glb_web_v1/uv.glb b/tests/files/web/m12_glb_web_v1/uv.glb new file mode 100644 index 00000000..6e708b0d Binary files /dev/null and b/tests/files/web/m12_glb_web_v1/uv.glb differ diff --git a/tests/files/web/m12_library_link_v1/m12_link_source.blend b/tests/files/web/m12_library_link_v1/m12_link_source.blend new file mode 100644 index 00000000..4c5941f8 Binary files /dev/null and b/tests/files/web/m12_library_link_v1/m12_link_source.blend differ diff --git a/tests/files/web/m12_library_link_v1/m12_link_target.blend b/tests/files/web/m12_library_link_v1/m12_link_target.blend new file mode 100644 index 00000000..d9d3e967 Binary files /dev/null and b/tests/files/web/m12_library_link_v1/m12_link_target.blend differ diff --git a/tests/files/web/m12_library_override_v1/m12_override_source.blend b/tests/files/web/m12_library_override_v1/m12_override_source.blend new file mode 100644 index 00000000..f805e8a9 Binary files /dev/null and b/tests/files/web/m12_library_override_v1/m12_override_source.blend differ diff --git a/tests/files/web/m12_library_override_v1/m12_override_target.blend b/tests/files/web/m12_library_override_v1/m12_override_target.blend new file mode 100644 index 00000000..32056b78 Binary files /dev/null and b/tests/files/web/m12_library_override_v1/m12_override_target.blend differ diff --git a/tests/files/web/m12_obj_desktop_v1/single-mesh.mtl b/tests/files/web/m12_obj_desktop_v1/single-mesh.mtl new file mode 100644 index 00000000..591c05c8 --- /dev/null +++ b/tests/files/web/m12_obj_desktop_v1/single-mesh.mtl @@ -0,0 +1,22 @@ +# Blender 5.2.0 LTS MTL File: 'None' +# www.blender.org + +newmtl M12_OBJ_Blue +Ns 250.000000 +Ka 1.000000 1.000000 1.000000 +Kd 0.800000 0.800000 0.800000 +Ks 0.500000 0.500000 0.500000 +Ke 0.000000 0.000000 0.000000 +Ni 1.500000 +d 1.000000 +illum 2 + +newmtl M12_OBJ_Red +Ns 250.000000 +Ka 1.000000 1.000000 1.000000 +Kd 0.800000 0.800000 0.800000 +Ks 0.500000 0.500000 0.500000 +Ke 0.000000 0.000000 0.000000 +Ni 1.500000 +d 1.000000 +illum 2 diff --git a/tests/files/web/m12_obj_desktop_v1/single-mesh.obj b/tests/files/web/m12_obj_desktop_v1/single-mesh.obj new file mode 100644 index 00000000..0010a540 --- /dev/null +++ b/tests/files/web/m12_obj_desktop_v1/single-mesh.obj @@ -0,0 +1,20 @@ +# Blender 5.2.0 LTS +# www.blender.org +mtllib single-mesh.mtl +o M12_OBJ_Single_Mesh +v -1.000000 0.000000 1.000000 +v 1.000000 0.000000 1.000000 +v 1.000000 0.000000 -1.000000 +v -1.000000 0.000000 -1.000000 +vn -0.0000 1.0000 -0.0000 +vt 0.000000 0.000000 +vt 1.000000 0.000000 +vt 1.000000 1.000000 +vt 0.000000 1.000000 +s 0 +g M12_OBJ_Single_Mesh_M12_OBJ_Red +usemtl M12_OBJ_Red +f 1/1/1 2/2/1 3/3/1 +g M12_OBJ_Single_Mesh_M12_OBJ_Blue +usemtl M12_OBJ_Blue +f 1/1/1 3/3/1 4/4/1 diff --git a/tests/files/web/m12_obj_multi_v1/m12_obj_texture.png b/tests/files/web/m12_obj_multi_v1/m12_obj_texture.png new file mode 100644 index 00000000..451bf9de Binary files /dev/null and b/tests/files/web/m12_obj_multi_v1/m12_obj_texture.png differ diff --git a/tests/files/web/m12_obj_multi_v1/malformed-face.obj b/tests/files/web/m12_obj_multi_v1/malformed-face.obj new file mode 100644 index 00000000..c2be5e87 --- /dev/null +++ b/tests/files/web/m12_obj_multi_v1/malformed-face.obj @@ -0,0 +1,27 @@ +# Blender 5.2.0 LTS +# www.blender.org +mtllib multi-object.mtl +g M12_OBJ_Left_M12_OBJ_Left_Mesh +v -1.750000 0.000000 0.750000 +v -0.250000 0.000000 0.750000 +v -1.000000 0.000000 -0.750000 +vn -0.0000 1.0000 -0.0000 +vt 0.000000 0.000000 +vt 1.000000 0.000000 +vt 0.500000 1.000000 +s 0 +g M12_OBJ_Left_M12_OBJ_Left_Material +usemtl M12_OBJ_Left_Material +f 1/1/1 2/2/1 +g M12_OBJ_Right_M12_OBJ_Right_Mesh +v 0.250000 0.000000 0.750000 +v 1.750000 0.000000 0.750000 +v 1.000000 0.000000 -0.750000 +vn -0.0000 1.0000 -0.0000 +vt 0.000000 0.000000 +vt 1.000000 0.000000 +vt 0.500000 1.000000 +s 0 +g M12_OBJ_Right_M12_OBJ_Right_Material +usemtl M12_OBJ_Right_Material +f 4/4/2 5/5/2 6/6/2 diff --git a/tests/files/web/m12_obj_multi_v1/multi-object.mtl b/tests/files/web/m12_obj_multi_v1/multi-object.mtl new file mode 100644 index 00000000..9ae17b5d --- /dev/null +++ b/tests/files/web/m12_obj_multi_v1/multi-object.mtl @@ -0,0 +1,22 @@ +# Blender 5.2.0 LTS MTL File: 'None' +# www.blender.org + +newmtl M12_OBJ_Left_Material +Ns 250.000000 +Ka 1.000000 1.000000 1.000000 +Ks 0.500000 0.500000 0.500000 +Ke 0.000000 0.000000 0.000000 +Ni 1.500000 +d 1.000000 +illum 2 +map_Kd m12_obj_texture.png + +newmtl M12_OBJ_Right_Material +Ns 250.000000 +Ka 1.000000 1.000000 1.000000 +Ks 0.500000 0.500000 0.500000 +Ke 0.000000 0.000000 0.000000 +Ni 1.500000 +d 1.000000 +illum 2 +map_Kd m12_obj_texture.png diff --git a/tests/files/web/m12_obj_multi_v1/multi-object.obj b/tests/files/web/m12_obj_multi_v1/multi-object.obj new file mode 100644 index 00000000..0175e48a --- /dev/null +++ b/tests/files/web/m12_obj_multi_v1/multi-object.obj @@ -0,0 +1,27 @@ +# Blender 5.2.0 LTS +# www.blender.org +mtllib multi-object.mtl +g M12_OBJ_Left_M12_OBJ_Left_Mesh +v -1.750000 0.000000 0.750000 +v -0.250000 0.000000 0.750000 +v -1.000000 0.000000 -0.750000 +vn -0.0000 1.0000 -0.0000 +vt 0.000000 0.000000 +vt 1.000000 0.000000 +vt 0.500000 1.000000 +s 0 +g M12_OBJ_Left_M12_OBJ_Left_Material +usemtl M12_OBJ_Left_Material +f 1/1/1 2/2/1 3/3/1 +g M12_OBJ_Right_M12_OBJ_Right_Mesh +v 0.250000 0.000000 0.750000 +v 1.750000 0.000000 0.750000 +v 1.000000 0.000000 -0.750000 +vn -0.0000 1.0000 -0.0000 +vt 0.000000 0.000000 +vt 1.000000 0.000000 +vt 0.500000 1.000000 +s 0 +g M12_OBJ_Right_M12_OBJ_Right_Material +usemtl M12_OBJ_Right_Material +f 4/4/2 5/5/2 6/6/2 diff --git a/tests/files/web/m12_obj_multi_v1/negative-index.obj b/tests/files/web/m12_obj_multi_v1/negative-index.obj new file mode 100644 index 00000000..d0709738 --- /dev/null +++ b/tests/files/web/m12_obj_multi_v1/negative-index.obj @@ -0,0 +1,27 @@ +# Blender 5.2.0 LTS +# www.blender.org +mtllib multi-object.mtl +g M12_OBJ_Left_M12_OBJ_Left_Mesh +v -1.750000 0.000000 0.750000 +v -0.250000 0.000000 0.750000 +v -1.000000 0.000000 -0.750000 +vn -0.0000 1.0000 -0.0000 +vt 0.000000 0.000000 +vt 1.000000 0.000000 +vt 0.500000 1.000000 +s 0 +g M12_OBJ_Left_M12_OBJ_Left_Material +usemtl M12_OBJ_Left_Material +f -1/-1/-1 -2/-2/-1 -3/-3/-1 +g M12_OBJ_Right_M12_OBJ_Right_Mesh +v 0.250000 0.000000 0.750000 +v 1.750000 0.000000 0.750000 +v 1.000000 0.000000 -0.750000 +vn -0.0000 1.0000 -0.0000 +vt 0.000000 0.000000 +vt 1.000000 0.000000 +vt 0.500000 1.000000 +s 0 +g M12_OBJ_Right_M12_OBJ_Right_Material +usemtl M12_OBJ_Right_Material +f -4/-4/-2 -5/-5/-2 -6/-6/-2 diff --git a/tests/files/web/m12_ply_capability_v1/capability-ascii.ply b/tests/files/web/m12_ply_capability_v1/capability-ascii.ply new file mode 100644 index 00000000..614c2363 --- /dev/null +++ b/tests/files/web/m12_ply_capability_v1/capability-ascii.ply @@ -0,0 +1,19 @@ +ply +format ascii 1.0 +comment Created in Blender version 5.2.0 LTS +element vertex 4 +property float x +property float y +property float z +property float nx +property float ny +property float nz +element face 2 +property list uchar uint vertex_indices +end_header +-1 0 1 0 1 0 +1 0 1 0 1 0 +1 0 -1 0 1 0 +-1 0 -1 0 1 0 +3 0 1 2 +3 0 2 3 diff --git a/tests/files/web/m12_ply_capability_v1/capability-binary-le.ply b/tests/files/web/m12_ply_capability_v1/capability-binary-le.ply new file mode 100644 index 00000000..795202c0 Binary files /dev/null and b/tests/files/web/m12_ply_capability_v1/capability-binary-le.ply differ diff --git a/tests/files/web/m12_ply_mapping_v1/mapping-ascii.ply b/tests/files/web/m12_ply_mapping_v1/mapping-ascii.ply new file mode 100644 index 00000000..ab5bd285 --- /dev/null +++ b/tests/files/web/m12_ply_mapping_v1/mapping-ascii.ply @@ -0,0 +1,25 @@ +ply +format ascii 1.0 +comment Created in Blender version 5.2.0 LTS +element vertex 4 +property float x +property float y +property float z +property float nx +property float ny +property float nz +property uchar red +property uchar green +property uchar blue +property uchar alpha +property float temperature +property float label +element face 2 +property list uchar uint vertex_indices +end_header +-1 0 1 0 1 0 254 0 0 255 10 1 +1 0 1 0 1 0 0 254 0 255 20 2 +1 0 -1 0 1 0 0 0 254 255 30 3 +-1 0 -1 0 1 0 254 254 0 255 40 4 +3 0 1 2 +3 0 2 3 diff --git a/tests/files/web/m12_ply_mapping_v1/mapping-binary-le.ply b/tests/files/web/m12_ply_mapping_v1/mapping-binary-le.ply new file mode 100644 index 00000000..090165c2 Binary files /dev/null and b/tests/files/web/m12_ply_mapping_v1/mapping-binary-le.ply differ diff --git a/tests/files/web/m12_ply_mapping_v1/unknown-property-ascii.ply b/tests/files/web/m12_ply_mapping_v1/unknown-property-ascii.ply new file mode 100644 index 00000000..ffac7da5 --- /dev/null +++ b/tests/files/web/m12_ply_mapping_v1/unknown-property-ascii.ply @@ -0,0 +1,26 @@ +ply +format ascii 1.0 +comment Created in Blender version 5.2.0 LTS +element vertex 4 +property float x +property float y +property float z +property float nx +property float ny +property float nz +property uchar red +property uchar green +property uchar blue +property uchar alpha +property float temperature +property float label +property list uchar float unknown_values +element face 2 +property list uchar uint vertex_indices +end_header +-1 0 1 0 1 0 254 0 0 255 10 1 1 0.5 +1 0 1 0 1 0 0 254 0 255 20 2 1 0.5 +1 0 -1 0 1 0 0 0 254 255 30 3 1 0.5 +-1 0 -1 0 1 0 254 254 0 255 40 4 1 0.5 +3 0 1 2 +3 0 2 3 diff --git a/tests/files/web/m12_ply_negative_v1/big-endian.ply b/tests/files/web/m12_ply_negative_v1/big-endian.ply new file mode 100644 index 00000000..45749b14 Binary files /dev/null and b/tests/files/web/m12_ply_negative_v1/big-endian.ply differ diff --git a/tests/files/web/m12_ply_negative_v1/malformed-list-ascii.ply b/tests/files/web/m12_ply_negative_v1/malformed-list-ascii.ply new file mode 100644 index 00000000..53f09542 --- /dev/null +++ b/tests/files/web/m12_ply_negative_v1/malformed-list-ascii.ply @@ -0,0 +1,13 @@ +ply +format ascii 1.0 +element vertex 3 +property float x +property float y +property float z +element face 1 +property list uchar uint vertex_indices +end_header +0 0 0 +1 0 0 +0 0 1 +3 0 1 diff --git a/tests/files/web/m12_ply_negative_v1/oversized-count-ascii.ply b/tests/files/web/m12_ply_negative_v1/oversized-count-ascii.ply new file mode 100644 index 00000000..70d3cf12 --- /dev/null +++ b/tests/files/web/m12_ply_negative_v1/oversized-count-ascii.ply @@ -0,0 +1,9 @@ +ply +format ascii 1.0 +element vertex 65537 +property float x +property float y +property float z +element face 0 +property list uchar uint vertex_indices +end_header diff --git a/tests/files/web/m12_stl_capability_v1/capability-ascii.stl b/tests/files/web/m12_stl_capability_v1/capability-ascii.stl new file mode 100644 index 00000000..eba7c37b --- /dev/null +++ b/tests/files/web/m12_stl_capability_v1/capability-ascii.stl @@ -0,0 +1,16 @@ +solid +facet normal 0 1 -0 + outer loop + vertex -1 0 1 + vertex 1 0 1 + vertex 1 0 -1 + endloop +endfacet +facet normal 0 1 -0 + outer loop + vertex -1 0 1 + vertex 1 0 -1 + vertex -1 0 -1 + endloop +endfacet +endsolid diff --git a/tests/files/web/m12_stl_capability_v1/capability-binary.stl b/tests/files/web/m12_stl_capability_v1/capability-binary.stl new file mode 100644 index 00000000..59c871cc Binary files /dev/null and b/tests/files/web/m12_stl_capability_v1/capability-binary.stl differ diff --git a/tests/files/web/m12_stl_edges_v1/capability-binary.stl b/tests/files/web/m12_stl_edges_v1/capability-binary.stl new file mode 100644 index 00000000..59c871cc Binary files /dev/null and b/tests/files/web/m12_stl_edges_v1/capability-binary.stl differ diff --git a/tests/files/web/m12_stl_edges_v1/degenerate-binary.stl b/tests/files/web/m12_stl_edges_v1/degenerate-binary.stl new file mode 100644 index 00000000..dd6e7e39 Binary files /dev/null and b/tests/files/web/m12_stl_edges_v1/degenerate-binary.stl differ diff --git a/tests/files/web/m12_stl_edges_v1/trailing-binary.stl b/tests/files/web/m12_stl_edges_v1/trailing-binary.stl new file mode 100644 index 00000000..b47f8b53 Binary files /dev/null and b/tests/files/web/m12_stl_edges_v1/trailing-binary.stl differ diff --git a/tests/files/web/m13_malicious_script_v1/malicious-script.blend b/tests/files/web/m13_malicious_script_v1/malicious-script.blend new file mode 100644 index 00000000..91b9b8a1 Binary files /dev/null and b/tests/files/web/m13_malicious_script_v1/malicious-script.blend differ diff --git a/tests/files/web/m13_script_entry_v1/script-entry-inventory.blend b/tests/files/web/m13_script_entry_v1/script-entry-inventory.blend new file mode 100644 index 00000000..5dff4ef9 Binary files /dev/null and b/tests/files/web/m13_script_entry_v1/script-entry-inventory.blend differ diff --git a/tests/golden/M12-01E/manifest.json b/tests/golden/M12-01E/manifest.json index 92276172..0696304f 100644 --- a/tests/golden/M12-01E/manifest.json +++ b/tests/golden/M12-01E/manifest.json @@ -20,7 +20,7 @@ }, "errorContract": { "path": "web/protocol/error.ts", - "sha256": "309ab84d5c755a69466ddb73e4b54e87caf62cbcac0f10f34d904e9f6f4a5f34" + "sha256": "751c70c898540fa7e82fb1b1a79254756ec8db8e4201a88b6d817d7c3d92103f" }, "generator": { "path": "tools/web/generate-asset-catalog-compatibility.mjs", diff --git a/tests/golden/M12-02B/manifest.json b/tests/golden/M12-02B/manifest.json index c8c762f9..ecd1b1e9 100644 --- a/tests/golden/M12-02B/manifest.json +++ b/tests/golden/M12-02B/manifest.json @@ -21,7 +21,7 @@ "generatorSettingsSha256": "07ebc55d4b6cbb293badf0640874846912df3d23549997cf6f87f4c325280ca0", "artifacts": { "protocol": { "path": "web/protocol/asset-preview.ts", "sha256": "2a1d877af42424014097c669e9d44c21b27e3171be185320554693cbefcd7438" }, - "errorContract": { "path": "web/protocol/error.ts", "sha256": "6aec7b8a9d6903d83ae67718db3ae775d709cabb207cf2a02968d4ff7c5f26c0" }, + "errorContract": { "path": "web/protocol/error.ts", "sha256": "751c70c898540fa7e82fb1b1a79254756ec8db8e4201a88b6d817d7c3d92103f" }, "generator": { "path": "tools/web/generate-asset-preview-identity.py", "sha256": "753a26be90f7d97828737ebc3a4ab88275a7c655b4f9992406c7c35fba6535ce" }, "blender": { "path": "build_blender_5.2.0/bin/blender", "sha256": "d4483926610484ef9c2ad9241aae1469f934d955ebe791f1920e263e0ba85b82" }, "source": { "path": "tests/files/web/media/sequencer-frame.png", "sha256": "295b083db2299ab904947eb39c17173de70c211882b0d855537d8f3cc27698ed" }, diff --git a/tests/golden/M12-03E/manifest.json b/tests/golden/M12-03E/manifest.json new file mode 100644 index 00000000..8a162b51 --- /dev/null +++ b/tests/golden/M12-03E/manifest.json @@ -0,0 +1,40 @@ +{ + "schemaVersion": 1, + "task": "M12-03E", + "parentTask": "M12-03D", + "enablingTask": false, + "parityStateChange": false, + "runtime": "BLENDER_5_2_WASM_CHROMIUM", + "operation": "APPEND", + "canonicalComparison": "DESKTOP_REPORT_EXACT_AFTER_IMAGE_ROW_NORMALIZATION_AND_SCENEIR_COLORSPACE_DEFAULT", + "assertions": { + "transactionCount": 1, + "revisionDelta": 1, + "undoRemovesClosure": true, + "redoRestoresCanonical": true, + "saveReopenRestoresCanonical": true + }, + "artifacts": { + "parentManifest": { + "path": "tests/golden/M12-03C/manifest.json", + "sha256": "cbfbd8c919125108334dd24925b9ef8e69880983515e56841e6ead4a2b182aed" + }, + "desktopReport": { + "path": "tests/golden/M12-03C/desktop-append-report.json", + "sha256": "b1d7b8b9e832d18d69081f63981062800e0f00f0c9aec025b18274510ed76e2a" + }, + "test": { + "path": "web/tests/e2e/library-append-main.spec.ts", + "sha256": "101f67d35c2320da57459f08a59687d769234b910dbd9aaec5a668cee7e0f9f0" + }, + "sourceBlend": { + "path": "tests/files/web/m12_library_append_v1/m12_append_source.blend", + "sha256": "5b60d02926efd588a6ca300ba31414cdf37dbc48786c17b383a70319057c0606" + }, + "targetBlend": { + "path": "tests/files/web/empty.blend", + "sha256": "9b1ecbcc3d7f8079ee5469de64193ffcaa0a7b01e0f2ac340bbb18aa88734a63" + } + }, + "nextTask": "M12-03F" +} diff --git a/tests/golden/M12-03F/desktop-link-report.json b/tests/golden/M12-03F/desktop-link-report.json new file mode 100644 index 00000000..419ad76d --- /dev/null +++ b/tests/golden/M12-03F/desktop-link-report.json @@ -0,0 +1,202 @@ +{ + "blenderVersion": "5.2.0", + "linkedGraph": { + "edges": [ + { + "from": "Object/M12 Link Object", + "relation": "OBJECT_DATA", + "to": "Mesh/M12 Link Mesh" + }, + { + "from": "Mesh/M12 Link Mesh", + "relation": "MATERIAL_SLOT[0]", + "to": "Material/M12 Link Material" + }, + { + "from": "Material/M12 Link Material", + "relation": "NODE_IMAGE[M12 Link Image Node]", + "to": "Image/M12 Link Image" + } + ], + "geometry": { + "edges": 4, + "loops": 4, + "materialSlots": [ + "M12 Link Material" + ], + "polygons": 1, + "uvLayers": [ + "UVMap" + ], + "vertices": 4 + }, + "ids": { + "IMAGE": { + "idType": "IMAGE", + "isLibraryOverride": false, + "library": "m12_link_source.blend", + "name": "M12 Link Image", + "nameFull": "M12 Link Image [m12_link_source.blend]" + }, + "MATERIAL": { + "idType": "MATERIAL", + "isLibraryOverride": false, + "library": "m12_link_source.blend", + "name": "M12 Link Material", + "nameFull": "M12 Link Material [m12_link_source.blend]" + }, + "MESH": { + "idType": "MESH", + "isLibraryOverride": false, + "library": "m12_link_source.blend", + "name": "M12 Link Mesh", + "nameFull": "M12 Link Mesh [m12_link_source.blend]" + }, + "OBJECT": { + "idType": "OBJECT", + "isLibraryOverride": false, + "library": "m12_link_source.blend", + "name": "M12 Link Object", + "nameFull": "M12 Link Object [m12_link_source.blend]" + } + }, + "image": { + "channels": 4, + "colorspace": "sRGB", + "packed": true, + "pixelFloat32Sha256": "6f0f8c231d65149e69e6ed12d370bcfa095ef90adc202065492ea8c8ef17e45a", + "size": [ + 2, + 2 + ] + }, + "root": { + "idType": "OBJECT", + "isLibraryOverride": false, + "library": "m12_link_source.blend", + "name": "M12 Link Object", + "nameFull": "M12 Link Object [m12_link_source.blend]" + }, + "sourceMarker": "M12-03F" + }, + "nextTask": "M12-03G", + "operation": "LINK", + "schemaVersion": 1, + "selectedRoots": [ + "Object/M12 Link Object" + ], + "source": { + "file": "m12_link_source.blend", + "sha256": "fae97569e9d2e2066fc92749672f86cc42717cd9b97b012faeb9eb92015d7fd1" + }, + "sourceGraph": { + "edges": [ + { + "from": "Object/M12 Link Object", + "relation": "OBJECT_DATA", + "to": "Mesh/M12 Link Mesh" + }, + { + "from": "Mesh/M12 Link Mesh", + "relation": "MATERIAL_SLOT[0]", + "to": "Material/M12 Link Material" + }, + { + "from": "Material/M12 Link Material", + "relation": "NODE_IMAGE[M12 Link Image Node]", + "to": "Image/M12 Link Image" + } + ], + "geometry": { + "edges": 4, + "loops": 4, + "materialSlots": [ + "M12 Link Material" + ], + "polygons": 1, + "uvLayers": [ + "UVMap" + ], + "vertices": 4 + }, + "ids": { + "IMAGE": { + "idType": "IMAGE", + "isLibraryOverride": false, + "library": null, + "name": "M12 Link Image", + "nameFull": "M12 Link Image" + }, + "MATERIAL": { + "idType": "MATERIAL", + "isLibraryOverride": false, + "library": null, + "name": "M12 Link Material", + "nameFull": "M12 Link Material" + }, + "MESH": { + "idType": "MESH", + "isLibraryOverride": false, + "library": null, + "name": "M12 Link Mesh", + "nameFull": "M12 Link Mesh" + }, + "OBJECT": { + "idType": "OBJECT", + "isLibraryOverride": false, + "library": null, + "name": "M12 Link Object", + "nameFull": "M12 Link Object" + } + }, + "image": { + "channels": 4, + "colorspace": "sRGB", + "packed": true, + "pixelFloat32Sha256": "6f0f8c231d65149e69e6ed12d370bcfa095ef90adc202065492ea8c8ef17e45a", + "size": [ + 2, + 2 + ] + }, + "root": { + "idType": "OBJECT", + "isLibraryOverride": false, + "library": null, + "name": "M12 Link Object", + "nameFull": "M12 Link Object" + }, + "sourceMarker": "M12-03F" + }, + "stableMapping": [ + { + "local": "Object/M12 Link Object", + "owner": "SOURCE_LIBRARY", + "readOnly": true, + "source": "Object/M12 Link Object" + }, + { + "local": "Mesh/M12 Link Mesh", + "owner": "SOURCE_LIBRARY", + "readOnly": true, + "source": "Mesh/M12 Link Mesh" + }, + { + "local": "Material/M12 Link Material", + "owner": "SOURCE_LIBRARY", + "readOnly": true, + "source": "Material/M12 Link Material" + }, + { + "local": "Image/M12 Link Image", + "owner": "SOURCE_LIBRARY", + "readOnly": true, + "source": "Image/M12 Link Image" + } + ], + "target": { + "file": "m12_link_target.blend", + "sha256": "acdaf0f297deb08c84e1a8beba30972e3414ef62e60e3b103fe0661199c438a1" + }, + "task": "M12-03F" +} diff --git a/tests/golden/M12-03F/manifest.json b/tests/golden/M12-03F/manifest.json new file mode 100644 index 00000000..ab6e2410 --- /dev/null +++ b/tests/golden/M12-03F/manifest.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 1, + "task": "M12-03F", + "parentTask": "M12-03E", + "enablingTask": true, + "parityStateChange": false, + "runtime": "BLENDER_5_2_DESKTOP", + "operation": "LINK", + "assertions": { + "selectedRoots": 1, + "dependencyClosure": 4, + "linkedOwnership": "SOURCE_LIBRARY", + "readOnly": true, + "saveReopenStable": true + }, + "artifacts": { + "parentManifest": { + "path": "tests/golden/M12-03E/manifest.json", + "sha256": "beaa88ea0385225e712f9816072420bd8744c15da3229ddc352462abec407739" + }, + "generator": { + "path": "tools/web/generate-library-link-fixture.py", + "sha256": "15a2e34c1c5b2a8ee63b10084dbb894e0f9677b9e1cf4adb7e2ddce6b4c965b1" + }, + "checker": { + "path": "tools/web/check-library-link-fixture.mjs", + "sha256": "6a4c024bea227d7bc14f793467b91766b006459fe4d7717cc75dfee12f49f894" + }, + "sourceBlend": { + "path": "tests/files/web/m12_library_link_v1/m12_link_source.blend", + "sha256": "fae97569e9d2e2066fc92749672f86cc42717cd9b97b012faeb9eb92015d7fd1" + }, + "targetBlend": { + "path": "tests/files/web/m12_library_link_v1/m12_link_target.blend", + "sha256": "acdaf0f297deb08c84e1a8beba30972e3414ef62e60e3b103fe0661199c438a1" + }, + "desktopReport": { + "path": "tests/golden/M12-03F/desktop-link-report.json", + "sha256": "b278d4c254eff63d41c8cb3ea1d5e0984192137d45b1695ba0672e16f95b58ea" + } + }, + "nextTask": "M12-03G" +} diff --git a/tests/golden/M12-03G/manifest.json b/tests/golden/M12-03G/manifest.json new file mode 100644 index 00000000..7e2c26d3 --- /dev/null +++ b/tests/golden/M12-03G/manifest.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": 1, + "task": "M12-03G", + "parentTask": "M12-03F", + "enablingTask": false, + "parityStateChange": false, + "runtime": "WEB_PROTOCOL_UNIT", + "operation": "LINK", + "assertions": { + "writerOperations": 6, + "linkedMutationCode": "LINKED_DATA_MUTATION_BLOCKED", + "staleRevisionCode": "REVISION_CONFLICT", + "mainMutation": false, + "recoverable": false + }, + "artifacts": { + "parentManifest": { + "path": "tests/golden/M12-03F/manifest.json", + "sha256": "8220a8f5d560d45a75a62cbed645b3febc1390fe37b07c3c48871fefc1a9b159" + }, + "protocol": { + "path": "web/protocol/library-linked-mutation.ts", + "sha256": "f629e0e7e04dc1f5437b6e2ca62fbe35484fc238830fa47e8358bcab46b7e104" + }, + "unit": { + "path": "web/tests/unit/library-linked-mutation.test.mjs", + "sha256": "f19d47cefe89daf6123062e045ec717e6ffe60977e8a4b20c996dd62e49da211" + }, + "package": { + "path": "web/package.json", + "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" + } + }, + "nextTask": "M12-03H" +} diff --git a/tests/golden/M12-03H/manifest.json b/tests/golden/M12-03H/manifest.json new file mode 100644 index 00000000..63ef71d8 --- /dev/null +++ b/tests/golden/M12-03H/manifest.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": 1, + "task": "M12-03H", + "parentTask": "M12-03G", + "enablingTask": false, + "parityStateChange": false, + "runtime": "WEB_PROTOCOL_UNIT", + "operation": "LINK_RELOAD", + "assertions": { + "matchingGenerationOnly": true, + "replacementGenerationStep": 1, + "staleCode": "REVISION_CONFLICT", + "preservesUnrelatedSnapshots": true, + "readOnlyOwner": "SOURCE_LIBRARY" + }, + "artifacts": { + "parentManifest": { + "path": "tests/golden/M12-03G/manifest.json", + "sha256": "ce7aba32499dbd22bce3bc78c4b0322f830e5d2648a47ce2d25271adf284dddb" + }, + "protocol": { + "path": "web/protocol/library-linked-reload.ts", + "sha256": "8be6f0b2abe36cd566ea7447d1b7de44c0e6a9a7351b863dfdd1372c1761060e" + }, + "unit": { + "path": "web/tests/unit/library-linked-reload.test.mjs", + "sha256": "dff866291468cc01e775fe3b3b95c632f5c0742566f79b956a0193bfd8fd43d2" + }, + "package": { + "path": "web/package.json", + "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" + } + }, + "nextTask": "M12-03I" +} diff --git a/tests/golden/M12-03I/manifest.json b/tests/golden/M12-03I/manifest.json new file mode 100644 index 00000000..b0c4c037 --- /dev/null +++ b/tests/golden/M12-03I/manifest.json @@ -0,0 +1,37 @@ +{ + "schemaVersion": 1, + "task": "M12-03I", + "parentTask": "M12-03H", + "enablingTask": false, + "parityStateChange": false, + "runtime": "WEB_PROTOCOL_UNIT", + "operation": "LINK_MISSING", + "assertions": { + "missingStatus": "MISSING", + "placeholderKind": "MISSING_LIBRARY", + "preservesSourceLocator": true, + "preservesSourceSha256": true, + "preservesDataBlockIds": true, + "staleCode": "REVISION_CONFLICT", + "sourceDriftCode": "ASSET_SOURCE_HASH_MISMATCH" + }, + "artifacts": { + "parentManifest": { + "path": "tests/golden/M12-03H/manifest.json", + "sha256": "72d3cf9a22d8a2015a7b8fcbc0496425a354e09cec56fd85949561e6b3700009" + }, + "protocol": { + "path": "web/protocol/library-linked-missing.ts", + "sha256": "5833e8c943ef6bd866a93a0e1666c9521fa6d3e8358861df57b628874b679ec2" + }, + "unit": { + "path": "web/tests/unit/library-linked-missing.test.mjs", + "sha256": "4ab6d6ff4845b4ca963399e7eea214ceb412871dc1fdef5bac1ed0333596da4f" + }, + "package": { + "path": "web/package.json", + "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" + } + }, + "nextTask": "M12-03J" +} diff --git a/tests/golden/M12-03J/desktop-override-report.json b/tests/golden/M12-03J/desktop-override-report.json new file mode 100644 index 00000000..9169b5c9 --- /dev/null +++ b/tests/golden/M12-03J/desktop-override-report.json @@ -0,0 +1,47 @@ +{ + "blenderVersion": "5.2.0", + "nextTask": "M12-03K", + "operation": "LIBRARY_OVERRIDE", + "overrideGraph": { + "local": { + "dataBlockId": "Object/M12 Override Object", + "hierarchyRootDataBlockId": "Object/M12 Override Object", + "idType": "OBJECT", + "isLibraryOverride": true, + "library": null, + "owner": "LOCAL_OVERRIDE", + "projectId": "m12-03j-project", + "readOnly": false, + "referenceSourceDataBlockId": "Object/M12 Override Object" + }, + "propertyOverride": { + "index": 0, + "operationCount": 1, + "propertyCount": 1, + "rnaPath": "[\"m12_override_value\"]", + "value": 2.5 + }, + "reference": { + "dataBlockId": "Object/M12 Override Object", + "idType": "OBJECT", + "isLibraryOverride": false, + "library": "m12_override_source.blend", + "owner": "SOURCE_LIBRARY", + "readOnly": true + }, + "sourceMarker": "M12-03J" + }, + "schemaVersion": 1, + "selectedRoots": [ + "Object/M12 Override Object" + ], + "source": { + "file": "m12_override_source.blend", + "sha256": "d7f8d78e7e91481bf46ecc9a6bcb01e900a6a397839dd31ab80a53def7675601" + }, + "target": { + "file": "m12_override_target.blend", + "sha256": "b008a1608ff1e8f679e1e1281bf7be0360f1137e815dd890cbd93e1e98675495" + }, + "task": "M12-03J" +} diff --git a/tests/golden/M12-03J/manifest.json b/tests/golden/M12-03J/manifest.json new file mode 100644 index 00000000..7a834a8b --- /dev/null +++ b/tests/golden/M12-03J/manifest.json @@ -0,0 +1,50 @@ +{ + "schemaVersion": 1, + "task": "M12-03J", + "parentTask": "M12-03I", + "enablingTask": false, + "parityStateChange": false, + "runtime": "BLENDER_5_2_DESKTOP", + "operation": "LIBRARY_OVERRIDE", + "assertions": { + "selectedRoots": 1, + "referenceOwner": "SOURCE_LIBRARY", + "referenceReadOnly": true, + "localOwner": "LOCAL_OVERRIDE", + "localReadOnly": false, + "propertyPath": "[\\\"m12_override_value\\\"]", + "propertyValue": 2.5, + "saveReopenStable": true + }, + "artifacts": { + "parentManifest": { + "path": "tests/golden/M12-03I/manifest.json", + "sha256": "f5fa606161b93e9ee42e7ca8144d2a83379acf601c5045eb8cc76732f8dba431" + }, + "generator": { + "path": "tools/web/generate-library-override-fixture.py", + "sha256": "2cdbac04cac7240360a9d70919380fd90f9479e2cb41d8032fb51626ed4b4dd6" + }, + "checker": { + "path": "tools/web/check-library-override-fixture.mjs", + "sha256": "afebb3c9b8715b9d2e0c9b17f463f038bd23e8747074137bccbf1c09c4bfb5ba" + }, + "sourceBlend": { + "path": "tests/files/web/m12_library_override_v1/m12_override_source.blend", + "sha256": "d7f8d78e7e91481bf46ecc9a6bcb01e900a6a397839dd31ab80a53def7675601" + }, + "targetBlend": { + "path": "tests/files/web/m12_library_override_v1/m12_override_target.blend", + "sha256": "b008a1608ff1e8f679e1e1281bf7be0360f1137e815dd890cbd93e1e98675495" + }, + "report": { + "path": "tests/golden/M12-03J/desktop-override-report.json", + "sha256": "19cb13a3417b4b65a8497b622337c42c4697b3f3d48de26a1f70f2d4527ddde0" + }, + "package": { + "path": "web/package.json", + "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" + } + }, + "nextTask": "M12-03K" +} diff --git a/tests/golden/M12-03K/manifest.json b/tests/golden/M12-03K/manifest.json new file mode 100644 index 00000000..2487ba27 --- /dev/null +++ b/tests/golden/M12-03K/manifest.json @@ -0,0 +1,37 @@ +{ + "schemaVersion": 1, + "task": "M12-03K", + "parentTask": "M12-03J", + "enablingTask": false, + "parityStateChange": false, + "runtime": "WEB_PROTOCOL_UNIT", + "operation": "LIBRARY_OVERRIDE_WRITER", + "assertions": { + "allowedOperation": "SET_M12_OVERRIDE_VALUE", + "allowedPropertyPath": "[\\\"m12_override_value\\\"]", + "allowedPropertyCount": 1, + "owner": "LOCAL_OVERRIDE", + "referenceReadOnly": true, + "staleCode": "REVISION_CONFLICT", + "linkedOwnerCode": "LINKED_DATA_MUTATION_BLOCKED" + }, + "artifacts": { + "parentManifest": { + "path": "tests/golden/M12-03J/manifest.json", + "sha256": "01d0f66bb3819eea3e92727d1b5bffbec935d24eeecc28d874b999df78d73fbf" + }, + "protocol": { + "path": "web/protocol/library-override-writer.ts", + "sha256": "dd181c7e9946b98334a5d1c686887afecfe3fc11d732beec246e40bf11a155aa" + }, + "unit": { + "path": "web/tests/unit/library-override-writer.test.mjs", + "sha256": "e41bd32eb4ce4d331a20ecb91f3325a27f461b9ae85e98940d5afd482ec21c4c" + }, + "package": { + "path": "web/package.json", + "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" + } + }, + "nextTask": "M12-03L" +} diff --git a/tests/golden/M12-03L/manifest.json b/tests/golden/M12-03L/manifest.json new file mode 100644 index 00000000..36206f03 --- /dev/null +++ b/tests/golden/M12-03L/manifest.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": 1, + "task": "M12-03L", + "parentTask": "M12-03K", + "enablingTask": false, + "parityStateChange": false, + "runtime": "WEB_PROTOCOL_UNIT", + "operation": "LIBRARY_OVERRIDE_FRESHNESS", + "assertions": { + "matchingStatus": "READY", + "staleCode": "REVISION_CONFLICT", + "identityDriftCode": "ASSET_SOURCE_HASH_MISMATCH", + "linkedOwnerCode": "LINKED_DATA_MUTATION_BLOCKED", + "mainCommit": false + }, + "artifacts": { + "parentManifest": { + "path": "tests/golden/M12-03K/manifest.json", + "sha256": "9eeee93e4a806aa59b5c53a6485fe3a1b5e3972c1d2585cdc71b7cfc293afb70" + }, + "protocol": { + "path": "web/protocol/library-override-freshness.ts", + "sha256": "2eff7ea7605b1579d7551336d87d4f30adb996b585596ff9eee67aea04ca7d22" + }, + "unit": { + "path": "web/tests/unit/library-override-freshness.test.mjs", + "sha256": "d48344f31e1ba12e557ca30ece56643583efa633da556f5de3896a8e9175ef8f" + }, + "package": { + "path": "web/package.json", + "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" + } + }, + "nextTask": "M12-03M" +} diff --git a/tests/golden/M12-03M/manifest.json b/tests/golden/M12-03M/manifest.json new file mode 100644 index 00000000..465bf504 --- /dev/null +++ b/tests/golden/M12-03M/manifest.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": 1, + "task": "M12-03M", + "parentTask": "M12-03L", + "enablingTask": false, + "parityStateChange": false, + "runtime": "WEB_PROTOCOL_UNIT", + "operation": "LIBRARY_NEGATIVE_CASES", + "assertions": { + "dependencyCycleCode": "LIBRARY_DEPENDENCY_CYCLE", + "crossLibraryCycleCode": "LIBRARY_DEPENDENCY_CYCLE", + "dataBlockCollisionCode": "TASK_VALIDATION_FAILED", + "duplicateReloadCode": "REVISION_CONFLICT", + "missingSourceCode": "ASSET_SOURCE_HASH_MISMATCH" + }, + "artifacts": { + "parentManifest": { + "path": "tests/golden/M12-03L/manifest.json", + "sha256": "237f3f168e037b67b805ba8d9c9455250c889a5e90c45cbb17d2d20d9fbdcc50" + }, + "protocol": { + "path": "web/protocol/library-negative-cases.ts", + "sha256": "efc7cc810089eab6178fc5c2f2a45d641625a032ecfa5b2b4ef37694d0decc97" + }, + "unit": { + "path": "web/tests/unit/library-negative-cases.test.mjs", + "sha256": "b7ae41b2c35b2fe1598bca4425dd434230bfe4bf342320c88214a060dcbb0a16" + }, + "package": { + "path": "web/package.json", + "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" + } + }, + "nextTask": "M12-03N" +} diff --git a/tests/golden/M12-03N/manifest.json b/tests/golden/M12-03N/manifest.json new file mode 100644 index 00000000..f8b34038 --- /dev/null +++ b/tests/golden/M12-03N/manifest.json @@ -0,0 +1,60 @@ +{ + "schemaVersion": 1, + "task": "M12-03N", + "parentTask": "M12-03M", + "enablingTask": false, + "parityStateChange": false, + "runtime": "DESKTOP_WASM_CHROMIUM", + "operation": "LIBRARY_OPERATION_COMMANDS", + "assertions": { + "operations": ["APPEND", "LINK", "LIBRARY_OVERRIDE"], + "lanes": ["DESKTOP", "WASM", "CHROMIUM"], + "independentCommands": 9, + "mainAppendChromiumTest": "web/tests/e2e/library-append-main.spec.ts", + "linkChromiumTest": "web/tests/e2e/library-link-chromium.spec.ts", + "overrideChromiumTest": "web/tests/e2e/library-override-chromium.spec.ts" + }, + "artifacts": { + "parentManifest": { + "path": "tests/golden/M12-03M/manifest.json", + "sha256": "693cbedfe8e29167283a753d119de5a9cfe5e20bc96aff7ba84d720ffde9148a" + }, + "commandChecker": { + "path": "tools/web/check-library-operation-commands.mjs", + "sha256": "3564347393134d835fdf279b8b8f58558ee24fe0165c3b4527195d094a451e98" + }, + "appendWasmProtocol": { + "path": "web/protocol/library-main-append.ts", + "sha256": "bfd98561cbe797d7b8f07c92c53a25460c839a64ab7222b7795cf58d54dff8d7" + }, + "appendWasmUnit": { + "path": "web/tests/unit/library-append-wasm.test.mjs", + "sha256": "75fc2d626f7ca7e820e9cacf659a453886e15e790cde43348828c03bed752ec7" + }, + "appendChromium": { + "path": "web/tests/e2e/library-append-main.spec.ts", + "sha256": "101f67d35c2320da57459f08a59687d769234b910dbd9aaec5a668cee7e0f9f0" + }, + "linkChromium": { + "path": "web/tests/e2e/library-link-chromium.spec.ts", + "sha256": "814e3486522fb4f0ffdd59acd385a4fca9c5660cdb07e5a2acb1cadd70376bff" + }, + "overrideChromium": { + "path": "web/tests/e2e/library-override-chromium.spec.ts", + "sha256": "1c12982b4eb4fb4b9a3c88907a842a1fc413b3a3a760a9f57b350f57060d007f" + }, + "linkChromiumWrapper": { + "path": "web/app/src/library-link-chromium.ts", + "sha256": "096af8e594a6d475476dc3253e3d99069d9f84bc383b84c690a38d08f1f0137a" + }, + "overrideChromiumWrapper": { + "path": "web/app/src/library-override-chromium.ts", + "sha256": "f3bb9c7d57c65a333ba6aa982a19956025c23e740e1771f741d161defc591f21" + }, + "package": { + "path": "web/package.json", + "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" + } + }, + "nextTask": "M12-04A" +} diff --git a/tests/golden/M12-04A/manifest.json b/tests/golden/M12-04A/manifest.json new file mode 100644 index 00000000..3340d37f --- /dev/null +++ b/tests/golden/M12-04A/manifest.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": 1, + "task": "M12-04A", + "parentTask": "M12-03N", + "enablingTask": false, + "parityStateChange": false, + "runtime": "WEB_PROTOCOL_UNIT", + "operation": "LIBRARY_SOURCE_ORIGIN", + "assertions": { + "acceptedKinds": ["HTTPS_ORIGIN", "PROJECT_ASSET", "USER_SELECTED_FILE"], + "undeclaredHttpsCode": "IO_EXTERNAL_URI_BLOCKED", + "unsafeProjectPathCode": "IO_EXTERNAL_URI_BLOCKED", + "sourceHash": true, + "credentialFreeHttps": true + }, + "artifacts": { + "parentManifest": { + "path": "tests/golden/M12-03N/manifest.json", + "sha256": "e4cccc8edc277b5047c3f8006ea40b26b119d28589fe2f3bd9bfa4cc3575c85a" + }, + "protocol": { + "path": "web/protocol/library-source-origin.ts", + "sha256": "67a1808f225194a09a72987c5340c7494c5fa8d99dffde40664a11a779caedeb" + }, + "unit": { + "path": "web/tests/unit/library-source-origin.test.mjs", + "sha256": "89b207c9cb13b4fb055a2dfed0237c0f8a00b13a39cc4175a378f5ef2abdb7ae" + }, + "package": { + "path": "web/package.json", + "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" + } + }, + "nextTask": "M12-04B" +} diff --git a/tests/golden/M12-04B/manifest.json b/tests/golden/M12-04B/manifest.json new file mode 100644 index 00000000..88ff6aac --- /dev/null +++ b/tests/golden/M12-04B/manifest.json @@ -0,0 +1,40 @@ +{ + "schemaVersion": 1, + "task": "M12-04B", + "parentTask": "M12-04A", + "enablingTask": false, + "parityStateChange": false, + "runtime": "WEB_PROTOCOL_UNIT", + "operation": "LIBRARY_PATH_NORMALIZATION", + "assertions": { + "canonicalSeparator": "/", + "dotSegmentsResolved": true, + "percentDecodePasses": 1, + "unicodeNormalization": "NFC", + "canonicalIdempotent": true, + "projectEscapeCode": "ASSET_PATH_OUTSIDE_PROJECT" + }, + "artifacts": { + "parentManifest": { + "path": "tests/golden/M12-04A/manifest.json", + "sha256": "af80827d925ddd96d8541e446e0f70c956fd4c56e64ac984d187f5449df4cb0d" + }, + "normalizer": { + "path": "web/protocol/asset-path.ts", + "sha256": "6109d05251fc7355ac32d4c0d8298f85ea8b731767c76c394577c4e44652a6bf" + }, + "sourceAdmission": { + "path": "web/protocol/library-source-origin.ts", + "sha256": "67a1808f225194a09a72987c5340c7494c5fa8d99dffde40664a11a779caedeb" + }, + "unit": { + "path": "web/tests/unit/library-path-normalization.test.mjs", + "sha256": "abde64c60397541e92a83dd9e4a24e65faf340a8d046650604c101a21037c777" + }, + "package": { + "path": "web/package.json", + "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" + } + }, + "nextTask": "M12-04C" +} diff --git a/tests/golden/M12-04C/manifest.json b/tests/golden/M12-04C/manifest.json new file mode 100644 index 00000000..23a4d47a --- /dev/null +++ b/tests/golden/M12-04C/manifest.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 1, + "task": "M12-04C", + "parentTask": "M12-04B", + "enablingTask": false, + "parityStateChange": false, + "runtime": "WEB_PROTOCOL_UNIT", + "operation": "LIBRARY_PATH_SECURITY", + "assertions": { + "absolutePathCode": "ASSET_PATH_OUTSIDE_PROJECT", + "uncPathCode": "ASSET_PATH_OUTSIDE_PROJECT", + "drivePathCode": "ASSET_PATH_OUTSIDE_PROJECT", + "controlCharacterCode": "ASSET_PATH_OUTSIDE_PROJECT", + "originEscapeCode": "IO_EXTERNAL_URI_BLOCKED", + "declaredOriginPathRejected": true, + "encodedControlsRejected": true + }, + "artifacts": { + "parentManifest": { + "path": "tests/golden/M12-04B/manifest.json", + "sha256": "8cd29c3f5281082584fc6aefe2ec385a2eedf8116e837a4db54c391391ff8f98" + }, + "pathNormalizer": { + "path": "web/protocol/asset-path.ts", + "sha256": "6109d05251fc7355ac32d4c0d8298f85ea8b731767c76c394577c4e44652a6bf" + }, + "sourceOrigin": { + "path": "web/protocol/library-source-origin.ts", + "sha256": "67a1808f225194a09a72987c5340c7494c5fa8d99dffde40664a11a779caedeb" + }, + "unit": { + "path": "web/tests/unit/library-path-security.test.mjs", + "sha256": "ab302cacb24634a3225dda5cb8f5282cb80b3bd81ed5c8900ddf4ff18267b7e2" + }, + "package": { + "path": "web/package.json", + "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" + } + }, + "nextTask": "M12-04D" +} diff --git a/tests/golden/M12-04D/manifest.json b/tests/golden/M12-04D/manifest.json new file mode 100644 index 00000000..f513a73b --- /dev/null +++ b/tests/golden/M12-04D/manifest.json @@ -0,0 +1,37 @@ +{ + "schemaVersion": 1, + "task": "M12-04D", + "parentTask": "M12-04C", + "enablingTask": false, + "parityStateChange": false, + "runtime": "WEB_PROTOCOL_UNIT", + "operation": "ARCHIVE_LINK_SAFETY", + "assertions": { + "symlinkTargetBase": "link-parent", + "hardlinkTargetBase": "archive-root", + "resolvedWithinTemporaryRoot": true, + "missingTargetCode": "IO_ARCHIVE_UNSAFE", + "cycleCode": "IO_ARCHIVE_UNSAFE", + "outsideRootCode": "IO_ARCHIVE_UNSAFE", + "hardlinkDirectoryCode": "IO_ARCHIVE_UNSAFE" + }, + "artifacts": { + "parentManifest": { + "path": "tests/golden/M12-04C/manifest.json", + "sha256": "a7f3a45eb7f68d022f38185a1c1409e1db1b27647fef587d66d2ffa52d78f98e" + }, + "protocol": { + "path": "web/protocol/archive-link-safety.ts", + "sha256": "d55a4ba762898aed22bdcc7aa6493c713ee94f6bb1bf58754fdc459d0ddf70d1" + }, + "unit": { + "path": "web/tests/unit/library-link-safety.test.mjs", + "sha256": "7739275be91222d7bfb61d2f5a1daf812c6860fe34d0aea27df8380a77322120" + }, + "package": { + "path": "web/package.json", + "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" + } + }, + "nextTask": "M12-04E" +} diff --git a/tests/golden/M12-04E/manifest.json b/tests/golden/M12-04E/manifest.json new file mode 100644 index 00000000..ba2c9e4c --- /dev/null +++ b/tests/golden/M12-04E/manifest.json @@ -0,0 +1,36 @@ +{ + "schemaVersion": 1, + "task": "M12-04E", + "parentTask": "M12-04D", + "enablingTask": false, + "parityStateChange": false, + "runtime": "WEB_PROTOCOL_UNIT", + "operation": "ARCHIVE_METADATA_FIRST", + "assertions": { + "zipFirstRead": "CENTRAL_DIRECTORY", + "tarFirstRead": "MANIFEST", + "payloadReadsBeforeMetadata": false, + "wrongRangeCode": "IO_ARCHIVE_UNSAFE", + "duplicateMetadataCode": "IO_ARCHIVE_UNSAFE", + "metadataRangeBoundBytes": 67108864 + }, + "artifacts": { + "parentManifest": { + "path": "tests/golden/M12-04D/manifest.json", + "sha256": "1c09abd1f4bd5908d22ab3b22e3e71d050f694af0b82a7f78a351d7a1bf1e664" + }, + "protocol": { + "path": "web/protocol/archive-metadata-first.ts", + "sha256": "869586b041e134c7d1cb2ebd7e13b35218b337223d401697a179f71cd1420f5b" + }, + "unit": { + "path": "web/tests/unit/library-metadata-first.test.mjs", + "sha256": "2da649722acee9cace8db6f337b4a93500a9c775a0b0f086bf38dd2b3e7f9e91" + }, + "package": { + "path": "web/package.json", + "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" + } + }, + "nextTask": "M12-04F" +} diff --git a/tests/golden/M12-04F/manifest.json b/tests/golden/M12-04F/manifest.json new file mode 100644 index 00000000..a6c0bfc1 --- /dev/null +++ b/tests/golden/M12-04F/manifest.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": 1, + "task": "M12-04F", + "parentTask": "M12-04E", + "enablingTask": false, + "parityStateChange": false, + "runtime": "WEB_PROTOCOL_UNIT", + "operation": "ARCHIVE_BUDGETS", + "assertions": { + "maxArchiveEntries": 100000, + "maxEntryBytes": 2147483648, + "maxArchiveBytes": 4294967296, + "maxPathDepth": 64, + "maxFileNameBytes": 255, + "budgetCode": "IO_ARCHIVE_UNSAFE" + }, + "artifacts": { + "parentManifest": { "path": "tests/golden/M12-04E/manifest.json", "sha256": "d935392fb23c2e6ad651fb6ad6cb67f8ead3d562e626bb230d2febd9d7f03b1c" }, + "protocol": { "path": "web/protocol/asset-library-io.ts", "sha256": "e02efa79668f4ee10b1c28786709eba2c93691b28ccf1155c6213dda12f59a8f" }, + "unit": { "path": "web/tests/unit/library-archive-budget.test.mjs", "sha256": "b0d8356c6fb348d98e28ce220052845a29439b34b3c976b21a4dbf370ea19593" }, + "package": { "path": "web/package.json", "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" } + }, + "nextTask": "M12-04G" +} diff --git a/tests/golden/M12-04G/manifest.json b/tests/golden/M12-04G/manifest.json new file mode 100644 index 00000000..179107e1 --- /dev/null +++ b/tests/golden/M12-04G/manifest.json @@ -0,0 +1,23 @@ +{ + "schemaVersion": 1, + "task": "M12-04G", + "parentTask": "M12-04F", + "enablingTask": false, + "parityStateChange": false, + "runtime": "WEB_PROTOCOL_UNIT", + "operation": "ARCHIVE_CONFLICTS", + "assertions": { + "compressionRatio": 100, + "overlapCode": "IO_ARCHIVE_UNSAFE", + "duplicatePathCode": "IO_ARCHIVE_UNSAFE", + "prefixConflictCode": "IO_ARCHIVE_UNSAFE", + "outOfRangeCode": "IO_ARCHIVE_UNSAFE" + }, + "artifacts": { + "parentManifest": { "path": "tests/golden/M12-04F/manifest.json", "sha256": "cda905b1e27b62d9ea3a05170f975015480ce6739c15bdf1f5a1f0b79f93ce06" }, + "protocol": { "path": "web/protocol/archive-conflicts.ts", "sha256": "3bec372e4f67ec309f28534cebcbaf8b492144adfa82ff6c8603f88c3ba16254" }, + "unit": { "path": "web/tests/unit/library-archive-conflicts.test.mjs", "sha256": "3f2d44dce33b1c17b3a48f58b04fdd821abc88d98ce3d3b5bb724859e0f0ab3c" }, + "package": { "path": "web/package.json", "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" } + }, + "nextTask": "M12-04H" +} diff --git a/tests/golden/M12-04H/manifest.json b/tests/golden/M12-04H/manifest.json new file mode 100644 index 00000000..dfd9981d --- /dev/null +++ b/tests/golden/M12-04H/manifest.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": 1, + "task": "M12-04H", + "parentTask": "M12-04G", + "enablingTask": false, + "parityStateChange": false, + "runtime": "WEB_PROTOCOL_REAL_FS_UNIT", + "operation": "ARCHIVE_EXTRACTION_CANCELLATION", + "assertions": { + "cancellationCode": "IO_ARCHIVE_CANCELLED", + "stagingEntriesAfter": 0, + "publishedProjects": 0, + "committedIdentityUnchanged": true, + "commitLinearization": "BEFORE_ATOMIC_COMMIT" + }, + "artifacts": { + "parentManifest": { "path": "tests/golden/M12-04G/manifest.json", "sha256": "c35e675e8f512e85b748f0b5578874fed8c99df7674a152e698236a67c9f4fa4" }, + "protocol": { "path": "web/protocol/archive-extraction-transaction.ts", "sha256": "c40adc1449172014cc1820db567e155828314abb404b66e4de13c26ddee06e4b" }, + "errorCodes": { "path": "web/protocol/error.ts", "sha256": "751c70c898540fa7e82fb1b1a79254756ec8db8e4201a88b6d817d7c3d92103f" }, + "unit": { "path": "web/tests/unit/library-archive-cancellation.test.mjs", "sha256": "77e7d5bc64dd6b313006ebf523602601acdaf7949a1484da872ddcd046983786" }, + "package": { "path": "web/package.json", "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" } + }, + "nextTask": "M12-04I" +} diff --git a/tests/golden/M12-04I/manifest.json b/tests/golden/M12-04I/manifest.json new file mode 100644 index 00000000..44c30b96 --- /dev/null +++ b/tests/golden/M12-04I/manifest.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": 1, + "task": "M12-04I", + "parentTask": "M12-04H", + "enablingTask": false, + "parityStateChange": false, + "runtime": "WEB_PROTOCOL_REAL_FS_UNIT", + "operation": "ARCHIVE_QUOTA_OOM_RECOVERY", + "assertions": { + "faultCodes": ["STORAGE_QUOTA", "WASM_OUT_OF_MEMORY"], + "partialStagingEntriesAfter": 0, + "committedIdentityUnchangedAfterFault": true, + "recoverySession": "SAME_STORAGE_INSTANCE", + "smallArchiveCommitted": true + }, + "artifacts": { + "parentManifest": { "path": "tests/golden/M12-04H/manifest.json", "sha256": "7fce600a416aec5ade1a91a13d86caf4cb1f65b710054b59d525ff8b1d3c7409" }, + "baseProtocol": { "path": "web/protocol/archive-extraction-transaction.ts", "sha256": "c40adc1449172014cc1820db567e155828314abb404b66e4de13c26ddee06e4b" }, + "recoveryProtocol": { "path": "web/protocol/archive-extraction-recovery.ts", "sha256": "0ae9801ea151403b244c5f58f7f99231bba7a25abb1f61e3669879510b92dccf" }, + "unit": { "path": "web/tests/unit/library-archive-recovery.test.mjs", "sha256": "a931edef8f3ca1243a80ec2b8e9ff5b8da1dfd339f3d8c162ad2ebd08d3db6a1" }, + "package": { "path": "web/package.json", "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" } + }, + "nextTask": "M12-04J" +} diff --git a/tests/golden/M12-04J/manifest.json b/tests/golden/M12-04J/manifest.json new file mode 100644 index 00000000..00f4eaf0 --- /dev/null +++ b/tests/golden/M12-04J/manifest.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": 1, + "task": "M12-04J", + "parentTask": "M12-04I", + "enablingTask": false, + "parityStateChange": false, + "runtime": "WEB_PROTOCOL_BINARY_FIXTURE_CHROMIUM", + "operation": "MALICIOUS_ZIP_TAR_REGRESSION", + "assertions": { + "fixtureCount": 6, + "zipFixtureCount": 3, + "tarFixtureCount": 3, + "metadataOnly": true, + "extractionAllowed": false, + "expectedCode": "IO_ARCHIVE_UNSAFE", + "deterministicRegeneration": true + }, + "artifacts": { + "parentManifest": { "path": "tests/golden/M12-04I/manifest.json", "sha256": "574afa68a787b9481d0e098d38b94cb810a9827fe185b0df796d62cc160ba7c5" }, + "generator": { "path": "tools/web/generate-malicious-archive-fixtures.mjs", "sha256": "b372ea8cb2f97b035ffb2cc18666dae9167dcfb349131851ad9f1ff0fc40ba16" }, + "checker": { "path": "tools/web/check-malicious-archive-fixtures.mjs", "sha256": "edda2f420f26e55f9990d24b755bb9b4f732ec32c2e072210144b3d0b6634d9b" }, + "fixtureManifest": { "path": "tests/files/web/archive-security/manifest.json", "sha256": "a93834316f6a23b8a0e6c1f1f806ec584d6f03aa339c2680cae2ce8133a40e58" }, + "chromium": { "path": "web/tests/e2e/archive-security-fixtures.spec.ts", "sha256": "f327500808dd67359a152ce28134b58d027aebd6758416b5535b659b9900bbfe" }, + "package": { "path": "web/package.json", "sha256": "342f75fe285f99da301eba97590fd9fc76cef4938508177fd43e1827f0295076" } + }, + "nextTask": "M12-05A" +} diff --git a/tests/golden/M12-05A/format-inventory.json b/tests/golden/M12-05A/format-inventory.json new file mode 100644 index 00000000..50d508d4 --- /dev/null +++ b/tests/golden/M12-05A/format-inventory.json @@ -0,0 +1,2796 @@ +{ + "formats": [ + { + "export": { + "buildOption": null, + "buildOptionEnabled": true, + "operator": "export_scene.gltf", + "properties": [ + { + "arrayLength": 0, + "identifier": "at_collection_center", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "check_existing", + "type": "BOOLEAN" + }, + { + "identifier": "collection", + "type": "STRING" + }, + { + "arrayLength": 0, + "identifier": "export_action_filter", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_active_vertex_color_when_no_material", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_all_influences", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_all_vertex_colors", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_anim_scene_split_object", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_anim_single_armature", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_anim_slide_to_zero", + "type": "BOOLEAN" + }, + { + "enumItems": [ + "ACTIONS", + "ACTIVE_ACTIONS", + "BROADCAST", + "NLA_TRACKS", + "SCENE" + ], + "identifier": "export_animation_mode", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "export_animations", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_apply", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_armature_object_remove", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_attributes", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_bake_animation", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_cameras", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_convert_animation_pointer", + "type": "BOOLEAN" + }, + { + "identifier": "export_copyright", + "type": "STRING" + }, + { + "arrayLength": 0, + "identifier": "export_current_frame", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_def_bones", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_draco_color_quantization", + "type": "INT" + }, + { + "arrayLength": 0, + "identifier": "export_draco_generic_quantization", + "type": "INT" + }, + { + "arrayLength": 0, + "identifier": "export_draco_mesh_compression_enable", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_draco_mesh_compression_level", + "type": "INT" + }, + { + "arrayLength": 0, + "identifier": "export_draco_normal_quantization", + "type": "INT" + }, + { + "arrayLength": 0, + "identifier": "export_draco_position_quantization", + "type": "INT" + }, + { + "arrayLength": 0, + "identifier": "export_draco_texcoord_quantization", + "type": "INT" + }, + { + "arrayLength": 0, + "identifier": "export_extra_animations", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_extras", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_force_sampling", + "type": "BOOLEAN" + }, + { + "enumItems": [], + "identifier": "export_format", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "export_frame_range", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_frame_step", + "type": "INT" + }, + { + "arrayLength": 0, + "identifier": "export_gltfpack_kn", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_gltfpack_noq", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_gltfpack_sa", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_gltfpack_si", + "type": "FLOAT" + }, + { + "arrayLength": 0, + "identifier": "export_gltfpack_slb", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_gltfpack_tc", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_gltfpack_tq", + "type": "INT" + }, + { + "arrayLength": 0, + "identifier": "export_gltfpack_vc", + "type": "INT" + }, + { + "arrayLength": 0, + "identifier": "export_gltfpack_vn", + "type": "INT" + }, + { + "arrayLength": 0, + "identifier": "export_gltfpack_vp", + "type": "INT" + }, + { + "enumItems": [ + "Integer", + "Normalized", + "Floating-point" + ], + "identifier": "export_gltfpack_vpi", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "export_gltfpack_vt", + "type": "INT" + }, + { + "arrayLength": 0, + "identifier": "export_gn_mesh", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_gpu_instances", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_hierarchy_flatten_bones", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_hierarchy_flatten_objs", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_hierarchy_full_collections", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_image_add_webp", + "type": "BOOLEAN" + }, + { + "enumItems": [ + "AUTO", + "JPEG", + "WEBP", + "NONE" + ], + "identifier": "export_image_format", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "export_image_quality", + "type": "INT" + }, + { + "arrayLength": 0, + "identifier": "export_image_webp_fallback", + "type": "BOOLEAN" + }, + { + "enumItems": [ + "SPEC", + "COMPAT", + "RAW" + ], + "identifier": "export_import_convert_lighting_mode", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "export_influence_nb", + "type": "INT" + }, + { + "arrayLength": 0, + "identifier": "export_jpeg_quality", + "type": "INT" + }, + { + "arrayLength": 0, + "identifier": "export_keep_originals", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_leaf_bone", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_lights", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_loglevel", + "type": "INT" + }, + { + "enumItems": [ + "EXPORT", + "PLACEHOLDER", + "VIEWPORT", + "NONE" + ], + "identifier": "export_materials", + "type": "ENUM" + }, + { + "enumItems": [ + "NLA_TRACK", + "ACTION", + "NONE" + ], + "identifier": "export_merge_animation", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "export_meshopt_compression_enable", + "type": "BOOLEAN" + }, + { + "enumItems": [ + "EXT_meshopt_compression", + "KHR_meshopt_compression" + ], + "identifier": "export_meshopt_extension", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "export_morph", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_morph_animation", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_morph_normal", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_morph_reset_sk_data", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_morph_tangent", + "type": "BOOLEAN" + }, + { + "enumItems": [ + "SLIDE", + "CROP" + ], + "identifier": "export_negative_frame", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "export_nla_strips", + "type": "BOOLEAN" + }, + { + "identifier": "export_nla_strips_merged_animation_name", + "type": "STRING" + }, + { + "arrayLength": 0, + "identifier": "export_normals", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_optimize_animation_keep_anim_armature", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_optimize_animation_keep_anim_object", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_optimize_animation_size", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_optimize_disable_viewport", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_original_specular", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_pointer_animation", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_reset_pose_bones", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_rest_position_armature", + "type": "BOOLEAN" + }, + { + "enumItems": [ + "LINEAR", + "STEP" + ], + "identifier": "export_sampling_interpolation_fallback", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "export_shared_accessors", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_skins", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_tangents", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_texcoords", + "type": "BOOLEAN" + }, + { + "identifier": "export_texture_dir", + "type": "STRING" + }, + { + "arrayLength": 0, + "identifier": "export_try_omit_sparse_sk", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_try_sparse_sk", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_unused_images", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_unused_textures", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_use_gltfpack", + "type": "BOOLEAN" + }, + { + "enumItems": [ + "MATERIAL", + "ACTIVE", + "NAME", + "NONE" + ], + "identifier": "export_vertex_color", + "type": "ENUM" + }, + { + "identifier": "export_vertex_color_name", + "type": "STRING" + }, + { + "arrayLength": 0, + "identifier": "export_yup", + "type": "BOOLEAN" + }, + { + "identifier": "filepath", + "type": "STRING" + }, + { + "identifier": "filter_glob", + "type": "STRING" + }, + { + "identifier": "gltf_export_id", + "type": "STRING" + }, + { + "enumItems": [ + "GENERAL", + "MESHES", + "OBJECTS", + "ANIMATION" + ], + "identifier": "ui_tab", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "use_active_collection", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "use_active_collection_with_nested", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "use_active_scene", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "use_mesh_edges", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "use_mesh_vertices", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "use_renderable", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "use_selection", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "use_visible", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "will_save_settings", + "type": "BOOLEAN" + } + ], + "registered": true, + "rnaIdentifier": "EXPORT_SCENE_OT_gltf", + "runtimeStatus": "AVAILABLE" + }, + "extensions": [ + ".gltf" + ], + "family": "GLTF", + "format": "GLTF", + "import": { + "buildOption": null, + "buildOptionEnabled": true, + "operator": "import_scene.gltf", + "properties": [ + { + "enumItems": [ + "BLENDER", + "TEMPERANCE", + "FORTUNE" + ], + "identifier": "bone_heuristic", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "bone_shape_scale_factor", + "type": "FLOAT" + }, + { + "identifier": "directory", + "type": "STRING" + }, + { + "arrayLength": 0, + "identifier": "disable_bone_shape", + "type": "BOOLEAN" + }, + { + "enumItems": [ + "SPEC", + "COMPAT", + "RAW" + ], + "identifier": "export_import_convert_lighting_mode", + "type": "ENUM" + }, + { + "identifier": "filepath", + "type": "STRING" + }, + { + "identifier": "files", + "type": "COLLECTION" + }, + { + "identifier": "filter_glob", + "type": "STRING" + }, + { + "arrayLength": 0, + "identifier": "guess_original_bind_pose", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "import_merge_material_slots", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "import_pack_images", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "import_point_as_pointcloud", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "import_scene_as_collection", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "import_scene_extras", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "import_select_created_objects", + "type": "BOOLEAN" + }, + { + "enumItems": [ + "NORMALS", + "FLAT", + "SMOOTH" + ], + "identifier": "import_shading", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "import_unused_materials", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "import_webp_texture", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "loglevel", + "type": "INT" + }, + { + "arrayLength": 0, + "identifier": "merge_vertices", + "type": "BOOLEAN" + } + ], + "registered": true, + "rnaIdentifier": "IMPORT_SCENE_OT_gltf", + "runtimeStatus": "AVAILABLE" + }, + "variants": [ + "GLTF_SEPARATE" + ] + }, + { + "export": { + "buildOption": null, + "buildOptionEnabled": true, + "operator": "export_scene.gltf", + "properties": [ + { + "arrayLength": 0, + "identifier": "at_collection_center", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "check_existing", + "type": "BOOLEAN" + }, + { + "identifier": "collection", + "type": "STRING" + }, + { + "arrayLength": 0, + "identifier": "export_action_filter", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_active_vertex_color_when_no_material", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_all_influences", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_all_vertex_colors", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_anim_scene_split_object", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_anim_single_armature", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_anim_slide_to_zero", + "type": "BOOLEAN" + }, + { + "enumItems": [ + "ACTIONS", + "ACTIVE_ACTIONS", + "BROADCAST", + "NLA_TRACKS", + "SCENE" + ], + "identifier": "export_animation_mode", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "export_animations", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_apply", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_armature_object_remove", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_attributes", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_bake_animation", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_cameras", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_convert_animation_pointer", + "type": "BOOLEAN" + }, + { + "identifier": "export_copyright", + "type": "STRING" + }, + { + "arrayLength": 0, + "identifier": "export_current_frame", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_def_bones", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_draco_color_quantization", + "type": "INT" + }, + { + "arrayLength": 0, + "identifier": "export_draco_generic_quantization", + "type": "INT" + }, + { + "arrayLength": 0, + "identifier": "export_draco_mesh_compression_enable", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_draco_mesh_compression_level", + "type": "INT" + }, + { + "arrayLength": 0, + "identifier": "export_draco_normal_quantization", + "type": "INT" + }, + { + "arrayLength": 0, + "identifier": "export_draco_position_quantization", + "type": "INT" + }, + { + "arrayLength": 0, + "identifier": "export_draco_texcoord_quantization", + "type": "INT" + }, + { + "arrayLength": 0, + "identifier": "export_extra_animations", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_extras", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_force_sampling", + "type": "BOOLEAN" + }, + { + "enumItems": [], + "identifier": "export_format", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "export_frame_range", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_frame_step", + "type": "INT" + }, + { + "arrayLength": 0, + "identifier": "export_gltfpack_kn", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_gltfpack_noq", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_gltfpack_sa", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_gltfpack_si", + "type": "FLOAT" + }, + { + "arrayLength": 0, + "identifier": "export_gltfpack_slb", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_gltfpack_tc", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_gltfpack_tq", + "type": "INT" + }, + { + "arrayLength": 0, + "identifier": "export_gltfpack_vc", + "type": "INT" + }, + { + "arrayLength": 0, + "identifier": "export_gltfpack_vn", + "type": "INT" + }, + { + "arrayLength": 0, + "identifier": "export_gltfpack_vp", + "type": "INT" + }, + { + "enumItems": [ + "Integer", + "Normalized", + "Floating-point" + ], + "identifier": "export_gltfpack_vpi", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "export_gltfpack_vt", + "type": "INT" + }, + { + "arrayLength": 0, + "identifier": "export_gn_mesh", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_gpu_instances", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_hierarchy_flatten_bones", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_hierarchy_flatten_objs", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_hierarchy_full_collections", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_image_add_webp", + "type": "BOOLEAN" + }, + { + "enumItems": [ + "AUTO", + "JPEG", + "WEBP", + "NONE" + ], + "identifier": "export_image_format", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "export_image_quality", + "type": "INT" + }, + { + "arrayLength": 0, + "identifier": "export_image_webp_fallback", + "type": "BOOLEAN" + }, + { + "enumItems": [ + "SPEC", + "COMPAT", + "RAW" + ], + "identifier": "export_import_convert_lighting_mode", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "export_influence_nb", + "type": "INT" + }, + { + "arrayLength": 0, + "identifier": "export_jpeg_quality", + "type": "INT" + }, + { + "arrayLength": 0, + "identifier": "export_keep_originals", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_leaf_bone", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_lights", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_loglevel", + "type": "INT" + }, + { + "enumItems": [ + "EXPORT", + "PLACEHOLDER", + "VIEWPORT", + "NONE" + ], + "identifier": "export_materials", + "type": "ENUM" + }, + { + "enumItems": [ + "NLA_TRACK", + "ACTION", + "NONE" + ], + "identifier": "export_merge_animation", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "export_meshopt_compression_enable", + "type": "BOOLEAN" + }, + { + "enumItems": [ + "EXT_meshopt_compression", + "KHR_meshopt_compression" + ], + "identifier": "export_meshopt_extension", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "export_morph", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_morph_animation", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_morph_normal", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_morph_reset_sk_data", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_morph_tangent", + "type": "BOOLEAN" + }, + { + "enumItems": [ + "SLIDE", + "CROP" + ], + "identifier": "export_negative_frame", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "export_nla_strips", + "type": "BOOLEAN" + }, + { + "identifier": "export_nla_strips_merged_animation_name", + "type": "STRING" + }, + { + "arrayLength": 0, + "identifier": "export_normals", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_optimize_animation_keep_anim_armature", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_optimize_animation_keep_anim_object", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_optimize_animation_size", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_optimize_disable_viewport", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_original_specular", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_pointer_animation", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_reset_pose_bones", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_rest_position_armature", + "type": "BOOLEAN" + }, + { + "enumItems": [ + "LINEAR", + "STEP" + ], + "identifier": "export_sampling_interpolation_fallback", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "export_shared_accessors", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_skins", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_tangents", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_texcoords", + "type": "BOOLEAN" + }, + { + "identifier": "export_texture_dir", + "type": "STRING" + }, + { + "arrayLength": 0, + "identifier": "export_try_omit_sparse_sk", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_try_sparse_sk", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_unused_images", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_unused_textures", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_use_gltfpack", + "type": "BOOLEAN" + }, + { + "enumItems": [ + "MATERIAL", + "ACTIVE", + "NAME", + "NONE" + ], + "identifier": "export_vertex_color", + "type": "ENUM" + }, + { + "identifier": "export_vertex_color_name", + "type": "STRING" + }, + { + "arrayLength": 0, + "identifier": "export_yup", + "type": "BOOLEAN" + }, + { + "identifier": "filepath", + "type": "STRING" + }, + { + "identifier": "filter_glob", + "type": "STRING" + }, + { + "identifier": "gltf_export_id", + "type": "STRING" + }, + { + "enumItems": [ + "GENERAL", + "MESHES", + "OBJECTS", + "ANIMATION" + ], + "identifier": "ui_tab", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "use_active_collection", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "use_active_collection_with_nested", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "use_active_scene", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "use_mesh_edges", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "use_mesh_vertices", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "use_renderable", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "use_selection", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "use_visible", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "will_save_settings", + "type": "BOOLEAN" + } + ], + "registered": true, + "rnaIdentifier": "EXPORT_SCENE_OT_gltf", + "runtimeStatus": "AVAILABLE" + }, + "extensions": [ + ".glb" + ], + "family": "GLTF", + "format": "GLB", + "import": { + "buildOption": null, + "buildOptionEnabled": true, + "operator": "import_scene.gltf", + "properties": [ + { + "enumItems": [ + "BLENDER", + "TEMPERANCE", + "FORTUNE" + ], + "identifier": "bone_heuristic", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "bone_shape_scale_factor", + "type": "FLOAT" + }, + { + "identifier": "directory", + "type": "STRING" + }, + { + "arrayLength": 0, + "identifier": "disable_bone_shape", + "type": "BOOLEAN" + }, + { + "enumItems": [ + "SPEC", + "COMPAT", + "RAW" + ], + "identifier": "export_import_convert_lighting_mode", + "type": "ENUM" + }, + { + "identifier": "filepath", + "type": "STRING" + }, + { + "identifier": "files", + "type": "COLLECTION" + }, + { + "identifier": "filter_glob", + "type": "STRING" + }, + { + "arrayLength": 0, + "identifier": "guess_original_bind_pose", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "import_merge_material_slots", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "import_pack_images", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "import_point_as_pointcloud", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "import_scene_as_collection", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "import_scene_extras", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "import_select_created_objects", + "type": "BOOLEAN" + }, + { + "enumItems": [ + "NORMALS", + "FLAT", + "SMOOTH" + ], + "identifier": "import_shading", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "import_unused_materials", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "import_webp_texture", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "loglevel", + "type": "INT" + }, + { + "arrayLength": 0, + "identifier": "merge_vertices", + "type": "BOOLEAN" + } + ], + "registered": true, + "rnaIdentifier": "IMPORT_SCENE_OT_gltf", + "runtimeStatus": "AVAILABLE" + }, + "variants": [ + "GLB" + ] + }, + { + "export": { + "buildOption": "io_wavefront_obj", + "buildOptionEnabled": true, + "operator": "wm.obj_export", + "properties": [ + { + "arrayLength": 0, + "identifier": "apply_modifiers", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "apply_transform", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "check_existing", + "type": "BOOLEAN" + }, + { + "identifier": "collection", + "type": "STRING" + }, + { + "enumItems": [ + "DEFAULT", + "LIST_VERTICAL", + "LIST_HORIZONTAL", + "THUMBNAIL" + ], + "identifier": "display_type", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "end_frame", + "type": "INT" + }, + { + "arrayLength": 0, + "identifier": "export_animation", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_colors", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_curves_as_nurbs", + "type": "BOOLEAN" + }, + { + "enumItems": [ + "DAG_EVAL_RENDER", + "DAG_EVAL_VIEWPORT" + ], + "identifier": "export_eval_mode", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "export_material_groups", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_materials", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_normals", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_object_groups", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_pbr_extensions", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_selected_objects", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_smooth_groups", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_triangulated_mesh", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_uv", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_vertex_groups", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filemode", + "type": "INT" + }, + { + "identifier": "filepath", + "type": "STRING" + }, + { + "arrayLength": 0, + "identifier": "filter_alembic", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_archive", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_backup", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_blender", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_blenlib", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_btx", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_folder", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_font", + "type": "BOOLEAN" + }, + { + "identifier": "filter_glob", + "type": "STRING" + }, + { + "arrayLength": 0, + "identifier": "filter_image", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_movie", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_obj", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_python", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_sound", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_text", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_usd", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_volume", + "type": "BOOLEAN" + }, + { + "enumItems": [ + "X", + "Y", + "Z", + "NEGATIVE_X", + "NEGATIVE_Y", + "NEGATIVE_Z" + ], + "identifier": "forward_axis", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "global_scale", + "type": "FLOAT" + }, + { + "enumItems": [ + "AUTO", + "ABSOLUTE", + "RELATIVE", + "MATCH", + "STRIP", + "COPY" + ], + "identifier": "path_mode", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "smooth_group_bitflags", + "type": "BOOLEAN" + }, + { + "enumItems": [], + "identifier": "sort_method", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "start_frame", + "type": "INT" + }, + { + "enumItems": [ + "X", + "Y", + "Z", + "NEGATIVE_X", + "NEGATIVE_Y", + "NEGATIVE_Z" + ], + "identifier": "up_axis", + "type": "ENUM" + } + ], + "registered": true, + "rnaIdentifier": "WM_OT_obj_export", + "runtimeStatus": "AVAILABLE" + }, + "extensions": [ + ".obj" + ], + "family": "OBJ", + "format": "OBJ", + "import": { + "buildOption": "io_wavefront_obj", + "buildOptionEnabled": true, + "operator": "wm.obj_import", + "properties": [ + { + "arrayLength": 0, + "identifier": "check_existing", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "clamp_size", + "type": "FLOAT" + }, + { + "arrayLength": 0, + "identifier": "close_spline_loops", + "type": "BOOLEAN" + }, + { + "identifier": "collection_separator", + "type": "STRING" + }, + { + "identifier": "directory", + "type": "STRING" + }, + { + "enumItems": [ + "DEFAULT", + "LIST_VERTICAL", + "LIST_HORIZONTAL", + "THUMBNAIL" + ], + "identifier": "display_type", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "filemode", + "type": "INT" + }, + { + "identifier": "filepath", + "type": "STRING" + }, + { + "identifier": "files", + "type": "COLLECTION" + }, + { + "arrayLength": 0, + "identifier": "filter_alembic", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_archive", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_backup", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_blender", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_blenlib", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_btx", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_folder", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_font", + "type": "BOOLEAN" + }, + { + "identifier": "filter_glob", + "type": "STRING" + }, + { + "arrayLength": 0, + "identifier": "filter_image", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_movie", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_obj", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_python", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_sound", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_text", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_usd", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_volume", + "type": "BOOLEAN" + }, + { + "enumItems": [ + "X", + "Y", + "Z", + "NEGATIVE_X", + "NEGATIVE_Y", + "NEGATIVE_Z" + ], + "identifier": "forward_axis", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "global_scale", + "type": "FLOAT" + }, + { + "arrayLength": 0, + "identifier": "import_vertex_groups", + "type": "BOOLEAN" + }, + { + "enumItems": [ + "MAKE_UNIQUE", + "REFERENCE_EXISTING" + ], + "identifier": "mtl_name_collision_mode", + "type": "ENUM" + }, + { + "enumItems": [], + "identifier": "sort_method", + "type": "ENUM" + }, + { + "enumItems": [ + "X", + "Y", + "Z", + "NEGATIVE_X", + "NEGATIVE_Y", + "NEGATIVE_Z" + ], + "identifier": "up_axis", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "use_split_groups", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "use_split_objects", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "validate_meshes", + "type": "BOOLEAN" + } + ], + "registered": true, + "rnaIdentifier": "WM_OT_obj_import", + "runtimeStatus": "AVAILABLE" + }, + "variants": [ + "OBJ" + ] + }, + { + "export": { + "buildOption": "io_stl", + "buildOptionEnabled": true, + "operator": "wm.stl_export", + "properties": [ + { + "arrayLength": 0, + "identifier": "apply_modifiers", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "ascii_format", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "check_existing", + "type": "BOOLEAN" + }, + { + "identifier": "collection", + "type": "STRING" + }, + { + "enumItems": [ + "DEFAULT", + "LIST_VERTICAL", + "LIST_HORIZONTAL", + "THUMBNAIL" + ], + "identifier": "display_type", + "type": "ENUM" + }, + { + "enumItems": [ + "DAG_EVAL_RENDER", + "DAG_EVAL_VIEWPORT" + ], + "identifier": "evaluation_mode", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "export_selected_objects", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filemode", + "type": "INT" + }, + { + "identifier": "filepath", + "type": "STRING" + }, + { + "arrayLength": 0, + "identifier": "filter_alembic", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_archive", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_backup", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_blender", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_blenlib", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_btx", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_folder", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_font", + "type": "BOOLEAN" + }, + { + "identifier": "filter_glob", + "type": "STRING" + }, + { + "arrayLength": 0, + "identifier": "filter_image", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_movie", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_obj", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_python", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_sound", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_text", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_usd", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_volume", + "type": "BOOLEAN" + }, + { + "enumItems": [ + "X", + "Y", + "Z", + "NEGATIVE_X", + "NEGATIVE_Y", + "NEGATIVE_Z" + ], + "identifier": "forward_axis", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "global_scale", + "type": "FLOAT" + }, + { + "enumItems": [], + "identifier": "sort_method", + "type": "ENUM" + }, + { + "enumItems": [ + "X", + "Y", + "Z", + "NEGATIVE_X", + "NEGATIVE_Y", + "NEGATIVE_Z" + ], + "identifier": "up_axis", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "use_batch", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "use_scene_unit", + "type": "BOOLEAN" + } + ], + "registered": true, + "rnaIdentifier": "WM_OT_stl_export", + "runtimeStatus": "AVAILABLE" + }, + "extensions": [ + ".stl" + ], + "family": "STL", + "format": "STL", + "import": { + "buildOption": "io_stl", + "buildOptionEnabled": true, + "operator": "wm.stl_import", + "properties": [ + { + "arrayLength": 0, + "identifier": "check_existing", + "type": "BOOLEAN" + }, + { + "identifier": "directory", + "type": "STRING" + }, + { + "enumItems": [ + "DEFAULT", + "LIST_VERTICAL", + "LIST_HORIZONTAL", + "THUMBNAIL" + ], + "identifier": "display_type", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "filemode", + "type": "INT" + }, + { + "identifier": "filepath", + "type": "STRING" + }, + { + "identifier": "files", + "type": "COLLECTION" + }, + { + "arrayLength": 0, + "identifier": "filter_alembic", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_archive", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_backup", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_blender", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_blenlib", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_btx", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_folder", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_font", + "type": "BOOLEAN" + }, + { + "identifier": "filter_glob", + "type": "STRING" + }, + { + "arrayLength": 0, + "identifier": "filter_image", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_movie", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_obj", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_python", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_sound", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_text", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_usd", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_volume", + "type": "BOOLEAN" + }, + { + "enumItems": [ + "X", + "Y", + "Z", + "NEGATIVE_X", + "NEGATIVE_Y", + "NEGATIVE_Z" + ], + "identifier": "forward_axis", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "global_scale", + "type": "FLOAT" + }, + { + "enumItems": [], + "identifier": "sort_method", + "type": "ENUM" + }, + { + "enumItems": [ + "X", + "Y", + "Z", + "NEGATIVE_X", + "NEGATIVE_Y", + "NEGATIVE_Z" + ], + "identifier": "up_axis", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "use_facet_normal", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "use_mesh_validate", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "use_scene_unit", + "type": "BOOLEAN" + } + ], + "registered": true, + "rnaIdentifier": "WM_OT_stl_import", + "runtimeStatus": "AVAILABLE" + }, + "variants": [ + "STL_BINARY", + "STL_ASCII" + ] + }, + { + "export": { + "buildOption": "io_ply", + "buildOptionEnabled": true, + "operator": "wm.ply_export", + "properties": [ + { + "arrayLength": 0, + "identifier": "apply_modifiers", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "ascii_format", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "check_existing", + "type": "BOOLEAN" + }, + { + "identifier": "collection", + "type": "STRING" + }, + { + "enumItems": [ + "DEFAULT", + "LIST_VERTICAL", + "LIST_HORIZONTAL", + "THUMBNAIL" + ], + "identifier": "display_type", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "export_attributes", + "type": "BOOLEAN" + }, + { + "enumItems": [ + "NONE", + "SRGB", + "LINEAR" + ], + "identifier": "export_colors", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "export_normals", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_selected_objects", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_triangulated_mesh", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "export_uv", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filemode", + "type": "INT" + }, + { + "identifier": "filepath", + "type": "STRING" + }, + { + "arrayLength": 0, + "identifier": "filter_alembic", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_archive", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_backup", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_blender", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_blenlib", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_btx", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_folder", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_font", + "type": "BOOLEAN" + }, + { + "identifier": "filter_glob", + "type": "STRING" + }, + { + "arrayLength": 0, + "identifier": "filter_image", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_movie", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_obj", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_python", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_sound", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_text", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_usd", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_volume", + "type": "BOOLEAN" + }, + { + "enumItems": [ + "X", + "Y", + "Z", + "NEGATIVE_X", + "NEGATIVE_Y", + "NEGATIVE_Z" + ], + "identifier": "forward_axis", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "global_scale", + "type": "FLOAT" + }, + { + "enumItems": [], + "identifier": "sort_method", + "type": "ENUM" + }, + { + "enumItems": [ + "X", + "Y", + "Z", + "NEGATIVE_X", + "NEGATIVE_Y", + "NEGATIVE_Z" + ], + "identifier": "up_axis", + "type": "ENUM" + } + ], + "registered": true, + "rnaIdentifier": "WM_OT_ply_export", + "runtimeStatus": "AVAILABLE" + }, + "extensions": [ + ".ply" + ], + "family": "PLY", + "format": "PLY", + "import": { + "buildOption": "io_ply", + "buildOptionEnabled": true, + "operator": "wm.ply_import", + "properties": [ + { + "arrayLength": 0, + "identifier": "check_existing", + "type": "BOOLEAN" + }, + { + "identifier": "directory", + "type": "STRING" + }, + { + "enumItems": [ + "DEFAULT", + "LIST_VERTICAL", + "LIST_HORIZONTAL", + "THUMBNAIL" + ], + "identifier": "display_type", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "filemode", + "type": "INT" + }, + { + "identifier": "filepath", + "type": "STRING" + }, + { + "identifier": "files", + "type": "COLLECTION" + }, + { + "arrayLength": 0, + "identifier": "filter_alembic", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_archive", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_backup", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_blender", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_blenlib", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_btx", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_folder", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_font", + "type": "BOOLEAN" + }, + { + "identifier": "filter_glob", + "type": "STRING" + }, + { + "arrayLength": 0, + "identifier": "filter_image", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_movie", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_obj", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_python", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_sound", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_text", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_usd", + "type": "BOOLEAN" + }, + { + "arrayLength": 0, + "identifier": "filter_volume", + "type": "BOOLEAN" + }, + { + "enumItems": [ + "X", + "Y", + "Z", + "NEGATIVE_X", + "NEGATIVE_Y", + "NEGATIVE_Z" + ], + "identifier": "forward_axis", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "global_scale", + "type": "FLOAT" + }, + { + "arrayLength": 0, + "identifier": "import_attributes", + "type": "BOOLEAN" + }, + { + "enumItems": [ + "NONE", + "SRGB", + "LINEAR" + ], + "identifier": "import_colors", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "merge_verts", + "type": "BOOLEAN" + }, + { + "enumItems": [], + "identifier": "sort_method", + "type": "ENUM" + }, + { + "enumItems": [ + "X", + "Y", + "Z", + "NEGATIVE_X", + "NEGATIVE_Y", + "NEGATIVE_Z" + ], + "identifier": "up_axis", + "type": "ENUM" + }, + { + "arrayLength": 0, + "identifier": "use_scene_unit", + "type": "BOOLEAN" + } + ], + "registered": true, + "rnaIdentifier": "WM_OT_ply_import", + "runtimeStatus": "AVAILABLE" + }, + "variants": [ + "PLY" + ] + }, + { + "export": { + "buildOption": "usd", + "buildOptionEnabled": null, + "error": "KeyError", + "operator": "wm.usd_export", + "properties": [], + "registered": false, + "rnaIdentifier": null, + "runtimeStatus": "OPERATOR_UNREGISTERED" + }, + "extensions": [ + ".usd", + ".usda", + ".usdc", + ".usdz" + ], + "family": "USD", + "format": "USD", + "import": { + "buildOption": "usd", + "buildOptionEnabled": null, + "error": "KeyError", + "operator": "wm.usd_import", + "properties": [], + "registered": false, + "rnaIdentifier": null, + "runtimeStatus": "OPERATOR_UNREGISTERED" + }, + "variants": [ + "USD", + "USDA", + "USDC", + "USDZ" + ] + }, + { + "export": { + "buildOption": "alembic", + "buildOptionEnabled": null, + "error": "KeyError", + "operator": "wm.alembic_export", + "properties": [], + "registered": false, + "rnaIdentifier": null, + "runtimeStatus": "OPERATOR_UNREGISTERED" + }, + "extensions": [ + ".abc" + ], + "family": "ALEMBIC", + "format": "ALEMBIC", + "import": { + "buildOption": "alembic", + "buildOptionEnabled": null, + "error": "KeyError", + "operator": "wm.alembic_import", + "properties": [], + "registered": false, + "rnaIdentifier": null, + "runtimeStatus": "OPERATOR_UNREGISTERED" + }, + "variants": [ + "ALEMBIC" + ] + } + ], + "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 + ] + }, + "schemaVersion": 1, + "task": "M12-05A" +} diff --git a/tests/golden/M12-05A/manifest.json b/tests/golden/M12-05A/manifest.json new file mode 100644 index 00000000..fa59980a --- /dev/null +++ b/tests/golden/M12-05A/manifest.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "task": "M12-05A", + "parentTask": "M12-04J", + "enablingTask": false, + "parityStateChange": false, + "runtime": "BLENDER_5_2_RUNTIME", + "operation": "IO_FORMAT_RUNTIME_INVENTORY", + "assertions": { + "blenderVersion": "5.2.0 LTS", + "formatCount": 7, + "formats": ["GLTF", "GLB", "OBJ", "STL", "PLY", "USD", "ALEMBIC"], + "availableFormats": ["GLTF", "GLB", "OBJ", "STL", "PLY"], + "buildDisabledFormats": ["USD", "ALEMBIC"], + "runtimeReceipt": true, + "deterministicRegeneration": true + }, + "artifacts": { + "parentManifest": { "path": "tests/golden/M12-04J/manifest.json", "sha256": "989d417ab44e165849c7054f331f7038ec1d779349c39dd4f5b30050bbaecf56" }, + "generator": { "path": "tools/web/generate-io-format-runtime-inventory.py", "sha256": "aa926397664240a5195f8864fc3ed5549bafcc963a03c513c7e37926b0559d7d" }, + "checker": { "path": "tools/web/check-io-format-runtime-inventory.mjs", "sha256": "12853306ea5eb2698ab636c7dfc2f27ae140295641473c57b84f93fa13d4864e" }, + "inventory": { "path": "tests/golden/M12-05A/format-inventory.json", "sha256": "0d660b0fd8b647ebbf4e91afebd5006bd100973ab8e2507a5bfe759f477b33b4" }, + "package": { "path": "web/package.json", "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" } + }, + "nextTask": "M12-05B" +} diff --git a/tests/golden/M12-05B/capability-matrix.json b/tests/golden/M12-05B/capability-matrix.json new file mode 100644 index 00000000..c5a59b8b --- /dev/null +++ b/tests/golden/M12-05B/capability-matrix.json @@ -0,0 +1,392 @@ +{ + "schemaVersion": 1, + "task": "M12-05B", + "runtimeInventorySha256": "0d660b0fd8b647ebbf4e91afebd5006bd100973ab8e2507a5bfe759f477b33b4", + "formats": [ + { + "format": "GLTF", + "runtimeImportStatus": "AVAILABLE", + "runtimeExportStatus": "AVAILABLE", + "operations": { + "IMPORT": { + "local": { + "status": "BLOCKED", + "execution": "NONE", + "code": "IO_FORMAT_UNSUPPORTED" + }, + "server": { + "status": "BLOCKED", + "execution": "NONE", + "code": "IO_FORMAT_UNSUPPORTED" + }, + "geometry": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + }, + "material": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + }, + "animation": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + } + }, + "EXPORT": { + "local": { + "status": "BLOCKED", + "execution": "NONE", + "code": "IO_FORMAT_UNSUPPORTED" + }, + "server": { + "status": "BLOCKED", + "execution": "NONE", + "code": "IO_FORMAT_UNSUPPORTED" + }, + "geometry": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + }, + "material": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + }, + "animation": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + } + } + } + }, + { + "format": "GLB", + "runtimeImportStatus": "AVAILABLE", + "runtimeExportStatus": "AVAILABLE", + "operations": { + "IMPORT": { + "local": { + "status": "BLOCKED", + "execution": "NONE", + "code": "IO_FORMAT_UNSUPPORTED" + }, + "server": { + "status": "BLOCKED", + "execution": "NONE", + "code": "IO_FORMAT_UNSUPPORTED" + }, + "geometry": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + }, + "material": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + }, + "animation": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + } + }, + "EXPORT": { + "local": { + "status": "READY", + "execution": "LOCAL", + "code": null + }, + "server": { + "status": "BLOCKED", + "execution": "NONE", + "code": "IO_FORMAT_UNSUPPORTED" + }, + "geometry": { + "status": "PARTIAL", + "evidence": "bounded GLB export gate exists; full format round-trip remains M12-06" + }, + "material": { + "status": "PARTIAL", + "evidence": "bounded GLB export gate exists; full format round-trip remains M12-06" + }, + "animation": { + "status": "PARTIAL", + "evidence": "bounded GLB export gate exists; full format round-trip remains M12-06" + } + } + } + }, + { + "format": "OBJ", + "runtimeImportStatus": "AVAILABLE", + "runtimeExportStatus": "AVAILABLE", + "operations": { + "IMPORT": { + "local": { + "status": "BLOCKED", + "execution": "NONE", + "code": "IO_FORMAT_UNSUPPORTED" + }, + "server": { + "status": "BLOCKED", + "execution": "NONE", + "code": "IO_FORMAT_UNSUPPORTED" + }, + "geometry": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + }, + "material": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + }, + "animation": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + } + }, + "EXPORT": { + "local": { + "status": "BLOCKED", + "execution": "NONE", + "code": "IO_FORMAT_UNSUPPORTED" + }, + "server": { + "status": "BLOCKED", + "execution": "NONE", + "code": "IO_FORMAT_UNSUPPORTED" + }, + "geometry": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + }, + "material": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + }, + "animation": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + } + } + } + }, + { + "format": "STL", + "runtimeImportStatus": "AVAILABLE", + "runtimeExportStatus": "AVAILABLE", + "operations": { + "IMPORT": { + "local": { + "status": "BLOCKED", + "execution": "NONE", + "code": "IO_FORMAT_UNSUPPORTED" + }, + "server": { + "status": "BLOCKED", + "execution": "NONE", + "code": "IO_FORMAT_UNSUPPORTED" + }, + "geometry": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + }, + "material": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + }, + "animation": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + } + }, + "EXPORT": { + "local": { + "status": "BLOCKED", + "execution": "NONE", + "code": "IO_FORMAT_UNSUPPORTED" + }, + "server": { + "status": "BLOCKED", + "execution": "NONE", + "code": "IO_FORMAT_UNSUPPORTED" + }, + "geometry": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + }, + "material": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + }, + "animation": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + } + } + } + }, + { + "format": "PLY", + "runtimeImportStatus": "AVAILABLE", + "runtimeExportStatus": "AVAILABLE", + "operations": { + "IMPORT": { + "local": { + "status": "BLOCKED", + "execution": "NONE", + "code": "IO_FORMAT_UNSUPPORTED" + }, + "server": { + "status": "BLOCKED", + "execution": "NONE", + "code": "IO_FORMAT_UNSUPPORTED" + }, + "geometry": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + }, + "material": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + }, + "animation": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + } + }, + "EXPORT": { + "local": { + "status": "BLOCKED", + "execution": "NONE", + "code": "IO_FORMAT_UNSUPPORTED" + }, + "server": { + "status": "BLOCKED", + "execution": "NONE", + "code": "IO_FORMAT_UNSUPPORTED" + }, + "geometry": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + }, + "material": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + }, + "animation": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + } + } + } + }, + { + "format": "USD", + "runtimeImportStatus": "OPERATOR_UNREGISTERED", + "runtimeExportStatus": "OPERATOR_UNREGISTERED", + "operations": { + "IMPORT": { + "local": { + "status": "BLOCKED", + "execution": "NONE", + "code": "IO_FORMAT_UNSUPPORTED" + }, + "server": { + "status": "BLOCKED", + "execution": "NONE", + "code": "IO_FORMAT_UNSUPPORTED" + }, + "geometry": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + }, + "material": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + }, + "animation": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + } + }, + "EXPORT": { + "local": { + "status": "BLOCKED", + "execution": "NONE", + "code": "IO_FORMAT_UNSUPPORTED" + }, + "server": { + "status": "BLOCKED", + "execution": "NONE", + "code": "IO_FORMAT_UNSUPPORTED" + }, + "geometry": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + }, + "material": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + }, + "animation": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + } + } + } + }, + { + "format": "ALEMBIC", + "runtimeImportStatus": "OPERATOR_UNREGISTERED", + "runtimeExportStatus": "OPERATOR_UNREGISTERED", + "operations": { + "IMPORT": { + "local": { + "status": "BLOCKED", + "execution": "NONE", + "code": "IO_FORMAT_UNSUPPORTED" + }, + "server": { + "status": "BLOCKED", + "execution": "NONE", + "code": "IO_FORMAT_UNSUPPORTED" + }, + "geometry": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + }, + "material": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + }, + "animation": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + } + }, + "EXPORT": { + "local": { + "status": "BLOCKED", + "execution": "NONE", + "code": "IO_FORMAT_UNSUPPORTED" + }, + "server": { + "status": "BLOCKED", + "execution": "NONE", + "code": "IO_FORMAT_UNSUPPORTED" + }, + "geometry": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + }, + "material": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + }, + "animation": { + "status": "UNVERIFIED", + "evidence": "no verified Web executor for this format/operation" + } + } + } + } + ] +} diff --git a/tests/golden/M12-05B/manifest.json b/tests/golden/M12-05B/manifest.json new file mode 100644 index 00000000..c4e839be --- /dev/null +++ b/tests/golden/M12-05B/manifest.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": 1, + "task": "M12-05B", + "parentTask": "M12-05A", + "enablingTask": false, + "parityStateChange": false, + "runtime": "WEB_CAPABILITY_MATRIX_PROTOCOL", + "operation": "IO_FORMAT_CAPABILITY_MATRIX", + "assertions": { + "formatCount": 7, + "localGlbExportReady": true, + "blockedImportRoutes": 7, + "blockedServerRoutes": 14, + "unverifiedUnimplementedFeatures": true, + "runtimeBinding": true + }, + "artifacts": { + "parentManifest": { "path": "tests/golden/M12-05A/manifest.json", "sha256": "202b144c91f8e2c39477e257aeb26f9bd0b1b05b459ff8a246668024e94deec7" }, + "protocol": { "path": "web/protocol/io-format-capability-matrix.ts", "sha256": "3063ae5e45f5b1642d329aa554187d121998d816a5a6740a2b9662f00c3c5738" }, + "generator": { "path": "tools/web/generate-io-format-capability-matrix.mjs", "sha256": "746078caaf29fa5aa2281b901b9ea5fc66f9a935eb3f6e282d4fbfb018d4c390" }, + "checker": { "path": "tools/web/check-io-format-capability-matrix.mjs", "sha256": "4a8b35cd7f87238c13627a00c3bb0b34c130fc34d597773574efac7d8909b804" }, + "unit": { "path": "web/tests/unit/io-format-capability-matrix.test.mjs", "sha256": "0cc4d8f1df1ecdab20d8100cf5f12b44cb2a68bca4384ef7964a032b2f74b36e" }, + "matrix": { "path": "tests/golden/M12-05B/capability-matrix.json", "sha256": "139c9d764736da176b32414ecda840c07eb0ba5f3864da2dc08ce04bae161366" }, + "package": { "path": "web/package.json", "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" } + }, + "nextTask": "M12-05C" +} diff --git a/tests/golden/M12-05C/manifest.json b/tests/golden/M12-05C/manifest.json new file mode 100644 index 00000000..7d151005 --- /dev/null +++ b/tests/golden/M12-05C/manifest.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": 1, + "task": "M12-05C", + "parentTask": "M12-05B", + "enablingTask": false, + "parityStateChange": false, + "runtime": "WEB_IO_FORMAT_UI_CAPABILITY_GATE", + "operation": "IO_FORMAT_UI_GATE", + "assertions": { + "matrixBound": true, + "projectFileAccept": ".blend,application/octet-stream", + "importRoutes": 0, + "localExportRoutes": 1, + "blockedRoutesHidden": true, + "operatorSearchUsesRegistry": true, + "unsupportedSelectionFailClosed": true + }, + "artifacts": { + "parentManifest": { "path": "tests/golden/M12-05B/manifest.json", "sha256": "8cc9517c9f25138c351bc5a79efe3b1ce6d9f6663d3b7c14397c5e4e8f11181a" }, + "protocol": { "path": "web/protocol/io-format-ui-gate.ts", "sha256": "fc397ceb947d4ede6c34c1d51438253a6b59244fc40f5eb77ce155bf1c477476" }, + "generator": { "path": "tools/web/generate-io-format-ui-gate.mjs", "sha256": "1ef4ae8a3e8d2f73101a87cba1c42ea99a065e1f6846f6a591841b97c0c39e6f" }, + "checker": { "path": "tools/web/check-io-format-ui-gate.mjs", "sha256": "81d6f27df26aab7b633fbe61c6d7b37519668aff41e43314924dceaacb3affde" }, + "unit": { "path": "web/tests/unit/io-format-ui-gate.test.mjs", "sha256": "84ca8fb6022da9f167daa104c44489a6c7d9f059699e57ce621b8d7f64f19082" }, + "chromium": { "path": "web/tests/e2e/io-format-ui-gate.spec.ts", "sha256": "eda2cdb9ede3bcbd717800c9c6fdb28d8cebef96f0296a2ee847a05e60cd0eed" }, + "registry": { "path": "web/app/src/capabilities/io-format-ui-registry.json", "sha256": "6b410bc6f2cad032af55bf13e1c8ddd841b01e9913ffc0ba381da8b7204285fd" }, + "package": { "path": "web/package.json", "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" } + }, + "nextTask": "M12-05D" +} diff --git a/tests/golden/M12-05D/manifest.json b/tests/golden/M12-05D/manifest.json new file mode 100644 index 00000000..5ad5476e --- /dev/null +++ b/tests/golden/M12-05D/manifest.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 1, + "task": "M12-05D", + "parentTask": "M12-05C", + "enablingTask": false, + "parityStateChange": false, + "runtime": "BLENDER_5_2_RUNTIME_RECEIPT_GATE", + "operation": "IO_FORMAT_RUNTIME_RECEIPT", + "assertions": { + "inventoryBound": true, + "formatCount": 7, + "receiptCount": 14, + "glbExport": "READY", + "usdExport": "BLOCKED", + "extensionIndependent": true, + "uiExecutorBound": true + }, + "artifacts": { + "parentManifest": { "path": "tests/golden/M12-05C/manifest.json", "sha256": "f46fd394e2144255d8b4304e8ce4fe60aad4302a80e991fd83d50cd915235ee3" }, + "protocol": { "path": "web/protocol/io-format-runtime-receipt.ts", "sha256": "461a84557fa368c29dbc6707959b689faae7c8f7050dccc4d3f12e358b61bb22" }, + "generator": { "path": "tools/web/generate-io-format-runtime-receipts.mjs", "sha256": "796cd641e86d8242c13317f771ae237cbf1692ff675a53bbdb689615edb5f372" }, + "checker": { "path": "tools/web/check-io-format-runtime-receipts.mjs", "sha256": "aaf9862041f155f96f50ea8481ff765089255bc59ecd8944f12362b81b8c1f1a" }, + "unit": { "path": "web/tests/unit/io-format-runtime-receipt.test.mjs", "sha256": "a5f09277f5dcdacd25c44191f7407d6cee944f8022b1c467c2a69e172fc2ac6b" }, + "runtimeReceipts": { "path": "tests/golden/M12-05D/runtime-receipts.json", "sha256": "f2c9a77e2cfad0ef3574b804fc06fb22972c07f55c72b0e6db2e359c4d95f57b" }, + "appReceipts": { "path": "web/app/src/capabilities/io-format-runtime-receipts.json", "sha256": "f2c9a77e2cfad0ef3574b804fc06fb22972c07f55c72b0e6db2e359c4d95f57b" }, + "app": { "path": "web/app/src/app/App.tsx", "sha256": "74216aac70992f8d879e983237ca324b998008582f0cd4658e90994cf9fae0a8" }, + "package": { "path": "web/package.json", "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" } + }, + "nextTask": "M12-05E" +} diff --git a/tests/golden/M12-05D/runtime-receipts.json b/tests/golden/M12-05D/runtime-receipts.json new file mode 100644 index 00000000..77eb5fcc --- /dev/null +++ b/tests/golden/M12-05D/runtime-receipts.json @@ -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" + ] + } + ] +} diff --git a/tests/golden/M12-05E/bound-runtime-receipts.json b/tests/golden/M12-05E/bound-runtime-receipts.json new file mode 100644 index 00000000..0175d2bc --- /dev/null +++ b/tests/golden/M12-05E/bound-runtime-receipts.json @@ -0,0 +1,325 @@ +{ + "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" + } + ] +} diff --git a/tests/golden/M12-05E/manifest.json b/tests/golden/M12-05E/manifest.json new file mode 100644 index 00000000..b7c617dc --- /dev/null +++ b/tests/golden/M12-05E/manifest.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": 1, + "task": "M12-05E", + "parentTask": "M12-05D", + "enablingTask": false, + "parityStateChange": false, + "runtime": "BLENDER_5_2_BOUND_RUNTIME_RECEIPT", + "operation": "IO_FORMAT_RECEIPT_BINDING", + "assertions": { + "receiptCount": 14, + "sourceHashBound": true, + "settingsHashBound": true, + "runtimeHashBound": true, + "parentInventoryBound": true, + "deterministic": true, + "packageHashPreserved": true + }, + "artifacts": { + "parentManifest": { "path": "tests/golden/M12-05D/manifest.json", "sha256": "3d0049a7b0331f01bb71df043270c18d1a5c9ce6dc74353c49c8ce591761df1b" }, + "protocol": { "path": "web/protocol/io-format-receipt-binding.ts", "sha256": "681e719ab2b18eb85d056547fb70b461dd73b3a7fb4fdbe6295b8e49d5649e49" }, + "generator": { "path": "tools/web/generate-io-format-receipt-bindings.mjs", "sha256": "eb7b5c499f141c1788bc37e53585662eedff3f2dabff870f4ad166f4a619796f" }, + "checker": { "path": "tools/web/check-io-format-receipt-bindings.mjs", "sha256": "ec2b22b468809ecc25ab2d4983065680a66ad3be6705d1827b44bf45ac622e26" }, + "unit": { "path": "web/tests/unit/io-format-receipt-binding.test.mjs", "sha256": "e9ae3e360ad41c32da3634a4efa915654853e79a35df36728c1ddf586a0bf7b5" }, + "boundReceipts": { "path": "tests/golden/M12-05E/bound-runtime-receipts.json", "sha256": "7f765bf16b62466f579b3de00751a0e012fef1c788f229c8e9675a57caa18aad" }, + "package": { "path": "web/package.json", "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" } + }, + "nextTask": "M12-05F" +} diff --git a/tests/golden/M12-05F/fresh-runtime-receipts.json b/tests/golden/M12-05F/fresh-runtime-receipts.json new file mode 100644 index 00000000..ef996cbd --- /dev/null +++ b/tests/golden/M12-05F/fresh-runtime-receipts.json @@ -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" + } + ] + } +} diff --git a/tests/golden/M12-05F/manifest.json b/tests/golden/M12-05F/manifest.json new file mode 100644 index 00000000..60ed60f4 --- /dev/null +++ b/tests/golden/M12-05F/manifest.json @@ -0,0 +1,34 @@ +{ + "schemaVersion": 1, + "task": "M12-05F", + "parentTask": "M12-05E", + "enablingTask": false, + "parityStateChange": false, + "runtime": "BLENDER_5_2_RUNTIME_RECEIPT_FRESHNESS", + "operation": "IO_FORMAT_RECEIPT_FRESHNESS_GATE", + "assertions": { + "receiptCount": 14, + "parentBindingBound": true, + "canonicalReceiptSetHash": true, + "runtimeIdentityHash": true, + "forgedReceiptBlocked": true, + "staleReceiptBlocked": true, + "crossVersionReceiptBlocked": true, + "appRouteBound": true, + "deterministic": true, + "packageHashPreserved": true + }, + "artifacts": { + "parentManifest": { "path": "tests/golden/M12-05E/manifest.json", "sha256": "2fbaaf39c6acd9f55fbdbadccc0b027e9e7763bf069c954a96ca49b193648990" }, + "protocol": { "path": "web/protocol/io-format-receipt-freshness.ts", "sha256": "111a630c7beccd31989dc4f78932b7d7b741fb6bef03e45cb39a8139f774d235" }, + "generator": { "path": "tools/web/generate-io-format-receipt-freshness.mjs", "sha256": "6a1e798ce46e1d14d833091d4d7ae452dccb13efe583594277785465eb725bbf" }, + "checker": { "path": "tools/web/check-io-format-receipt-freshness.mjs", "sha256": "7a8fe69ea1aa2c1e121be008a9fb60fc236f7ef776d2088a7b00b14f46fe3fba" }, + "unit": { "path": "web/tests/unit/io-format-receipt-freshness.test.mjs", "sha256": "cd1c2adca81653f98f9a1c6c7d104ad0e3052283213fb7166dac827c956928fe" }, + "freshnessReceipts": { "path": "tests/golden/M12-05F/fresh-runtime-receipts.json", "sha256": "0187ad0d9ea05fc4b152b7abd4945191dbe9ec24dfde4ca7dbe4aadfd1230efe" }, + "appFreshnessReceipts": { "path": "web/app/src/capabilities/io-format-runtime-receipts-freshness.json", "sha256": "0187ad0d9ea05fc4b152b7abd4945191dbe9ec24dfde4ca7dbe4aadfd1230efe" }, + "appExpected": { "path": "web/app/src/capabilities/io-format-runtime-receipts-freshness-expected.json", "sha256": "8d65f78aa774ff252871cb1cc09e69b4ff04fbb45e61200eaf67534c9d2651b2" }, + "app": { "path": "web/app/src/app/App.tsx", "sha256": "043ff3a2f39ef3a1303791081b80851b427e7db5dfd9af2161926dd6f1ea9ca4" }, + "package": { "path": "web/package.json", "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" } + }, + "nextTask": "M12-06A" +} diff --git a/tests/golden/M12-06A/desktop-fixtures.json b/tests/golden/M12-06A/desktop-fixtures.json new file mode 100644 index 00000000..e4fcadae --- /dev/null +++ b/tests/golden/M12-06A/desktop-fixtures.json @@ -0,0 +1,697 @@ +{ + "fixtureCount": 5, + "fixtures": [ + { + "byteLength": 1236, + "file": "mesh.glb", + "id": "mesh", + "semantic": { + "animations": [], + "asset": { + "generator": "Khronos glTF Blender I/O v5.2.39", + "version": "2.0" + }, + "extensionsRequired": [], + "extensionsUsed": [], + "images": [], + "materials": [ + { + "alphaMode": "OPAQUE", + "doubleSided": true, + "emissiveFactor": null, + "name": "M12 Mesh Material", + "normalTexture": null, + "pbr": { + "baseColorFactor": null, + "baseColorTexture": null, + "metallicFactor": 0.15000000596046448, + "roughnessFactor": 0.550000011920929 + } + } + ], + "meshes": [ + { + "name": "M12 Mesh Fixture Mesh", + "primitives": [ + { + "attributes": { + "COLOR_0": { + "componentType": 5126, + "count": 4, + "max": null, + "min": null, + "normalized": false, + "type": "VEC3" + }, + "NORMAL": { + "componentType": 5126, + "count": 4, + "max": null, + "min": null, + "normalized": false, + "type": "VEC3" + }, + "POSITION": { + "componentType": 5126, + "count": 4, + "max": [ + 1, + 0, + 1 + ], + "min": [ + -1, + 0, + -1 + ], + "normalized": false, + "type": "VEC3" + } + }, + "indices": { + "componentType": 5123, + "count": 6, + "max": null, + "min": null, + "normalized": false, + "type": "SCALAR" + }, + "material": 0, + "mode": 4, + "targets": [] + } + ] + } + ], + "nodeNames": [ + "M12 Mesh Fixture" + ], + "nodes": [ + { + "children": [], + "mesh": 0, + "name": "M12 Mesh Fixture", + "rotation": null, + "scale": null, + "skin": null, + "translation": null + } + ], + "samplers": [], + "scene": 0, + "skins": [], + "textures": [] + }, + "sha256": "e52b9268b6524744fb498691f976000635e544ba3b47e9f8ff22b03bcdf17a94" + }, + { + "byteLength": 1208, + "file": "pbr.glb", + "id": "pbr", + "semantic": { + "animations": [], + "asset": { + "generator": "Khronos glTF Blender I/O v5.2.39", + "version": "2.0" + }, + "extensionsRequired": [], + "extensionsUsed": [], + "images": [], + "materials": [ + { + "alphaMode": "OPAQUE", + "doubleSided": true, + "emissiveFactor": [ + 0.029999999329447746, + 0.05999999865889549, + 0.11999999731779099 + ], + "name": "M12 PBR Material", + "normalTexture": null, + "pbr": { + "baseColorFactor": [ + 0.3100000023841858, + 0.5699999928474426, + 0.9100000262260437, + 1 + ], + "baseColorTexture": null, + "metallicFactor": 0.7200000286102295, + "roughnessFactor": 0.2800000011920929 + } + } + ], + "meshes": [ + { + "name": "M12 PBR Fixture Mesh", + "primitives": [ + { + "attributes": { + "NORMAL": { + "componentType": 5126, + "count": 4, + "max": null, + "min": null, + "normalized": false, + "type": "VEC3" + }, + "POSITION": { + "componentType": 5126, + "count": 4, + "max": [ + 1, + 0, + 1 + ], + "min": [ + -1, + 0, + -1 + ], + "normalized": false, + "type": "VEC3" + } + }, + "indices": { + "componentType": 5123, + "count": 6, + "max": null, + "min": null, + "normalized": false, + "type": "SCALAR" + }, + "material": 0, + "mode": 4, + "targets": [] + } + ] + } + ], + "nodeNames": [ + "M12 PBR Fixture" + ], + "nodes": [ + { + "children": [], + "mesh": 0, + "name": "M12 PBR Fixture", + "rotation": null, + "scale": null, + "skin": null, + "translation": null + } + ], + "samplers": [], + "scene": 0, + "skins": [], + "textures": [] + }, + "sha256": "244cc8a992c5a70692b8bbc8333533718b3bac7022110715ce3b23d2e4c0b361" + }, + { + "byteLength": 1704, + "file": "uv.glb", + "id": "uv", + "semantic": { + "animations": [], + "asset": { + "generator": "Khronos glTF Blender I/O v5.2.39", + "version": "2.0" + }, + "extensionsRequired": [], + "extensionsUsed": [], + "images": [ + { + "bufferView": 4, + "mimeType": "image/png", + "name": "M12 UV Texture" + } + ], + "materials": [ + { + "alphaMode": "OPAQUE", + "doubleSided": true, + "emissiveFactor": null, + "name": "M12 UV Material", + "normalTexture": null, + "pbr": { + "baseColorFactor": null, + "baseColorTexture": { + "index": 0 + }, + "metallicFactor": 0, + "roughnessFactor": 0.44999998807907104 + } + } + ], + "meshes": [ + { + "name": "M12 UV Fixture Mesh", + "primitives": [ + { + "attributes": { + "NORMAL": { + "componentType": 5126, + "count": 4, + "max": null, + "min": null, + "normalized": false, + "type": "VEC3" + }, + "POSITION": { + "componentType": 5126, + "count": 4, + "max": [ + 1, + 0, + 1 + ], + "min": [ + -1, + 0, + -1 + ], + "normalized": false, + "type": "VEC3" + }, + "TEXCOORD_0": { + "componentType": 5126, + "count": 4, + "max": null, + "min": null, + "normalized": false, + "type": "VEC2" + } + }, + "indices": { + "componentType": 5123, + "count": 6, + "max": null, + "min": null, + "normalized": false, + "type": "SCALAR" + }, + "material": 0, + "mode": 4, + "targets": [] + } + ] + } + ], + "nodeNames": [ + "M12 UV Fixture" + ], + "nodes": [ + { + "children": [], + "mesh": 0, + "name": "M12 UV Fixture", + "rotation": null, + "scale": null, + "skin": null, + "translation": null + } + ], + "samplers": [ + { + "magFilter": 9728, + "minFilter": 9984 + } + ], + "scene": 0, + "skins": [], + "textures": [ + { + "sampler": 0, + "source": 0 + } + ] + }, + "sha256": "1e0397d2b69d8261b3252451b50ab4aba6525b94713719f35069a9a9d96fcfa2" + }, + { + "byteLength": 1912, + "file": "skin.glb", + "id": "skin", + "semantic": { + "animations": [], + "asset": { + "generator": "Khronos glTF Blender I/O v5.2.39", + "version": "2.0" + }, + "extensionsRequired": [], + "extensionsUsed": [], + "images": [], + "materials": [ + { + "alphaMode": "OPAQUE", + "doubleSided": true, + "emissiveFactor": null, + "name": "M12 Skin Material", + "normalTexture": null, + "pbr": { + "baseColorFactor": [ + 0.7599999904632568, + 0.23999999463558197, + 0.18000000715255737, + 1 + ], + "baseColorTexture": null, + "metallicFactor": 0.05000000074505806, + "roughnessFactor": 0.5 + } + } + ], + "meshes": [ + { + "name": "M12 Skin Fixture Mesh", + "primitives": [ + { + "attributes": { + "JOINTS_0": { + "componentType": 5121, + "count": 4, + "max": null, + "min": null, + "normalized": false, + "type": "VEC4" + }, + "NORMAL": { + "componentType": 5126, + "count": 4, + "max": null, + "min": null, + "normalized": false, + "type": "VEC3" + }, + "POSITION": { + "componentType": 5126, + "count": 4, + "max": [ + 1, + 0, + 0.5 + ], + "min": [ + -1, + 0, + -0.5 + ], + "normalized": false, + "type": "VEC3" + }, + "WEIGHTS_0": { + "componentType": 5126, + "count": 4, + "max": null, + "min": null, + "normalized": false, + "type": "VEC4" + } + }, + "indices": { + "componentType": 5123, + "count": 6, + "max": null, + "min": null, + "normalized": false, + "type": "SCALAR" + }, + "material": 0, + "mode": 4, + "targets": [] + } + ] + } + ], + "nodeNames": [ + "Tip", + "Root", + "M12 Skin Fixture", + "M12 Skin Armature" + ], + "nodes": [ + { + "children": [], + "mesh": null, + "name": "Tip", + "rotation": null, + "scale": null, + "skin": null, + "translation": [ + 0, + 1, + 0 + ] + }, + { + "children": [ + 0 + ], + "mesh": null, + "name": "Root", + "rotation": null, + "scale": null, + "skin": null, + "translation": null + }, + { + "children": [], + "mesh": 0, + "name": "M12 Skin Fixture", + "rotation": null, + "scale": null, + "skin": 0, + "translation": null + }, + { + "children": [ + 2, + 1 + ], + "mesh": null, + "name": "M12 Skin Armature", + "rotation": null, + "scale": null, + "skin": null, + "translation": null + } + ], + "samplers": [], + "scene": 0, + "skins": [ + { + "inverseBindMatrices": { + "componentType": 5126, + "count": 2, + "max": null, + "min": null, + "normalized": false, + "type": "MAT4" + }, + "joints": [ + 1, + 0 + ], + "name": "M12 Skin Armature", + "skeleton": null + } + ], + "textures": [] + }, + "sha256": "4086ee4c8873aa03090338b676575b7a5335fb7c9384fec6ccb36108e71929f6" + }, + { + "byteLength": 2616, + "file": "animation.glb", + "id": "animation", + "semantic": { + "animations": [ + { + "channels": [ + { + "sampler": 0, + "target": { + "node": 0, + "path": "translation" + } + }, + { + "sampler": 1, + "target": { + "node": 0, + "path": "rotation" + } + } + ], + "name": "M12 Animation Action", + "samplers": [ + { + "input": { + "componentType": 5126, + "count": 25, + "max": [ + 1.0416666666666667 + ], + "min": [ + 0.041666666666666664 + ], + "normalized": false, + "type": "SCALAR" + }, + "interpolation": "LINEAR", + "output": { + "componentType": 5126, + "count": 25, + "max": null, + "min": null, + "normalized": false, + "type": "VEC3" + } + }, + { + "input": { + "componentType": 5126, + "count": 25, + "max": [ + 1.0416666666666667 + ], + "min": [ + 0.041666666666666664 + ], + "normalized": false, + "type": "SCALAR" + }, + "interpolation": "LINEAR", + "output": { + "componentType": 5126, + "count": 25, + "max": null, + "min": null, + "normalized": false, + "type": "VEC4" + } + } + ] + } + ], + "asset": { + "generator": "Khronos glTF Blender I/O v5.2.39", + "version": "2.0" + }, + "extensionsRequired": [], + "extensionsUsed": [], + "images": [], + "materials": [ + { + "alphaMode": "OPAQUE", + "doubleSided": true, + "emissiveFactor": null, + "name": "M12 Animation Material", + "normalTexture": null, + "pbr": { + "baseColorFactor": [ + 0.1599999964237213, + 0.7200000286102295, + 0.3799999952316284, + 1 + ], + "baseColorTexture": null, + "metallicFactor": 0.10000000149011612, + "roughnessFactor": 0.4000000059604645 + } + } + ], + "meshes": [ + { + "name": "M12 Animation Fixture Mesh", + "primitives": [ + { + "attributes": { + "NORMAL": { + "componentType": 5126, + "count": 3, + "max": null, + "min": null, + "normalized": false, + "type": "VEC3" + }, + "POSITION": { + "componentType": 5126, + "count": 3, + "max": [ + 0.75, + 0, + 0.75 + ], + "min": [ + -0.75, + 0, + -0.75 + ], + "normalized": false, + "type": "VEC3" + } + }, + "indices": { + "componentType": 5123, + "count": 3, + "max": null, + "min": null, + "normalized": false, + "type": "SCALAR" + }, + "material": 0, + "mode": 4, + "targets": [] + } + ] + } + ], + "nodeNames": [ + "M12 Animation Fixture" + ], + "nodes": [ + { + "children": [], + "mesh": 0, + "name": "M12 Animation Fixture", + "rotation": null, + "scale": null, + "skin": null, + "translation": [ + -1, + 0, + 0 + ] + } + ], + "samplers": [], + "scene": 0, + "skins": [], + "textures": [] + }, + "sha256": "44f6cc47961a146dc97ea337ae31a8b64bb4ab213b716f81ec22129db704f1fb" + } + ], + "maxFixtureBytes": 524288, + "nextTask": "M12-06B", + "operation": "DESKTOP_GLB_FIXTURE_GENERATION", + "runtime": { + "binarySha256": "d4483926610484ef9c2ad9241aae1469f934d955ebe791f1920e263e0ba85b82", + "blenderVersion": "5.2.0 LTS", + "buildBranch": "unknown", + "buildDate": "2026-07-24", + "buildHash": "unknown", + "buildPlatform": "Linux", + "buildTime": "08:03:47", + "buildType": "Release", + "versionTuple": [ + 5, + 2, + 0 + ] + }, + "schemaVersion": 1, + "task": "M12-06A" +} diff --git a/tests/golden/M12-06A/manifest.json b/tests/golden/M12-06A/manifest.json new file mode 100644 index 00000000..fde669a5 --- /dev/null +++ b/tests/golden/M12-06A/manifest.json @@ -0,0 +1,57 @@ +{ + "schemaVersion": 1, + "task": "M12-06A", + "parentTask": "M12-05F", + "enablingTask": true, + "parityStateChange": false, + "runtime": "BLENDER_5_2_DESKTOP", + "operation": "DESKTOP_GLB_FIXTURE_GENERATION", + "fixtureIds": [ + "mesh", + "pbr", + "uv", + "skin", + "animation" + ], + "fixtureCount": 5, + "maxFixtureBytes": 524288, + "artifacts": { + "parentManifest": { + "path": "tests/golden/M12-05F/manifest.json", + "sha256": "63e1506801ddee2f1eaf64cad68a9d5c918405f642ca237c6a1ec249ff297623" + }, + "generator": { + "path": "tools/web/generate-glb-desktop-fixtures.py", + "sha256": "0247dd2405a68b42d99c2b005546ad2aa99b46416e3fe9489832467f6ea4eb4d" + }, + "checker": { + "path": "tools/web/check-glb-desktop-fixtures.mjs", + "sha256": "211ebdb66bf2e2d349455d5303e889a4a1ae3323639a89dd78b3bda574dd820b" + }, + "desktopReport": { + "path": "tests/golden/M12-06A/desktop-fixtures.json", + "sha256": "dea31cc861622166dc502b333bc177b70b66b54d9c62aaccc6522745dab5ae13" + }, + "meshFixture": { + "path": "tests/files/web/m12_glb_desktop_v1/mesh.glb", + "sha256": "e52b9268b6524744fb498691f976000635e544ba3b47e9f8ff22b03bcdf17a94" + }, + "pbrFixture": { + "path": "tests/files/web/m12_glb_desktop_v1/pbr.glb", + "sha256": "244cc8a992c5a70692b8bbc8333533718b3bac7022110715ce3b23d2e4c0b361" + }, + "uvFixture": { + "path": "tests/files/web/m12_glb_desktop_v1/uv.glb", + "sha256": "1e0397d2b69d8261b3252451b50ab4aba6525b94713719f35069a9a9d96fcfa2" + }, + "skinFixture": { + "path": "tests/files/web/m12_glb_desktop_v1/skin.glb", + "sha256": "4086ee4c8873aa03090338b676575b7a5335fb7c9384fec6ccb36108e71929f6" + }, + "animationFixture": { + "path": "tests/files/web/m12_glb_desktop_v1/animation.glb", + "sha256": "44f6cc47961a146dc97ea337ae31a8b64bb4ab213b716f81ec22129db704f1fb" + } + }, + "nextTask": "M12-06B" +} diff --git a/tests/golden/M12-06B/manifest.json b/tests/golden/M12-06B/manifest.json new file mode 100644 index 00000000..f92e9c9e --- /dev/null +++ b/tests/golden/M12-06B/manifest.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 1, + "task": "M12-06B", + "parentTask": "M12-06A", + "enablingTask": true, + "parityStateChange": false, + "runtime": "WEB_TYPESCRIPT_CHROMIUM_WORKER", + "operation": "WEB_GLB_IMPORT_SEMANTIC_COMPARISON", + "fixtureIds": [ + "mesh", + "pbr", + "uv", + "skin", + "animation" + ], + "comparedDomains": [ + "topology", + "attributes", + "materials", + "nodes", + "animations" + ], + "routeState": "BLOCKED_UNTIL_MAIN_PERSISTENCE", + "artifacts": { + "parentManifest": { + "path": "tests/golden/M12-06A/manifest.json", + "sha256": "e3554a1e739cbe3f217f6169130532365538b0c0eafbb97afc58d4d56c4287cb" + }, + "protocol": { + "path": "web/protocol/glb-import.ts", + "sha256": "45d9a7912c4a59a552789a8ea0dfaff5f1849f8d213f14d238de024b77acdb0d" + }, + "generator": { + "path": "tools/web/generate-glb-desktop-import-report.mjs", + "sha256": "6f97527b7da04c7b4f9b09c48b9f367d1270db9f7820498d33832a80754436c2" + }, + "checker": { + "path": "tools/web/check-glb-desktop-import.mjs", + "sha256": "804a4b5545d95d7ab1ba265469a981d0a10b71dab36f0c96283eb73b401443d2" + }, + "unit": { + "path": "web/tests/unit/glb-desktop-import.test.mjs", + "sha256": "6bf2a96b3e43e5fae93589ea5dcf98e7a69b10266378e442988198925286a8d8" + }, + "worker": { + "path": "web/app/src/workers/glb-desktop-import-test.worker.ts", + "sha256": "eb32049631113270fb62acb7ae9379e8ff9a03e38086db2fa309c6891cd707bf" + }, + "chromium": { + "path": "web/tests/e2e/glb-desktop-import.spec.ts", + "sha256": "fa3e06419c918406f24b5a4260523bd94cfe729a84786e9434517fdfed624d45" + }, + "webImportReport": { + "path": "tests/golden/M12-06B/web-import-report.json", + "sha256": "79933888cc5aa2d1e3639ec349ca4c31d028fe89f751ebb8d4342f06d15ecfc2" + }, + "package": { + "path": "web/package.json", + "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" + } + }, + "nextTask": "M12-06C" +} diff --git a/tests/golden/M12-06B/web-import-report.json b/tests/golden/M12-06B/web-import-report.json new file mode 100644 index 00000000..7b74c6c5 --- /dev/null +++ b/tests/golden/M12-06B/web-import-report.json @@ -0,0 +1,220 @@ +{ + "schemaVersion": 1, + "task": "M12-06B", + "operation": "WEB_GLB_IMPORT_SEMANTIC_COMPARISON", + "parentManifestSha256": "e3554a1e739cbe3f217f6169130532365538b0c0eafbb97afc58d4d56c4287cb", + "fixtureReportSha256": "dea31cc861622166dc502b333bc177b70b66b54d9c62aaccc6522745dab5ae13", + "fixtureCount": 5, + "comparedDomains": [ + "topology", + "attributes", + "materials", + "nodes", + "animations" + ], + "allCompatible": true, + "comparisons": [ + { + "id": "mesh", + "file": "mesh.glb", + "sourceSha256": "e52b9268b6524744fb498691f976000635e544ba3b47e9f8ff22b03bcdf17a94", + "byteLength": 1236, + "desktopSemanticSha256": "8f9e9049e7d45c6ab0665853efacfd549548b972b12d99b9cf7c5046341c0040", + "webSemanticSha256": "8f9e9049e7d45c6ab0665853efacfd549548b972b12d99b9cf7c5046341c0040", + "compatible": true, + "mismatchCount": 0, + "topology": { + "meshCount": 1, + "primitiveCount": 1, + "indexCount": 6, + "modes": [ + 4 + ] + }, + "attributes": [ + "COLOR_0", + "NORMAL", + "POSITION" + ], + "materials": { + "count": 1, + "pbrCount": 1, + "texturedCount": 0 + }, + "nodes": { + "count": 1, + "namedCount": 1, + "hierarchyEdges": 0, + "skinnedCount": 0 + }, + "animations": { + "count": 0, + "channelPaths": [], + "sampleCounts": [] + } + }, + { + "id": "pbr", + "file": "pbr.glb", + "sourceSha256": "244cc8a992c5a70692b8bbc8333533718b3bac7022110715ce3b23d2e4c0b361", + "byteLength": 1208, + "desktopSemanticSha256": "6a44faf6de63ed31261f30a494921c18427779f04c01e3923b2cb92b43e29ad2", + "webSemanticSha256": "6a44faf6de63ed31261f30a494921c18427779f04c01e3923b2cb92b43e29ad2", + "compatible": true, + "mismatchCount": 0, + "topology": { + "meshCount": 1, + "primitiveCount": 1, + "indexCount": 6, + "modes": [ + 4 + ] + }, + "attributes": [ + "NORMAL", + "POSITION" + ], + "materials": { + "count": 1, + "pbrCount": 1, + "texturedCount": 0 + }, + "nodes": { + "count": 1, + "namedCount": 1, + "hierarchyEdges": 0, + "skinnedCount": 0 + }, + "animations": { + "count": 0, + "channelPaths": [], + "sampleCounts": [] + } + }, + { + "id": "uv", + "file": "uv.glb", + "sourceSha256": "1e0397d2b69d8261b3252451b50ab4aba6525b94713719f35069a9a9d96fcfa2", + "byteLength": 1704, + "desktopSemanticSha256": "c98257548ff7bf7e83f5a975a7a8988736452ffe39f68f1f48da17297c6ba9a4", + "webSemanticSha256": "c98257548ff7bf7e83f5a975a7a8988736452ffe39f68f1f48da17297c6ba9a4", + "compatible": true, + "mismatchCount": 0, + "topology": { + "meshCount": 1, + "primitiveCount": 1, + "indexCount": 6, + "modes": [ + 4 + ] + }, + "attributes": [ + "NORMAL", + "POSITION", + "TEXCOORD_0" + ], + "materials": { + "count": 1, + "pbrCount": 1, + "texturedCount": 1 + }, + "nodes": { + "count": 1, + "namedCount": 1, + "hierarchyEdges": 0, + "skinnedCount": 0 + }, + "animations": { + "count": 0, + "channelPaths": [], + "sampleCounts": [] + } + }, + { + "id": "skin", + "file": "skin.glb", + "sourceSha256": "4086ee4c8873aa03090338b676575b7a5335fb7c9384fec6ccb36108e71929f6", + "byteLength": 1912, + "desktopSemanticSha256": "f1be7ed4b7cf41dfc8a76cd291fa9deb8143c9ed85b2b189408d699447cb1241", + "webSemanticSha256": "f1be7ed4b7cf41dfc8a76cd291fa9deb8143c9ed85b2b189408d699447cb1241", + "compatible": true, + "mismatchCount": 0, + "topology": { + "meshCount": 1, + "primitiveCount": 1, + "indexCount": 6, + "modes": [ + 4 + ] + }, + "attributes": [ + "JOINTS_0", + "NORMAL", + "POSITION", + "WEIGHTS_0" + ], + "materials": { + "count": 1, + "pbrCount": 1, + "texturedCount": 0 + }, + "nodes": { + "count": 4, + "namedCount": 4, + "hierarchyEdges": 3, + "skinnedCount": 1 + }, + "animations": { + "count": 0, + "channelPaths": [], + "sampleCounts": [] + } + }, + { + "id": "animation", + "file": "animation.glb", + "sourceSha256": "44f6cc47961a146dc97ea337ae31a8b64bb4ab213b716f81ec22129db704f1fb", + "byteLength": 2616, + "desktopSemanticSha256": "51e28284353b913f01ba43947350fd24000712260886d67fc619dd0d24b1e46e", + "webSemanticSha256": "51e28284353b913f01ba43947350fd24000712260886d67fc619dd0d24b1e46e", + "compatible": true, + "mismatchCount": 0, + "topology": { + "meshCount": 1, + "primitiveCount": 1, + "indexCount": 3, + "modes": [ + 4 + ] + }, + "attributes": [ + "NORMAL", + "POSITION" + ], + "materials": { + "count": 1, + "pbrCount": 1, + "texturedCount": 0 + }, + "nodes": { + "count": 1, + "namedCount": 1, + "hierarchyEdges": 0, + "skinnedCount": 0 + }, + "animations": { + "count": 1, + "channelPaths": [ + "rotation", + "translation" + ], + "sampleCounts": [ + 25, + 25 + ] + } + } + ], + "routeState": "BLOCKED_UNTIL_MAIN_PERSISTENCE", + "nextTask": "M12-06C" +} diff --git a/tests/golden/M12-06C/desktop-main-report.json b/tests/golden/M12-06C/desktop-main-report.json new file mode 100644 index 00000000..bbe4cdc4 --- /dev/null +++ b/tests/golden/M12-06C/desktop-main-report.json @@ -0,0 +1,451 @@ +{ + "blenderVersion": "5.2.0 LTS", + "fixtureCount": 5, + "fixtures": [ + { + "blend": { + "byteLength": 88625, + "file": "mesh.blend", + "sha256": "66e29adc016f07e220019b6f24eb7e5016c684dca4b3102b20c8e836968d422d" + }, + "glb": { + "file": "mesh.glb", + "sha256": "e52b9268b6524744fb498691f976000635e544ba3b47e9f8ff22b03bcdf17a94" + }, + "graph": { + "actions": [], + "armatures": [], + "images": [], + "materials": [ + { + "name": "M12 Mesh Material", + "nodeNames": [ + "Color Attribute", + "Material Output", + "Principled BSDF" + ] + } + ], + "meshes": [ + { + "materials": [ + "M12 Mesh Material" + ], + "name": "M12 Mesh Fixture Mesh", + "polygonCount": 2, + "triangleCount": 2, + "uvLayers": [], + "vertexCount": 4 + } + ], + "objects": [ + { + "children": [], + "data": "M12 Mesh Fixture Mesh", + "location": [ + 0.0, + 0.0, + 0.0 + ], + "name": "M12 Mesh Fixture", + "parent": null, + "type": "MESH" + } + ], + "stableIds": { + "actions": [], + "armatures": [], + "images": [], + "materials": [ + "material:M12 Mesh Material" + ], + "meshes": [ + "mesh:M12 Mesh Fixture Mesh" + ], + "objects": [ + "object:M12 Mesh Fixture" + ] + } + }, + "id": "mesh" + }, + { + "blend": { + "byteLength": 88389, + "file": "pbr.blend", + "sha256": "ba08aeb2876d82ddf731abe81d3a42041a1be36617d0b6324c870d5bcd4cc771" + }, + "glb": { + "file": "pbr.glb", + "sha256": "244cc8a992c5a70692b8bbc8333533718b3bac7022110715ce3b23d2e4c0b361" + }, + "graph": { + "actions": [], + "armatures": [], + "images": [], + "materials": [ + { + "name": "M12 PBR Material", + "nodeNames": [ + "Material Output", + "Principled BSDF" + ] + } + ], + "meshes": [ + { + "materials": [ + "M12 PBR Material" + ], + "name": "M12 PBR Fixture Mesh", + "polygonCount": 2, + "triangleCount": 2, + "uvLayers": [], + "vertexCount": 4 + } + ], + "objects": [ + { + "children": [], + "data": "M12 PBR Fixture Mesh", + "location": [ + 0.0, + 0.0, + 0.0 + ], + "name": "M12 PBR Fixture", + "parent": null, + "type": "MESH" + } + ], + "stableIds": { + "actions": [], + "armatures": [], + "images": [], + "materials": [ + "material:M12 PBR Material" + ], + "meshes": [ + "mesh:M12 PBR Fixture Mesh" + ], + "objects": [ + "object:M12 PBR Fixture" + ] + } + }, + "id": "pbr" + }, + { + "blend": { + "byteLength": 89127, + "file": "uv.blend", + "sha256": "1270cb452d34d2fd7a19181a2b20f70b7a028bdd2db7cf1bba4e1003f7de0f48" + }, + "glb": { + "file": "uv.glb", + "sha256": "1e0397d2b69d8261b3252451b50ab4aba6525b94713719f35069a9a9d96fcfa2" + }, + "graph": { + "actions": [], + "armatures": [], + "images": [ + { + "mimeType": "PNG", + "name": "M12 UV Texture", + "packed": true, + "size": [ + 2, + 2 + ] + } + ], + "materials": [ + { + "name": "M12 UV Material", + "nodeNames": [ + "Image Texture", + "Material Output", + "Principled BSDF" + ] + } + ], + "meshes": [ + { + "materials": [ + "M12 UV Material" + ], + "name": "M12 UV Fixture Mesh", + "polygonCount": 2, + "triangleCount": 2, + "uvLayers": [ + "UVMap" + ], + "vertexCount": 4 + } + ], + "objects": [ + { + "children": [], + "data": "M12 UV Fixture Mesh", + "location": [ + 0.0, + 0.0, + 0.0 + ], + "name": "M12 UV Fixture", + "parent": null, + "type": "MESH" + } + ], + "stableIds": { + "actions": [], + "armatures": [], + "images": [ + "image:M12 UV Texture" + ], + "materials": [ + "material:M12 UV Material" + ], + "meshes": [ + "mesh:M12 UV Fixture Mesh" + ], + "objects": [ + "object:M12 UV Fixture" + ] + } + }, + "id": "uv" + }, + { + "blend": { + "byteLength": 91781, + "file": "skin.blend", + "sha256": "23f175e44ec588c75db99d3f9134863fc3d7d1f192bbd815d5d1c4e3218bce0c" + }, + "glb": { + "file": "skin.glb", + "sha256": "4086ee4c8873aa03090338b676575b7a5335fb7c9384fec6ccb36108e71929f6" + }, + "graph": { + "actions": [], + "armatures": [ + { + "bones": [ + { + "name": "Root", + "parent": null + }, + { + "name": "Tip", + "parent": "Root" + } + ], + "name": "M12 Skin Armature" + } + ], + "images": [], + "materials": [ + { + "name": "M12 Skin Material", + "nodeNames": [ + "Material Output", + "Principled BSDF" + ] + } + ], + "meshes": [ + { + "materials": [], + "name": "Icosphere", + "polygonCount": 80, + "triangleCount": 80, + "uvLayers": [ + "UVMap" + ], + "vertexCount": 42 + }, + { + "materials": [ + "M12 Skin Material" + ], + "name": "M12 Skin Fixture Mesh", + "polygonCount": 2, + "triangleCount": 2, + "uvLayers": [], + "vertexCount": 4 + } + ], + "objects": [ + { + "children": [], + "data": "Icosphere", + "location": [ + 0.0, + 0.0, + 0.0 + ], + "name": "Icosphere", + "parent": null, + "type": "MESH" + }, + { + "children": [ + "M12 Skin Fixture" + ], + "data": "M12 Skin Armature", + "location": [ + 0.0, + 0.0, + 0.0 + ], + "name": "M12 Skin Armature", + "parent": null, + "type": "ARMATURE" + }, + { + "children": [], + "data": "M12 Skin Fixture Mesh", + "location": [ + 0.0, + 0.0, + 0.0 + ], + "name": "M12 Skin Fixture", + "parent": "M12 Skin Armature", + "type": "MESH" + } + ], + "stableIds": { + "actions": [], + "armatures": [ + "armature:M12 Skin Armature" + ], + "images": [], + "materials": [ + "material:M12 Skin Material" + ], + "meshes": [ + "mesh:Icosphere", + "mesh:M12 Skin Fixture Mesh" + ], + "objects": [ + "object:Icosphere", + "object:M12 Skin Armature", + "object:M12 Skin Fixture" + ] + } + }, + "id": "skin" + }, + { + "blend": { + "byteLength": 91107, + "file": "animation.blend", + "sha256": "fa6baf3a67ff11fe3d2922210089a7c508e4ee28bfaabe4cf628ba6addee1ff5" + }, + "glb": { + "file": "animation.glb", + "sha256": "44f6cc47961a146dc97ea337ae31a8b64bb4ab213b716f81ec22129db704f1fb" + }, + "graph": { + "actions": [ + { + "fcurves": [ + [ + "location", + 0 + ], + [ + "location", + 1 + ], + [ + "location", + 2 + ], + [ + "rotation_quaternion", + 0 + ], + [ + "rotation_quaternion", + 1 + ], + [ + "rotation_quaternion", + 2 + ], + [ + "rotation_quaternion", + 3 + ] + ], + "frameRange": [ + 1.0, + 25.0 + ], + "name": "M12 Animation Action" + } + ], + "armatures": [], + "images": [], + "materials": [ + { + "name": "M12 Animation Material", + "nodeNames": [ + "Material Output", + "Principled BSDF" + ] + } + ], + "meshes": [ + { + "materials": [ + "M12 Animation Material" + ], + "name": "M12 Animation Fixture Mesh", + "polygonCount": 1, + "triangleCount": 1, + "uvLayers": [], + "vertexCount": 3 + } + ], + "objects": [ + { + "children": [], + "data": "M12 Animation Fixture Mesh", + "location": [ + -1.0, + -0.0, + 0.0 + ], + "name": "M12 Animation Fixture", + "parent": null, + "type": "MESH" + } + ], + "stableIds": { + "actions": [ + "action:M12 Animation Action:object:M12 Animation Fixture" + ], + "armatures": [], + "images": [], + "materials": [ + "material:M12 Animation Material" + ], + "meshes": [ + "mesh:M12 Animation Fixture Mesh" + ], + "objects": [ + "object:M12 Animation Fixture" + ] + } + }, + "id": "animation" + } + ], + "nextTask": "M12-06D", + "operation": "DESKTOP_GLB_IMPORT_MAIN_PERSISTENCE_BASELINE", + "schemaVersion": 1, + "task": "M12-06C" +} diff --git a/tests/golden/M12-06C/manifest.json b/tests/golden/M12-06C/manifest.json new file mode 100644 index 00000000..2b41eaf3 --- /dev/null +++ b/tests/golden/M12-06C/manifest.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 1, + "task": "M12-06C", + "parentTask": "M12-06B", + "enablingTask": false, + "parityStateChange": false, + "runtime": "BLENDER_5_2_DESKTOP_WASM_CHROMIUM", + "operation": "GLB_IMPORT_MAIN_SAVE_REOPEN", + "assertions": { + "fixtureCount": 5, + "desktopImportSaveReopen": true, + "webMainAuthority": true, + "oneVisibilityTransaction": true, + "stableIdsPreserved": true, + "resourceCountersZeroAfterOpen": true + }, + "artifacts": { + "parentManifest": { + "path": "tests/golden/M12-06B/manifest.json", + "sha256": "5275310394b34726a05b30ab67658e1381662dddf9163a97cb02f47f646a8fa4" + }, + "generator": { + "path": "tools/web/generate-glb-main-persistence-fixtures.py", + "sha256": "8989d6ce4196f140a7bfb44b863dc530a6aa462ca4a57fa7b77c468e06ab61ad" + }, + "checker": { + "path": "tools/web/check-glb-main-persistence.mjs", + "sha256": "5bbbbeaf2d20ff9bbdeb74c8dd10fa6ffc421d1d59f86540e95f29c2cfd10903" + }, + "desktopReport": { + "path": "tests/golden/M12-06C/desktop-main-report.json", + "sha256": "76b000454a9073ef762d25fa546d5c65a004659a93eb6ec82fad1e679b2e81be" + }, + "chromium": { + "path": "web/tests/e2e/glb-main-persistence.spec.ts", + "sha256": "7c97877f1cbbfd0166e106c80faee53f474f78816cca68ea924df5aa3c47776d" + }, + "package": { + "path": "web/package.json", + "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" + }, + "meshBlend": { + "path": "tests/files/web/m12_glb_main_v1/mesh.blend", + "sha256": "66e29adc016f07e220019b6f24eb7e5016c684dca4b3102b20c8e836968d422d" + }, + "pbrBlend": { + "path": "tests/files/web/m12_glb_main_v1/pbr.blend", + "sha256": "ba08aeb2876d82ddf731abe81d3a42041a1be36617d0b6324c870d5bcd4cc771" + }, + "uvBlend": { + "path": "tests/files/web/m12_glb_main_v1/uv.blend", + "sha256": "1270cb452d34d2fd7a19181a2b20f70b7a028bdd2db7cf1bba4e1003f7de0f48" + }, + "skinBlend": { + "path": "tests/files/web/m12_glb_main_v1/skin.blend", + "sha256": "23f175e44ec588c75db99d3f9134863fc3d7d1f192bbd815d5d1c4e3218bce0c" + }, + "animationBlend": { + "path": "tests/files/web/m12_glb_main_v1/animation.blend", + "sha256": "fa6baf3a67ff11fe3d2922210089a7c508e4ee28bfaabe4cf628ba6addee1ff5" + } + }, + "nextTask": "M12-06D" +} diff --git a/tests/golden/M12-06D/manifest.json b/tests/golden/M12-06D/manifest.json new file mode 100644 index 00000000..efdef943 --- /dev/null +++ b/tests/golden/M12-06D/manifest.json @@ -0,0 +1,55 @@ +{ + "schemaVersion": 1, + "task": "M12-06D", + "parentTask": "M12-06C", + "enablingTask": false, + "parityStateChange": false, + "runtime": "BLENDER_5_2_WASM_CHROMIUM", + "operation": "GLB_EXPORT_LOSS_REPORT", + "assertions": { + "fixtureCount": 5, + "machineReport": true, + "unsupportedShaderFailsClosed": true, + "exportableFixtureOutputsHashed": true, + "warningOrderDeterministic": true + }, + "artifacts": { + "parentManifest": { + "path": "tests/golden/M12-06C/manifest.json", + "sha256": "e3a57636a47591e3b02dff5a07e8a0aec5e0dc43bf6b4829bffc811035dbbb31" + }, + "exportProtocol": { + "path": "web/protocol/glb-export.ts", + "sha256": "a5d342827860a9e55b49a3bb3a196a01b1e53546325d6e594778afb9360c3125" + }, + "lossReportProtocol": { + "path": "web/protocol/glb-loss-report.ts", + "sha256": "dce9c790b2f52d46efe0908ecb044d63d21b3c6c3f7d77ddb5e697b29a7a2d6e" + }, + "worker": { + "path": "web/app/src/workers/glb-loss-report-test.worker.ts", + "sha256": "6f6294d26fe7b717416a5dd25fe834fb4b9a2ecbe8b011395c9559a26701ec77" + }, + "chromium": { + "path": "web/tests/e2e/glb-export-loss-report.spec.ts", + "sha256": "ce334b6bb5d82c133d317e916c03711029fba1c19c634dec06c06da9eddbd776" + }, + "checker": { + "path": "tools/web/check-glb-loss-report.mjs", + "sha256": "a23346860fa26405b2cd24591b0ab36fea29cb561ec8c2991ecfeabcffc17ee1" + }, + "unit": { + "path": "web/tests/unit/glb-loss-report.test.mjs", + "sha256": "dfcc2d968e4e0c0cef4496bc304cb481f10d1f0dc4c76a40ea1d6e2f11b6b708" + }, + "webLossReport": { + "path": "tests/golden/M12-06D/web-loss-report.json", + "sha256": "c6db52234d867985ef5fbcdc7c62298ec9aaa013028b5a54e2b9a16aa4133397" + }, + "package": { + "path": "web/package.json", + "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" + } + }, + "nextTask": "M12-06E" +} diff --git a/tests/golden/M12-06D/web-loss-report.json b/tests/golden/M12-06D/web-loss-report.json new file mode 100644 index 00000000..ce02d1e5 --- /dev/null +++ b/tests/golden/M12-06D/web-loss-report.json @@ -0,0 +1,163 @@ +{ + "schemaVersion": 1, + "task": "M12-06D", + "operation": "WEB_GLB_EXPORT_LOSS_REPORT", + "fixtureCount": 5, + "fixtures": [ + { + "fixtureId": "mesh", + "sourceBlendSha256": "66e29adc016f07e220019b6f24eb7e5016c684dca4b3102b20c8e836968d422d", + "lossReport": { + "schemaVersion": 1, + "operation": "GLB_EXPORT_LOSS_REPORT", + "sceneId": "scene:Scene", + "sourceRevision": 1, + "canExport": false, + "errorCount": 1, + "warningCount": 1, + "losses": [ + { + "code": "LINKED_MATERIAL_INPUT_UNEVALUATED", + "severity": "warning", + "message": "Material M12 Mesh Material contains unevaluated linked inputs", + "id": "material:M12 Mesh Material" + }, + { + "code": "SHADER_GRAPH_UNMAPPABLE", + "severity": "error", + "message": "Material M12 Mesh Material: Shader node UNSUPPORTED cannot be represented by glTF PBR", + "id": "material:M12 Mesh Material" + } + ], + "surface": { + "nodeCount": 1, + "meshCount": 1, + "materialCount": 1, + "imageCount": 0, + "animationCount": 0, + "nonMeshCount": 0 + } + }, + "output": null + }, + { + "fixtureId": "pbr", + "sourceBlendSha256": "ba08aeb2876d82ddf731abe81d3a42041a1be36617d0b6324c870d5bcd4cc771", + "lossReport": { + "schemaVersion": 1, + "operation": "GLB_EXPORT_LOSS_REPORT", + "sceneId": "scene:Scene", + "sourceRevision": 1, + "canExport": true, + "errorCount": 0, + "warningCount": 0, + "losses": [], + "surface": { + "nodeCount": 1, + "meshCount": 1, + "materialCount": 1, + "imageCount": 0, + "animationCount": 0, + "nonMeshCount": 0 + } + }, + "output": { + "byteLength": 1840, + "sha256": "1ab7936fae6e0c28e1b90e8a6ae24b3893c075c9fc58ac8896f780fdefb8277e" + } + }, + { + "fixtureId": "uv", + "sourceBlendSha256": "1270cb452d34d2fd7a19181a2b20f70b7a028bdd2db7cf1bba4e1003f7de0f48", + "lossReport": { + "schemaVersion": 1, + "operation": "GLB_EXPORT_LOSS_REPORT", + "sceneId": "scene:Scene", + "sourceRevision": 1, + "canExport": true, + "errorCount": 0, + "warningCount": 1, + "losses": [ + { + "code": "LINKED_MATERIAL_INPUT_UNEVALUATED", + "severity": "warning", + "message": "Material M12 UV Material contains unevaluated linked inputs", + "id": "material:M12 UV Material" + } + ], + "surface": { + "nodeCount": 1, + "meshCount": 1, + "materialCount": 1, + "imageCount": 1, + "animationCount": 0, + "nonMeshCount": 0 + } + }, + "output": { + "byteLength": 2756, + "sha256": "8bd045388527ddad7cf886c0c7a2a45b6e7d6db603568a10a959bc6d423ae145" + } + }, + { + "fixtureId": "skin", + "sourceBlendSha256": "23f175e44ec588c75db99d3f9134863fc3d7d1f192bbd815d5d1c4e3218bce0c", + "lossReport": { + "schemaVersion": 1, + "operation": "GLB_EXPORT_LOSS_REPORT", + "sceneId": "scene:Scene", + "sourceRevision": 1, + "canExport": true, + "errorCount": 0, + "warningCount": 1, + "losses": [ + { + "code": "MODIFIER_STACK_NOT_BAKED", + "severity": "warning", + "message": "Mesh M12 Skin Fixture Mesh has enabled modifiers that are not baked for export", + "id": "mesh:M12 Skin Fixture Mesh" + } + ], + "surface": { + "nodeCount": 3, + "meshCount": 2, + "materialCount": 1, + "imageCount": 0, + "animationCount": 0, + "nonMeshCount": 0 + } + }, + "output": { + "byteLength": 14264, + "sha256": "4a45d00dfa23638682bb61a4f74b283bcd986126da4890d068902e3c85efc332" + } + }, + { + "fixtureId": "animation", + "sourceBlendSha256": "fa6baf3a67ff11fe3d2922210089a7c508e4ee28bfaabe4cf628ba6addee1ff5", + "lossReport": { + "schemaVersion": 1, + "operation": "GLB_EXPORT_LOSS_REPORT", + "sceneId": "scene:Scene", + "sourceRevision": 1, + "canExport": true, + "errorCount": 0, + "warningCount": 0, + "losses": [], + "surface": { + "nodeCount": 1, + "meshCount": 1, + "materialCount": 1, + "imageCount": 0, + "animationCount": 1, + "nonMeshCount": 0 + } + }, + "output": { + "byteLength": 3708, + "sha256": "b83de103df3f5a49487868f3ede3046875c90b0d084bbd998511c3a7c987a4fa" + } + } + ], + "nextTask": "M12-06E" +} diff --git a/tests/golden/M12-06E/desktop-reimport-report.json b/tests/golden/M12-06E/desktop-reimport-report.json new file mode 100644 index 00000000..bf2d1d52 --- /dev/null +++ b/tests/golden/M12-06E/desktop-reimport-report.json @@ -0,0 +1,393 @@ +{ + "blenderVersion": "5.2.0 LTS", + "fixtureCount": 4, + "fixtures": [ + { + "glb": { + "byteLength": 1840, + "file": "pbr.glb", + "sha256": "1ab7936fae6e0c28e1b90e8a6ae24b3893c075c9fc58ac8896f780fdefb8277e" + }, + "graph": { + "actions": [], + "armatures": [], + "images": [], + "materials": [ + { + "name": "M12 PBR Material", + "nodeNames": [ + "Material Output", + "Principled BSDF" + ] + } + ], + "meshes": [ + { + "materials": [ + "M12 PBR Material" + ], + "name": "M12 PBR Fixture Mesh", + "polygonCount": 2, + "triangleCount": 2, + "uvLayers": [], + "vertexCount": 4 + } + ], + "objects": [ + { + "children": [], + "data": "M12 PBR Fixture Mesh", + "location": [ + 0.0, + 0.0, + 0.0 + ], + "name": "M12 PBR Fixture", + "parent": null, + "type": "MESH" + } + ], + "stableIds": { + "actions": [], + "armatures": [], + "images": [], + "materials": [ + "material:M12 PBR Material" + ], + "meshes": [ + "mesh:M12 PBR Fixture Mesh" + ], + "objects": [ + "object:M12 PBR Fixture" + ] + } + }, + "id": "pbr" + }, + { + "glb": { + "byteLength": 2756, + "file": "uv.glb", + "sha256": "8bd045388527ddad7cf886c0c7a2a45b6e7d6db603568a10a959bc6d423ae145" + }, + "graph": { + "actions": [], + "armatures": [], + "images": [ + { + "mimeType": "PNG", + "name": "M12 UV Texture", + "packed": true, + "size": [ + 2, + 2 + ] + } + ], + "materials": [ + { + "name": "M12 UV Material", + "nodeNames": [ + "Image Texture", + "Material Output", + "Mix", + "Principled BSDF" + ] + } + ], + "meshes": [ + { + "materials": [ + "M12 UV Material" + ], + "name": "M12 UV Fixture Mesh", + "polygonCount": 2, + "triangleCount": 2, + "uvLayers": [ + "UVMap" + ], + "vertexCount": 6 + } + ], + "objects": [ + { + "children": [], + "data": "M12 UV Fixture Mesh", + "location": [ + 0.0, + 0.0, + 0.0 + ], + "name": "M12 UV Fixture", + "parent": null, + "type": "MESH" + } + ], + "stableIds": { + "actions": [], + "armatures": [], + "images": [ + "image:M12 UV Texture" + ], + "materials": [ + "material:M12 UV Material" + ], + "meshes": [ + "mesh:M12 UV Fixture Mesh" + ], + "objects": [ + "object:M12 UV Fixture" + ] + } + }, + "id": "uv" + }, + { + "glb": { + "byteLength": 14264, + "file": "skin.glb", + "sha256": "4a45d00dfa23638682bb61a4f74b283bcd986126da4890d068902e3c85efc332" + }, + "graph": { + "actions": [], + "armatures": [ + { + "bones": [ + { + "name": "Root", + "parent": null + }, + { + "name": "Tip", + "parent": "Root" + } + ], + "name": "M12 Skin Armature" + } + ], + "images": [], + "materials": [ + { + "name": "M12 Skin Material", + "nodeNames": [ + "Material Output", + "Principled BSDF" + ] + } + ], + "meshes": [ + { + "materials": [], + "name": "Icosphere", + "polygonCount": 80, + "triangleCount": 80, + "uvLayers": [ + "UVMap" + ], + "vertexCount": 240 + }, + { + "materials": [], + "name": "Icosphere.001", + "polygonCount": 80, + "triangleCount": 80, + "uvLayers": [ + "UVMap" + ], + "vertexCount": 42 + }, + { + "materials": [ + "M12 Skin Material" + ], + "name": "M12 Skin Fixture Mesh", + "polygonCount": 2, + "triangleCount": 2, + "uvLayers": [], + "vertexCount": 4 + } + ], + "objects": [ + { + "children": [], + "data": "Icosphere", + "location": [ + 0.0, + 0.0, + 0.0 + ], + "name": "Icosphere", + "parent": null, + "type": "MESH" + }, + { + "children": [], + "data": "Icosphere.001", + "location": [ + 0.0, + 0.0, + 0.0 + ], + "name": "Icosphere.001", + "parent": null, + "type": "MESH" + }, + { + "children": [ + "M12 Skin Fixture" + ], + "data": "M12 Skin Armature", + "location": [ + 0.0, + 0.0, + 0.0 + ], + "name": "M12 Skin Armature", + "parent": null, + "type": "ARMATURE" + }, + { + "children": [], + "data": "M12 Skin Fixture Mesh", + "location": [ + 0.0, + 0.0, + 0.0 + ], + "name": "M12 Skin Fixture", + "parent": "M12 Skin Armature", + "type": "MESH" + } + ], + "stableIds": { + "actions": [], + "armatures": [ + "armature:M12 Skin Armature" + ], + "images": [], + "materials": [ + "material:M12 Skin Material" + ], + "meshes": [ + "mesh:Icosphere", + "mesh:Icosphere.001", + "mesh:M12 Skin Fixture Mesh" + ], + "objects": [ + "object:Icosphere", + "object:Icosphere.001", + "object:M12 Skin Armature", + "object:M12 Skin Fixture" + ] + } + }, + "id": "skin" + }, + { + "glb": { + "byteLength": 3708, + "file": "animation.glb", + "sha256": "b83de103df3f5a49487868f3ede3046875c90b0d084bbd998511c3a7c987a4fa" + }, + "graph": { + "actions": [ + { + "fcurves": [ + [ + "location", + 0 + ], + [ + "location", + 1 + ], + [ + "location", + 2 + ], + [ + "rotation_quaternion", + 0 + ], + [ + "rotation_quaternion", + 1 + ], + [ + "rotation_quaternion", + 2 + ], + [ + "rotation_quaternion", + 3 + ] + ], + "frameRange": [ + 1.0, + 25.0 + ], + "name": "M12 Animation Action" + } + ], + "armatures": [], + "images": [], + "materials": [ + { + "name": "M12 Animation Material", + "nodeNames": [ + "Material Output", + "Principled BSDF" + ] + } + ], + "meshes": [ + { + "materials": [ + "M12 Animation Material" + ], + "name": "M12 Animation Fixture Mesh", + "polygonCount": 1, + "triangleCount": 1, + "uvLayers": [], + "vertexCount": 3 + } + ], + "objects": [ + { + "children": [], + "data": "M12 Animation Fixture Mesh", + "location": [ + -1.0, + -0.0, + 0.0 + ], + "name": "M12 Animation Fixture", + "parent": null, + "type": "MESH" + } + ], + "stableIds": { + "actions": [ + "action:M12 Animation Action:object:M12 Animation Fixture" + ], + "armatures": [], + "images": [], + "materials": [ + "material:M12 Animation Material" + ], + "meshes": [ + "mesh:M12 Animation Fixture Mesh" + ], + "objects": [ + "object:M12 Animation Fixture" + ] + } + }, + "id": "animation" + } + ], + "nextTask": "M12-06F", + "operation": "DESKTOP_REIMPORT_WEB_GLB_CANONICAL_REPORT", + "schemaVersion": 1, + "task": "M12-06E" +} diff --git a/tests/golden/M12-06E/manifest.json b/tests/golden/M12-06E/manifest.json new file mode 100644 index 00000000..ac5c473d --- /dev/null +++ b/tests/golden/M12-06E/manifest.json @@ -0,0 +1,60 @@ +{ + "schemaVersion": 1, + "task": "M12-06E", + "parentTask": "M12-06D", + "enablingTask": false, + "parityStateChange": false, + "runtime": "BLENDER_5_2_DESKTOP", + "operation": "DESKTOP_REIMPORT_WEB_GLB_CANONICAL_REPORT", + "assertions": { + "fixtureCount": 4, + "desktopReimportSaveReopen": true, + "exactFixtures": ["pbr", "animation"], + "knownMismatchedFixtures": ["uv", "skin"], + "mismatchesReported": true, + "deterministicReport": true + }, + "artifacts": { + "parentManifest": { + "path": "tests/golden/M12-06D/manifest.json", + "sha256": "c01c5861d493e1f177e8cde3038bc5283a7671fa39bf84484662d307623325a2" + }, + "generator": { + "path": "tools/web/generate-glb-web-reimport-report.py", + "sha256": "3ae1ba45e15cae5d0308fc3fcb3766764cd374c096e27eaaddba9228a3d75004" + }, + "checker": { + "path": "tools/web/check-glb-web-reimport.mjs", + "sha256": "c21b45a975ec70586da1b9e50b8cf4ccf74644300b4f381c3fee0ad735cfcdb9" + }, + "desktopReport": { + "path": "tests/golden/M12-06E/desktop-reimport-report.json", + "sha256": "e4d03d25cfac3c4beff4d0af0769b1c39b058670c679c12f4a11ae30d2d91f3c" + }, + "webExportTest": { + "path": "web/tests/e2e/glb-export-loss-report.spec.ts", + "sha256": "ce334b6bb5d82c133d317e916c03711029fba1c19c634dec06c06da9eddbd776" + }, + "pbrGLB": { + "path": "tests/files/web/m12_glb_web_v1/pbr.glb", + "sha256": "1ab7936fae6e0c28e1b90e8a6ae24b3893c075c9fc58ac8896f780fdefb8277e" + }, + "uvGLB": { + "path": "tests/files/web/m12_glb_web_v1/uv.glb", + "sha256": "8bd045388527ddad7cf886c0c7a2a45b6e7d6db603568a10a959bc6d423ae145" + }, + "skinGLB": { + "path": "tests/files/web/m12_glb_web_v1/skin.glb", + "sha256": "4a45d00dfa23638682bb61a4f74b283bcd986126da4890d068902e3c85efc332" + }, + "animationGLB": { + "path": "tests/files/web/m12_glb_web_v1/animation.glb", + "sha256": "b83de103df3f5a49487868f3ede3046875c90b0d084bbd998511c3a7c987a4fa" + }, + "package": { + "path": "web/package.json", + "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" + } + }, + "nextTask": "M12-06F" +} diff --git a/tests/golden/M12-06F/manifest.json b/tests/golden/M12-06F/manifest.json new file mode 100644 index 00000000..64c9b251 --- /dev/null +++ b/tests/golden/M12-06F/manifest.json @@ -0,0 +1,51 @@ +{ + "schemaVersion": 1, + "task": "M12-06F", + "parentTask": "M12-06E", + "enablingTask": false, + "parityStateChange": false, + "runtime": "WEB_TYPESCRIPT_CHROMIUM_WORKER", + "operation": "GLB_IMPORT_NEGATIVE_CASES", + "assertions": { + "sparseAccessor": "GLB_SPARSE_ACCESSOR_UNSUPPORTED", + "extensions": "GLB_EXTENSION_UNSUPPORTED", + "externalUri": "GLB_EXTERNAL_URI_BLOCKED", + "overBudget": "GLB_IMPORT_BUDGET_EXCEEDED", + "browserWorker": true + }, + "artifacts": { + "parentManifest": { + "path": "tests/golden/M12-06E/manifest.json", + "sha256": "3d3b13fd54a314fd6f6907fc2d314e66ef74e8ca770a8accd823370d272737fc" + }, + "protocol": { + "path": "web/protocol/glb-import.ts", + "sha256": "0b6329c08e6f3cd9af6f27ef7c463b037a7cab94192afb4538d3d6c4cfcbc147" + }, + "unit": { + "path": "web/tests/unit/glb-negative-cases.test.mjs", + "sha256": "9af1c155143a0c685a62f569f705afd9fcc3bb49e0d724ebf3ad2c6356d63d6d" + }, + "worker": { + "path": "web/app/src/workers/glb-negative-cases-test.worker.ts", + "sha256": "d84a6b884ce1bfedd598b2602d803493bf10f6ad62fdfac49d64fca3f30f3931" + }, + "chromium": { + "path": "web/tests/e2e/glb-negative-cases.spec.ts", + "sha256": "c6694689ce04ff2faea2c20be648f438a9f7eb25cdfd2f714b94c1f556ea1510" + }, + "checker": { + "path": "tools/web/check-glb-negative-cases.mjs", + "sha256": "08377c306fd7d1082f7fd734e37809960c9798d29d626681fe9ad3f94cb7a29e" + }, + "report": { + "path": "tests/golden/M12-06F/negative-report.json", + "sha256": "7367a624b562a9613b4b908c894ab04c731ae0a348a3b58689075f4a9decd90c" + }, + "package": { + "path": "web/package.json", + "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" + } + }, + "nextTask": "M12-06G" +} diff --git a/tests/golden/M12-06F/negative-report.json b/tests/golden/M12-06F/negative-report.json new file mode 100644 index 00000000..240219b4 --- /dev/null +++ b/tests/golden/M12-06F/negative-report.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "task": "M12-06F", + "operation": "GLB_IMPORT_NEGATIVE_CASES", + "budget": { + "maxBytes": 524288, + "maxJsonBytes": 262144, + "maxBufferViews": 4096, + "maxAccessors": 8192 + }, + "cases": [ + { "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" } + ], + "nextTask": "M12-06G" +} diff --git a/tests/golden/M12-06G/manifest.json b/tests/golden/M12-06G/manifest.json new file mode 100644 index 00000000..6a12df86 --- /dev/null +++ b/tests/golden/M12-06G/manifest.json @@ -0,0 +1,69 @@ +{ + "schemaVersion": 1, + "task": "M12-06G", + "parentTask": "M12-06F", + "enablingTask": false, + "parityStateChange": false, + "runtime": "WEB_TYPESCRIPT_CHROMIUM_WORKER_OPFS", + "operation": "GLB_IMPORT_EXPORT_RECOVERY", + "assertions": { + "importCancellation": "GLB_OPERATION_CANCELLED", + "exportCancellation": "GLB_OPERATION_CANCELLED", + "workerRestart": "GLB_WORKER_RESTARTED", + "opfsQuota": "GLB_OPFS_QUOTA", + "temporaryResourcesAfterCancel": 0, + "committedAssetPreserved": true, + "smallAssetRecovery": true + }, + "artifacts": { + "parentManifest": { + "path": "tests/golden/M12-06F/manifest.json", + "sha256": "27b42da0683dab11a884553440088c30cf58d51364268fdaac86668786aaedd4" + }, + "protocol": { + "path": "web/protocol/glb-recovery.ts", + "sha256": "da4e2a546d7598e80798cfebd105e52b7522c896acb545710c78bb8a5c3705aa" + }, + "storageProtocol": { + "path": "web/protocol/storage.ts", + "sha256": "2c8ce9e32fcae973e9262a024fd849221680104bcd427ec308f99ef25112d951" + }, + "storageClient": { + "path": "web/app/src/storage/StorageClient.ts", + "sha256": "00cd35e4632479e4cd153314113e201c822fd209ae6c8e9fbeff5f1399728565" + }, + "storageWorker": { + "path": "web/app/src/workers/storage.worker.ts", + "sha256": "80bf4d2ac59cbf7998c2a72c8961ae38abe43685b60df564d0746e1a45e9e1bc" + }, + "operationWorker": { + "path": "web/app/src/workers/glb-recovery-test.worker.ts", + "sha256": "947c4a768422725ff2f689b2eefa8068bee51bb7ff342af17e8d4a4d0a4a7ca2" + }, + "browserAdapter": { + "path": "web/app/src/testing/glb-recovery.ts", + "sha256": "2c753a04c153471c2bf7691073b836d968a1a1300e3da038f493983b46738cdf" + }, + "unit": { + "path": "web/tests/unit/glb-recovery.test.mjs", + "sha256": "6dd48f62a85c5aaf3a04b6e572a47a7986b23cbec84a099ea510af81e8dd3a3a" + }, + "chromium": { + "path": "web/tests/e2e/glb-recovery.spec.ts", + "sha256": "7d95e992f44fb913f70c104f0d6b23bd76f47d48db4a21899e35ae1d188e2093" + }, + "checker": { + "path": "tools/web/check-glb-recovery.mjs", + "sha256": "b185248fdcd6b3cee84252bd68fcb6921bb6385bfa4a00486e93c58e94755cce" + }, + "report": { + "path": "tests/golden/M12-06G/recovery-report.json", + "sha256": "1a008a76590573468446ca6e5cbdfe0ea5a8b27493291590d937b90db90d6e42" + }, + "package": { + "path": "web/package.json", + "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" + } + }, + "nextTask": "M12-07A" +} diff --git a/tests/golden/M12-06G/recovery-report.json b/tests/golden/M12-06G/recovery-report.json new file mode 100644 index 00000000..2f8a466d --- /dev/null +++ b/tests/golden/M12-06G/recovery-report.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 1, + "task": "M12-06G", + "operation": "GLB_IMPORT_EXPORT_RECOVERY", + "cancellation": [ + { + "operation": "IMPORT", + "status": "CANCELLED", + "code": "GLB_OPERATION_CANCELLED", + "committed": false, + "temporaryBytes": 0, + "liveRequests": 0 + }, + { + "operation": "EXPORT", + "status": "CANCELLED", + "code": "GLB_OPERATION_CANCELLED", + "committed": false, + "temporaryBytes": 0, + "liveRequests": 0 + } + ], + "workerRestart": { + "operation": "IMPORT", + "generationBefore": 1, + "generationAfter": 2, + "status": "RECOVERED", + "code": "GLB_WORKER_RESTARTED", + "resultHash": "EXACT" + }, + "opfsQuota": { + "operation": "EXPORT_ASSET_COMMIT", + "status": "BLOCKED", + "code": "GLB_OPFS_QUOTA", + "backend": "OPFS", + "committedAssetPreserved": true, + "workerRestart": true, + "smallAssetRecovery": true + }, + "nextTask": "M12-07A" +} diff --git a/tests/golden/M12-07A/desktop-fixture.json b/tests/golden/M12-07A/desktop-fixture.json new file mode 100644 index 00000000..54d04e55 --- /dev/null +++ b/tests/golden/M12-07A/desktop-fixture.json @@ -0,0 +1,223 @@ +{ + "files": [ + { + "byteLength": 465, + "name": "single-mesh.obj", + "sha256": "a56694d1ee28735c68381ff18c3a52581612feebac0101a53e502956c27d144f" + }, + { + "byteLength": 426, + "name": "single-mesh.mtl", + "sha256": "392f1b10ff5a0b142d520a9443b4c96191985f15d4244c79b36fdeda0f153715" + } + ], + "nextTask": "M12-07B", + "operation": "DESKTOP_OBJ_SINGLE_MESH_FIXTURE", + "operator": "wm.obj_export", + "runtime": { + "binarySha256": "d4483926610484ef9c2ad9241aae1469f934d955ebe791f1920e263e0ba85b82", + "blenderVersion": "5.2.0 LTS", + "buildBranch": "unknown", + "buildDate": "2026-07-24", + "buildHash": "unknown", + "buildPlatform": "Linux", + "buildTime": "08:03:47", + "buildType": "Release", + "versionTuple": [ + 5, + 2, + 0 + ] + }, + "schemaVersion": 1, + "semantic": { + "faces": [ + { + "groups": [ + "M12_OBJ_Single_Mesh_M12_OBJ_Red" + ], + "material": "M12_OBJ_Red", + "vertices": [ + { + "normal": 1, + "position": 1, + "texcoord": 1 + }, + { + "normal": 1, + "position": 2, + "texcoord": 2 + }, + { + "normal": 1, + "position": 3, + "texcoord": 3 + } + ] + }, + { + "groups": [ + "M12_OBJ_Single_Mesh_M12_OBJ_Blue" + ], + "material": "M12_OBJ_Blue", + "vertices": [ + { + "normal": 1, + "position": 1, + "texcoord": 1 + }, + { + "normal": 1, + "position": 3, + "texcoord": 3 + }, + { + "normal": 1, + "position": 4, + "texcoord": 4 + } + ] + } + ], + "materialLibraries": [ + "single-mesh.mtl" + ], + "materials": [ + { + "name": "M12_OBJ_Blue", + "properties": { + "Ka": [ + 1.0, + 1.0, + 1.0 + ], + "Kd": [ + 0.8, + 0.8, + 0.8 + ], + "Ke": [ + 0.0, + 0.0, + 0.0 + ], + "Ks": [ + 0.5, + 0.5, + 0.5 + ], + "Ni": [ + 1.5 + ], + "Ns": [ + 250.0 + ], + "d": [ + 1.0 + ], + "illum": [ + 2.0 + ] + } + }, + { + "name": "M12_OBJ_Red", + "properties": { + "Ka": [ + 1.0, + 1.0, + 1.0 + ], + "Kd": [ + 0.8, + 0.8, + 0.8 + ], + "Ke": [ + 0.0, + 0.0, + 0.0 + ], + "Ks": [ + 0.5, + 0.5, + 0.5 + ], + "Ni": [ + 1.5 + ], + "Ns": [ + 250.0 + ], + "d": [ + 1.0 + ], + "illum": [ + 2.0 + ] + } + } + ], + "normals": [ + [ + 0.0, + 1.0, + 0.0 + ] + ], + "objects": [ + "M12_OBJ_Single_Mesh" + ], + "positions": [ + [ + -1.0, + 0.0, + 1.0 + ], + [ + 1.0, + 0.0, + 1.0 + ], + [ + 1.0, + 0.0, + -1.0 + ], + [ + -1.0, + 0.0, + -1.0 + ] + ], + "texcoords": [ + [ + 0.0, + 0.0 + ], + [ + 1.0, + 0.0 + ], + [ + 1.0, + 1.0 + ], + [ + 0.0, + 1.0 + ] + ] + }, + "settings": { + "exportMaterialGroups": true, + "exportMaterials": true, + "exportNormals": true, + "exportUV": true, + "forwardAxis": "NEGATIVE_Z", + "globalScale": 1.0, + "upAxis": "Y" + }, + "sourceAnchor": "blender-5.2.0/source/blender/io/wavefront_obj", + "task": "M12-07A" +} diff --git a/tests/golden/M12-07A/manifest.json b/tests/golden/M12-07A/manifest.json new file mode 100644 index 00000000..200a5587 --- /dev/null +++ b/tests/golden/M12-07A/manifest.json @@ -0,0 +1,56 @@ +{ + "schemaVersion": 1, + "task": "M12-07A", + "parentTask": "M12-06G", + "enablingTask": true, + "parityStateChange": false, + "runtime": "BLENDER_5_2_DESKTOP", + "operation": "DESKTOP_OBJ_SINGLE_MESH_FIXTURE", + "assertions": { + "sourceAnchor": "blender-5.2.0/source/blender/io/wavefront_obj", + "operator": "wm.obj_export", + "objectCount": 1, + "positionCount": 4, + "normalCount": 1, + "uvCount": 4, + "faceCount": 2, + "materialCount": 2, + "materialGroupCount": 2, + "deterministicRegeneration": true + }, + "artifacts": { + "parentManifest": { + "path": "tests/golden/M12-06G/manifest.json", + "sha256": "1c732b8d9f1a0bdb502f44e2e843e91efea12e93483a35658f8a9c70a69a2430" + }, + "generator": { + "path": "tools/web/generate-obj-single-mesh-fixture.py", + "sha256": "604c3006f194c7c64a487b8f760693b66830f3cfa602928b46769955e7d4f358" + }, + "checker": { + "path": "tools/web/check-obj-single-mesh-fixture.mjs", + "sha256": "3d0208f61a86d4d29796d8284756f53931c90864b15b7dfe4fcc84d7f65a8040" + }, + "desktopReport": { + "path": "tests/golden/M12-07A/desktop-fixture.json", + "sha256": "6c560a8eecf84973c2b05f9fd50d33bd45515e97c4f7970f1502de406c39f6a5" + }, + "objFixture": { + "path": "tests/files/web/m12_obj_desktop_v1/single-mesh.obj", + "sha256": "a56694d1ee28735c68381ff18c3a52581612feebac0101a53e502956c27d144f" + }, + "mtlFixture": { + "path": "tests/files/web/m12_obj_desktop_v1/single-mesh.mtl", + "sha256": "392f1b10ff5a0b142d520a9443b4c96191985f15d4244c79b36fdeda0f153715" + }, + "runtimeInventory": { + "path": "tests/golden/M12-05A/format-inventory.json", + "sha256": "0d660b0fd8b647ebbf4e91afebd5006bd100973ab8e2507a5bfe759f477b33b4" + }, + "package": { + "path": "web/package.json", + "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" + } + }, + "nextTask": "M12-07B" +} diff --git a/tests/golden/M12-07B/desktop-fixtures.json b/tests/golden/M12-07B/desktop-fixtures.json new file mode 100644 index 00000000..482cd679 --- /dev/null +++ b/tests/golden/M12-07B/desktop-fixtures.json @@ -0,0 +1,223 @@ +{ + "files": [ + { + "byteLength": 670, + "name": "multi-object.obj", + "sha256": "4ed3dced8445b03754cd3916ae2e8cf0dff85e6a5882f1c6f0a4a4e4c5251f6a" + }, + { + "byteLength": 440, + "name": "multi-object.mtl", + "sha256": "80338f569c67a8b1616515ba878ffc72b26e93ef2e8c361131023fb14f132f5f" + }, + { + "byteLength": 261, + "name": "m12_obj_texture.png", + "sha256": "44e4fd08bdda8403908349e2a62dcf103ce2d0851099fc61cd868d65ddbef78c" + }, + { + "byteLength": 688, + "name": "negative-index.obj", + "sha256": "9457954284cac1d68e23627e3ff734c0c591b3a24086ce5aa3d0dbbfc22f01bc" + }, + { + "byteLength": 664, + "name": "malformed-face.obj", + "sha256": "6dd508b5ba944c2d15e2889c9ad9a3693845145c3bf6c9bf7df992081c915864" + } + ], + "malformedFace": { + "expectedCode": "OBJ_FACE_ARITY_INVALID", + "file": "malformed-face.obj" + }, + "negativeIndex": { + "expectedStatus": "ACCEPT_WITH_NEGATIVE_INDICES", + "faceCount": 2, + "file": "negative-index.obj" + }, + "nextTask": "M12-07C", + "operation": "DESKTOP_OBJ_MULTI_OBJECT_AND_NEGATIVE_FIXTURES", + "operator": "wm.obj_export", + "runtime": { + "binarySha256": "d4483926610484ef9c2ad9241aae1469f934d955ebe791f1920e263e0ba85b82", + "blenderVersion": "5.2.0 LTS", + "buildBranch": "unknown", + "buildDate": "2026-07-24", + "buildHash": "unknown", + "buildPlatform": "Linux", + "buildTime": "08:03:47", + "buildType": "Release", + "versionTuple": [ + 5, + 2, + 0 + ] + }, + "schemaVersion": 1, + "semantic": { + "faces": [ + { + "groups": [ + "M12_OBJ_Left_M12_OBJ_Left_Material" + ], + "material": "M12_OBJ_Left_Material", + "object": "M12_OBJ_Left_M12_OBJ_Left_Mesh", + "vertices": [ + { + "normal": 1, + "position": 1, + "texcoord": 1 + }, + { + "normal": 1, + "position": 2, + "texcoord": 2 + }, + { + "normal": 1, + "position": 3, + "texcoord": 3 + } + ] + }, + { + "groups": [ + "M12_OBJ_Right_M12_OBJ_Right_Material" + ], + "material": "M12_OBJ_Right_Material", + "object": "M12_OBJ_Right_M12_OBJ_Right_Mesh", + "vertices": [ + { + "normal": 2, + "position": 4, + "texcoord": 4 + }, + { + "normal": 2, + "position": 5, + "texcoord": 5 + }, + { + "normal": 2, + "position": 6, + "texcoord": 6 + } + ] + } + ], + "groups": [ + "M12_OBJ_Left_M12_OBJ_Left_Mesh", + "M12_OBJ_Left_M12_OBJ_Left_Material", + "M12_OBJ_Right_M12_OBJ_Right_Mesh", + "M12_OBJ_Right_M12_OBJ_Right_Material" + ], + "materialLibraries": [ + "multi-object.mtl" + ], + "materials": [ + { + "mapKd": "m12_obj_texture.png", + "name": "M12_OBJ_Left_Material" + }, + { + "mapKd": "m12_obj_texture.png", + "name": "M12_OBJ_Right_Material" + } + ], + "normals": [ + [ + -0.0, + 1.0, + -0.0 + ], + [ + -0.0, + 1.0, + -0.0 + ] + ], + "objects": [ + "M12_OBJ_Left_M12_OBJ_Left_Mesh", + "M12_OBJ_Right_M12_OBJ_Right_Mesh" + ], + "positions": [ + [ + -1.75, + 0.0, + 0.75 + ], + [ + -0.25, + 0.0, + 0.75 + ], + [ + -1.0, + 0.0, + -0.75 + ], + [ + 0.25, + 0.0, + 0.75 + ], + [ + 1.75, + 0.0, + 0.75 + ], + [ + 1.0, + 0.0, + -0.75 + ] + ], + "texcoords": [ + [ + 0.0, + 0.0 + ], + [ + 1.0, + 0.0 + ], + [ + 0.5, + 1.0 + ], + [ + 0.0, + 0.0 + ], + [ + 1.0, + 0.0 + ], + [ + 0.5, + 1.0 + ] + ] + }, + "settings": { + "exportMaterialGroups": true, + "exportMaterials": true, + "exportNormals": true, + "exportObjectGroups": true, + "exportUV": true, + "forwardAxis": "NEGATIVE_Z", + "globalScale": 1.0, + "pathMode": "RELATIVE", + "upAxis": "Y" + }, + "sourceAnchor": "blender-5.2.0/source/blender/io/wavefront_obj", + "task": "M12-07B", + "textureOrigin": { + "mtlMapKd": [ + "m12_obj_texture.png", + "m12_obj_texture.png" + ], + "relative": true, + "textureFile": "m12_obj_texture.png" + } +} diff --git a/tests/golden/M12-07B/manifest.json b/tests/golden/M12-07B/manifest.json new file mode 100644 index 00000000..f1a1f564 --- /dev/null +++ b/tests/golden/M12-07B/manifest.json @@ -0,0 +1,68 @@ +{ + "schemaVersion": 1, + "task": "M12-07B", + "parentTask": "M12-07A", + "enablingTask": true, + "parityStateChange": false, + "runtime": "BLENDER_5_2_DESKTOP_AND_OBJ_NEGATIVE_FIXTURES", + "operation": "DESKTOP_OBJ_MULTI_OBJECT_AND_NEGATIVE_FIXTURES", + "assertions": { + "objectCount": 2, + "positionCount": 6, + "uvCount": 6, + "normalCount": 2, + "faceCount": 2, + "materialCount": 2, + "relativeTextureOrigin": true, + "negativeIndices": "ACCEPT_WITH_NEGATIVE_INDICES", + "malformedFace": "OBJ_FACE_ARITY_INVALID", + "deterministicRegeneration": true + }, + "artifacts": { + "parentManifest": { + "path": "tests/golden/M12-07A/manifest.json", + "sha256": "d80b74502202effa7be15640c945dc7e04af703b58e7a15c3fe7babc43c17b46" + }, + "generator": { + "path": "tools/web/generate-obj-multi-negative-fixtures.py", + "sha256": "8d4faed82cba76e46bf639e5031b30568e8b9a15240cbddb1c22ee7ad11d697b" + }, + "checker": { + "path": "tools/web/check-obj-multi-negative-fixtures.mjs", + "sha256": "61188d67efd6133e714f2d55c7c8516b9d04896de068f994523a3c398eebdd2f" + }, + "desktopReport": { + "path": "tests/golden/M12-07B/desktop-fixtures.json", + "sha256": "b60ff785676c0f0168588605ad442a650615191ac00770b7e5a308cc4ade83ce" + }, + "objFixture": { + "path": "tests/files/web/m12_obj_multi_v1/multi-object.obj", + "sha256": "4ed3dced8445b03754cd3916ae2e8cf0dff85e6a5882f1c6f0a4a4e4c5251f6a" + }, + "mtlFixture": { + "path": "tests/files/web/m12_obj_multi_v1/multi-object.mtl", + "sha256": "80338f569c67a8b1616515ba878ffc72b26e93ef2e8c361131023fb14f132f5f" + }, + "textureFixture": { + "path": "tests/files/web/m12_obj_multi_v1/m12_obj_texture.png", + "sha256": "44e4fd08bdda8403908349e2a62dcf103ce2d0851099fc61cd868d65ddbef78c" + }, + "negativeIndexFixture": { + "path": "tests/files/web/m12_obj_multi_v1/negative-index.obj", + "sha256": "9457954284cac1d68e23627e3ff734c0c591b3a24086ce5aa3d0dbbfc22f01bc" + }, + "malformedFixture": { + "path": "tests/files/web/m12_obj_multi_v1/malformed-face.obj", + "sha256": "6dd508b5ba944c2d15e2889c9ad9a3693845145c3bf6c9bf7df992081c915864" + }, + "runtimeInventory": { + "path": "tests/golden/M12-05A/format-inventory.json", + "sha256": "0d660b0fd8b647ebbf4e91afebd5006bd100973ab8e2507a5bfe759f477b33b4" + }, + "package": { + "path": "web/package.json", + "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" + } + }, + "nextTask": "M12-07C" +} diff --git a/tests/golden/M12-07C/manifest.json b/tests/golden/M12-07C/manifest.json new file mode 100644 index 00000000..82cfb816 --- /dev/null +++ b/tests/golden/M12-07C/manifest.json @@ -0,0 +1,70 @@ +{ + "schemaVersion": 1, + "task": "M12-07C", + "parentTask": "M12-07B", + "enablingTask": false, + "parityStateChange": false, + "runtime": "WEB_TYPESCRIPT_CHROMIUM_WORKER_AND_BLENDER_5_2", + "operation": "WEB_OBJ_TO_DESKTOP_ROUNDTRIP", + "assertions": { + "browserObjectCount": 2, + "desktopObjectCount": 2, + "triangleCountExact": true, + "uvLayerPresent": true, + "materialPresent": true, + "boundTextureLossWarnings": 0, + "missingTextureLossWarnings": 2, + "desktopExact": true + }, + "artifacts": { + "parentManifest": { + "path": "tests/golden/M12-07B/manifest.json", + "sha256": "785f593271058098704498cb0a7c948305087f1a83bf8f29b6e6fb9fe9341926" + }, + "protocol": { + "path": "web/protocol/obj-import.ts", + "sha256": "e7240af65e0d90f3ee690a4e3f391416fe4744ac6c46edc8ac0d72386857cab9" + }, + "worker": { + "path": "web/app/src/workers/obj-roundtrip-test.worker.ts", + "sha256": "bf1fcc60ec318cf6c2747d44f4af741b46f284cf5f5307e142f0ab6d50b14302" + }, + "desktopImporter": { + "path": "tools/web/check-obj-web-roundtrip.py", + "sha256": "9f1eb4ab679255a0f1f596696e8befc33a4e30c0cbe6ea423e2aaa0314cec22d" + }, + "unit": { + "path": "web/tests/unit/obj-import.test.mjs", + "sha256": "485a669be5de8e370e9b5a3239357b028ba61ca5c4076819cd46aed52a5c210a" + }, + "chromium": { + "path": "web/tests/e2e/obj-web-roundtrip.spec.ts", + "sha256": "93785b4017ba14b007926b0f82442f8ae5968f242a2a762e5f5dc77d36110f5b" + }, + "checker": { + "path": "tools/web/check-obj-web-roundtrip.mjs", + "sha256": "9d53014f4b89f786c516ebe8d300030c2728e4a36a86a5ef136b2769a12ac96b" + }, + "report": { + "path": "tests/golden/M12-07C/web-roundtrip-report.json", + "sha256": "45eaffc9c2e50c84b557d13ebbe9f4f53b63e19e00bc69b272491ef6366dff81" + }, + "objFixture": { + "path": "tests/files/web/m12_obj_multi_v1/multi-object.obj", + "sha256": "4ed3dced8445b03754cd3916ae2e8cf0dff85e6a5882f1c6f0a4a4e4c5251f6a" + }, + "mtlFixture": { + "path": "tests/files/web/m12_obj_multi_v1/multi-object.mtl", + "sha256": "80338f569c67a8b1616515ba878ffc72b26e93ef2e8c361131023fb14f132f5f" + }, + "textureFixture": { + "path": "tests/files/web/m12_obj_multi_v1/m12_obj_texture.png", + "sha256": "44e4fd08bdda8403908349e2a62dcf103ce2d0851099fc61cd868d65ddbef78c" + }, + "package": { + "path": "web/package.json", + "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" + } + }, + "nextTask": "M12-07D" +} diff --git a/tests/golden/M12-07C/web-roundtrip-report.json b/tests/golden/M12-07C/web-roundtrip-report.json new file mode 100644 index 00000000..6c0442c7 --- /dev/null +++ b/tests/golden/M12-07C/web-roundtrip-report.json @@ -0,0 +1,95 @@ +{ + "schemaVersion": 1, + "task": "M12-07C", + "operation": "WEB_OBJ_TO_DESKTOP_ROUNDTRIP", + "source": { + "objSha256": "4ed3dced8445b03754cd3916ae2e8cf0dff85e6a5882f1c6f0a4a4e4c5251f6a", + "mtlSha256": "80338f569c67a8b1616515ba878ffc72b26e93ef2e8c361131023fb14f132f5f", + "textureSha256": "44e4fd08bdda8403908349e2a62dcf103ce2d0851099fc61cd868d65ddbef78c" + }, + "browser": { + "imported": { + "schemaVersion": 1, + "objectCount": 2, + "positionCount": 6, + "texcoordCount": 6, + "normalCount": 2, + "faceCount": 2, + "materialCount": 2 + }, + "outputObjSha256": "64d419bea9738bd584e61214b34aa67e5ed2204a0e2e1db6ed334f8561d63276", + "outputMtlSha256": "4e00c589ed29d99994119cfa12ae9f4ef7cc13c33fdd6ee8ce2eb97e6cf61c29", + "lossReport": { + "schemaVersion": 1, + "operation": "OBJ_EXPORT_LOSS_REPORT", + "canRoundTrip": true, + "warningCount": 0, + "warnings": [] + }, + "missingTextureLoss": { + "schemaVersion": 1, + "operation": "OBJ_EXPORT_LOSS_REPORT", + "canRoundTrip": true, + "warningCount": 2, + "warnings": [ + { + "code": "OBJ_TEXTURE_ORIGIN_UNRESOLVED", + "severity": "warning", + "message": "OBJ texture m12_obj_texture.png is not bound to a supplied asset", + "path": "m12_obj_texture.png" + }, + { + "code": "OBJ_TEXTURE_ORIGIN_UNRESOLVED", + "severity": "warning", + "message": "OBJ texture m12_obj_texture.png is not bound to a supplied asset", + "path": "m12_obj_texture.png" + } + ] + } + }, + "desktop": { + "meshCount": 2, + "objectCount": 2, + "objects": [ + { + "materials": [ + "M12_OBJ_Left_Material" + ], + "name": "M12_OBJ_Left_M12_OBJ_Left_Material", + "normalCount": 3, + "polygonCount": 1, + "triangleCount": 1, + "uvLayers": [ + "UVMap" + ], + "uvLoopCount": 3, + "vertexCount": 3 + }, + { + "materials": [ + "M12_OBJ_Right_Material" + ], + "name": "M12_OBJ_Right_M12_OBJ_Right_Material", + "normalCount": 3, + "polygonCount": 1, + "triangleCount": 1, + "uvLayers": [ + "UVMap" + ], + "uvLoopCount": 3, + "vertexCount": 3 + } + ], + "operation": "DESKTOP_IMPORT_WEB_OBJ", + "schemaVersion": 1, + "sourceObjBytes": 518, + "sourceObjSha256": "64d419bea9738bd584e61214b34aa67e5ed2204a0e2e1db6ed334f8561d63276" + }, + "comparisons": { + "objectCountExact": true, + "triangleCountExact": true, + "uvLayerPresent": true, + "materialPresent": true + }, + "nextTask": "M12-07D" +} diff --git a/tests/golden/M12-07D/capability-report.json b/tests/golden/M12-07D/capability-report.json new file mode 100644 index 00000000..e047ea57 --- /dev/null +++ b/tests/golden/M12-07D/capability-report.json @@ -0,0 +1,68 @@ +{ + "files": [ + { + "byteLength": 184, + "name": "capability-binary.stl", + "sha256": "50161172cd12e4240067064db6f80e74b2df69c475e3e34154e9d0523928c1ca" + }, + { + "byteLength": 213, + "name": "capability-ascii.stl", + "sha256": "a463a5add8be070fb67b34f00ef6e7b46025aed31fa429d4a033d75a11cf0113" + } + ], + "nextTask": "M12-07E", + "operation": "DESKTOP_STL_BINARY_ASCII_CAPABILITY", + "operator": "wm.stl_export", + "runtime": { + "binarySha256": "d4483926610484ef9c2ad9241aae1469f934d955ebe791f1920e263e0ba85b82", + "blenderVersion": "5.2.0 LTS", + "buildBranch": "unknown", + "buildDate": "2026-07-24", + "buildHash": "unknown", + "buildPlatform": "Linux", + "buildTime": "08:03:47", + "buildType": "Release", + "versionTuple": [ + 5, + 2, + 0 + ] + }, + "schemaVersion": 1, + "settings": { + "evaluationMode": "DAG_EVAL_VIEWPORT", + "exportSelectedObjects": true, + "forwardAxis": "NEGATIVE_Z", + "globalScale": 1.0, + "upAxis": "Y", + "useSceneUnit": false + }, + "sourceAnchor": "blender-5.2.0/source/blender/io/stl", + "task": "M12-07D", + "variants": [ + { + "asciiFormat": false, + "file": "capability-binary.stl", + "id": "STL_BINARY", + "semantic": { + "byteLength": 184, + "format": "STL_BINARY", + "header": "", + "triangleCount": 2 + } + }, + { + "asciiFormat": true, + "file": "capability-ascii.stl", + "id": "STL_ASCII", + "semantic": { + "byteLength": 213, + "facetCount": 2, + "format": "STL_ASCII", + "solid": "", + "vertexCount": 6 + } + } + ] +} diff --git a/tests/golden/M12-07D/manifest.json b/tests/golden/M12-07D/manifest.json new file mode 100644 index 00000000..c86c10d0 --- /dev/null +++ b/tests/golden/M12-07D/manifest.json @@ -0,0 +1,54 @@ +{ + "schemaVersion": 1, + "task": "M12-07D", + "parentTask": "M12-07C", + "enablingTask": true, + "parityStateChange": false, + "runtime": "BLENDER_5_2_DESKTOP", + "operation": "DESKTOP_STL_BINARY_ASCII_CAPABILITY", + "assertions": { + "operator": "wm.stl_export", + "binaryVariant": "STL_BINARY", + "asciiVariant": "STL_ASCII", + "binaryTriangleCount": 2, + "asciiFacetCount": 2, + "asciiVertexCount": 6, + "binaryByteLength": 184, + "deterministicRegeneration": true + }, + "artifacts": { + "parentManifest": { + "path": "tests/golden/M12-07C/manifest.json", + "sha256": "0c639954900b1fcc3b8285d0f4c0ecfa5807df6cde6d1f2cd3b59c4636d71674" + }, + "generator": { + "path": "tools/web/generate-stl-capability-fixtures.py", + "sha256": "8310104e66f246b32ac7cb4619e7f4da109baf2ece39ea670cbb6d33bd88c8b8" + }, + "checker": { + "path": "tools/web/check-stl-capability-fixtures.mjs", + "sha256": "b2ad901eaa9701436cf7bcc8aa4e40d1b2d6eda7c6c44a0af830347a37cc9ca8" + }, + "desktopReport": { + "path": "tests/golden/M12-07D/capability-report.json", + "sha256": "29c7d2a6e6764440c99eec25bb5760c7e0880839691757fb3e607ed4f5050013" + }, + "binaryFixture": { + "path": "tests/files/web/m12_stl_capability_v1/capability-binary.stl", + "sha256": "50161172cd12e4240067064db6f80e74b2df69c475e3e34154e9d0523928c1ca" + }, + "asciiFixture": { + "path": "tests/files/web/m12_stl_capability_v1/capability-ascii.stl", + "sha256": "a463a5add8be070fb67b34f00ef6e7b46025aed31fa429d4a033d75a11cf0113" + }, + "runtimeInventory": { + "path": "tests/golden/M12-05A/format-inventory.json", + "sha256": "0d660b0fd8b647ebbf4e91afebd5006bd100973ab8e2507a5bfe759f477b33b4" + }, + "package": { + "path": "web/package.json", + "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" + } + }, + "nextTask": "M12-07E" +} diff --git a/tests/golden/M12-07E/desktop-edge-report.json b/tests/golden/M12-07E/desktop-edge-report.json new file mode 100644 index 00000000..c3529c5d --- /dev/null +++ b/tests/golden/M12-07E/desktop-edge-report.json @@ -0,0 +1,144 @@ +{ + "cases": [ + { + "byteLength": 184, + "file": "capability-binary.stl", + "globalScale": 1.0, + "id": "BINARY_UNIT_1", + "result": { + "bounds": { + "max": [ + 1.0, + 1.0, + 7.549790126404332e-08 + ], + "min": [ + -1.0, + -1.0, + -7.549790126404332e-08 + ] + }, + "objectCount": 1, + "polygonCount": 2, + "polygonNormals": [ + [ + 0.0, + 1.0, + -0.0 + ], + [ + 0.0, + 1.0, + -0.0 + ] + ], + "status": "ACCEPTED", + "triangleCount": 2, + "vertexCount": 4 + }, + "sha256": "50161172cd12e4240067064db6f80e74b2df69c475e3e34154e9d0523928c1ca" + }, + { + "byteLength": 184, + "file": "capability-binary.stl", + "globalScale": 0.001, + "id": "BINARY_UNIT_001", + "result": { + "bounds": { + "max": [ + 0.0010000000474974513, + 0.0010000000474974513, + 7.549790653760269e-11 + ], + "min": [ + -0.0010000000474974513, + -0.0010000000474974513, + -7.549790653760269e-11 + ] + }, + "objectCount": 1, + "polygonCount": 2, + "polygonNormals": [ + [ + 0.0, + 1.0, + -0.0 + ], + [ + 0.0, + 1.0, + -0.0 + ] + ], + "status": "ACCEPTED", + "triangleCount": 2, + "vertexCount": 4 + }, + "sha256": "50161172cd12e4240067064db6f80e74b2df69c475e3e34154e9d0523928c1ca" + }, + { + "byteLength": 184, + "file": "degenerate-binary.stl", + "globalScale": 1.0, + "id": "DEGENERATE_TRIANGLE", + "result": { + "bounds": { + "max": [ + 1.0, + 1.0, + 7.549790126404332e-08 + ], + "min": [ + -1.0, + -1.0, + -7.549790126404332e-08 + ] + }, + "objectCount": 1, + "polygonCount": 1, + "polygonNormals": [ + [ + 0.0, + 1.0, + -0.0 + ] + ], + "status": "ACCEPTED", + "triangleCount": 1, + "vertexCount": 3 + }, + "sha256": "c120bb0f218819eb89a3607c0b4f8fafd9afb599402e3b21deaa3b2be143e7d0" + }, + { + "byteLength": 188, + "file": "trailing-binary.stl", + "globalScale": 1.0, + "id": "TRAILING_BYTES", + "result": { + "bounds": { + "max": [ + 0.0, + 0.0, + 0.0 + ], + "min": [ + 0.0, + 0.0, + 0.0 + ] + }, + "objectCount": 1, + "polygonCount": 0, + "polygonNormals": [], + "status": "ACCEPTED", + "triangleCount": 0, + "vertexCount": 0 + }, + "sha256": "0a30aab6db1588722067000e2a08c3c6206a8ed5b7531caa0b082723700a3681" + } + ], + "nextTask": "M12-07F", + "operation": "BLENDER_STL_EDGE_PROBE", + "schemaVersion": 1, + "task": "M12-07E" +} diff --git a/tests/golden/M12-07E/edge-fixtures.json b/tests/golden/M12-07E/edge-fixtures.json new file mode 100644 index 00000000..9c65512a --- /dev/null +++ b/tests/golden/M12-07E/edge-fixtures.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": 1, + "task": "M12-07E", + "operation": "STL_EDGE_FIXTURE_GENERATION", + "parent": { + "path": "tests/files/web/m12_stl_capability_v1/capability-binary.stl", + "byteLength": 184, + "sha256": "50161172cd12e4240067064db6f80e74b2df69c475e3e34154e9d0523928c1ca" + }, + "fixtures": [ + { + "id": "DEGENERATE_TRIANGLE", + "file": "degenerate-binary.stl", + "byteLength": 184, + "sha256": "c120bb0f218819eb89a3607c0b4f8fafd9afb599402e3b21deaa3b2be143e7d0", + "expectedCode": "STL_DEGENERATE_TRIANGLE" + }, + { + "id": "TRAILING_BYTES", + "file": "trailing-binary.stl", + "byteLength": 188, + "sha256": "0a30aab6db1588722067000e2a08c3c6206a8ed5b7531caa0b082723700a3681", + "expectedCode": "STL_TRAILING_BYTES" + } + ], + "nextTask": "M12-07F" +} diff --git a/tests/golden/M12-07E/manifest.json b/tests/golden/M12-07E/manifest.json new file mode 100644 index 00000000..e0e47f26 --- /dev/null +++ b/tests/golden/M12-07E/manifest.json @@ -0,0 +1,78 @@ +{ + "schemaVersion": 1, + "task": "M12-07E", + "parentTask": "M12-07D", + "enablingTask": false, + "parityStateChange": false, + "runtime": "WEB_TYPESCRIPT_CHROMIUM_WORKER_AND_BLENDER_5_2", + "operation": "STL_NORMAL_UNIT_EDGE_PARITY", + "assertions": { + "binaryAsciiNormalExact": true, + "desktopNormalExact": true, + "unitRatioExactWithinFloat32": true, + "degenerateTriangleExact": true, + "removedDegenerateTriangles": 1, + "trailingWeb": "BLOCKED/STL_TRAILING_BYTES", + "trailingDesktop": "ACCEPTED_EMPTY", + "trailingParity": "STRICTER_WEB_BLOCK" + }, + "artifacts": { + "parentManifest": { + "path": "tests/golden/M12-07D/manifest.json", + "sha256": "bbc78e47f389562e7d648bf49c9b3bf8a08aaa36e914589a6eab0a1f6e72748d" + }, + "fixtureGenerator": { + "path": "tools/web/generate-stl-edge-fixtures.mjs", + "sha256": "018013347642603c21237ebb023bdc7a4e338f5a3f875fea536a996eb121e716" + }, + "desktopProbe": { + "path": "tools/web/probe-stl-edge-fixtures.py", + "sha256": "b3d6ea197ef77b5a14f60f5e6b43d4392e943ff5d56acf73915507fc9a4161b7" + }, + "protocol": { + "path": "web/protocol/stl-import.ts", + "sha256": "87eec72e2b8aa7ad0b168ca0ee8c4016c941f56f5f1ac53aa5fe71d0832f1dd8" + }, + "worker": { + "path": "web/app/src/workers/stl-edge-test.worker.ts", + "sha256": "d714f1f3a218a2226dd349aea81c6c54efa6246de1c4fe51aaa9f5c352ef44fc" + }, + "unit": { + "path": "web/tests/unit/stl-import.test.mjs", + "sha256": "4af52833486421c017f3af2b833b0776e60a6ab57ed66fea2161cf9674f4b243" + }, + "chromium": { + "path": "web/tests/e2e/stl-edge-parity.spec.ts", + "sha256": "121d348250e26d29ad966f7427bbef9da77de2098e2e305827b11aae52002c5c" + }, + "checker": { + "path": "tools/web/check-stl-edge-parity.mjs", + "sha256": "b5ed7fbb41bb46fba982d5726cb8c3eaecf360b772a01d6337c309b1c4a4f535" + }, + "fixtureReport": { + "path": "tests/golden/M12-07E/edge-fixtures.json", + "sha256": "545463a984356d6635cbaae3865d13fe95bd2d841c394ac9ddff496c95d46f18" + }, + "desktopReport": { + "path": "tests/golden/M12-07E/desktop-edge-report.json", + "sha256": "2d3028a3b7d97f39654cff5fcef1d0c1c71e9f2d08e3e29c2e926651ed3860f1" + }, + "webReport": { + "path": "tests/golden/M12-07E/web-edge-report.json", + "sha256": "e0686cc9be3b68e564651207443e0ebf3e3b0eb3d07a32915b03f44b1908357b" + }, + "degenerateFixture": { + "path": "tests/files/web/m12_stl_edges_v1/degenerate-binary.stl", + "sha256": "c120bb0f218819eb89a3607c0b4f8fafd9afb599402e3b21deaa3b2be143e7d0" + }, + "trailingFixture": { + "path": "tests/files/web/m12_stl_edges_v1/trailing-binary.stl", + "sha256": "0a30aab6db1588722067000e2a08c3c6206a8ed5b7531caa0b082723700a3681" + }, + "package": { + "path": "web/package.json", + "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" + } + }, + "nextTask": "M12-07F" +} diff --git a/tests/golden/M12-07E/web-edge-report.json b/tests/golden/M12-07E/web-edge-report.json new file mode 100644 index 00000000..ab69a089 --- /dev/null +++ b/tests/golden/M12-07E/web-edge-report.json @@ -0,0 +1,436 @@ +{ + "schemaVersion": 1, + "task": "M12-07E", + "operation": "STL_NORMAL_UNIT_EDGE_PARITY", + "browser": [ + { + "id": "BINARY_UNIT_1", + "status": "ACCEPTED", + "result": { + "schemaVersion": 1, + "variant": "STL_BINARY", + "unitScale": 1, + "declaredTriangleCount": 2, + "triangleCount": 2, + "removedDegenerateTriangles": 0, + "normals": [ + [ + 0, + 1, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "vertices": [ + [ + [ + -1, + 0, + 1 + ], + [ + 1, + 0, + 1 + ], + [ + 1, + 0, + -1 + ] + ], + [ + [ + -1, + 0, + 1 + ], + [ + 1, + 0, + -1 + ], + [ + -1, + 0, + -1 + ] + ] + ], + "bounds": { + "min": [ + -1, + 0, + -1 + ], + "max": [ + 1, + 0, + 1 + ] + } + } + }, + { + "id": "BINARY_UNIT_001", + "status": "ACCEPTED", + "result": { + "schemaVersion": 1, + "variant": "STL_BINARY", + "unitScale": 0.001, + "declaredTriangleCount": 2, + "triangleCount": 2, + "removedDegenerateTriangles": 0, + "normals": [ + [ + 0, + 1, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "vertices": [ + [ + [ + -0.001, + 0, + 0.001 + ], + [ + 0.001, + 0, + 0.001 + ], + [ + 0.001, + 0, + -0.001 + ] + ], + [ + [ + -0.001, + 0, + 0.001 + ], + [ + 0.001, + 0, + -0.001 + ], + [ + -0.001, + 0, + -0.001 + ] + ] + ], + "bounds": { + "min": [ + -0.001, + 0, + -0.001 + ], + "max": [ + 0.001, + 0, + 0.001 + ] + } + } + }, + { + "id": "ASCII_UNIT_1", + "status": "ACCEPTED", + "result": { + "schemaVersion": 1, + "variant": "STL_ASCII", + "unitScale": 1, + "declaredTriangleCount": 2, + "triangleCount": 2, + "removedDegenerateTriangles": 0, + "normals": [ + [ + 0, + 1, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "vertices": [ + [ + [ + -1, + 0, + 1 + ], + [ + 1, + 0, + 1 + ], + [ + 1, + 0, + -1 + ] + ], + [ + [ + -1, + 0, + 1 + ], + [ + 1, + 0, + -1 + ], + [ + -1, + 0, + -1 + ] + ] + ], + "bounds": { + "min": [ + -1, + 0, + -1 + ], + "max": [ + 1, + 0, + 1 + ] + } + } + }, + { + "id": "DEGENERATE_TRIANGLE", + "status": "ACCEPTED", + "result": { + "schemaVersion": 1, + "variant": "STL_BINARY", + "unitScale": 1, + "declaredTriangleCount": 2, + "triangleCount": 1, + "removedDegenerateTriangles": 1, + "normals": [ + [ + 0, + 1, + 0 + ] + ], + "vertices": [ + [ + [ + -1, + 0, + 1 + ], + [ + 1, + 0, + -1 + ], + [ + -1, + 0, + -1 + ] + ] + ], + "bounds": { + "min": [ + -1, + 0, + -1 + ], + "max": [ + 1, + 0, + 1 + ] + } + } + }, + { + "id": "TRAILING_BYTES", + "status": "BLOCKED", + "code": "STL_TRAILING_BYTES" + } + ], + "desktop": { + "cases": [ + { + "byteLength": 184, + "file": "capability-binary.stl", + "globalScale": 1, + "id": "BINARY_UNIT_1", + "result": { + "bounds": { + "max": [ + 1, + 1, + 7.549790126404332e-8 + ], + "min": [ + -1, + -1, + -7.549790126404332e-8 + ] + }, + "objectCount": 1, + "polygonCount": 2, + "polygonNormals": [ + [ + 0, + 1, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "status": "ACCEPTED", + "triangleCount": 2, + "vertexCount": 4 + }, + "sha256": "50161172cd12e4240067064db6f80e74b2df69c475e3e34154e9d0523928c1ca" + }, + { + "byteLength": 184, + "file": "capability-binary.stl", + "globalScale": 0.001, + "id": "BINARY_UNIT_001", + "result": { + "bounds": { + "max": [ + 0.0010000000474974513, + 0.0010000000474974513, + 7.549790653760269e-11 + ], + "min": [ + -0.0010000000474974513, + -0.0010000000474974513, + -7.549790653760269e-11 + ] + }, + "objectCount": 1, + "polygonCount": 2, + "polygonNormals": [ + [ + 0, + 1, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "status": "ACCEPTED", + "triangleCount": 2, + "vertexCount": 4 + }, + "sha256": "50161172cd12e4240067064db6f80e74b2df69c475e3e34154e9d0523928c1ca" + }, + { + "byteLength": 184, + "file": "degenerate-binary.stl", + "globalScale": 1, + "id": "DEGENERATE_TRIANGLE", + "result": { + "bounds": { + "max": [ + 1, + 1, + 7.549790126404332e-8 + ], + "min": [ + -1, + -1, + -7.549790126404332e-8 + ] + }, + "objectCount": 1, + "polygonCount": 1, + "polygonNormals": [ + [ + 0, + 1, + 0 + ] + ], + "status": "ACCEPTED", + "triangleCount": 1, + "vertexCount": 3 + }, + "sha256": "c120bb0f218819eb89a3607c0b4f8fafd9afb599402e3b21deaa3b2be143e7d0" + }, + { + "byteLength": 188, + "file": "trailing-binary.stl", + "globalScale": 1, + "id": "TRAILING_BYTES", + "result": { + "bounds": { + "max": [ + 0, + 0, + 0 + ], + "min": [ + 0, + 0, + 0 + ] + }, + "objectCount": 1, + "polygonCount": 0, + "polygonNormals": [], + "status": "ACCEPTED", + "triangleCount": 0, + "vertexCount": 0 + }, + "sha256": "0a30aab6db1588722067000e2a08c3c6206a8ed5b7531caa0b082723700a3681" + } + ], + "nextTask": "M12-07F", + "operation": "BLENDER_STL_EDGE_PROBE", + "schemaVersion": 1, + "task": "M12-07E" + }, + "comparisons": { + "binaryAsciiNormalExact": true, + "desktopNormalExact": true, + "webUnitRatio": 1000, + "desktopUnitRatio": 999.999952502551, + "unitRatioExactWithinFloat32": true, + "degenerateTriangleExact": true, + "trailingBytes": { + "web": "BLOCKED/STL_TRAILING_BYTES", + "desktop": "ACCEPTED_EMPTY", + "parity": "STRICTER_WEB_BLOCK" + } + }, + "nextTask": "M12-07F" +} diff --git a/tests/golden/M12-07F/manifest.json b/tests/golden/M12-07F/manifest.json new file mode 100644 index 00000000..9f2788a6 --- /dev/null +++ b/tests/golden/M12-07F/manifest.json @@ -0,0 +1,59 @@ +{ + "schemaVersion": 1, + "task": "M12-07F", + "parentTask": "M12-07E", + "enablingTask": false, + "parityStateChange": false, + "runtime": "WEB_TYPESCRIPT_CHROMIUM_WORKER_AND_BLENDER_5_2", + "operation": "WEB_STL_TO_DESKTOP_ROUNDTRIP", + "assertions": { + "browserTriangles": 2, + "desktopTriangles": 2, + "normalExact": true, + "materialLossCode": "STL_MATERIAL_UNSUPPORTED", + "desktopExact": true + }, + "artifacts": { + "parentManifest": { + "path": "tests/golden/M12-07E/manifest.json", + "sha256": "19a9f460d1b939c9504dadd364cb87dab3b1769b0599035e2d2b51b47091e034" + }, + "protocol": { + "path": "web/protocol/stl-export.ts", + "sha256": "3b9c409de9fb3eaea34f82c1ddd466670d2b478845d8ff6af3c4e44cb4e04a52" + }, + "worker": { + "path": "web/app/src/workers/stl-roundtrip-test.worker.ts", + "sha256": "1da8b8281cf8ebf9ccf5fd2f42da8ed5886001ab83daba8d9fcd88980257af87" + }, + "desktopImporter": { + "path": "tools/web/check-stl-web-roundtrip.py", + "sha256": "c4cbff52ff2e7cecba7aeab47e865975955da23a0e1e8ff885ece56b894558d5" + }, + "unit": { + "path": "web/tests/unit/stl-export.test.mjs", + "sha256": "f06af242df80ec26d06e92b749449cfebaf909476198ff825207cdd4b9484102" + }, + "chromium": { + "path": "web/tests/e2e/stl-web-roundtrip.spec.ts", + "sha256": "2e979dcd030f5316fcbe28e2c19b1c99fe5d111e849d3fc1d2ea316d2159032b" + }, + "checker": { + "path": "tools/web/check-stl-web-roundtrip.mjs", + "sha256": "093de04bf80b0909eee56ea590e04e7aa4749168e2e49615c62ab39ddc0d16cf" + }, + "report": { + "path": "tests/golden/M12-07F/web-roundtrip-report.json", + "sha256": "0495c645b4280f6e7821f0ba02010e25d4accbc552e3cd7c58e052607799040e" + }, + "sourceFixture": { + "path": "tests/files/web/m12_stl_capability_v1/capability-binary.stl", + "sha256": "50161172cd12e4240067064db6f80e74b2df69c475e3e34154e9d0523928c1ca" + }, + "package": { + "path": "web/package.json", + "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" + } + }, + "nextTask": "M12-07G" +} diff --git a/tests/golden/M12-07F/web-roundtrip-report.json b/tests/golden/M12-07F/web-roundtrip-report.json new file mode 100644 index 00000000..0c3ed3d0 --- /dev/null +++ b/tests/golden/M12-07F/web-roundtrip-report.json @@ -0,0 +1,122 @@ +{ + "schemaVersion": 1, + "task": "M12-07F", + "operation": "WEB_STL_TO_DESKTOP_ROUNDTRIP", + "source": { + "sha256": "50161172cd12e4240067064db6f80e74b2df69c475e3e34154e9d0523928c1ca", + "bytes": 184 + }, + "browser": { + "imported": { + "schemaVersion": 1, + "variant": "STL_BINARY", + "unitScale": 1, + "declaredTriangleCount": 2, + "triangleCount": 2, + "removedDegenerateTriangles": 0, + "normals": [ + [ + 0, + 1, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "vertices": [ + [ + [ + -1, + 0, + 1 + ], + [ + 1, + 0, + 1 + ], + [ + 1, + 0, + -1 + ] + ], + [ + [ + -1, + 0, + 1 + ], + [ + 1, + 0, + -1 + ], + [ + -1, + 0, + -1 + ] + ] + ], + "bounds": { + "min": [ + -1, + 0, + -1 + ], + "max": [ + 1, + 0, + 1 + ] + } + }, + "outputSha256": "689d060c125c41a79d98f4ff241ebe10d9e0f1faebe6e66e507efa962dce1b2d", + "outputBytes": 184, + "lossReport": { + "schemaVersion": 1, + "operation": "STL_EXPORT_LOSS_REPORT", + "canRoundTrip": true, + "warningCount": 1, + "warnings": [ + { + "code": "STL_MATERIAL_UNSUPPORTED", + "severity": "warning", + "message": "STL has no material slots; 2 source material assignments are omitted" + } + ] + } + }, + "desktop": { + "objectCount": 1, + "operation": "DESKTOP_IMPORT_WEB_STL", + "polygonCount": 2, + "polygonNormals": [ + [ + 0, + 1, + 0 + ], + [ + 0, + 1, + 0 + ] + ], + "schemaVersion": 1, + "sourceBytes": 184, + "sourceSha256": "689d060c125c41a79d98f4ff241ebe10d9e0f1faebe6e66e507efa962dce1b2d", + "triangleCount": 2, + "vertexCount": 4 + }, + "comparison": { + "triangleCountExact": true, + "normalExact": true, + "materialLossExplicit": true + }, + "nextTask": "M12-07G" +} diff --git a/tests/golden/M12-07G/capability-report.json b/tests/golden/M12-07G/capability-report.json new file mode 100644 index 00000000..cf2a51fd --- /dev/null +++ b/tests/golden/M12-07G/capability-report.json @@ -0,0 +1,87 @@ +{ + "files": [ + { + "byteLength": 322, + "name": "capability-ascii.ply", + "sha256": "63646cab9bf6ccecb23092e5e24d04df1e0a690c866127c72a35791e99262331" + }, + { + "byteLength": 391, + "name": "capability-binary-le.ply", + "sha256": "e40fbb494b8b147c555a0fc06eb191415406bb9a6c061acf6befd8464c8c41a5" + } + ], + "nextTask": "M12-07H", + "operation": "DESKTOP_PLY_ASCII_BINARY_LE_CAPABILITY", + "operator": "wm.ply_export", + "runtime": { + "binarySha256": "d4483926610484ef9c2ad9241aae1469f934d955ebe791f1920e263e0ba85b82", + "blenderVersion": "5.2.0 LTS", + "buildBranch": "unknown", + "buildDate": "2026-07-24", + "buildHash": "unknown", + "buildPlatform": "Linux", + "buildTime": "08:03:47", + "buildType": "Release", + "versionTuple": [ + 5, + 2, + 0 + ] + }, + "schemaVersion": 1, + "settings": { + "exportColors": "NONE", + "exportNormals": true, + "exportSelectedObjects": true, + "exportTriangulatedMesh": true, + "exportUV": false, + "forwardAxis": "NEGATIVE_Z", + "globalScale": 1.0, + "upAxis": "Y" + }, + "sourceAnchor": "blender-5.2.0/source/blender/io/ply", + "task": "M12-07G", + "variants": [ + { + "asciiFormat": true, + "file": "capability-ascii.ply", + "id": "PLY_ASCII", + "semantic": { + "byteLength": 322, + "elements": [ + { + "count": 4, + "name": "vertex" + }, + { + "count": 2, + "name": "face" + } + ], + "format": "ascii", + "headerBytes": 254 + } + }, + { + "asciiFormat": false, + "file": "capability-binary-le.ply", + "id": "PLY_BINARY_LITTLE_ENDIAN", + "semantic": { + "byteLength": 391, + "elements": [ + { + "count": 4, + "name": "vertex" + }, + { + "count": 2, + "name": "face" + } + ], + "format": "binary_little_endian", + "headerBytes": 269 + } + } + ] +} diff --git a/tests/golden/M12-07G/manifest.json b/tests/golden/M12-07G/manifest.json new file mode 100644 index 00000000..37db16e5 --- /dev/null +++ b/tests/golden/M12-07G/manifest.json @@ -0,0 +1,52 @@ +{ + "schemaVersion": 1, + "task": "M12-07G", + "parentTask": "M12-07F", + "enablingTask": true, + "parityStateChange": false, + "runtime": "BLENDER_5_2_DESKTOP", + "operation": "DESKTOP_PLY_ASCII_BINARY_LE_CAPABILITY", + "assertions": { + "asciiVariant": "ascii", + "binaryVariant": "binary_little_endian", + "vertexCount": 4, + "faceCount": 2, + "binaryLittleEndian": true, + "deterministicRegeneration": true + }, + "artifacts": { + "parentManifest": { + "path": "tests/golden/M12-07F/manifest.json", + "sha256": "3cbbcb541ee123da0ef8916ac2ca8805680d539bbf8db3b4a8d331aec418148c" + }, + "generator": { + "path": "tools/web/generate-ply-capability-fixtures.py", + "sha256": "581cd84057357e3a87997cf9c07d5d5633209648ac11e44611782eb254a38ebd" + }, + "checker": { + "path": "tools/web/check-ply-capability-fixtures.mjs", + "sha256": "5280d6e57b7a722ea881e27b792c6082c5113c1577ac0e87f87cc87fdf59516c" + }, + "desktopReport": { + "path": "tests/golden/M12-07G/capability-report.json", + "sha256": "26b1ce83334b41778cef5ce2a1f2c4d34254f63d7c7573cb3fb550f93ddeabb2" + }, + "asciiFixture": { + "path": "tests/files/web/m12_ply_capability_v1/capability-ascii.ply", + "sha256": "63646cab9bf6ccecb23092e5e24d04df1e0a690c866127c72a35791e99262331" + }, + "binaryFixture": { + "path": "tests/files/web/m12_ply_capability_v1/capability-binary-le.ply", + "sha256": "e40fbb494b8b147c555a0fc06eb191415406bb9a6c061acf6befd8464c8c41a5" + }, + "runtimeInventory": { + "path": "tests/golden/M12-05A/format-inventory.json", + "sha256": "0d660b0fd8b647ebbf4e91afebd5006bd100973ab8e2507a5bfe759f477b33b4" + }, + "package": { + "path": "web/package.json", + "sha256": "cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c" + } + }, + "nextTask": "M12-07H" +} diff --git a/tests/golden/M12-07H/manifest.json b/tests/golden/M12-07H/manifest.json new file mode 100644 index 00000000..41204b69 --- /dev/null +++ b/tests/golden/M12-07H/manifest.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": 1, + "task": "M12-07H", + "parentTask": "M12-07G", + "enablingTask": false, + "parityStateChange": false, + "runtime": "BLENDER_5_2_DESKTOP_AND_CHROMIUM", + "operation": "PLY_VERTEX_FACE_COLOR_CUSTOM_MAPPING", + "artifacts": { + "parentManifest": { "path": "tests/golden/M12-07G/manifest.json", "sha256": "87df9c12f9c6eacf63051b70e9bb2eb43ebd1e5c4487b3512c45adb94cbb82ea" }, + "generator": { "path": "tools/web/generate-ply-mapping-fixtures.py", "sha256": "ffc051eccfc6973ee00cc791cdbd641fcce308b4083741e3e6ae82291d9347b7" }, + "fixtureChecker": { "path": "tools/web/check-ply-mapping-fixtures.mjs", "sha256": "2b055dbc705830848efdf4d8db8543a3ccc684de1f258732bcafefa86cf65c3a" }, + "desktopImporter": { "path": "tools/web/check-ply-web-roundtrip.py", "sha256": "6edbc6e401a1a902dcfd11c4d767e8c8620d694e40eb4ca29dd8479f9c55ef37" }, + "protocol": { "path": "web/protocol/ply-import.ts", "sha256": "058a3263fde553637840a4e9ad4e8e9d923eb52c250f8000317c7f43a228881d" }, + "worker": { "path": "web/app/src/workers/ply-roundtrip-test.worker.ts", "sha256": "f6bf5e39e83286d7b257bbdce88f4d7fa027c64651da9765567788b5cf298c71" }, + "unitTest": { "path": "web/tests/unit/ply-import.test.mjs", "sha256": "b03a5f4b4b2a974fa26e653763470b92d1433c5d352ae09ab5492b841e6e5e1f" }, + "e2eTest": { "path": "web/tests/e2e/ply-web-roundtrip.spec.ts", "sha256": "2cadebc89057655d78e9b520bb6adf9debb27c6d783cd002efee08d269636fb9" }, + "mappingReport": { "path": "tests/golden/M12-07H/mapping-report.json", "sha256": "8a02fc9e364ed79e50ef00897affa9ab63808d3cb3cdabd00fdb8ee4c26ec18a" }, + "webRoundtripReport": { "path": "tests/golden/M12-07H/web-roundtrip-report.json", "sha256": "1ca9d3b7870fcc8c9e3c9bbbd90ef48bd13bbc9ae0462312c9482f24f05f13ec" }, + "asciiFixture": { "path": "tests/files/web/m12_ply_mapping_v1/mapping-ascii.ply", "sha256": "acaa369c17252d4d089a2e3064d0c1fcdfdb2d066759ed160adf7869d3e859d5" }, + "binaryFixture": { "path": "tests/files/web/m12_ply_mapping_v1/mapping-binary-le.ply", "sha256": "d0fc195fd1a101c42ac984a2382bccdddb7d0bf60dd618e9f637c964f1b30b9e" }, + "unknownFixture": { "path": "tests/files/web/m12_ply_mapping_v1/unknown-property-ascii.ply", "sha256": "a994c7126f235d0067ce4af35f8b0c6699cc83073e2a37e982edd45ece459f33" }, + "runtimeInventory": { "path": "tests/golden/M12-05A/format-inventory.json", "sha256": "0d660b0fd8b647ebbf4e91afebd5006bd100973ab8e2507a5bfe759f477b33b4" }, + "package": { "path": "web/package.json", "sha256": "c56c653effb38f9ac9c53aa19a531ca0cdab04d4457212f286349091d5b65c22" } + }, + "nextTask": "M12-07I" +} diff --git a/tests/golden/M12-07H/mapping-report.json b/tests/golden/M12-07H/mapping-report.json new file mode 100644 index 00000000..84ebee3c --- /dev/null +++ b/tests/golden/M12-07H/mapping-report.json @@ -0,0 +1,292 @@ +{ + "files": [ + { + "byteLength": 521, + "name": "mapping-ascii.ply", + "sha256": "acaa369c17252d4d089a2e3064d0c1fcdfdb2d066759ed160adf7869d3e859d5" + }, + { + "byteLength": 568, + "name": "mapping-binary-le.ply", + "sha256": "d0fc195fd1a101c42ac984a2382bccdddb7d0bf60dd618e9f637c964f1b30b9e" + }, + { + "byteLength": 586, + "name": "unknown-property-ascii.ply", + "sha256": "a994c7126f235d0067ce4af35f8b0c6699cc83073e2a37e982edd45ece459f33" + } + ], + "nextTask": "M12-07I", + "operation": "PLY_VERTEX_FACE_COLOR_CUSTOM_MAPPING", + "operator": "wm.ply_export", + "runtime": { + "binarySha256": "d4483926610484ef9c2ad9241aae1469f934d955ebe791f1920e263e0ba85b82", + "blenderVersion": "5.2.0 LTS", + "buildBranch": "unknown", + "buildDate": "2026-07-24", + "buildHash": "unknown", + "buildPlatform": "Linux", + "buildTime": "08:03:47", + "buildType": "Release", + "versionTuple": [ + 5, + 2, + 0 + ] + }, + "schemaVersion": 1, + "settings": { + "exportAttributes": true, + "exportColors": "SRGB", + "exportNormals": true, + "exportSelectedObjects": true, + "exportTriangulatedMesh": true, + "exportUV": false, + "forwardAxis": "NEGATIVE_Z", + "globalScale": 1.0, + "upAxis": "Y" + }, + "sourceAnchor": "blender-5.2.0/source/blender/io/ply", + "task": "M12-07H", + "unknownProperty": { + "expectedCode": "PLY_UNKNOWN_PROPERTY", + "file": "unknown-property-ascii.ply", + "property": "unknown_values" + }, + "variants": [ + { + "file": "mapping-ascii.ply", + "id": "PLY_ASCII_MAPPING", + "semantic": { + "faceCount": 2, + "faces": [ + { + "customProperties": {}, + "indices": [ + 0, + 1, + 2 + ] + }, + { + "customProperties": {}, + "indices": [ + 0, + 2, + 3 + ] + } + ], + "format": "ascii", + "vertexCount": 4, + "vertices": [ + { + "color": [ + 254, + 0, + 0, + 255 + ], + "customProperties": { + "label": 1.0, + "temperature": 10.0 + }, + "normal": [ + 0.0, + 1.0, + 0.0 + ], + "position": [ + -1.0, + 0.0, + 1.0 + ] + }, + { + "color": [ + 0, + 254, + 0, + 255 + ], + "customProperties": { + "label": 2.0, + "temperature": 20.0 + }, + "normal": [ + 0.0, + 1.0, + 0.0 + ], + "position": [ + 1.0, + 0.0, + 1.0 + ] + }, + { + "color": [ + 0, + 0, + 254, + 255 + ], + "customProperties": { + "label": 3.0, + "temperature": 30.0 + }, + "normal": [ + 0.0, + 1.0, + 0.0 + ], + "position": [ + 1.0, + 0.0, + -1.0 + ] + }, + { + "color": [ + 254, + 254, + 0, + 255 + ], + "customProperties": { + "label": 4.0, + "temperature": 40.0 + }, + "normal": [ + 0.0, + 1.0, + 0.0 + ], + "position": [ + -1.0, + 0.0, + -1.0 + ] + } + ] + } + }, + { + "file": "mapping-binary-le.ply", + "id": "PLY_BINARY_LITTLE_ENDIAN_MAPPING", + "semantic": { + "faceCount": 2, + "faces": [ + { + "customProperties": {}, + "indices": [ + 0, + 1, + 2 + ] + }, + { + "customProperties": {}, + "indices": [ + 0, + 2, + 3 + ] + } + ], + "format": "binary_little_endian", + "vertexCount": 4, + "vertices": [ + { + "color": [ + 254, + 0, + 0, + 255 + ], + "customProperties": { + "label": 1.0, + "temperature": 10.0 + }, + "normal": [ + 0.0, + 1.0, + 0.0 + ], + "position": [ + -1.0, + 0.0, + 1.0 + ] + }, + { + "color": [ + 0, + 254, + 0, + 255 + ], + "customProperties": { + "label": 2.0, + "temperature": 20.0 + }, + "normal": [ + 0.0, + 1.0, + 0.0 + ], + "position": [ + 1.0, + 0.0, + 1.0 + ] + }, + { + "color": [ + 0, + 0, + 254, + 255 + ], + "customProperties": { + "label": 3.0, + "temperature": 30.0 + }, + "normal": [ + 0.0, + 1.0, + 0.0 + ], + "position": [ + 1.0, + 0.0, + -1.0 + ] + }, + { + "color": [ + 254, + 254, + 0, + 255 + ], + "customProperties": { + "label": 4.0, + "temperature": 40.0 + }, + "normal": [ + 0.0, + 1.0, + 0.0 + ], + "position": [ + -1.0, + 0.0, + -1.0 + ] + } + ] + } + } + ] +} diff --git a/tests/golden/M12-07H/web-roundtrip-report.json b/tests/golden/M12-07H/web-roundtrip-report.json new file mode 100644 index 00000000..d7b6cc0f --- /dev/null +++ b/tests/golden/M12-07H/web-roundtrip-report.json @@ -0,0 +1,179 @@ +{ + "schemaVersion": 1, + "task": "M12-07H", + "operation": "PLY_VERTEX_FACE_COLOR_CUSTOM_MAPPING", + "source": { + "asciiSha256": "acaa369c17252d4d089a2e3064d0c1fcdfdb2d066759ed160adf7869d3e859d5", + "binarySha256": "d0fc195fd1a101c42ac984a2382bccdddb7d0bf60dd618e9f637c964f1b30b9e", + "unknownSha256": "a994c7126f235d0067ce4af35f8b0c6699cc83073e2a37e982edd45ece459f33" + }, + "browser": { + "format": "ascii", + "vertexCount": 4, + "faceCount": 2, + "colors": [ + [ + 0.996078431372549, + 0, + 0, + 1 + ], + [ + 0, + 0.996078431372549, + 0, + 1 + ], + [ + 0, + 0, + 0.996078431372549, + 1 + ], + [ + 0.996078431372549, + 0.996078431372549, + 0, + 1 + ] + ], + "customProperties": [ + { + "temperature": 10, + "label": 1 + }, + { + "temperature": 20, + "label": 2 + }, + { + "temperature": 30, + "label": 3 + }, + { + "temperature": 40, + "label": 4 + } + ], + "outputSha256": "399a06c8f593dcdeda9ce9f4c6be74fa83b9fc905ae22a01f4af414284585cf5", + "outputBytes": 509, + "lossReport": { + "schemaVersion": 1, + "operation": "PLY_IMPORT_LOSS_REPORT", + "canImport": true, + "warningCount": 0, + "warnings": [] + }, + "binarySemanticEqual": true, + "unknownPropertyLoss": { + "schemaVersion": 1, + "operation": "PLY_IMPORT_LOSS_REPORT", + "canImport": true, + "warningCount": 1, + "warnings": [ + { + "code": "PLY_UNKNOWN_PROPERTY", + "severity": "warning", + "element": "vertex", + "property": "unknown_values", + "message": "vertex list property unknown_values is not mapped" + } + ] + } + }, + "desktop": { + "attributes": [ + { + "dataType": "FLOAT_COLOR", + "domain": "POINT", + "name": "Col", + "values": [ + [ + 0.9911020398139954, + 0, + 0, + 1 + ], + [ + 0, + 0.9911020398139954, + 0, + 1 + ], + [ + 0, + 0, + 0.9911020398139954, + 1 + ], + [ + 0.9911020398139954, + 0.9911020398139954, + 0, + 1 + ] + ] + }, + { + "dataType": "FLOAT", + "domain": "POINT", + "name": "label", + "values": [ + 1, + 2, + 3, + 4 + ] + }, + { + "dataType": "FLOAT", + "domain": "POINT", + "name": "temperature", + "values": [ + 10, + 20, + 30, + 40 + ] + } + ], + "objectCount": 1, + "operation": "DESKTOP_IMPORT_WEB_PLY", + "polygonCount": 2, + "positions": [ + [ + -1, + 0, + 1 + ], + [ + 1, + 0, + 1 + ], + [ + 1, + 0, + -1 + ], + [ + -1, + 0, + -1 + ] + ], + "schemaVersion": 1, + "sourceBytes": 509, + "sourceSha256": "399a06c8f593dcdeda9ce9f4c6be74fa83b9fc905ae22a01f4af414284585cf5", + "triangleCount": 2, + "vertexCount": 4 + }, + "comparisons": { + "vertexCountExact": true, + "faceCountExact": true, + "positionExact": true, + "customPropertiesPresent": true, + "colorMapped": true + }, + "nextTask": "M12-07I" +} diff --git a/tests/golden/M12-07I/manifest.json b/tests/golden/M12-07I/manifest.json new file mode 100644 index 00000000..11d4a282 --- /dev/null +++ b/tests/golden/M12-07I/manifest.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": 1, + "task": "M12-07I", + "parentTask": "M12-07H", + "enablingTask": false, + "parityStateChange": false, + "runtime": "CHROMIUM_WORKER", + "operation": "PLY_NEGATIVE_FORMAT_LIST_COUNT", + "artifacts": { + "parentManifest": { "path": "tests/golden/M12-07H/manifest.json", "sha256": "0665f7c8278cad182f9af0be6af80838fb204e8e1160e5a0cdeb58e052f3b878" }, + "protocol": { "path": "web/protocol/ply-import.ts", "sha256": "058a3263fde553637840a4e9ad4e8e9d923eb52c250f8000317c7f43a228881d" }, + "generator": { "path": "tools/web/generate-ply-negative-fixtures.mjs", "sha256": "9c09707bdfe73ba467bbfbf61ed3846fb59fd33ab5563a3a62c8032722ff6938" }, + "checker": { "path": "tools/web/check-ply-negative-fixtures.mjs", "sha256": "b1d047d4cb1fa15dff7821687e7028ad073fdc62d4c76f60a2656732c0ec5e2b" }, + "unitTest": { "path": "web/tests/unit/ply-negative.test.mjs", "sha256": "60439f9e6bc221df8866956bf926816a347e85828b5a93deb26ee23015cf449a" }, + "e2eTest": { "path": "web/tests/e2e/ply-negative.spec.ts", "sha256": "6509a29d6842f3423d4dfcc40dbd52a959a5efa7ee1121143dce06e5bbc4a460" }, + "negativeReport": { "path": "tests/golden/M12-07I/negative-report.json", "sha256": "fbe2830d1b19294ea98eea66c3e00125bc0809a4bb8a750612939cbe1f5acca9" }, + "bigEndianFixture": { "path": "tests/files/web/m12_ply_negative_v1/big-endian.ply", "sha256": "cda92943a3690529fbb3463d328b6796ac0d07e19139450556f7411fc1f031e3" }, + "malformedListFixture": { "path": "tests/files/web/m12_ply_negative_v1/malformed-list-ascii.ply", "sha256": "a6a576b7a04d919b540520a6f43a89c089f0d706a17fd8b390711fff6b8b03df" }, + "oversizedCountFixture": { "path": "tests/files/web/m12_ply_negative_v1/oversized-count-ascii.ply", "sha256": "fcd99e0838025429420a47466251cfef80e638d2b5816ad14d5aa696364525bb" } + }, + "nextTask": "M12-07J" +} diff --git a/tests/golden/M12-07I/negative-report.json b/tests/golden/M12-07I/negative-report.json new file mode 100644 index 00000000..633ef401 --- /dev/null +++ b/tests/golden/M12-07I/negative-report.json @@ -0,0 +1,40 @@ +{ + "schemaVersion": 1, + "task": "M12-07I", + "operation": "PLY_NEGATIVE_FORMAT_LIST_COUNT", + "cases": [ + { + "id": "big-endian", + "file": "big-endian.ply", + "expectedCode": "PLY_FORMAT_UNSUPPORTED" + }, + { + "id": "malformed-list", + "file": "malformed-list-ascii.ply", + "expectedCode": "PLY_DATA_TRUNCATED" + }, + { + "id": "oversized-count", + "file": "oversized-count-ascii.ply", + "expectedCode": "PLY_IMPORT_BUDGET_EXCEEDED: vertex" + } + ], + "files": [ + { + "name": "big-endian.ply", + "byteLength": 179, + "sha256": "cda92943a3690529fbb3463d328b6796ac0d07e19139450556f7411fc1f031e3" + }, + { + "name": "malformed-list-ascii.ply", + "byteLength": 179, + "sha256": "a6a576b7a04d919b540520a6f43a89c089f0d706a17fd8b390711fff6b8b03df" + }, + { + "name": "oversized-count-ascii.ply", + "byteLength": 159, + "sha256": "fcd99e0838025429420a47466251cfef80e638d2b5816ad14d5aa696364525bb" + } + ], + "nextTask": "M12-07J" +} diff --git a/tests/golden/M12-07J/io-format-recovery-report.json b/tests/golden/M12-07J/io-format-recovery-report.json new file mode 100644 index 00000000..95047007 --- /dev/null +++ b/tests/golden/M12-07J/io-format-recovery-report.json @@ -0,0 +1,376 @@ +{ + "schemaVersion": 1, + "task": "M12-07J", + "operation": "IO_FORMAT_THREE_WAY_RECOVERY", + "formats": { + "OBJ": { + "sourceSha256": "4ed3dced8445b03754cd3916ae2e8cf0dff85e6a5882f1c6f0a4a4e4c5251f6a", + "cancel": { + "schemaVersion": 1, + "operationId": "obj-cancel-1", + "format": "OBJ", + "operation": "IMPORT", + "status": "CANCELLED", + "workerGeneration": 1, + "baseRevision": 4, + "candidateRevision": 5, + "inputBytes": 670, + "inputSha256": "4ed3dced8445b03754cd3916ae2e8cf0dff85e6a5882f1c6f0a4a4e4c5251f6a", + "outputBytes": 0, + "outputSha256": null, + "temporaryBytes": 0, + "liveRequests": 0, + "publishedResults": 0, + "committed": false, + "errorCode": "IO_FORMAT_OPERATION_CANCELLED" + }, + "oom": { + "schemaVersion": 1, + "operationId": "obj-oom-1", + "format": "OBJ", + "operation": "IMPORT", + "status": "BLOCKED", + "workerGeneration": 1, + "baseRevision": 4, + "candidateRevision": 5, + "inputBytes": 524289, + "inputSha256": "42b47b7a78c2a45c3ab9694e86574825450221fe25d23bf505b97af4ca20283b", + "outputBytes": 0, + "outputSha256": null, + "temporaryBytes": 0, + "liveRequests": 0, + "publishedResults": 0, + "committed": false, + "errorCode": "IO_FORMAT_OOM" + }, + "first": { + "schemaVersion": 1, + "operationId": "obj-first-1", + "format": "OBJ", + "operation": "IMPORT", + "status": "COMMITTED", + "workerGeneration": 1, + "baseRevision": 4, + "candidateRevision": 5, + "inputBytes": 670, + "inputSha256": "4ed3dced8445b03754cd3916ae2e8cf0dff85e6a5882f1c6f0a4a4e4c5251f6a", + "outputBytes": 530, + "outputSha256": "caa36e17111d6dbc9369581b9a52ecb4ff81a951eb11ff3094f80ac273c4c53d", + "temporaryBytes": 0, + "liveRequests": 0, + "publishedResults": 1, + "committed": true + }, + "second": { + "schemaVersion": 1, + "operationId": "obj-second-2", + "format": "OBJ", + "operation": "IMPORT", + "status": "COMMITTED", + "workerGeneration": 2, + "baseRevision": 4, + "candidateRevision": 5, + "inputBytes": 670, + "inputSha256": "4ed3dced8445b03754cd3916ae2e8cf0dff85e6a5882f1c6f0a4a4e4c5251f6a", + "outputBytes": 530, + "outputSha256": "caa36e17111d6dbc9369581b9a52ecb4ff81a951eb11ff3094f80ac273c4c53d", + "temporaryBytes": 0, + "liveRequests": 0, + "publishedResults": 1, + "committed": true + }, + "recovered": { + "schemaVersion": 1, + "operationId": "obj-first-1", + "format": "OBJ", + "operation": "IMPORT", + "status": "RECOVERED", + "workerGeneration": 2, + "baseRevision": 4, + "candidateRevision": 5, + "inputBytes": 670, + "inputSha256": "4ed3dced8445b03754cd3916ae2e8cf0dff85e6a5882f1c6f0a4a4e4c5251f6a", + "outputBytes": 530, + "outputSha256": "caa36e17111d6dbc9369581b9a52ecb4ff81a951eb11ff3094f80ac273c4c53d", + "temporaryBytes": 0, + "liveRequests": 0, + "publishedResults": 1, + "committed": true, + "errorCode": "IO_FORMAT_WORKER_RESTARTED" + }, + "small": { + "schemaVersion": 1, + "operationId": "obj-small-3", + "format": "OBJ", + "operation": "IMPORT", + "status": "COMMITTED", + "workerGeneration": 3, + "baseRevision": 4, + "candidateRevision": 5, + "inputBytes": 670, + "inputSha256": "4ed3dced8445b03754cd3916ae2e8cf0dff85e6a5882f1c6f0a4a4e4c5251f6a", + "outputBytes": 530, + "outputSha256": "caa36e17111d6dbc9369581b9a52ecb4ff81a951eb11ff3094f80ac273c4c53d", + "temporaryBytes": 0, + "liveRequests": 0, + "publishedResults": 1, + "committed": true + }, + "hashes": { + "first": "caa36e17111d6dbc9369581b9a52ecb4ff81a951eb11ff3094f80ac273c4c53d", + "second": "caa36e17111d6dbc9369581b9a52ecb4ff81a951eb11ff3094f80ac273c4c53d", + "small": "caa36e17111d6dbc9369581b9a52ecb4ff81a951eb11ff3094f80ac273c4c53d" + } + }, + "STL": { + "sourceSha256": "50161172cd12e4240067064db6f80e74b2df69c475e3e34154e9d0523928c1ca", + "cancel": { + "schemaVersion": 1, + "operationId": "stl-cancel-1", + "format": "STL", + "operation": "IMPORT", + "status": "CANCELLED", + "workerGeneration": 1, + "baseRevision": 4, + "candidateRevision": 5, + "inputBytes": 184, + "inputSha256": "50161172cd12e4240067064db6f80e74b2df69c475e3e34154e9d0523928c1ca", + "outputBytes": 0, + "outputSha256": null, + "temporaryBytes": 0, + "liveRequests": 0, + "publishedResults": 0, + "committed": false, + "errorCode": "IO_FORMAT_OPERATION_CANCELLED" + }, + "oom": { + "schemaVersion": 1, + "operationId": "stl-oom-1", + "format": "STL", + "operation": "IMPORT", + "status": "BLOCKED", + "workerGeneration": 1, + "baseRevision": 4, + "candidateRevision": 5, + "inputBytes": 524289, + "inputSha256": "42b47b7a78c2a45c3ab9694e86574825450221fe25d23bf505b97af4ca20283b", + "outputBytes": 0, + "outputSha256": null, + "temporaryBytes": 0, + "liveRequests": 0, + "publishedResults": 0, + "committed": false, + "errorCode": "IO_FORMAT_OOM" + }, + "first": { + "schemaVersion": 1, + "operationId": "stl-first-1", + "format": "STL", + "operation": "IMPORT", + "status": "COMMITTED", + "workerGeneration": 1, + "baseRevision": 4, + "candidateRevision": 5, + "inputBytes": 184, + "inputSha256": "50161172cd12e4240067064db6f80e74b2df69c475e3e34154e9d0523928c1ca", + "outputBytes": 184, + "outputSha256": "689d060c125c41a79d98f4ff241ebe10d9e0f1faebe6e66e507efa962dce1b2d", + "temporaryBytes": 0, + "liveRequests": 0, + "publishedResults": 1, + "committed": true + }, + "second": { + "schemaVersion": 1, + "operationId": "stl-second-2", + "format": "STL", + "operation": "IMPORT", + "status": "COMMITTED", + "workerGeneration": 2, + "baseRevision": 4, + "candidateRevision": 5, + "inputBytes": 184, + "inputSha256": "50161172cd12e4240067064db6f80e74b2df69c475e3e34154e9d0523928c1ca", + "outputBytes": 184, + "outputSha256": "689d060c125c41a79d98f4ff241ebe10d9e0f1faebe6e66e507efa962dce1b2d", + "temporaryBytes": 0, + "liveRequests": 0, + "publishedResults": 1, + "committed": true + }, + "recovered": { + "schemaVersion": 1, + "operationId": "stl-first-1", + "format": "STL", + "operation": "IMPORT", + "status": "RECOVERED", + "workerGeneration": 2, + "baseRevision": 4, + "candidateRevision": 5, + "inputBytes": 184, + "inputSha256": "50161172cd12e4240067064db6f80e74b2df69c475e3e34154e9d0523928c1ca", + "outputBytes": 184, + "outputSha256": "689d060c125c41a79d98f4ff241ebe10d9e0f1faebe6e66e507efa962dce1b2d", + "temporaryBytes": 0, + "liveRequests": 0, + "publishedResults": 1, + "committed": true, + "errorCode": "IO_FORMAT_WORKER_RESTARTED" + }, + "small": { + "schemaVersion": 1, + "operationId": "stl-small-3", + "format": "STL", + "operation": "IMPORT", + "status": "COMMITTED", + "workerGeneration": 3, + "baseRevision": 4, + "candidateRevision": 5, + "inputBytes": 184, + "inputSha256": "50161172cd12e4240067064db6f80e74b2df69c475e3e34154e9d0523928c1ca", + "outputBytes": 184, + "outputSha256": "689d060c125c41a79d98f4ff241ebe10d9e0f1faebe6e66e507efa962dce1b2d", + "temporaryBytes": 0, + "liveRequests": 0, + "publishedResults": 1, + "committed": true + }, + "hashes": { + "first": "689d060c125c41a79d98f4ff241ebe10d9e0f1faebe6e66e507efa962dce1b2d", + "second": "689d060c125c41a79d98f4ff241ebe10d9e0f1faebe6e66e507efa962dce1b2d", + "small": "689d060c125c41a79d98f4ff241ebe10d9e0f1faebe6e66e507efa962dce1b2d" + } + }, + "PLY": { + "sourceSha256": "acaa369c17252d4d089a2e3064d0c1fcdfdb2d066759ed160adf7869d3e859d5", + "cancel": { + "schemaVersion": 1, + "operationId": "ply-cancel-1", + "format": "PLY", + "operation": "IMPORT", + "status": "CANCELLED", + "workerGeneration": 1, + "baseRevision": 4, + "candidateRevision": 5, + "inputBytes": 521, + "inputSha256": "acaa369c17252d4d089a2e3064d0c1fcdfdb2d066759ed160adf7869d3e859d5", + "outputBytes": 0, + "outputSha256": null, + "temporaryBytes": 0, + "liveRequests": 0, + "publishedResults": 0, + "committed": false, + "errorCode": "IO_FORMAT_OPERATION_CANCELLED" + }, + "oom": { + "schemaVersion": 1, + "operationId": "ply-oom-1", + "format": "PLY", + "operation": "IMPORT", + "status": "BLOCKED", + "workerGeneration": 1, + "baseRevision": 4, + "candidateRevision": 5, + "inputBytes": 524289, + "inputSha256": "42b47b7a78c2a45c3ab9694e86574825450221fe25d23bf505b97af4ca20283b", + "outputBytes": 0, + "outputSha256": null, + "temporaryBytes": 0, + "liveRequests": 0, + "publishedResults": 0, + "committed": false, + "errorCode": "IO_FORMAT_OOM" + }, + "first": { + "schemaVersion": 1, + "operationId": "ply-first-1", + "format": "PLY", + "operation": "IMPORT", + "status": "COMMITTED", + "workerGeneration": 1, + "baseRevision": 4, + "candidateRevision": 5, + "inputBytes": 521, + "inputSha256": "acaa369c17252d4d089a2e3064d0c1fcdfdb2d066759ed160adf7869d3e859d5", + "outputBytes": 509, + "outputSha256": "399a06c8f593dcdeda9ce9f4c6be74fa83b9fc905ae22a01f4af414284585cf5", + "temporaryBytes": 0, + "liveRequests": 0, + "publishedResults": 1, + "committed": true + }, + "second": { + "schemaVersion": 1, + "operationId": "ply-second-2", + "format": "PLY", + "operation": "IMPORT", + "status": "COMMITTED", + "workerGeneration": 2, + "baseRevision": 4, + "candidateRevision": 5, + "inputBytes": 521, + "inputSha256": "acaa369c17252d4d089a2e3064d0c1fcdfdb2d066759ed160adf7869d3e859d5", + "outputBytes": 509, + "outputSha256": "399a06c8f593dcdeda9ce9f4c6be74fa83b9fc905ae22a01f4af414284585cf5", + "temporaryBytes": 0, + "liveRequests": 0, + "publishedResults": 1, + "committed": true + }, + "recovered": { + "schemaVersion": 1, + "operationId": "ply-first-1", + "format": "PLY", + "operation": "IMPORT", + "status": "RECOVERED", + "workerGeneration": 2, + "baseRevision": 4, + "candidateRevision": 5, + "inputBytes": 521, + "inputSha256": "acaa369c17252d4d089a2e3064d0c1fcdfdb2d066759ed160adf7869d3e859d5", + "outputBytes": 509, + "outputSha256": "399a06c8f593dcdeda9ce9f4c6be74fa83b9fc905ae22a01f4af414284585cf5", + "temporaryBytes": 0, + "liveRequests": 0, + "publishedResults": 1, + "committed": true, + "errorCode": "IO_FORMAT_WORKER_RESTARTED" + }, + "small": { + "schemaVersion": 1, + "operationId": "ply-small-3", + "format": "PLY", + "operation": "IMPORT", + "status": "COMMITTED", + "workerGeneration": 3, + "baseRevision": 4, + "candidateRevision": 5, + "inputBytes": 521, + "inputSha256": "acaa369c17252d4d089a2e3064d0c1fcdfdb2d066759ed160adf7869d3e859d5", + "outputBytes": 509, + "outputSha256": "399a06c8f593dcdeda9ce9f4c6be74fa83b9fc905ae22a01f4af414284585cf5", + "temporaryBytes": 0, + "liveRequests": 0, + "publishedResults": 1, + "committed": true + }, + "hashes": { + "first": "399a06c8f593dcdeda9ce9f4c6be74fa83b9fc905ae22a01f4af414284585cf5", + "second": "399a06c8f593dcdeda9ce9f4c6be74fa83b9fc905ae22a01f4af414284585cf5", + "small": "399a06c8f593dcdeda9ce9f4c6be74fa83b9fc905ae22a01f4af414284585cf5" + } + } + }, + "assertions": { + "formats": [ + "OBJ", + "STL", + "PLY" + ], + "cancellationUnpublished": true, + "oomUnpublished": true, + "restartHashStable": true, + "smallRecoveryStable": true + }, + "nextTask": "M13-01A" +} diff --git a/tests/golden/M12-07J/manifest.json b/tests/golden/M12-07J/manifest.json new file mode 100644 index 00000000..fe389a9f --- /dev/null +++ b/tests/golden/M12-07J/manifest.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "task": "M12-07J", + "parentTask": "M12-07I", + "enablingTask": false, + "parityStateChange": false, + "runtime": "CHROMIUM_WORKER", + "operation": "IO_FORMAT_THREE_WAY_RECOVERY", + "artifacts": { + "parentManifest": { "path": "tests/golden/M12-07I/manifest.json", "sha256": "02be4b2e2cabecf4a239ba52be385b926a70e794c6f8c745d89dada568e6d674" }, + "protocol": { "path": "web/protocol/io-format-recovery.ts", "sha256": "7e352539e3300969c7409c34d6056eb6ad31055431ffce84b1b0a459c335480a" }, + "worker": { "path": "web/app/src/workers/io-format-recovery-test.worker.ts", "sha256": "63b78fbf25132cad28147ef68731f2cd212a26c96b464fdec349a13ebaa1174f" }, + "testingAdapter": { "path": "web/app/src/testing/io-format-recovery.ts", "sha256": "cfcebb6ec38936a66983957b0dede716871cfbfbea3cb53cddc16f3e24fb19b0" }, + "unitTest": { "path": "web/tests/unit/io-format-recovery.test.mjs", "sha256": "aaed1f2abe4789b084c8a6a60bcce8595d38220d7e6678a93924cd05c5716b4f" }, + "e2eTest": { "path": "web/tests/e2e/io-format-recovery.spec.ts", "sha256": "5c1750fa408e1297fc43f2e5749be3e9ac08a16b86265d22f0f06053f52b3bd7" }, + "checker": { "path": "tools/web/check-io-format-recovery.mjs", "sha256": "6ef6bc4a168f8675a4933a06c296f61076b9ea64cec25b295e961a9b610ab1a2" }, + "report": { "path": "tests/golden/M12-07J/io-format-recovery-report.json", "sha256": "35d4fe10d8a4fa84d6e05a85f20ca50975d93a86df41e73c7bbc5875edc4fc70" } + }, + "nextTask": "M13-01A" +} diff --git a/tests/golden/M13-01A/entry-inventory.json b/tests/golden/M13-01A/entry-inventory.json new file mode 100644 index 00000000..c3bd1e7c --- /dev/null +++ b/tests/golden/M13-01A/entry-inventory.json @@ -0,0 +1,156 @@ +{ + "executionPolicy": { + "addon": "DENY", + "autorun": "DENY", + "driverExpression": "DENY", + "handler": "DENY", + "pythonConsole": "DENY", + "text": "READ_METADATA_ONLY" + }, + "fixture": { + "byteLength": 497845, + "name": "script-entry-inventory.blend", + "sha256": "bd6375d02908bfcc57ff7cdeec3f9827bfc4336d1e46e165c5fbbf4d04f19645" + }, + "inventory": { + "addons": { + "defaultExecution": "DENY", + "enabledAddons": [ + "bl_pkg", + "cycles", + "io_anim_bvh", + "io_curve_svg", + "io_mesh_uv_layout", + "io_scene_fbx", + "io_scene_gltf2", + "pose_library" + ], + "operatorIds": [ + "preferences.addon_install", + "preferences.addon_enable", + "preferences.addon_disable", + "preferences.addon_remove" + ] + }, + "autorun": { + "defaultExecution": "DENY", + "moduleTextNames": [ + "ModuleAutorun.py" + ] + }, + "driverExpressions": { + "count": 1, + "defaultExecution": "DENY", + "entries": [ + { + "arrayIndex": 0, + "dataPath": "location", + "expression": "var * 2", + "object": "M13 Script Inventory Object", + "variableCount": 1 + } + ] + }, + "handlers": { + "defaultExecution": "DENY", + "groups": [ + "animation_playback_post", + "animation_playback_pre", + "annotation_post", + "annotation_pre", + "blend_import_post", + "blend_import_pre", + "composite_cancel", + "composite_post", + "composite_pre", + "depsgraph_update_post", + "depsgraph_update_pre", + "exit_pre", + "frame_change_post", + "frame_change_pre", + "load_factory_preferences_post", + "load_factory_startup_post", + "load_post", + "load_post_fail", + "load_pre", + "object_bake_cancel", + "object_bake_complete", + "object_bake_pre", + "redo_post", + "redo_pre", + "render_cancel", + "render_complete", + "render_init", + "render_post", + "render_pre", + "render_stats", + "render_write", + "save_post", + "save_post_fail", + "save_pre", + "translation_update_post", + "undo_post", + "undo_pre", + "version_update", + "xr_session_start_pre" + ] + }, + "pythonConsole": { + "available": true, + "operatorIds": [ + "console.execute", + "console.history_append", + "console.scrollback_append" + ] + }, + "text": { + "count": 3, + "entries": [ + { + "byteLength": 29, + "filepath": "//scripts/external_project.py", + "internal": false, + "lineCount": 2, + "name": "ExternalProject.py", + "useModule": false + }, + { + "byteLength": 23, + "filepath": "", + "internal": true, + "lineCount": 3, + "name": "InternalSafe.py", + "useModule": false + }, + { + "byteLength": 37, + "filepath": "", + "internal": true, + "lineCount": 3, + "name": "ModuleAutorun.py", + "useModule": true + } + ] + } + }, + "nextTask": "M13-01B", + "operation": "BLENDER_SCRIPT_ENTRY_INVENTORY", + "runtime": { + "binarySha256": "d4483926610484ef9c2ad9241aae1469f934d955ebe791f1920e263e0ba85b82", + "blenderVersion": "5.2.0 LTS", + "buildBranch": "unknown", + "buildDate": "2026-07-24", + "buildHash": "unknown", + "buildPlatform": "Linux", + "buildTime": "08:03:47", + "buildType": "Release", + "versionTuple": [ + 5, + 2, + 0 + ] + }, + "schemaVersion": 1, + "sourceAnchor": "blender-5.2.0/source/blender/python", + "task": "M13-01A" +} diff --git a/tests/golden/M13-01A/manifest.json b/tests/golden/M13-01A/manifest.json new file mode 100644 index 00000000..1c14f961 --- /dev/null +++ b/tests/golden/M13-01A/manifest.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "task": "M13-01A", + "parentTask": "M12-07J", + "enablingTask": true, + "parityStateChange": false, + "runtime": "BLENDER_5_2_DESKTOP", + "operation": "BLENDER_SCRIPT_ENTRY_INVENTORY", + "artifacts": { + "parentManifest": { "path": "tests/golden/M12-07J/manifest.json", "sha256": "a1c4cf9c2cc300ace388e6c430bd1aa991511a7d1c5482c638fb298bb362cd02" }, + "generator": { "path": "tools/web/generate-script-entry-inventory.py", "sha256": "b72667493cfe9957161ef3fb9814180e0cfad5f8aaa1c60c9b6f132e915f3d6f" }, + "checker": { "path": "tools/web/check-script-entry-inventory.mjs", "sha256": "68478f15de4d63ed175e6b580761fd478b1fe9bccbfcaeee82218d13063dfa51" }, + "report": { "path": "tests/golden/M13-01A/entry-inventory.json", "sha256": "e024995c53f01beaf725f281f74a6e5ec6018a53435da61a66f5e58a9e50973c" }, + "fixture": { "path": "tests/files/web/m13_script_entry_v1/script-entry-inventory.blend", "sha256": "bd6375d02908bfcc57ff7cdeec3f9827bfc4336d1e46e165c5fbbf4d04f19645" }, + "runtimeInventory": { "path": "tests/golden/M12-05A/format-inventory.json", "sha256": "0d660b0fd8b647ebbf4e91afebd5006bd100973ab8e2507a5bfe759f477b33b4" } + }, + "nextTask": "M13-01B" +} diff --git a/tests/golden/M13-01B/manifest.json b/tests/golden/M13-01B/manifest.json new file mode 100644 index 00000000..84ce2bd3 --- /dev/null +++ b/tests/golden/M13-01B/manifest.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "task": "M13-01B", + "parentTask": "M13-01A", + "enablingTask": false, + "parityStateChange": false, + "runtime": "BLENDER_WASM_MAIN_AND_CHROMIUM", + "operation": "BLEND_OPEN_SCRIPT_METADATA_ONLY", + "artifacts": { + "parentManifest": { "path": "tests/golden/M13-01A/manifest.json", "sha256": "d4a305033343ef5fb1ffc073617b59d9012f64b80ecb233e743fb0789a8b4893" }, + "checker": { "path": "tools/web/check-script-open-metadata.mjs", "sha256": "66396d5c9a5294021ae077bb162278c74d46de8b52ac8a444a5de4f6d84cfe05" }, + "e2eTest": { "path": "web/tests/e2e/script-open-metadata.spec.ts", "sha256": "df6412cda113c830996e08c3d66a035dc1ac309e392f5946a762d11121b1926c" }, + "report": { "path": "tests/golden/M13-01B/open-metadata-report.json", "sha256": "f92470e57c048452134664f7f6208e50b6f087dbcbc33369e6b882c51813990c" }, + "fixture": { "path": "tests/files/web/script_scene.blend", "sha256": "2b8fbdb05419e1e61d64777998d6a3b2562f726c17f548cd1852efc723e1a037" } + }, + "nextTask": "M13-01C" +} diff --git a/tests/golden/M13-01B/open-metadata-report.json b/tests/golden/M13-01B/open-metadata-report.json new file mode 100644 index 00000000..a5751139 --- /dev/null +++ b/tests/golden/M13-01B/open-metadata-report.json @@ -0,0 +1,31 @@ +{ + "schemaVersion": 1, + "task": "M13-01B", + "operation": "BLEND_OPEN_SCRIPT_METADATA_ONLY", + "fixture": { + "path": "tests/files/web/script_scene.blend", + "byteLength": 490191, + "sha256": "2b8fbdb05419e1e61d64777998d6a3b2562f726c17f548cd1852efc723e1a037" + }, + "sources": [ + { + "name": "ExternalProject.py", + "executionStatus": "BLOCKED", + "readOnly": true + }, + { + "name": "InternalSafe.py", + "executionStatus": "BLOCKED", + "readOnly": true + }, + { + "name": "ModuleAutorun.py", + "executionStatus": "BLOCKED", + "readOnly": true, + "moduleAutorunRequested": true, + "errorCode": "SCRIPT_POLICY_DENIED" + } + ], + "execution": "DENY", + "nextTask": "M13-01C" +} diff --git a/tests/golden/M13-01C/manifest.json b/tests/golden/M13-01C/manifest.json new file mode 100644 index 00000000..98939f89 --- /dev/null +++ b/tests/golden/M13-01C/manifest.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "task": "M13-01C", + "parentTask": "M13-01B", + "enablingTask": false, + "parityStateChange": false, + "runtime": "WASM_PROTOCOL_NODE", + "operation": "SCRIPT_DEFAULT_DENY_POLICY_CODES", + "artifacts": { + "parentManifest": { "path": "tests/golden/M13-01B/manifest.json", "sha256": "0bb0abdb9f4d250a15c3889a4fb5c2630b8c416f4eb5c6523aa30097c8e45fd3" }, + "checker": { "path": "tools/web/check-script-policy-codes.mjs", "sha256": "22588c2198bc8c425ab8e104e8559f0053654ae778aaea3783ec7d54822bc8f4" }, + "unitTest": { "path": "web/tests/unit/script-policy-codes.test.mjs", "sha256": "b5a52b8e707963545f1cd71f1a148fa9c9b9d1e4b4c5f51c87d33f8bf3777430" }, + "report": { "path": "tests/golden/M13-01C/policy-codes-report.json", "sha256": "956db548ee71688a4b89c9a993259d3e57129cd4abe9efd1b9397b3e30b1bf84" } + }, + "nextTask": "M13-01D" +} diff --git a/tests/golden/M13-01C/policy-codes-report.json b/tests/golden/M13-01C/policy-codes-report.json new file mode 100644 index 00000000..01c276b3 --- /dev/null +++ b/tests/golden/M13-01C/policy-codes-report.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "task": "M13-01C", + "operation": "SCRIPT_DEFAULT_DENY_POLICY_CODES", + "deniedEntries": [ + { + "entry": "autorun", + "code": "SCRIPT_POLICY_DENIED" + }, + { + "entry": "driverExpressions", + "code": "DRIVER_EXECUTION_BLOCKED" + }, + { + "entry": "addonInstall", + "code": "ADDON_INSTALL_BLOCKED" + } + ], + "approvedKeySandboxCode": "SCRIPT_SANDBOX_UNAVAILABLE", + "nextTask": "M13-01D" +} diff --git a/tests/golden/M13-01D/manifest.json b/tests/golden/M13-01D/manifest.json new file mode 100644 index 00000000..5ab327c0 --- /dev/null +++ b/tests/golden/M13-01D/manifest.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "task": "M13-01D", + "parentTask": "M13-01C", + "enablingTask": false, + "parityStateChange": false, + "runtime": "STATIC_PRODUCTION_SOURCE", + "operation": "SCRIPT_UI_DIRECT_EVAL_BYPASS_SCAN", + "artifacts": { + "parentManifest": { "path": "tests/golden/M13-01C/manifest.json", "sha256": "b35728fcd8e6267a3e5ee925d45abde019597b20eeb4c4c8e0a373ca99907d9b" }, + "checker": { "path": "tools/web/check-script-ui-bypass.mjs", "sha256": "f34cb64891c2b22bc3da353f6b864ba3e558d3c286cf716bcb778d9a542cb3d9" }, + "report": { "path": "tests/golden/M13-01D/ui-bypass-report.json", "sha256": "6b050092088508ebd5d769d95a2e66f0cd4020c97f3407724a19b9707f51f6dd" } + }, + "nextTask": "M13-01E" +} diff --git a/tests/golden/M13-01D/ui-bypass-report.json b/tests/golden/M13-01D/ui-bypass-report.json new file mode 100644 index 00000000..b69d234f --- /dev/null +++ b/tests/golden/M13-01D/ui-bypass-report.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": 1, + "task": "M13-01D", + "operation": "SCRIPT_UI_DIRECT_EVAL_BYPASS_SCAN", + "roots": [ + "web/app/src/app", + "web/app/src/workers", + "web/protocol" + ], + "scannedFiles": 176, + "violations": [], + "policyEntrypoints": [ + "gateScriptExecution", + "gateServerScriptJob", + "parseScriptSourceInventory" + ], + "execution": "DENY", + "nextTask": "M13-01E" +} diff --git a/tests/golden/M13-01E/manifest.json b/tests/golden/M13-01E/manifest.json new file mode 100644 index 00000000..60d180bd --- /dev/null +++ b/tests/golden/M13-01E/manifest.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "task": "M13-01E", + "parentTask": "M13-01D", + "enablingTask": false, + "parityStateChange": false, + "runtime": "CHROMIUM_WEB_ENGINE", + "operation": "SCRIPT_TEXT_SAVE_REOPEN", + "artifacts": { + "parentManifest": { "path": "tests/golden/M13-01D/manifest.json", "sha256": "6b28688b3ebcce5629bcfc437d4ca903d0b1e053b32a796690ee4e021726c75b" }, + "checker": { "path": "tools/web/check-script-save-reopen.mjs", "sha256": "7fc9a370cb472636e2699565cb21a1450e3cc8a2c90ffdcdff96533b344afa7c" }, + "e2eTest": { "path": "web/tests/e2e/script-save-reopen.spec.ts", "sha256": "481f78f3a0cce923b67ab14436cc23cbcd2ce66725de29dc874fea4fb5801227" }, + "report": { "path": "tests/golden/M13-01E/script-save-reopen-report.json", "sha256": "0f6869a8ec8342ed87649312d2872ab2c172cbf12d6be81bb0b770ebcbe8a398" }, + "fixture": { "path": "tests/files/web/script_scene.blend", "sha256": "2b8fbdb05419e1e61d64777998d6a3b2562f726c17f548cd1852efc723e1a037" } + }, + "nextTask": "M13-01F" +} diff --git a/tests/golden/M13-01E/script-save-reopen-report.json b/tests/golden/M13-01E/script-save-reopen-report.json new file mode 100644 index 00000000..850ab0c4 --- /dev/null +++ b/tests/golden/M13-01E/script-save-reopen-report.json @@ -0,0 +1,102 @@ +{ + "schemaVersion": 1, + "task": "M13-01E", + "operation": "SCRIPT_TEXT_SAVE_REOPEN", + "source": { + "fixtureSha256": "2b8fbdb05419e1e61d64777998d6a3b2562f726c17f548cd1852efc723e1a037", + "fixtureBytes": 490191 + }, + "before": { + "schemaVersion": 1, + "sources": [ + { + "byteLength": 29, + "errorCode": "SCRIPT_SANDBOX_UNAVAILABLE", + "executionStatus": "BLOCKED", + "id": "text:ExternalProject.py", + "internal": false, + "lineCount": 2, + "moduleAutorunRequested": false, + "name": "ExternalProject.py", + "readOnly": true, + "source": "message = 'project relative'\n", + "sourcePath": "//scripts/external_project.py", + "sourceSha256": "1332a556fa4a8c0d55c6661931bcc7c3cd79a193bab5e34c8b060cf97fec6a96" + }, + { + "byteLength": 23, + "errorCode": "SCRIPT_SANDBOX_UNAVAILABLE", + "executionStatus": "BLOCKED", + "id": "text:InternalSafe.py", + "internal": true, + "lineCount": 3, + "moduleAutorunRequested": false, + "name": "InternalSafe.py", + "readOnly": true, + "source": "value = 7\nprint(value)\n", + "sourceSha256": "1676bea86b7d12f595d531c32286e26ad0ac7a249d1bd40744b01634977ae582" + }, + { + "byteLength": 37, + "errorCode": "SCRIPT_POLICY_DENIED", + "executionStatus": "BLOCKED", + "id": "text:ModuleAutorun.py", + "internal": true, + "lineCount": 3, + "moduleAutorunRequested": true, + "name": "ModuleAutorun.py", + "readOnly": true, + "source": "def register():\n return 'blocked'\n", + "sourceSha256": "4b1d1ffa97bf18cd834d1a2472cab16f24f1439143964d09d283b6cc043e17bd" + } + ] + }, + "after": { + "schemaVersion": 1, + "sources": [ + { + "byteLength": 29, + "errorCode": "SCRIPT_SANDBOX_UNAVAILABLE", + "executionStatus": "BLOCKED", + "id": "text:ExternalProject.py", + "internal": false, + "lineCount": 2, + "moduleAutorunRequested": false, + "name": "ExternalProject.py", + "readOnly": true, + "source": "message = 'project relative'\n", + "sourcePath": "//scripts/external_project.py", + "sourceSha256": "1332a556fa4a8c0d55c6661931bcc7c3cd79a193bab5e34c8b060cf97fec6a96" + }, + { + "byteLength": 23, + "errorCode": "SCRIPT_SANDBOX_UNAVAILABLE", + "executionStatus": "BLOCKED", + "id": "text:InternalSafe.py", + "internal": true, + "lineCount": 3, + "moduleAutorunRequested": false, + "name": "InternalSafe.py", + "readOnly": true, + "source": "value = 7\nprint(value)\n", + "sourceSha256": "1676bea86b7d12f595d531c32286e26ad0ac7a249d1bd40744b01634977ae582" + }, + { + "byteLength": 37, + "errorCode": "SCRIPT_POLICY_DENIED", + "executionStatus": "BLOCKED", + "id": "text:ModuleAutorun.py", + "internal": true, + "lineCount": 3, + "moduleAutorunRequested": true, + "name": "ModuleAutorun.py", + "readOnly": true, + "source": "def register():\n return 'blocked'\n", + "sourceSha256": "4b1d1ffa97bf18cd834d1a2472cab16f24f1439143964d09d283b6cc043e17bd" + } + ] + }, + "savedBytes": 490191, + "exact": true, + "nextTask": "M13-01F" +} diff --git a/tests/golden/M13-01F/malicious-report.json b/tests/golden/M13-01F/malicious-report.json new file mode 100644 index 00000000..21545d29 --- /dev/null +++ b/tests/golden/M13-01F/malicious-report.json @@ -0,0 +1,37 @@ +{ + "fixture": { + "byteLength": 491160, + "name": "malicious-script.blend", + "sha256": "7b1921931afc5bb74a2b73e90b175017c583ea878cdbb66f131718b2d8cd23b5" + }, + "nextTask": "M13-02A", + "operation": "MALICIOUS_SCRIPT_FIXTURE", + "schemaVersion": 1, + "sources": [ + { + "expectedExecution": "BLOCKED", + "name": "DriverExploit.py", + "sourceSha256": "065c9f659ba12657ad0d5cc513aea1f589c6ed69feabb0f56dd786d2002b691d", + "useModule": false + }, + { + "expectedExecution": "BLOCKED", + "name": "EmbeddedModule.py", + "sourceSha256": "7bc119a7cbfb129a2fa48e3636b9e05e03326f73711942594f6a8ee28f8bbd3f", + "useModule": true + }, + { + "expectedExecution": "BLOCKED", + "name": "HandlerExploit.py", + "sourceSha256": "bc028c5143813ecc35384e0f366dcd4f42f7531f1e748713626f9710f5147373", + "useModule": false + }, + { + "expectedExecution": "BLOCKED", + "name": "MaliciousText.py", + "sourceSha256": "c852d3e669074d4951900312aed2625fdb5d9e106328257607a1e4cad43172eb", + "useModule": false + } + ], + "task": "M13-01F" +} diff --git a/tests/golden/M13-01F/manifest.json b/tests/golden/M13-01F/manifest.json new file mode 100644 index 00000000..2df0cdb2 --- /dev/null +++ b/tests/golden/M13-01F/manifest.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "task": "M13-01F", + "parentTask": "M13-01E", + "enablingTask": false, + "parityStateChange": false, + "runtime": "BLENDER_5_2_DESKTOP_AND_CHROMIUM", + "operation": "MALICIOUS_SCRIPT_FIXTURE", + "artifacts": { + "parentManifest": { "path": "tests/golden/M13-01E/manifest.json", "sha256": "889a4e06f7e8c0ea55b3ca4baa9fe85dccbe97053e497a45e3d364ce2b70a7c6" }, + "generator": { "path": "tools/web/generate-malicious-script-fixture.py", "sha256": "013285befeb59de21a887cefd377adf8cf53ff1c785b28a9c87d29f76f092731" }, + "checker": { "path": "tools/web/check-malicious-script-fixture.mjs", "sha256": "36608da893ae59e2e03856a62866ea6e7c349ec4a63200f042b2329e5f61caa9" }, + "e2eTest": { "path": "web/tests/e2e/malicious-script.spec.ts", "sha256": "98fbde6728ddf55cce5262522197a5b4db1dfce618e52259bf0b0c9e0e9165f2" }, + "report": { "path": "tests/golden/M13-01F/malicious-report.json", "sha256": "a628b73411d6f9a761f659ae28867ea9b0c0df30d47668353278b70d4b44180c" }, + "fixture": { "path": "tests/files/web/m13_malicious_script_v1/malicious-script.blend", "sha256": "7b1921931afc5bb74a2b73e90b175017c583ea878cdbb66f131718b2d8cd23b5" } + }, + "nextTask": "M13-02A" +} diff --git a/tests/golden/M13-02A/manifest-budget-report.json b/tests/golden/M13-02A/manifest-budget-report.json new file mode 100644 index 00000000..792e8abe --- /dev/null +++ b/tests/golden/M13-02A/manifest-budget-report.json @@ -0,0 +1,45 @@ +{ + "schemaVersion": 1, + "task": "M13-02A", + "operation": "SCRIPT_MANIFEST_BUDGETS", + "accepted": { + "scriptCount": 2, + "canonicalEntryPath": "scripts/base.py", + "canonicalDependencyPath": "deps/base.py", + "totalSourceBytes": 256, + "module": false + }, + "denied": { + "count": { + "status": "BLOCKED", + "code": "SCRIPT_BUDGET_EXCEEDED" + }, + "totalBytes": { + "status": "BLOCKED", + "code": "SCRIPT_BUDGET_EXCEEDED" + }, + "module": { + "status": "BLOCKED", + "code": "SCRIPT_POLICY_DENIED" + }, + "path": { + "status": "BLOCKED", + "code": "SCRIPT_MANIFEST_INVALID" + }, + "dependency": { + "status": "BLOCKED", + "code": "SCRIPT_MANIFEST_INVALID" + }, + "permission": { + "status": "BLOCKED", + "code": "SCRIPT_POLICY_DENIED" + } + }, + "budgets": { + "maxScripts": 1024, + "maxSourceBytes": 1048576, + "maxDependencies": 128, + "maxPermissions": 64 + }, + "nextTask": "M13-02B" +} diff --git a/tests/golden/M13-02A/manifest.json b/tests/golden/M13-02A/manifest.json new file mode 100644 index 00000000..ccd4f04f --- /dev/null +++ b/tests/golden/M13-02A/manifest.json @@ -0,0 +1,40 @@ +{ + "schemaVersion": 1, + "task": "M13-02A", + "parentTask": "M13-01F", + "enablingTask": false, + "parityStateChange": false, + "runtime": "PRODUCTION_PROTOCOL_AND_CHROMIUM_WORKER", + "operation": "SCRIPT_MANIFEST_BUDGETS", + "artifacts": { + "parentManifest": { + "path": "tests/golden/M13-01F/manifest.json", + "sha256": "9a0b7c4097b3755365a9fb92b4848e6ac2ddd36ee2c7e9df3cb8808ce247cf3d" + }, + "protocol": { + "path": "web/protocol/scripting-platform.ts", + "sha256": "6699fc0a19e0736823f164e707a420655803e36019cf0071f7ff9c760d25e9f2" + }, + "worker": { + "path": "web/app/src/workers/scripting-manifest-budget-test.worker.ts", + "sha256": "8d8eae67fa6915f0337850e15247baf01f0abde0b3362fbb120f0b448f1a4cf5" + }, + "checker": { + "path": "tools/web/check-script-manifest-budgets.mjs", + "sha256": "b3457324d72ab233cd17a142ea19fac6a0d024dd95de7b8fb5d26810437fb0d2" + }, + "unit": { + "path": "web/tests/unit/script-manifest-budgets.test.mjs", + "sha256": "6ce7847a0b745ba4081e4332d012e0b7f86e4906c71c95bdeda8c39977a630ee" + }, + "e2e": { + "path": "web/tests/e2e/script-manifest-budgets.spec.ts", + "sha256": "1ef4fd4caaeb1fa3d832fc8881823d5284755372575eab186c751f408dc9314c" + }, + "report": { + "path": "tests/golden/M13-02A/manifest-budget-report.json", + "sha256": "860672fe844b7969ca376d4b2708771189d1767efa819f7b97667d8a26438d3a" + } + }, + "nextTask": "M13-02B" +} diff --git a/tests/golden/M13-02B/manifest-canonical-report.json b/tests/golden/M13-02B/manifest-canonical-report.json new file mode 100644 index 00000000..4a3db2b4 --- /dev/null +++ b/tests/golden/M13-02B/manifest-canonical-report.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 1, + "task": "M13-02B", + "operation": "SCRIPT_MANIFEST_CANONICAL_SERIALIZATION", + "equalOrderVariants": true, + "canonical": { + "scriptOrder": [ + "alpha", + "beta", + "zeta" + ], + "alphaPermissions": [ + "READ_ASSET", + "SUBMIT_SERVER_JOB" + ], + "zetaDependencies": [ + "alpha", + "beta" + ], + "unknownFieldsDropped": true + }, + "signatureInput": { + "schemaVersion": 1, + "byteLength": 1923, + "sha256": "8dcb1c31e9ff0066f2a4e225af49f84daeb8b0d0e713e4525228ee015846480a", + "sourceByteLengthMutationChangesInput": true, + "schemaMutationRejected": true + }, + "nextTask": "M13-02C" +} diff --git a/tests/golden/M13-02B/manifest.json b/tests/golden/M13-02B/manifest.json new file mode 100644 index 00000000..e60b4a4e --- /dev/null +++ b/tests/golden/M13-02B/manifest.json @@ -0,0 +1,40 @@ +{ + "schemaVersion": 1, + "task": "M13-02B", + "parentTask": "M13-02A", + "enablingTask": false, + "parityStateChange": false, + "runtime": "PRODUCTION_PROTOCOL_AND_CHROMIUM_WORKER", + "operation": "SCRIPT_MANIFEST_CANONICAL_SERIALIZATION", + "artifacts": { + "parentManifest": { + "path": "tests/golden/M13-02A/manifest.json", + "sha256": "7148e0387dadffdb55e94e80dc30cfd5cdd40ebe990d06378e90c376d36ebd51" + }, + "protocol": { + "path": "web/protocol/scripting-platform.ts", + "sha256": "6699fc0a19e0736823f164e707a420655803e36019cf0071f7ff9c760d25e9f2" + }, + "worker": { + "path": "web/app/src/workers/scripting-manifest-canonical-test.worker.ts", + "sha256": "de27fdcd6d16e27a07806ac609f908edfdb7a93d653676174cdd5e06ffa394af" + }, + "checker": { + "path": "tools/web/check-script-manifest-canonical.mjs", + "sha256": "5ed344cab6428ae5538a450171ba40c73ff738666515492298e30aaed1394f56" + }, + "unit": { + "path": "web/tests/unit/script-manifest-canonical.test.mjs", + "sha256": "33eae0f3ad2ae7243c9ce2835ab8e42186f65f574ab682099766f1d0f4c481f6" + }, + "e2e": { + "path": "web/tests/e2e/script-manifest-canonical.spec.ts", + "sha256": "5342301165ae82a01b46f5fed0cc0ec34b9813a6ecbc8032900d90b89628e064" + }, + "report": { + "path": "tests/golden/M13-02B/manifest-canonical-report.json", + "sha256": "5f4bc0b098ea65de47f1255d30130376d74260e2b912bcd0e28354171fbd07df" + } + }, + "nextTask": "M13-02C" +} diff --git a/tests/golden/M13-02C/manifest.json b/tests/golden/M13-02C/manifest.json new file mode 100644 index 00000000..2eae5663 --- /dev/null +++ b/tests/golden/M13-02C/manifest.json @@ -0,0 +1,40 @@ +{ + "schemaVersion": 1, + "task": "M13-02C", + "parentTask": "M13-02B", + "enablingTask": false, + "parityStateChange": false, + "runtime": "PRODUCTION_PROTOCOL_AND_CHROMIUM_WORKER", + "operation": "SCRIPT_TRUST_POLICY", + "artifacts": { + "parentManifest": { + "path": "tests/golden/M13-02B/manifest.json", + "sha256": "605c656780a206a0d3b81d06b0294108b488a62d2b709e886884a2973c6cb091" + }, + "protocol": { + "path": "web/protocol/scripting-platform.ts", + "sha256": "6699fc0a19e0736823f164e707a420655803e36019cf0071f7ff9c760d25e9f2" + }, + "worker": { + "path": "web/app/src/workers/script-trust-policy-test.worker.ts", + "sha256": "c6e206b29e7cacc3e23e2ac12a3d523a9c0b16ff29759126dab13c8665547421" + }, + "checker": { + "path": "tools/web/check-script-trust-policy.mjs", + "sha256": "0e55ce20d142f2bcbc5ce9c6fb955ef2771d284dff636c79da3f59a33b8b0aeb" + }, + "unit": { + "path": "web/tests/unit/script-trust-policy.test.mjs", + "sha256": "aac3ff0a1c7ae27e35112614bedabc02bb248882ec9b0b52d65f9794d06830d8" + }, + "e2e": { + "path": "web/tests/e2e/script-trust-policy.spec.ts", + "sha256": "614091bda15367090bc78a6f465fb10d02d0aa08775b5088699b12e2490034d2" + }, + "report": { + "path": "tests/golden/M13-02C/trust-policy-report.json", + "sha256": "0f59f733920edbbe5cb799e5f50ff31d19e55ced98e7a890828f6337a237e4bd" + } + }, + "nextTask": "M13-02D" +} diff --git a/tests/golden/M13-02C/trust-policy-report.json b/tests/golden/M13-02C/trust-policy-report.json new file mode 100644 index 00000000..aa7dd5cd --- /dev/null +++ b/tests/golden/M13-02C/trust-policy-report.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "task": "M13-02C", + "operation": "SCRIPT_TRUST_POLICY", + "identity": { + "algorithm": "ED25519", + "publisher": "Team", + "activeKey": "key:new", + "predecessor": "key:old", + "predecessorStatus": "REVOKED" + }, + "timestampPolicy": { + "issuedAt": "2026-01-01T00:00:00.000Z", + "expiresAt": "2027-01-01T00:00:00.000Z", + "maxClockSkewMs": 300000 + }, + "decisions": { + "eligible": "ELIGIBLE", + "cryptographicVerification": "REQUIRED", + "revoked": "REVOKED", + "crossPublisher": "SCRIPT_POLICY_DENIED", + "policyExpired": "POLICY_EXPIRED" + }, + "policySha256": "6c272f0b6662656194fff46c71faf69a87e0eafa31091ec474bf96eb76948323", + "nextTask": "M13-02D" +} diff --git a/tests/golden/M13-02D/manifest.json b/tests/golden/M13-02D/manifest.json new file mode 100644 index 00000000..32409fdb --- /dev/null +++ b/tests/golden/M13-02D/manifest.json @@ -0,0 +1,40 @@ +{ + "schemaVersion": 1, + "task": "M13-02D", + "parentTask": "M13-02C", + "enablingTask": false, + "parityStateChange": false, + "runtime": "PRODUCTION_PROTOCOL_AND_CHROMIUM_WORKER", + "operation": "SCRIPT_SIGNATURE_VERIFICATION", + "artifacts": { + "parentManifest": { + "path": "tests/golden/M13-02C/manifest.json", + "sha256": "89745daca88371335f4bd56982791266461082066c6c1345056fbc697bb7e6a2" + }, + "protocol": { + "path": "web/protocol/scripting-platform.ts", + "sha256": "6699fc0a19e0736823f164e707a420655803e36019cf0071f7ff9c760d25e9f2" + }, + "worker": { + "path": "web/app/src/workers/script-signature-test.worker.ts", + "sha256": "f4e8ff45d06efcfa9e634ef28e654241d70554ed05dfe9c3fecd9f6eef166ce0" + }, + "checker": { + "path": "tools/web/check-script-signature.mjs", + "sha256": "2d6e7b403dd6d401eb1a41978016b40bd1fb86120464e09f185de4445c177fa4" + }, + "unit": { + "path": "web/tests/unit/script-trust-policy.test.mjs", + "sha256": "aac3ff0a1c7ae27e35112614bedabc02bb248882ec9b0b52d65f9794d06830d8" + }, + "e2e": { + "path": "web/tests/e2e/script-signature.spec.ts", + "sha256": "8a882fc8fa2c0c4e2eab3660810cec0f60d2f7475f62a1bb406fc3b2bdf60882" + }, + "report": { + "path": "tests/golden/M13-02D/signature-report.json", + "sha256": "957daf8e5899714cc5ce253b1f0fb2b258f1cc966238fff31e2626f8f56feb3a" + } + }, + "nextTask": "M13-02E" +} diff --git a/tests/golden/M13-02D/signature-report.json b/tests/golden/M13-02D/signature-report.json new file mode 100644 index 00000000..d6e45abc --- /dev/null +++ b/tests/golden/M13-02D/signature-report.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": 1, + "task": "M13-02D", + "operation": "SCRIPT_SIGNATURE_VERIFICATION", + "signer": { + "algorithm": "ED25519", + "keyId": "key:new", + "publisher": "Team", + "cryptographicVerification": "REQUIRED" + }, + "decisions": { + "verified": "SCRIPT_SIGNATURE_VERIFIED", + "sourceHashChanged": "SCRIPT_SIGNATURE_INVALID", + "signatureChanged": "SCRIPT_SIGNATURE_INVALID", + "revoked": "SCRIPT_POLICY_DENIED" + }, + "input": { + "verifiedSha256": "978efe254fc421028bc1c62e7fee809f7b08b60ea2928501d0b2cfb71bf59cd2", + "changedSha256": "5e7aed5aea6b3dcc635f8d1ba99099e7e27ddb3110f12afc7d40995986ba8cfb", + "sourceHashMutationChangesInput": true, + "signatureExcludedFromInput": true + }, + "nextTask": "M13-02E" +} diff --git a/tests/golden/M13-02E/manifest.json b/tests/golden/M13-02E/manifest.json new file mode 100644 index 00000000..ffbd63ec --- /dev/null +++ b/tests/golden/M13-02E/manifest.json @@ -0,0 +1,40 @@ +{ + "schemaVersion": 1, + "task": "M13-02E", + "parentTask": "M13-02D", + "enablingTask": false, + "parityStateChange": false, + "runtime": "PRODUCTION_PROTOCOL_AND_CHROMIUM_WORKER", + "operation": "SCRIPT_PERMISSION_MINIMIZATION", + "artifacts": { + "parentManifest": { + "path": "tests/golden/M13-02D/manifest.json", + "sha256": "c6b79a77e4b7ffe5f4a9ba785a30305f9cf6e86caf89b38a4b303a86ab662a31" + }, + "protocol": { + "path": "web/protocol/scripting-platform.ts", + "sha256": "6699fc0a19e0736823f164e707a420655803e36019cf0071f7ff9c760d25e9f2" + }, + "worker": { + "path": "web/app/src/workers/script-permission-policy-test.worker.ts", + "sha256": "5df074e676542475009a4980fecaf3fdde009d149de84b9ca146647fb6aa1c02" + }, + "checker": { + "path": "tools/web/check-script-permission-policy.mjs", + "sha256": "169ab096295e08c5a630ef09d814da6ce742efd7a7143ff9a17984b4ca20593a" + }, + "unit": { + "path": "web/tests/unit/script-permission-policy.test.mjs", + "sha256": "43bb6796616f3f92d71b51ef2a119a4e263864121a03356764e4b9c9241c4043" + }, + "e2e": { + "path": "web/tests/e2e/script-permission-policy.spec.ts", + "sha256": "dc13412a44f277bbe68315da0d38a2c4aa520d7489c81e7c908f9768c0547b7e" + }, + "report": { + "path": "tests/golden/M13-02E/permission-policy-report.json", + "sha256": "8dc41e75620ba5bcb08d0edc52c3994e4f4dbc37ad3633757634bf10fe97b252" + } + }, + "nextTask": "M13-02F" +} diff --git a/tests/golden/M13-02E/permission-policy-report.json b/tests/golden/M13-02E/permission-policy-report.json new file mode 100644 index 00000000..08ecd4a9 --- /dev/null +++ b/tests/golden/M13-02E/permission-policy-report.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "task": "M13-02E", + "operation": "SCRIPT_PERMISSION_MINIMIZATION", + "decisions": { + "defaultGrant": "ALLOWED", + "declaredGrant": "ALLOWED", + "escalation": "SCRIPT_POLICY_DENIED", + "unknownRequest": "SCRIPT_POLICY_DENIED", + "duplicateRequest": "SCRIPT_POLICY_DENIED", + "unknownDeclaration": "SCRIPT_POLICY_DENIED" + }, + "invariant": { + "undeclaredNeverGranted": true, + "defaultGrantedCount": 0, + "declaredGranted": [ + "READ_MAIN" + ] + }, + "nextTask": "M13-02F" +} diff --git a/tests/golden/M13-02F/manifest.json b/tests/golden/M13-02F/manifest.json new file mode 100644 index 00000000..4ca36f81 --- /dev/null +++ b/tests/golden/M13-02F/manifest.json @@ -0,0 +1,40 @@ +{ + "schemaVersion": 1, + "task": "M13-02F", + "parentTask": "M13-02E", + "enablingTask": false, + "parityStateChange": false, + "runtime": "PRODUCTION_PROTOCOL_AND_CHROMIUM_WORKER", + "operation": "SCRIPT_SIGNATURE_NEGATIVE_CASES", + "artifacts": { + "parentManifest": { + "path": "tests/golden/M13-02E/manifest.json", + "sha256": "691376d3cd33d3cd339b7195034abaf89cc02fba7e740c40ff051c4496400eef" + }, + "protocol": { + "path": "web/protocol/scripting-platform.ts", + "sha256": "6699fc0a19e0736823f164e707a420655803e36019cf0071f7ff9c760d25e9f2" + }, + "worker": { + "path": "web/app/src/workers/script-signature-negative-test.worker.ts", + "sha256": "49b289110a6533fcd1a29ad78be00792fd3c0d251c6027bae0440f5daba929a1" + }, + "checker": { + "path": "tools/web/check-script-signature-negative.mjs", + "sha256": "35f2e6727aef5e6ea2e2623e2effa6ea0356faaf66d417976db7f5bbe671a38c" + }, + "unit": { + "path": "web/tests/unit/script-signature-negative.test.mjs", + "sha256": "8b9c8d63c97fce07299622b9ad261791a97bb91a6ed340e72c24c55e685978bd" + }, + "e2e": { + "path": "web/tests/e2e/script-signature-negative.spec.ts", + "sha256": "26c52ef67d6b1ea2994d92637826994e98e5234e042049f8d69a905f5cdb3309" + }, + "report": { + "path": "tests/golden/M13-02F/signature-negative-report.json", + "sha256": "d23a2ae5613b2442ad79420aa344ebfe3767d10ac19b3ea30a62afcccd3a167c" + } + }, + "nextTask": "M13-03A" +} diff --git a/tests/golden/M13-02F/signature-negative-report.json b/tests/golden/M13-02F/signature-negative-report.json new file mode 100644 index 00000000..7a47310f --- /dev/null +++ b/tests/golden/M13-02F/signature-negative-report.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "task": "M13-02F", + "operation": "SCRIPT_SIGNATURE_NEGATIVE_CASES", + "decisions": { + "missing": "SCRIPT_POLICY_DENIED", + "expired": "SCRIPT_POLICY_DENIED", + "notYetValid": "SCRIPT_POLICY_DENIED", + "publisherMismatch": "SCRIPT_POLICY_DENIED", + "swapped": "SCRIPT_SIGNATURE_INVALID" + }, + "invariants": { + "missingKeyDenied": true, + "expiredKeyDenied": true, + "notYetValidKeyDenied": true, + "publisherConfusionDenied": true, + "swappedSignatureDenied": true, + "noExecution": true + }, + "nextTask": "M13-03A" +} diff --git a/tests/golden/M13-03A/manifest.json b/tests/golden/M13-03A/manifest.json new file mode 100644 index 00000000..8f37c3e2 --- /dev/null +++ b/tests/golden/M13-03A/manifest.json @@ -0,0 +1,40 @@ +{ + "schemaVersion": 1, + "task": "M13-03A", + "parentTask": "M13-02F", + "enablingTask": false, + "parityStateChange": false, + "runtime": "PRODUCTION_PROTOCOL_AND_CHROMIUM_WORKER", + "operation": "SCRIPT_SANDBOX_SCOPE", + "artifacts": { + "parentManifest": { + "path": "tests/golden/M13-02F/manifest.json", + "sha256": "283673b21e6c9b89dc6ecb7007f3cecf28d53a84ec84f3f8b52373c055538a74" + }, + "protocol": { + "path": "web/protocol/scripting-platform.ts", + "sha256": "6699fc0a19e0736823f164e707a420655803e36019cf0071f7ff9c760d25e9f2" + }, + "worker": { + "path": "web/app/src/workers/script-sandbox-scope-test.worker.ts", + "sha256": "27058fbb602791cbe6691fa14b3bc9c3d6cdea79641ba9c72fe18b6226d2a651" + }, + "checker": { + "path": "tools/web/check-script-sandbox-scope.mjs", + "sha256": "ad26ea3b078e480ba8f99cbf8dbd0d9b135c7605f4a3958d24845b7f5f8b0f43" + }, + "unit": { + "path": "web/tests/unit/script-sandbox-scope.test.mjs", + "sha256": "a15da2e645d2d681c7e9ac1380f6b224d196d1d2e78d20f1bacad01e138753d4" + }, + "e2e": { + "path": "web/tests/e2e/script-sandbox-scope.spec.ts", + "sha256": "49930747e6663f79b61093706318b72e59388d79e3e1cc105bc1571693326667" + }, + "report": { + "path": "tests/golden/M13-03A/sandbox-scope-report.json", + "sha256": "ba5784c751d5a347f38aa522ffcbed270d38fb474f0f10a2d06ea501d0234021" + } + }, + "nextTask": "M13-03B" +} diff --git a/tests/golden/M13-03A/sandbox-scope-report.json b/tests/golden/M13-03A/sandbox-scope-report.json new file mode 100644 index 00000000..d2a8c608 --- /dev/null +++ b/tests/golden/M13-03A/sandbox-scope-report.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": 1, + "task": "M13-03A", + "operation": "SCRIPT_SANDBOX_SCOPE", + "accepted": { + "schemaVersion": 1, + "dom": false, + "hostWorker": false, + "opfs": false, + "indexedDB": false, + "network": false + }, + "blocked": { + "dom": "SCRIPT_POLICY_DENIED", + "hostWorker": "SCRIPT_POLICY_DENIED", + "opfs": "SCRIPT_POLICY_DENIED", + "indexedDB": "SCRIPT_POLICY_DENIED", + "network": "SCRIPT_POLICY_DENIED" + }, + "execution": "DISABLED", + "invariants": { + "noDom": true, + "noHostWorker": true, + "noOPFS": true, + "noIndexedDB": true, + "noNetwork": true + }, + "nextTask": "M13-03B" +} diff --git a/tests/golden/M13-03B/manifest.json b/tests/golden/M13-03B/manifest.json new file mode 100644 index 00000000..9ca7b9ce --- /dev/null +++ b/tests/golden/M13-03B/manifest.json @@ -0,0 +1,40 @@ +{ + "schemaVersion": 1, + "task": "M13-03B", + "parentTask": "M13-03A", + "enablingTask": false, + "parityStateChange": false, + "runtime": "PRODUCTION_PROTOCOL_AND_CHROMIUM_WORKER", + "operation": "SCRIPT_SANDBOX_BUDGET", + "artifacts": { + "parentManifest": { + "path": "tests/golden/M13-03A/manifest.json", + "sha256": "76a7ce0fd3a38fb770c9cedccbd05ed566b931caa1782fdae898d3b66c3a20bf" + }, + "protocol": { + "path": "web/protocol/scripting-platform.ts", + "sha256": "6699fc0a19e0736823f164e707a420655803e36019cf0071f7ff9c760d25e9f2" + }, + "worker": { + "path": "web/app/src/workers/script-sandbox-budget-test.worker.ts", + "sha256": "ed836a032b33f54154fdd36f9f6e99ee43ca1755ea0dd8326474a13300ec965e" + }, + "checker": { + "path": "tools/web/check-script-sandbox-budget.mjs", + "sha256": "ab10d1d32c20022c848be4b33e1be846168a9ca4cf22ac932ea000001e9e2391" + }, + "unit": { + "path": "web/tests/unit/script-sandbox-budget.test.mjs", + "sha256": "caa40fc58a73aa4d433285c94009e5436a29fbcb6b4e18b74b27c49fd0b07490" + }, + "e2e": { + "path": "web/tests/e2e/script-sandbox-budget.spec.ts", + "sha256": "8be77fd55e76149bfe2e0e449d81a62707ebfa430514936808ec4805fc96e07c" + }, + "report": { + "path": "tests/golden/M13-03B/sandbox-budget-report.json", + "sha256": "4355710e07e95cbb0f96a82fdefca3607f363d3aea9ba89c332e8a4708b7bed2" + } + }, + "nextTask": "M13-03C" +} diff --git a/tests/golden/M13-03B/sandbox-budget-report.json b/tests/golden/M13-03B/sandbox-budget-report.json new file mode 100644 index 00000000..5d2de7cb --- /dev/null +++ b/tests/golden/M13-03B/sandbox-budget-report.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": 1, + "task": "M13-03B", + "operation": "SCRIPT_SANDBOX_BUDGET", + "accepted": { + "schemaVersion": 1, + "cpuMs": 1000, + "wallMs": 5000, + "memoryBytes": 1048576, + "maxMessageBytes": 4096, + "maxOutputBytes": 8192 + }, + "blocked": { + "cpuMs": "SCRIPT_BUDGET_EXCEEDED", + "wallMs": "SCRIPT_BUDGET_EXCEEDED", + "memoryBytes": "SCRIPT_BUDGET_EXCEEDED", + "maxMessageBytes": "SCRIPT_BUDGET_EXCEEDED", + "maxOutputBytes": "SCRIPT_BUDGET_EXCEEDED" + }, + "execution": "DISABLED", + "invariants": { + "cpuBounded": true, + "wallBounded": true, + "memoryBounded": true, + "messageBounded": true, + "outputBounded": true + }, + "nextTask": "M13-03C" +} diff --git a/tests/golden/M13-03C/host-call-report.json b/tests/golden/M13-03C/host-call-report.json new file mode 100644 index 00000000..36e99ffb --- /dev/null +++ b/tests/golden/M13-03C/host-call-report.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": 1, + "task": "M13-03C", + "operation": "SCRIPT_HOST_CALL_ALLOWLIST", + "acceptedCalls": [ + "READ_MAIN", + "READ_ASSET", + "WRITE_MAIN", + "WRITE_ASSET", + "SUBMIT_SERVER_JOB" + ], + "blocked": { + "unknown": "SCRIPT_POLICY_DENIED", + "permission": "SCRIPT_POLICY_DENIED", + "fields": "SCRIPT_MANIFEST_INVALID", + "path": "SCRIPT_MANIFEST_INVALID" + }, + "structuredParameters": true, + "execution": "DISABLED", + "invariants": { + "allowlistOnly": true, + "permissionBound": true, + "unknownFieldsRejected": true, + "projectPathsNormalized": true + }, + "nextTask": "M13-03D" +} diff --git a/tests/golden/M13-03C/manifest.json b/tests/golden/M13-03C/manifest.json new file mode 100644 index 00000000..95a58015 --- /dev/null +++ b/tests/golden/M13-03C/manifest.json @@ -0,0 +1,40 @@ +{ + "schemaVersion": 1, + "task": "M13-03C", + "parentTask": "M13-03B", + "enablingTask": false, + "parityStateChange": false, + "runtime": "PRODUCTION_PROTOCOL_AND_CHROMIUM_WORKER", + "operation": "SCRIPT_HOST_CALL_ALLOWLIST", + "artifacts": { + "parentManifest": { + "path": "tests/golden/M13-03B/manifest.json", + "sha256": "b61978c22cdfffe81b812e2eea788d52326f9f0ebb4540e77f86dc9e19ff21dd" + }, + "protocol": { + "path": "web/protocol/scripting-platform.ts", + "sha256": "6699fc0a19e0736823f164e707a420655803e36019cf0071f7ff9c760d25e9f2" + }, + "worker": { + "path": "web/app/src/workers/script-host-call-test.worker.ts", + "sha256": "b26a37f20e829172bb70fbe9b9e3a194923818127cccbe8bbc55f6d994be55e8" + }, + "checker": { + "path": "tools/web/check-script-host-call.mjs", + "sha256": "e90042f4eefd9e89da6d1cead87ca1cb9db9b3bd65ba8028593e093903577e2e" + }, + "unit": { + "path": "web/tests/unit/script-host-call.test.mjs", + "sha256": "e7679cd902c75504204d528343e4fa6be316d1c56c02b2871a67637aa847a1ec" + }, + "e2e": { + "path": "web/tests/e2e/script-host-call.spec.ts", + "sha256": "7b17a7da440aee07895101efb8e1eb13bfbebbe54e955bde09299f6b33d6aaf9" + }, + "report": { + "path": "tests/golden/M13-03C/host-call-report.json", + "sha256": "afb140a3aecff1de52c4363e7cd0ce476b0c9140ffdc016ec13a085049f6dd1a" + } + }, + "nextTask": "M13-03D" +} diff --git a/tests/golden/M13-03D/manifest.json b/tests/golden/M13-03D/manifest.json new file mode 100644 index 00000000..f15ee9cb --- /dev/null +++ b/tests/golden/M13-03D/manifest.json @@ -0,0 +1,40 @@ +{ + "schemaVersion": 1, + "task": "M13-03D", + "parentTask": "M13-03C", + "enablingTask": false, + "parityStateChange": false, + "runtime": "PRODUCTION_PROTOCOL_AND_CHROMIUM_WORKER", + "operation": "SCRIPT_SANDBOX_ISOLATION", + "artifacts": { + "parentManifest": { + "path": "tests/golden/M13-03C/manifest.json", + "sha256": "54318c45b11dd1707fecf78b0637257158102ea1dbb88d4d442e33b77df2cdbf" + }, + "protocol": { + "path": "web/protocol/scripting-platform.ts", + "sha256": "6699fc0a19e0736823f164e707a420655803e36019cf0071f7ff9c760d25e9f2" + }, + "worker": { + "path": "web/app/src/workers/script-sandbox-isolation-test.worker.ts", + "sha256": "8c810ee7c8bd15d83ecfb4981068f947536bfe8c9e63b906861f42b8465722da" + }, + "checker": { + "path": "tools/web/check-script-sandbox-isolation.mjs", + "sha256": "e5c0c3c7cd500c269ae43a1a9eb05589e82aad28199ab4f11ad1167b486f6937" + }, + "unit": { + "path": "web/tests/unit/script-sandbox-isolation.test.mjs", + "sha256": "47986f93638fdc18b817b03222484f4691dc294185040eaebac8e1f386bc1549" + }, + "e2e": { + "path": "web/tests/e2e/script-sandbox-isolation.spec.ts", + "sha256": "2c16c42f09827a0c0100ef2cc6295ca6bc96933ed7475e08b1fa195d7940c141" + }, + "report": { + "path": "tests/golden/M13-03D/sandbox-isolation-report.json", + "sha256": "4af91cdb21101039b379136c7559b46df55f4f5ab5478c05c1c57cd6e1624d2a" + } + }, + "nextTask": "M13-03E" +} diff --git a/tests/golden/M13-03D/sandbox-isolation-report.json b/tests/golden/M13-03D/sandbox-isolation-report.json new file mode 100644 index 00000000..0d9cd227 --- /dev/null +++ b/tests/golden/M13-03D/sandbox-isolation-report.json @@ -0,0 +1,51 @@ +{ + "schemaVersion": 1, + "task": "M13-03D", + "operation": "SCRIPT_SANDBOX_ISOLATION", + "crash": { + "status": "CRASHED", + "errorCode": "SCRIPT_SANDBOX_CRASHED", + "workerGeneration": 4, + "mainRevisionBefore": 9, + "mainRevisionAfter": 9, + "temporaryBytes": 0, + "publishedResults": 0, + "lateResults": 0, + "committed": false, + "execution": "DISABLED" + }, + "timeout": { + "status": "TIMED_OUT", + "errorCode": "SCRIPT_SANDBOX_TIMEOUT", + "workerGeneration": 4, + "mainRevisionBefore": 9, + "mainRevisionAfter": 9, + "temporaryBytes": 0, + "publishedResults": 0, + "lateResults": 0, + "committed": false, + "execution": "DISABLED" + }, + "cancel": { + "status": "CANCELLED", + "errorCode": "SCRIPT_SANDBOX_CANCELLED", + "workerGeneration": 4, + "mainRevisionBefore": 9, + "mainRevisionAfter": 9, + "temporaryBytes": 0, + "publishedResults": 0, + "lateResults": 0, + "committed": false, + "execution": "DISABLED" + }, + "lateResult": "SCRIPT_SANDBOX_LATE_RESULT", + "execution": "DISABLED", + "invariants": { + "mainRevisionUnchanged": true, + "jobTerminated": true, + "temporaryResourcesReleased": true, + "noPublishedResults": true, + "lateResultsRejected": true + }, + "nextTask": "M13-03E" +} diff --git a/tests/golden/M13-03E/manifest.json b/tests/golden/M13-03E/manifest.json new file mode 100644 index 00000000..90a5311f --- /dev/null +++ b/tests/golden/M13-03E/manifest.json @@ -0,0 +1,44 @@ +{ + "schemaVersion": 1, + "task": "M13-03E", + "parentTask": "M13-03D", + "enablingTask": false, + "parityStateChange": false, + "runtime": "PRODUCTION_PROTOCOL_AND_CHROMIUM_WORKER", + "operation": "SCRIPT_SANDBOX_CANCELLATION_GATE", + "artifacts": { + "parentManifest": { + "path": "tests/golden/M13-03D/manifest.json", + "sha256": "8066013f3d356ba438c36d1f1ea1cf692dbb4f60b086f552072e36b783a96d42" + }, + "protocol": { + "path": "web/protocol/scripting-platform.ts", + "sha256": "6699fc0a19e0736823f164e707a420655803e36019cf0071f7ff9c760d25e9f2" + }, + "worker": { + "path": "web/app/src/workers/script-sandbox-cancellation-test.worker.ts", + "sha256": "0feb70c8c491cc24ebfe6c3e267b92522d616b6f260efcecfa57cf489d0742c0" + }, + "checker": { + "path": "tools/web/check-script-sandbox-cancellation.mjs", + "sha256": "b6fbe7102b7d0808673931c66797f8976e79b21bd4c32e429f21832c6090708e" + }, + "unit": { + "path": "web/tests/unit/script-sandbox-cancellation.test.mjs", + "sha256": "374cc87f66bd42226b750e387663ef8c86bef92348fa7cbc6ed7f0027945204d" + }, + "e2e": { + "path": "web/tests/e2e/script-sandbox-cancellation.spec.ts", + "sha256": "b233d9cafa1f70798ec19d76ca936abb610681522264a12237f532eb98e09fca" + }, + "package": { + "path": "web/package.json", + "sha256": "6499a70fc4a93c925053ddf5009c74d9c10754b443afc9d58e898fe20d7f1b05" + }, + "report": { + "path": "tests/golden/M13-03E/sandbox-cancellation-report.json", + "sha256": "066d9f19716b8229f2cf7755ec3009a3edef942ebab5f70159bec7f2afbaf71f" + } + }, + "nextTask": "M13-03F" +} diff --git a/tests/golden/M13-03E/sandbox-cancellation-report.json b/tests/golden/M13-03E/sandbox-cancellation-report.json new file mode 100644 index 00000000..2b7f9541 --- /dev/null +++ b/tests/golden/M13-03E/sandbox-cancellation-report.json @@ -0,0 +1,32 @@ +{ + "schemaVersion": 1, + "task": "M13-03E", + "operation": "SCRIPT_SANDBOX_CANCELLATION_GATE", + "receipt": { + "status": "CANCELLED", + "errorCode": "SCRIPT_SANDBOX_CANCELLED", + "workerGeneration": 5, + "mainRevisionBefore": 11, + "mainRevisionAfter": 11, + "temporaryBytes": 0, + "publishedResults": 0, + "lateResults": 0, + "committed": false, + "execution": "DISABLED" + }, + "lateResult": "SCRIPT_SANDBOX_LATE_RESULT", + "runtime": { + "lateMessages": 0, + "cacheWrites": 0, + "cancelled": true + }, + "invariants": { + "cancellationStable": true, + "mainRevisionUnchanged": true, + "noPublishedResults": true, + "noCacheWrites": true, + "lateResultsRejected": true + }, + "execution": "DISABLED", + "nextTask": "M13-03F" +} diff --git a/tests/golden/M13-03F/manifest.json b/tests/golden/M13-03F/manifest.json new file mode 100644 index 00000000..e87d5c30 --- /dev/null +++ b/tests/golden/M13-03F/manifest.json @@ -0,0 +1,44 @@ +{ + "schemaVersion": 1, + "task": "M13-03F", + "parentTask": "M13-03E", + "enablingTask": false, + "parityStateChange": false, + "runtime": "PRODUCTION_PROTOCOL_AND_CHROMIUM_WORKER", + "operation": "SCRIPT_SANDBOX_DISPOSE_GATE", + "artifacts": { + "parentManifest": { + "path": "tests/golden/M13-03E/manifest.json", + "sha256": "0cdf625e6bd3ed7aca43318a3b04353cb806c51cfa4cf3c5dae9a65a0eae2e69" + }, + "protocol": { + "path": "web/app/src/testing/script-sandbox-dispose.ts", + "sha256": "f3d9f667c3809cfad0f52bc6028d93e36e25c4b639fb6081c9c933fbad0ebc0a" + }, + "worker": { + "path": "web/app/src/workers/script-sandbox-dispose-test.worker.ts", + "sha256": "2a074b05d301c4083e60534eb9452aabf5d95aea0ab316fa657441ef4bcd5c96" + }, + "checker": { + "path": "tools/web/check-script-sandbox-dispose.mjs", + "sha256": "6549bd10f588281d23c4bea705fd164252c9e3f44d8f4b93c893a2c410907135" + }, + "unit": { + "path": "web/tests/unit/script-sandbox-dispose.test.mjs", + "sha256": "6e00aa6286aae0eabc1345c04c7628dcc9acb32d51c1c27436a0e1b4c170b64c" + }, + "e2e": { + "path": "web/tests/e2e/script-sandbox-dispose.spec.ts", + "sha256": "b240d92c90e0d81272cd9cb3c0eb4e7d77102ce2e49ad05c761f763d1812b18e" + }, + "package": { + "path": "web/package.json", + "sha256": "0f06cd7ccdeed4213b6cb956aecf94e60dceff811ad8c3a2e239397685cfc1bf" + }, + "report": { + "path": "tests/golden/M13-03F/sandbox-dispose-report.json", + "sha256": "2dbd3e79939b500c0fc83216c51f258b88ead42a667ed1a6da755e893d7f73c6" + } + }, + "nextTask": "M13-03G" +} diff --git a/tests/golden/M13-03F/sandbox-dispose-report.json b/tests/golden/M13-03F/sandbox-dispose-report.json new file mode 100644 index 00000000..0fafadbd --- /dev/null +++ b/tests/golden/M13-03F/sandbox-dispose-report.json @@ -0,0 +1,76 @@ +{ + "schemaVersion": 1, + "task": "M13-03F", + "operation": "SCRIPT_SANDBOX_DISPOSE_GATE", + "runtime": "PRODUCTION_CHROMIUM_WORKER", + "resources": { + "before": { + "messagePorts": 2, + "timers": 1, + "abortControllers": 1, + "transferableBuffers": 1, + "pendingRequests": 1, + "cacheReferences": 1 + }, + "afterFirstDispose": { + "messagePorts": 0, + "timers": 0, + "abortControllers": 0, + "transferableBuffers": 0, + "pendingRequests": 0, + "cacheReferences": 0 + }, + "afterSecondDispose": { + "messagePorts": 0, + "timers": 0, + "abortControllers": 0, + "transferableBuffers": 0, + "pendingRequests": 0, + "cacheReferences": 0 + } + }, + "receipts": { + "first": { + "schemaVersion": 1, + "disposeCount": 1, + "idempotent": false, + "resources": { + "messagePorts": 0, + "timers": 0, + "abortControllers": 0, + "transferableBuffers": 0, + "pendingRequests": 0, + "cacheReferences": 0 + }, + "lateTimerMessages": 0 + }, + "second": { + "schemaVersion": 1, + "disposeCount": 2, + "idempotent": true, + "resources": { + "messagePorts": 0, + "timers": 0, + "abortControllers": 0, + "transferableBuffers": 0, + "pendingRequests": 0, + "cacheReferences": 0 + }, + "lateTimerMessages": 0 + } + }, + "lateTimerMessages": 0, + "invariants": { + "workerTerminated": true, + "messagePortsZero": true, + "timersZero": true, + "abortControllersZero": true, + "transferableBuffersZero": true, + "pendingRequestsZero": true, + "cacheReferencesZero": true, + "repeatedDisposeIdempotent": true, + "noLateTimerMessages": true + }, + "execution": "DISABLED", + "nextTask": "M13-03G" +} diff --git a/tests/golden/M13-03G/manifest.json b/tests/golden/M13-03G/manifest.json new file mode 100644 index 00000000..4282cda2 --- /dev/null +++ b/tests/golden/M13-03G/manifest.json @@ -0,0 +1,23 @@ +{ + "schemaVersion": 1, + "task": "M13-03G", + "parentTask": "M13-03F", + "enablingTask": false, + "parityStateChange": false, + "runtime": "PRODUCTION_PROTOCOL_AND_CHROMIUM_WORKER", + "operation": "SCRIPT_SANDBOX_RECOVERY", + "artifacts": { + "parentManifest": { + "path": "tests/golden/M13-03F/manifest.json", + "sha256": "96abc7c7613ef4c18c64b84ffb8420c39369f9e4733bd1fa4d7d9ee9e53d6f89" + }, + "protocol": { "path": "web/app/src/testing/script-sandbox-recovery.ts", "sha256": "8034faded657d49e1376ea42051bc302a090b485024a9ff3781e46aa82638b64" }, + "worker": { "path": "web/app/src/workers/script-sandbox-recovery-test.worker.ts", "sha256": "7b02331c375d4fb7dbadadd179f0ab9a0e336c8b95fabb071c06e67f3e7b3fe4" }, + "checker": { "path": "tools/web/check-script-sandbox-recovery.mjs", "sha256": "612015a3fca44bae0f0b78e622f044a5297ed8aa3a0efc774a40f0d47ddbdc9e" }, + "unit": { "path": "web/tests/unit/script-sandbox-recovery.test.mjs", "sha256": "69a2d34e38d591043ff62b2d305bd9aae684ecebd9ecba718580d77318931226" }, + "e2e": { "path": "web/tests/e2e/script-sandbox-recovery.spec.ts", "sha256": "73176e513f115ba917be74f62a5deb8fe2918fafa8a84c9294e80d2a86b8f1ed" }, + "package": { "path": "web/package.json", "sha256": "3df02b6049ac5d7185c23aef7764933ee460fedeb6595b3861272b0d49da5ecd" }, + "report": { "path": "tests/golden/M13-03G/sandbox-recovery-report.json", "sha256": "9057b3f49c3bb15cf53d638ada4bd2743949d3914173cd3799414489d584111d" } + }, + "nextTask": "M13-04A" +} diff --git a/tests/golden/M13-03G/sandbox-recovery-report.json b/tests/golden/M13-03G/sandbox-recovery-report.json new file mode 100644 index 00000000..5171adeb --- /dev/null +++ b/tests/golden/M13-03G/sandbox-recovery-report.json @@ -0,0 +1,65 @@ +{ + "schemaVersion": 1, + "task": "M13-03G", + "operation": "SCRIPT_SANDBOX_RECOVERY", + "runtime": "PRODUCTION_PROTOCOL_AND_CHROMIUM_WORKER", + "receipt": { + "schemaVersion": 1, + "operation": "SCRIPT_SANDBOX_RECOVERY", + "previousGeneration": 4, + "nextGeneration": 5, + "mainRevisionBefore": 11, + "mainRevisionAfter": 11, + "sourceSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "manifestSha256": "90eddf56b5ee5a7a95ec365e0d0bfc81672922ce0d828f808e078d437dfe5435", + "audit": { + "entries": 2, + "first": { + "sequence": 1, + "requestId": "sandbox-recovery:g4", + "previousEntrySha256": null, + "entrySha256": "e7fddd846ecc5d7c89e05fd38fed84838fc7d40575ec69866707268aa4195ff0", + "sourceSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "manifestSha256": "90eddf56b5ee5a7a95ec365e0d0bfc81672922ce0d828f808e078d437dfe5435" + }, + "second": { + "sequence": 2, + "requestId": "sandbox-recovery:g5", + "previousEntrySha256": "e7fddd846ecc5d7c89e05fd38fed84838fc7d40575ec69866707268aa4195ff0", + "entrySha256": "bb7af3609c5eb1a5770d08f2beec42dad0c21428d4ca154ae4f8bd734f5e51de", + "sourceSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "manifestSha256": "90eddf56b5ee5a7a95ec365e0d0bfc81672922ce0d828f808e078d437dfe5435" + } + }, + "recovered": true, + "execution": "DISABLED" + }, + "audit": { + "entryCount": 2, + "firstEntrySha256": "e7fddd846ecc5d7c89e05fd38fed84838fc7d40575ec69866707268aa4195ff0", + "secondPreviousEntrySha256": "e7fddd846ecc5d7c89e05fd38fed84838fc7d40575ec69866707268aa4195ff0", + "secondEntrySha256": "bb7af3609c5eb1a5770d08f2beec42dad0c21428d4ca154ae4f8bd734f5e51de", + "requestIds": [ + "sandbox-recovery:g4", + "sandbox-recovery:g5" + ], + "sourceSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "manifestSha256": "90eddf56b5ee5a7a95ec365e0d0bfc81672922ce0d828f808e078d437dfe5435" + }, + "negative": { + "replayError": "SCRIPT_MANIFEST_INVALID", + "tamperError": "SCRIPT_MANIFEST_INVALID" + }, + "invariants": { + "generationAdvancedOnce": true, + "mainRevisionUnchanged": true, + "sourceHashStable": true, + "manifestHashStable": true, + "sequenceContinuous": true, + "previousHashContinuous": true, + "requestIdsUnique": true, + "executionDisabled": true + }, + "execution": "DISABLED", + "nextTask": "M13-04A" +} diff --git a/tests/golden/M13-04A/manifest.json b/tests/golden/M13-04A/manifest.json new file mode 100644 index 00000000..9b6d634b --- /dev/null +++ b/tests/golden/M13-04A/manifest.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "task": "M13-04A", + "parentTask": "M13-03G", + "enablingTask": false, + "parityStateChange": false, + "runtime": "NODE_SERVER_FILESYSTEM", + "operation": "SERVER_JOB_ONE_SHOT_DIRECTORY", + "artifacts": { + "parentManifest": { "path": "tests/golden/M13-03G/manifest.json", "sha256": "5cd365eeebbd6a7f56f380af103b66f2beb11eb49cb6db6adb49972b572cf2ec" }, + "protocol": { "path": "tools/web/server-job-isolation.mjs", "sha256": "3aa5678a2c2769e4db5bb8f5c755b97e23045c4106e5636459748ab9b41929cd" }, + "checker": { "path": "tools/web/check-server-job-isolation.mjs", "sha256": "b8d0e741144f742946246370bd35820d806686fb00dc0b040c925cf16f2179da" }, + "unit": { "path": "web/tests/unit/server-job-isolation.test.mjs", "sha256": "b1ac3324332180f7b3522f135520e7b5dace334dc461c92f2bce48fbcba2e147" }, + "package": { "path": "web/package.json", "sha256": "3df02b6049ac5d7185c23aef7764933ee460fedeb6595b3861272b0d49da5ecd" }, + "report": { "path": "tests/golden/M13-04A/server-job-directory-report.json", "sha256": "07ed66fe0b2e1f1bbdba1cfb1089b052a5961d38f761bfcf79d8362980d2d502" } + }, + "nextTask": "M13-04B" +} diff --git a/tests/golden/M13-04A/server-job-directory-report.json b/tests/golden/M13-04A/server-job-directory-report.json new file mode 100644 index 00000000..3b94c135 --- /dev/null +++ b/tests/golden/M13-04A/server-job-directory-report.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "task": "M13-04A", + "operation": "SERVER_JOB_ONE_SHOT_DIRECTORY", + "runtime": "NODE_SERVER_FILESYSTEM", + "allocated": 2, + "cleaned": 2, + "uniqueDirectories": true, + "requestIdsNotInDirectoryNames": true, + "mode": "0700", + "noResidualDirectories": true, + "repeatedCleanupIdempotent": true, + "execution": "DISABLED", + "nextTask": "M13-04B" +} diff --git a/tests/golden/M13-04B/manifest.json b/tests/golden/M13-04B/manifest.json new file mode 100644 index 00000000..cc29bf09 --- /dev/null +++ b/tests/golden/M13-04B/manifest.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "task": "M13-04B", + "parentTask": "M13-04A", + "enablingTask": false, + "parityStateChange": false, + "runtime": "NODE_SERVER_FILESYSTEM", + "operation": "SERVER_JOB_SOURCE_READONLY_OUTPUT_ISOLATION", + "artifacts": { + "parentManifest": { "path": "tests/golden/M13-04A/manifest.json", "sha256": "fd2407a7b9fefe67e2d77ed844e6c2ca17cdc4a746857a5825a38ff02ab664e3" }, + "protocol": { "path": "tools/web/server-job-isolation.mjs", "sha256": "3aa5678a2c2769e4db5bb8f5c755b97e23045c4106e5636459748ab9b41929cd" }, + "checker": { "path": "tools/web/check-server-job-workspace.mjs", "sha256": "9490c2eeb9980073fc1fe77fdba1fb6e4ef528de8eb666d9eaf1fb582ba658c2" }, + "unit": { "path": "web/tests/unit/server-job-isolation.test.mjs", "sha256": "b1ac3324332180f7b3522f135520e7b5dace334dc461c92f2bce48fbcba2e147" }, + "package": { "path": "web/package.json", "sha256": "3df02b6049ac5d7185c23aef7764933ee460fedeb6595b3861272b0d49da5ecd" }, + "report": { "path": "tests/golden/M13-04B/server-job-workspace-report.json", "sha256": "650a643cc92617d068cb96d8bd4303429ef169ea89e3d4cb63e3ce5e2428e6d2" } + }, + "nextTask": "M13-04C" +} diff --git a/tests/golden/M13-04B/server-job-workspace-report.json b/tests/golden/M13-04B/server-job-workspace-report.json new file mode 100644 index 00000000..92e6d102 --- /dev/null +++ b/tests/golden/M13-04B/server-job-workspace-report.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "task": "M13-04B", + "operation": "SERVER_JOB_SOURCE_READONLY_OUTPUT_ISOLATION", + "runtime": "NODE_SERVER_FILESYSTEM", + "sourceDirectoryMode": "0555", + "sourceFileMode": "0444", + "outputDirectoryMode": "0700", + "sourceWrite": "EACCES", + "outputWrite": "OK", + "sourceOutputDistinct": true, + "cleanupNoResidual": true, + "execution": "DISABLED", + "nextTask": "M13-04C" +} diff --git a/tests/golden/M13-04C/manifest.json b/tests/golden/M13-04C/manifest.json new file mode 100644 index 00000000..80a66b4a --- /dev/null +++ b/tests/golden/M13-04C/manifest.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "task": "M13-04C", + "parentTask": "M13-04B", + "enablingTask": false, + "parityStateChange": false, + "runtime": "NODE_SERVER_FILESYSTEM", + "operation": "SERVER_JOB_RESOURCE_BUDGET", + "artifacts": { + "parentManifest": { "path": "tests/golden/M13-04B/manifest.json", "sha256": "9bc906b9e4c9751229f03271ef45a5925122b89b15bfe775591775f121ce1a4c" }, + "protocol": { "path": "tools/web/server-job-resource-budget.mjs", "sha256": "9746f0aef9dd76411320792dee1213c03755a3c03a87bbc4cbf88d4324c728d6" }, + "checker": { "path": "tools/web/check-server-job-resource-budget.mjs", "sha256": "5599f4e25fc3ceca18e04be322450a4dc5bb6ce9c72c3abe9f1afc9c81851ff8" }, + "unit": { "path": "web/tests/unit/server-job-resource-budget.test.mjs", "sha256": "05212b2aa639f4c37e37e1cac0597d9b367a1280e519240f073762394914dc63" }, + "package": { "path": "web/package.json", "sha256": "3df02b6049ac5d7185c23aef7764933ee460fedeb6595b3861272b0d49da5ecd" }, + "report": { "path": "tests/golden/M13-04C/server-job-resource-budget-report.json", "sha256": "76ba684966bf7e680b0ebfc630ae9e080f04dd6daa6dd744bbb22dcff43f8eb1" } + }, + "nextTask": "M13-04D" +} diff --git a/tests/golden/M13-04C/server-job-resource-budget-report.json b/tests/golden/M13-04C/server-job-resource-budget-report.json new file mode 100644 index 00000000..17a31350 --- /dev/null +++ b/tests/golden/M13-04C/server-job-resource-budget-report.json @@ -0,0 +1,55 @@ +{ + "schemaVersion": 1, + "task": "M13-04C", + "operation": "SERVER_JOB_RESOURCE_BUDGET", + "limits": { + "cpuMs": 60000, + "memoryBytes": 536870912, + "processCount": 1, + "fileCount": 1024, + "wallMs": 300000, + "outputBytes": 536870912 + }, + "accepted": { + "schemaVersion": 1, + "status": "SUCCEEDED", + "budget": { + "schemaVersion": 1, + "cpuMs": 60000, + "memoryBytes": 536870912, + "processCount": 1, + "fileCount": 1024, + "wallMs": 300000, + "outputBytes": 536870912 + }, + "usage": { + "cpuMs": 10, + "memoryBytes": 1024, + "processCount": 1, + "fileCount": 2, + "wallMs": 20, + "outputBytes": 512 + }, + "enforced": true, + "exceeded": [] + }, + "blocked": { + "cpuMs": "SERVER_JOB_BUDGET_EXCEEDED", + "memoryBytes": "SERVER_JOB_BUDGET_EXCEEDED", + "processCount": "SERVER_JOB_BUDGET_EXCEEDED", + "fileCount": "SERVER_JOB_BUDGET_EXCEEDED", + "wallMs": "SERVER_JOB_BUDGET_EXCEEDED", + "outputBytes": "SERVER_JOB_BUDGET_EXCEEDED" + }, + "invariants": { + "cpuBounded": true, + "memoryBounded": true, + "processBounded": true, + "fileBounded": true, + "wallBounded": true, + "outputBounded": true, + "executionDisabled": true + }, + "execution": "DISABLED", + "nextTask": "M13-04D" +} diff --git a/tests/golden/M13-04D/manifest.json b/tests/golden/M13-04D/manifest.json new file mode 100644 index 00000000..49bb6920 --- /dev/null +++ b/tests/golden/M13-04D/manifest.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "task": "M13-04D", + "parentTask": "M13-04C", + "enablingTask": false, + "parityStateChange": false, + "runtime": "NODE_SERVER_FILESYSTEM", + "operation": "SERVER_JOB_NETWORK_POLICY", + "artifacts": { + "parentManifest": { "path": "tests/golden/M13-04C/manifest.json", "sha256": "d00fff709b57909ec418ff0e476dca97281fc39456f674c63b51248377fc6ab2" }, + "protocol": { "path": "tools/web/server-job-network-policy.mjs", "sha256": "0dc3698383fbfa511bebfeca41504c7c04a8302d47168cfae94f01f2a11a316c" }, + "checker": { "path": "tools/web/check-server-job-network-policy.mjs", "sha256": "6f3e9cd9f988313863345f392036e6dbb522102c969197034800f419e497ad92" }, + "unit": { "path": "web/tests/unit/server-job-network-policy.test.mjs", "sha256": "c0ba10ccd2fb6ec8878a1b4a54bc6a12e49d35818566e0d55ef307779e8c8b77" }, + "package": { "path": "web/package.json", "sha256": "92547295bdc2f3fc8b691dac4fbf20c50aa91e8bb05a9d5b471fdeedb8974100" }, + "report": { "path": "tests/golden/M13-04D/server-job-network-policy-report.json", "sha256": "37bcba2666e8c76c2e07d23e285737004affbb0372ec6dd84bb6802dba551527" } + }, + "nextTask": "M13-04E" +} diff --git a/tests/golden/M13-04D/server-job-network-policy-report.json b/tests/golden/M13-04D/server-job-network-policy-report.json new file mode 100644 index 00000000..a6b1a8ad --- /dev/null +++ b/tests/golden/M13-04D/server-job-network-policy-report.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": 1, + "task": "M13-04D", + "operation": "SERVER_JOB_NETWORK_POLICY", + "defaultNetwork": "DENY", + "declaredOrigin": { + "status": "ALLOWED", + "code": "SERVER_NETWORK_ALLOWED_ORIGIN", + "origin": "https://example.com", + "network": "DECLARED_ORIGIN" + }, + "denied": { + "missing": { + "status": "DENIED", + "code": "SERVER_NETWORK_DENIED", + "origin": null, + "network": "DISABLED" + }, + "undeclared": { + "status": "DENIED", + "code": "SERVER_NETWORK_DENIED", + "origin": "https://other.example", + "network": "DISABLED" + } + }, + "execution": "DISABLED", + "nextTask": "M13-04E" +} diff --git a/tests/golden/M13-04E/manifest.json b/tests/golden/M13-04E/manifest.json new file mode 100644 index 00000000..8819be27 --- /dev/null +++ b/tests/golden/M13-04E/manifest.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "task": "M13-04E", + "parentTask": "M13-04D", + "enablingTask": false, + "parityStateChange": false, + "runtime": "PINNED_BLENDER_5_2_BACKGROUND", + "operation": "SERVER_JOB_BLENDER_STARTUP", + "artifacts": { + "parentManifest": { "path": "tests/golden/M13-04D/manifest.json", "sha256": "11ce246597e3321b346ed4fed00edba22708d97250163d332d7ba8a003d8849a" }, + "startup": { "path": "tools/web/server-job-startup.py", "sha256": "c8c5b5a7a7880d4df9477a94a98e54e328baf582ade649807067f988484e1f12" }, + "checker": { "path": "tools/web/check-server-job-startup.mjs", "sha256": "66b2e25efe0e7455348e8f488cd0f6ebef07f916fcf6541680e7d18e4f69ab10" }, + "package": { "path": "web/package.json", "sha256": "83889e43d03a791ee26e0aeabaa4c76044f6ee8f3f0ed69969681e2f63735e57" }, + "report": { "path": "tests/golden/M13-04E/server-job-startup-report.json", "sha256": "c67d2972e584782d03479dbdb36dae0976074978f4ce9a48e41e7ad5aea66089" } + }, + "nextTask": "M13-04F" +} diff --git a/tests/golden/M13-04E/server-job-startup-report.json b/tests/golden/M13-04E/server-job-startup-report.json new file mode 100644 index 00000000..7087c59c --- /dev/null +++ b/tests/golden/M13-04E/server-job-startup-report.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": 1, + "task": "M13-04E", + "operation": "SERVER_JOB_BLENDER_STARTUP", + "blender": "/home/mes123456/workinf_Blender_Wasm/build_blender_5.2.0/bin/blender", + "argv": [ + "--background", + "--factory-startup", + "--python", + "/home/mes123456/workinf_Blender_Wasm/tools/web/server-job-startup.py", + "--", + "SERVER_JOB_STARTUP_V1" + ], + "receipt": { + "argv": [ + "SERVER_JOB_STARTUP_V1" + ], + "background": true, + "runtime": "BLENDER_BACKGROUND_FACTORY_STARTUP", + "schemaVersion": 1, + "version": "5.2.0 LTS" + }, + "factoryStartup": true, + "background": true, + "userPrefsDisabled": true, + "fixedStartupScript": true, + "execution": "DISABLED", + "nextTask": "M13-04F" +} diff --git a/tests/golden/M13-04F/manifest.json b/tests/golden/M13-04F/manifest.json new file mode 100644 index 00000000..66365d86 --- /dev/null +++ b/tests/golden/M13-04F/manifest.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "task": "M13-04F", + "parentTask": "M13-04E", + "enablingTask": false, + "parityStateChange": false, + "runtime": "NODE_SERVER_OUTPUT_RECEIPT", + "operation": "SERVER_JOB_OUTPUT_REDACTION", + "artifacts": { + "parentManifest": { "path": "tests/golden/M13-04E/manifest.json", "sha256": "a24ea87bcf305bc15d8ae70b2abd03aa16ffbd6b127d5e9da9da23617d7201cc" }, + "protocol": { "path": "tools/web/server-job-output.mjs", "sha256": "5a58e7080324b399905c6a20a313225153ba94c75a55d21c4bbdd957ae4a2462" }, + "checker": { "path": "tools/web/check-server-job-output-redaction.mjs", "sha256": "5c2db9d3313484bb68ac65822828467b3f99cb03e8374935757cfcb4b4ed908d" }, + "unit": { "path": "web/tests/unit/server-job-output.test.mjs", "sha256": "4ae6266bce22f84e73113b93a590a0c32d9452e748003af251a602024d0c6db5" }, + "package": { "path": "web/package.json", "sha256": "52781eb2650133b43271889a22ce38023e611641da15411d31698c6cfc0ce19c" }, + "report": { "path": "tests/golden/M13-04F/server-job-output-report.json", "sha256": "cb6e3ed8d6cc0a69b333b6bff3199593bdfbae14fa7fa700dcb12e9e9cea519a" } + }, + "nextTask": "M13-04G" +} diff --git a/tests/golden/M13-04F/server-job-output-report.json b/tests/golden/M13-04F/server-job-output-report.json new file mode 100644 index 00000000..971f8c94 --- /dev/null +++ b/tests/golden/M13-04F/server-job-output-report.json @@ -0,0 +1,70 @@ +{ + "schemaVersion": 1, + "task": "M13-04F", + "operation": "SERVER_JOB_OUTPUT_REDACTION", + "limits": { + "stdoutBytes": 65536, + "stderrBytes": 65536, + "totalBytes": 131072 + }, + "normal": { + "schemaVersion": 1, + "stdout": { + "text": "Blender 5.2 background\n", + "originalBytes": 23, + "emittedBytes": 23, + "redactionCount": 0, + "truncated": false + }, + "stderr": { + "text": "", + "originalBytes": 0, + "emittedBytes": 0, + "redactionCount": 0, + "truncated": false + }, + "limits": { + "stdoutBytes": 65536, + "stderrBytes": 65536, + "totalBytes": 131072 + }, + "totalOriginalBytes": 23, + "totalEmittedBytes": 23, + "totalRedactions": 0, + "totalTruncated": false, + "execution": "DISABLED" + }, + "oversized": { + "schemaVersion": 1, + "stdout": { + "text": "INFO source= authorization: \nxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n", + "originalBytes": 80094, + "emittedBytes": 65536, + "redactionCount": 4, + "truncated": true + }, + "stderr": { + "text": "ERROR \nyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy\n", + "originalBytes": 80062, + "emittedBytes": 65536, + "redactionCount": 2, + "truncated": true + }, + "limits": { + "stdoutBytes": 65536, + "stderrBytes": 65536, + "totalBytes": 131072 + }, + "totalOriginalBytes": 160156, + "totalEmittedBytes": 131072, + "totalRedactions": 6, + "totalTruncated": true, + "execution": "DISABLED" + }, + "negative": { + "missingOutput": 0, + "invalidBudget": "SERVER_JOB_OUTPUT_INVALID" + }, + "execution": "DISABLED", + "nextTask": "M13-04G" +} diff --git a/tests/golden/M13-04G/manifest.json b/tests/golden/M13-04G/manifest.json new file mode 100644 index 00000000..3a5c1fa6 --- /dev/null +++ b/tests/golden/M13-04G/manifest.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "task": "M13-04G", + "parentTask": "M13-04F", + "enablingTask": false, + "parityStateChange": false, + "runtime": "NODE_REAL_PROCESS_GROUP", + "operation": "SERVER_JOB_PROCESS_TREE_CANCELLATION", + "artifacts": { + "parentManifest": { "path": "tests/golden/M13-04F/manifest.json", "sha256": "b5b4b26183dc48ebcae009e49999010d9b60d512f5daa4674a0aff0577d6602c" }, + "protocol": { "path": "tools/web/server-job-process.mjs", "sha256": "74fbbac806f34904bc9cb8e40eaf386cb60d44d978973950f1809262d311ec95" }, + "checker": { "path": "tools/web/check-server-job-cancellation.mjs", "sha256": "751ecafc642595d0de69b618e223cbff49d414f2958fc371371e4a668d66a8cc" }, + "unit": { "path": "web/tests/unit/server-job-process.test.mjs", "sha256": "39ac445ff51a9bfe249363204cf2edca906b16bf67ab1f8321166b440eba8629" }, + "package": { "path": "web/package.json", "sha256": "ca092a9a1d48ac41dc20f978bb26c8268adde9f7602a06459c7df8c2cb2d4cc7" }, + "report": { "path": "tests/golden/M13-04G/server-job-cancellation-report.json", "sha256": "61f5c3ad315f94bac48e58627fcc46015c173dd9f303d5914acb8dbbe93caa97" } + }, + "nextTask": "M13-04H" +} diff --git a/tests/golden/M13-04G/server-job-cancellation-report.json b/tests/golden/M13-04G/server-job-cancellation-report.json new file mode 100644 index 00000000..c044d7ff --- /dev/null +++ b/tests/golden/M13-04G/server-job-cancellation-report.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "task": "M13-04G", + "operation": "SERVER_JOB_PROCESS_TREE_CANCELLATION", + "runtime": "NODE_REAL_PROCESS_GROUP", + "state": "CANCELLED", + "treeSignal": "SIGTERM_GROUP", + "cleanupCount": 1, + "orphanCount": 0, + "residualDirectory": false, + "repeatedCancelIdempotent": true, + "execution": "DISABLED", + "nextTask": "M13-04H" +} diff --git a/tests/golden/M13-04H/manifest.json b/tests/golden/M13-04H/manifest.json new file mode 100644 index 00000000..762fe426 --- /dev/null +++ b/tests/golden/M13-04H/manifest.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "task": "M13-04H", + "parentTask": "M13-04G", + "enablingTask": false, + "parityStateChange": false, + "runtime": "NODE_REAL_PROCESS_AND_BOUNDED_FAULTS", + "operation": "SERVER_JOB_FAULT_CODE_MAPPING", + "artifacts": { + "parentManifest": { "path": "tests/golden/M13-04G/manifest.json", "sha256": "7103a25ec7eb4a1c7f0d9f7acfb5ed30da32c87d512d6f9d53e081db275f7b89" }, + "protocol": { "path": "tools/web/server-job-fault.mjs", "sha256": "858360b4468a7b6cf796fc83a1627ba18626b61a0799f83531de996ea374519b" }, + "checker": { "path": "tools/web/check-server-job-fault-codes.mjs", "sha256": "cb67246ef3df111525ca5e14af0546154ea2beee930b9840ad4ac7e2ebbb91ec" }, + "unit": { "path": "web/tests/unit/server-job-fault.test.mjs", "sha256": "3551e28d78e5069c3d97eabcacac824bf142f54fdcb3ad4904668909af1cc545" }, + "package": { "path": "web/package.json", "sha256": "77bae66fd3885e36d2ead6f261754724144e5f51fafc5ccad9863dfc8e5ab548" }, + "report": { "path": "tests/golden/M13-04H/server-job-fault-report.json", "sha256": "01999232f926496909fc9ef072e19aadb688822d3f9d7e03b42871ac5d54a0f6" } + }, + "nextTask": "M13-04I" +} diff --git a/tests/golden/M13-04H/server-job-fault-report.json b/tests/golden/M13-04H/server-job-fault-report.json new file mode 100644 index 00000000..cd8d2c16 --- /dev/null +++ b/tests/golden/M13-04H/server-job-fault-report.json @@ -0,0 +1,80 @@ +{ + "schemaVersion": 1, + "task": "M13-04H", + "operation": "SERVER_JOB_FAULT_CODE_MAPPING", + "runtime": "NODE_REAL_PROCESS_AND_BOUNDED_FAULTS", + "receipts": { + "timeout": { + "schemaVersion": 1, + "state": "FAILED", + "code": "SERVER_JOB_TIMEOUT", + "stage": "PROCESS", + "exitCode": null, + "signal": null, + "timedOut": true, + "memoryExceeded": false, + "baseRevision": 11, + "currentRevision": 11, + "committedRevision": 11, + "publish": false, + "revisionPreserved": true, + "execution": "DISABLED" + }, + "oom": { + "schemaVersion": 1, + "state": "FAILED", + "code": "SERVER_JOB_OOM", + "stage": "PROCESS", + "exitCode": null, + "signal": null, + "timedOut": false, + "memoryExceeded": true, + "baseRevision": 11, + "currentRevision": 11, + "committedRevision": 11, + "publish": false, + "revisionPreserved": true, + "execution": "DISABLED" + }, + "signal": { + "schemaVersion": 1, + "state": "FAILED", + "code": "SERVER_JOB_SIGNAL", + "stage": "PROCESS", + "exitCode": null, + "signal": "SIGTERM", + "timedOut": false, + "memoryExceeded": false, + "baseRevision": 11, + "currentRevision": 11, + "committedRevision": 11, + "publish": false, + "revisionPreserved": true, + "execution": "DISABLED" + }, + "exit": { + "schemaVersion": 1, + "state": "FAILED", + "code": "SERVER_JOB_EXIT_FAILED", + "stage": "PROCESS", + "exitCode": 7, + "signal": null, + "timedOut": false, + "memoryExceeded": false, + "baseRevision": 11, + "currentRevision": 11, + "committedRevision": 11, + "publish": false, + "revisionPreserved": true, + "execution": "DISABLED" + } + }, + "invariants": { + "oldRevisionPreserved": true, + "failurePublish": false, + "cleanupRequired": true, + "executionDisabled": true + }, + "execution": "DISABLED", + "nextTask": "M13-04I" +} diff --git a/tests/golden/M13-04I/manifest.json b/tests/golden/M13-04I/manifest.json new file mode 100644 index 00000000..5ad57dce --- /dev/null +++ b/tests/golden/M13-04I/manifest.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "task": "M13-04I", + "parentTask": "M13-04H", + "enablingTask": false, + "parityStateChange": false, + "runtime": "NODE_ATOMIC_RESULT_WORKSPACE", + "operation": "SERVER_JOB_RESULT_HASH_BINDING", + "artifacts": { + "parentManifest": { "path": "tests/golden/M13-04H/manifest.json", "sha256": "ba530ddff6e6fdd84057444ce757fad05a30d825a5915abebe2d5910a689af7f" }, + "protocol": { "path": "tools/web/server-job-result-binding.mjs", "sha256": "ecf3bddef53e2766dcc055ec027b04c14e3d884132c7fdea476663617364ad86" }, + "checker": { "path": "tools/web/check-server-job-result-binding.mjs", "sha256": "b0fbffd2937ab20e72d337bb5ff63ccf188ccf8a939d8ccc8b4878d80c9a4a19" }, + "unit": { "path": "web/tests/unit/server-job-result-binding.test.mjs", "sha256": "c5538798aa6263b8a1c20270a019f8b4fb8b176b141a6411b72aad21a457bfd0" }, + "package": { "path": "web/package.json", "sha256": "5d269d4e5ff4385d919c0ee33f996dcb814e3e415da25dbf3b4dbdc3312fbe24" }, + "report": { "path": "tests/golden/M13-04I/server-job-result-report.json", "sha256": "ec63faeba35a641a22c3b3a69757bd37e8e9acaa1dda11a64faf7bed80439784" } + }, + "nextTask": "M13-04J" +} diff --git a/tests/golden/M13-04I/server-job-result-report.json b/tests/golden/M13-04I/server-job-result-report.json new file mode 100644 index 00000000..4c9b83c4 --- /dev/null +++ b/tests/golden/M13-04I/server-job-result-report.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "task": "M13-04I", + "operation": "SERVER_JOB_RESULT_HASH_BINDING", + "identityHashes": { + "source": "41cf6794ba4200b839c53531555f0f3998df4cbb01a4d5cb0b94e3ca5e23947d", + "settings": "cde0fb0dec1400c54a0f7e7eafa73624c53e4da258bbd34b3380a0defeba95c1", + "build": "b1a7a669e8007748b017f3010e59b76376c823e832a863ce57f025adec26beb8" + }, + "outputSha256": "6ff2c765a84cd1cb50960c12d9c436bac1260375f05fa967e2903197f66c4220", + "outputByteLength": 4, + "verified": true, + "tamperAndQuotaBlocked": true, + "atomicTarget": true, + "execution": "DISABLED", + "nextTask": "M13-04J" +} diff --git a/tests/golden/M13-04J/manifest.json b/tests/golden/M13-04J/manifest.json new file mode 100644 index 00000000..1367f5e9 --- /dev/null +++ b/tests/golden/M13-04J/manifest.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "task": "M13-04J", + "parentTask": "M13-04I", + "enablingTask": false, + "parityStateChange": false, + "runtime": "NODE_ATOMIC_RECEIPT_STORE", + "operation": "SERVER_JOB_REQUEST_IDEMPOTENCY", + "artifacts": { + "parentManifest": { "path": "tests/golden/M13-04I/manifest.json", "sha256": "f9658954d3dbd0cd1edd696c10f6a1b880bb3288c91a39a47ca84c4496046a97" }, + "protocol": { "path": "tools/web/server-job-idempotency.mjs", "sha256": "15eca7550964c9985e26a22a7900d3f3aa69fdef35fe5962b48a8f55ebba5863" }, + "checker": { "path": "tools/web/check-server-job-idempotency.mjs", "sha256": "02247ffda07c095373642a7fa22268bb9f5ac181d41571f7fff685812976d3c4" }, + "unit": { "path": "web/tests/unit/server-job-idempotency.test.mjs", "sha256": "6ee08e67cd8493e856f67309bd6562316eca42ef52dd4a9aad3a15efdc4b6b41" }, + "package": { "path": "web/package.json", "sha256": "5b47e94cf828fc9d3021019eed9922eb67815aba5416266d8c967c7cb5bd96ba" }, + "report": { "path": "tests/golden/M13-04J/server-job-idempotency-report.json", "sha256": "31f95a8cc1c4792303986b8b5987ab02fc11a77ab25fd76b31c02d1ed1b104d1" } + }, + "nextTask": "M13-05A" +} diff --git a/tests/golden/M13-04J/server-job-idempotency-report.json b/tests/golden/M13-04J/server-job-idempotency-report.json new file mode 100644 index 00000000..6f87acab --- /dev/null +++ b/tests/golden/M13-04J/server-job-idempotency-report.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "task": "M13-04J", + "operation": "SERVER_JOB_REQUEST_IDEMPOTENCY", + "runtime": "NODE_ATOMIC_RECEIPT_STORE", + "firstCommitReused": false, + "exactRetryReused": true, + "conflictBlocked": true, + "differentRequestIsolated": true, + "concurrentReuse": [ + false, + true + ], + "execution": "DISABLED", + "nextTask": "M13-05A" +} diff --git a/tests/golden/M13-05A/csp-report.json b/tests/golden/M13-05A/csp-report.json new file mode 100644 index 00000000..55aeeea8 --- /dev/null +++ b/tests/golden/M13-05A/csp-report.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "task": "M13-05A", + "operation": "CSP_POLICY", + "policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; font-src 'self'; img-src 'self'; media-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'", + "sourceChecks": { + "inlineScript": "DENY", + "inlineHandler": "DENY", + "eval": "DENY", + "dataScript": "DENY", + "undeclaredConnect": "DENY" + }, + "responseCount": 3, + "buildChecked": true, + "execution": "DISABLED", + "nextTask": "M13-05B" +} diff --git a/tests/golden/M13-05A/manifest.json b/tests/golden/M13-05A/manifest.json new file mode 100644 index 00000000..181b0bbe --- /dev/null +++ b/tests/golden/M13-05A/manifest.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "task": "M13-05A", + "parentTask": "M13-04J", + "enablingTask": false, + "parityStateChange": false, + "runtime": "NODE_DEPLOYMENT_HTTP", + "operation": "CSP_POLICY", + "artifacts": { + "parentManifest": { "path": "tests/golden/M13-04J/manifest.json", "sha256": "d230ae49dbf38cd9e95e7379a1a49332e478d3d23b3638730d2ec6167204c368" }, + "contract": { "path": "docs/web/deployment-contract.json", "sha256": "e384c73ee6deac8ec50fd496f97d16b20d992da25a5fba45138997f7b3677b7c" }, + "server": { "path": "tools/web/deployment-http-server.mjs", "sha256": "a5734ce9760f65cf5bade8cc209454d39fa963ad7dfaf20653728a9d7a57b04c" }, + "contractChecker": { "path": "tools/web/check-deployment-contract.mjs", "sha256": "9758f8bedd6c4faec3c8ae2cd361db754b6f02ac3b01872495abf00b90236b87" }, + "checker": { "path": "tools/web/check-csp-policy.mjs", "sha256": "f259f6d9a9432ef84713bd19f859717c0fb2c3e572672dcafdc3c141f3d4f6e0" }, + "index": { "path": "web/app/index.html", "sha256": "16b7691b191127f84c5143e23aecdfc203dc9e279d34cb270b37631fcbd5f856" }, + "vite": { "path": "web/app/vite.config.ts", "sha256": "fd160c685ed780738e2b37ce9a32e5d4718e11a2875e12d86ddad310213abaf3" }, + "package": { "path": "web/package.json", "sha256": "b12daaf74ed89e058d27682e7ebcfddf102b2f3cb8354a921966d636bc651162" }, + "report": { "path": "tests/golden/M13-05A/csp-report.json", "sha256": "2021b5194671920732d40812dc8c058c4349ad8cd14bd1123ca1853fb638df68" } + }, + "nextTask": "M13-05B" +} diff --git a/tests/golden/M13-05B/csp-resource-report.json b/tests/golden/M13-05B/csp-resource-report.json new file mode 100644 index 00000000..2e924494 --- /dev/null +++ b/tests/golden/M13-05B/csp-resource-report.json @@ -0,0 +1,47 @@ +{ + "schemaVersion": 1, + "task": "M13-05B", + "operation": "CSP_RESOURCE_POLICY", + "policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; font-src 'self'; img-src 'self'; media-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'", + "resources": { + "worker": "SAME_ORIGIN", + "wasm": "SAME_ORIGIN_WASM_UNSAFE_EVAL", + "font": "SAME_ORIGIN", + "image": "SAME_ORIGIN", + "media": "SAME_ORIGIN" + }, + "denied": { + "dataImage": "DENY", + "blobWorker": "DENY", + "crossOriginConnect": "DENY" + }, + "responseCount": 7, + "browser": { + "result": { + "worker": true, + "wasm": true, + "image": true, + "font": true, + "media": true, + "dataImageBlocked": true, + "blobWorkerBlocked": true, + "crossOriginBlocked": true + }, + "violations": [ + { + "directive": "worker-src", + "blockedURI": "blob" + }, + { + "directive": "connect-src", + "blockedURI": "http://127.0.0.1:9/csp-cross-origin" + }, + { + "directive": "img-src", + "blockedURI": "data" + } + ] + }, + "execution": "DISABLED", + "nextTask": "M13-05C" +} diff --git a/tests/golden/M13-05B/manifest.json b/tests/golden/M13-05B/manifest.json new file mode 100644 index 00000000..21fb90cb --- /dev/null +++ b/tests/golden/M13-05B/manifest.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": 1, + "task": "M13-05B", + "parentTask": "M13-05A", + "enablingTask": false, + "parityStateChange": false, + "runtime": "NODE_CHROMIUM_DEPLOYMENT_HTTP", + "operation": "CSP_RESOURCE_POLICY", + "artifacts": { + "parentManifest": { "path": "tests/golden/M13-05A/manifest.json", "sha256": "6a53b75ad4178707f1e73152d3f80edac489a34ba440aaca664e42547c424150" }, + "contract": { "path": "docs/web/deployment-contract.json", "sha256": "e384c73ee6deac8ec50fd496f97d16b20d992da25a5fba45138997f7b3677b7c" }, + "server": { "path": "tools/web/deployment-http-server.mjs", "sha256": "a5734ce9760f65cf5bade8cc209454d39fa963ad7dfaf20653728a9d7a57b04c" }, + "contractChecker": { "path": "tools/web/check-deployment-contract.mjs", "sha256": "9758f8bedd6c4faec3c8ae2cd361db754b6f02ac3b01872495abf00b90236b87" }, + "checker": { "path": "tools/web/check-csp-resource-policy.mjs", "sha256": "ad68d11d15ebe5bc11df18d495c784058c9b15d38ef28bac756bd943059bbd3c" }, + "package": { "path": "web/package.json", "sha256": "b12daaf74ed89e058d27682e7ebcfddf102b2f3cb8354a921966d636bc651162" }, + "report": { "path": "tests/golden/M13-05B/csp-resource-report.json", "sha256": "b0f50e5e8924eb0ec0475a39ed0833023037e3e2cea36bb8ecf22710e97591df" } + }, + "nextTask": "M13-05C" +} diff --git a/tests/golden/M13-05C/dependency-inventory.json b/tests/golden/M13-05C/dependency-inventory.json new file mode 100644 index 00000000..04308e08 --- /dev/null +++ b/tests/golden/M13-05C/dependency-inventory.json @@ -0,0 +1,1862 @@ +{ + "schemaVersion": 1, + "task": "M13-05C", + "operation": "NPM_DEPENDENCY_INVENTORY", + "package": { + "path": "web/package.json", + "sha256": "b12daaf74ed89e058d27682e7ebcfddf102b2f3cb8354a921966d636bc651162", + "name": "blender-web-editor", + "version": "0.1.0-rc.1", + "lockfileVersion": 3 + }, + "lockfile": { + "path": "web/package-lock.json", + "sha256": "61f6d13e0148321d3c625a5baa212b5444296d111193c4d62a692685faebff1f", + "packageCount": 144 + }, + "roots": { + "production": [ + "react", + "react-dom" + ], + "build": [ + "@eslint/js", + "@types/react", + "@types/react-dom", + "@types/three", + "@vitejs/plugin-react", + "eslint", + "typescript", + "typescript-eslint", + "vite" + ], + "test": [ + "@axe-core/playwright", + "@playwright/test" + ] + }, + "categoryCounts": { + "production": 3, + "build": 135, + "test": 6 + }, + "sharedCount": 0, + "packages": [ + { + "path": "node_modules/@axe-core/playwright", + "name": "@axe-core/playwright", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz", + "integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==", + "categories": [ + "test" + ], + "dependencies": [ + "axe-core" + ] + }, + { + "path": "node_modules/@dimforge/rapier3d-compat", + "name": "@dimforge/rapier3d-compat", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@eslint-community/eslint-utils", + "name": "@eslint-community/eslint-utils", + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "categories": [ + "build" + ], + "dependencies": [ + "eslint-visitor-keys" + ] + }, + { + "path": "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys", + "name": "eslint-visitor-keys", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@eslint-community/regexpp", + "name": "@eslint-community/regexpp", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@eslint/config-array", + "name": "@eslint/config-array", + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "categories": [ + "build" + ], + "dependencies": [ + "@eslint/object-schema", + "debug", + "minimatch" + ] + }, + { + "path": "node_modules/@eslint/config-helpers", + "name": "@eslint/config-helpers", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "categories": [ + "build" + ], + "dependencies": [ + "@eslint/core" + ] + }, + { + "path": "node_modules/@eslint/core", + "name": "@eslint/core", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "categories": [ + "build" + ], + "dependencies": [ + "@types/json-schema" + ] + }, + { + "path": "node_modules/@eslint/js", + "name": "@eslint/js", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@eslint/object-schema", + "name": "@eslint/object-schema", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@eslint/plugin-kit", + "name": "@eslint/plugin-kit", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "categories": [ + "build" + ], + "dependencies": [ + "@eslint/core", + "levn" + ] + }, + { + "path": "node_modules/@humanfs/core", + "name": "@humanfs/core", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "categories": [ + "build" + ], + "dependencies": [ + "@humanfs/types" + ] + }, + { + "path": "node_modules/@humanfs/node", + "name": "@humanfs/node", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "categories": [ + "build" + ], + "dependencies": [ + "@humanfs/core", + "@humanfs/types", + "@humanwhocodes/retry" + ] + }, + { + "path": "node_modules/@humanfs/types", + "name": "@humanfs/types", + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@humanwhocodes/module-importer", + "name": "@humanwhocodes/module-importer", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@humanwhocodes/retry", + "name": "@humanwhocodes/retry", + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@oxc-project/types", + "name": "@oxc-project/types", + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@playwright/test", + "name": "@playwright/test", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "categories": [ + "test" + ], + "dependencies": [ + "playwright" + ] + }, + { + "path": "node_modules/@rolldown/binding-android-arm64", + "name": "@rolldown/binding-android-arm64", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@rolldown/binding-darwin-arm64", + "name": "@rolldown/binding-darwin-arm64", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@rolldown/binding-darwin-x64", + "name": "@rolldown/binding-darwin-x64", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@rolldown/binding-freebsd-x64", + "name": "@rolldown/binding-freebsd-x64", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@rolldown/binding-linux-arm-gnueabihf", + "name": "@rolldown/binding-linux-arm-gnueabihf", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@rolldown/binding-linux-arm64-gnu", + "name": "@rolldown/binding-linux-arm64-gnu", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@rolldown/binding-linux-arm64-musl", + "name": "@rolldown/binding-linux-arm64-musl", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@rolldown/binding-linux-ppc64-gnu", + "name": "@rolldown/binding-linux-ppc64-gnu", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@rolldown/binding-linux-s390x-gnu", + "name": "@rolldown/binding-linux-s390x-gnu", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@rolldown/binding-linux-x64-gnu", + "name": "@rolldown/binding-linux-x64-gnu", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@rolldown/binding-linux-x64-musl", + "name": "@rolldown/binding-linux-x64-musl", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@rolldown/binding-openharmony-arm64", + "name": "@rolldown/binding-openharmony-arm64", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@rolldown/binding-win32-arm64-msvc", + "name": "@rolldown/binding-win32-arm64-msvc", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@rolldown/binding-win32-x64-msvc", + "name": "@rolldown/binding-win32-x64-msvc", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@rolldown/pluginutils", + "name": "@rolldown/pluginutils", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@tweenjs/tween.js", + "name": "@tweenjs/tween.js", + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@types/esrecurse", + "name": "@types/esrecurse", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@types/estree", + "name": "@types/estree", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@types/json-schema", + "name": "@types/json-schema", + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@types/react", + "name": "@types/react", + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "categories": [ + "build" + ], + "dependencies": [ + "csstype" + ] + }, + { + "path": "node_modules/@types/react-dom", + "name": "@types/react-dom", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@types/stats.js", + "name": "@types/stats.js", + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@types/three", + "name": "@types/three", + "version": "0.185.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.1.tgz", + "integrity": "sha512-db1xTb+EgYF2didW+eudSvVPtn75zo+fGsY8ShQrJY/B5ZBmC2Fiaykv3aImHAlCNEGuMPkPGXBJGLwzu5mC7A==", + "categories": [ + "build" + ], + "dependencies": [ + "@dimforge/rapier3d-compat", + "@tweenjs/tween.js", + "@types/stats.js", + "@types/webxr", + "fflate", + "meshoptimizer" + ] + }, + { + "path": "node_modules/@types/webxr", + "name": "@types/webxr", + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@typescript-eslint/eslint-plugin", + "name": "@typescript-eslint/eslint-plugin", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz", + "integrity": "sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==", + "categories": [ + "build" + ], + "dependencies": [ + "@eslint-community/regexpp", + "@typescript-eslint/scope-manager", + "@typescript-eslint/type-utils", + "@typescript-eslint/utils", + "@typescript-eslint/visitor-keys", + "ignore", + "natural-compare", + "ts-api-utils" + ] + }, + { + "path": "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore", + "name": "ignore", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@typescript-eslint/parser", + "name": "@typescript-eslint/parser", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.66.0.tgz", + "integrity": "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==", + "categories": [ + "build" + ], + "dependencies": [ + "@typescript-eslint/scope-manager", + "@typescript-eslint/types", + "@typescript-eslint/typescript-estree", + "@typescript-eslint/visitor-keys", + "debug" + ] + }, + { + "path": "node_modules/@typescript-eslint/project-service", + "name": "@typescript-eslint/project-service", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.66.0.tgz", + "integrity": "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==", + "categories": [ + "build" + ], + "dependencies": [ + "@typescript-eslint/tsconfig-utils", + "@typescript-eslint/types", + "debug" + ] + }, + { + "path": "node_modules/@typescript-eslint/scope-manager", + "name": "@typescript-eslint/scope-manager", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz", + "integrity": "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==", + "categories": [ + "build" + ], + "dependencies": [ + "@typescript-eslint/types", + "@typescript-eslint/visitor-keys" + ] + }, + { + "path": "node_modules/@typescript-eslint/tsconfig-utils", + "name": "@typescript-eslint/tsconfig-utils", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz", + "integrity": "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@typescript-eslint/type-utils", + "name": "@typescript-eslint/type-utils", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.66.0.tgz", + "integrity": "sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==", + "categories": [ + "build" + ], + "dependencies": [ + "@typescript-eslint/types", + "@typescript-eslint/typescript-estree", + "@typescript-eslint/utils", + "debug", + "ts-api-utils" + ] + }, + { + "path": "node_modules/@typescript-eslint/types", + "name": "@typescript-eslint/types", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.66.0.tgz", + "integrity": "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/@typescript-eslint/typescript-estree", + "name": "@typescript-eslint/typescript-estree", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz", + "integrity": "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==", + "categories": [ + "build" + ], + "dependencies": [ + "@typescript-eslint/project-service", + "@typescript-eslint/tsconfig-utils", + "@typescript-eslint/types", + "@typescript-eslint/visitor-keys", + "debug", + "minimatch", + "semver", + "tinyglobby", + "ts-api-utils" + ] + }, + { + "path": "node_modules/@typescript-eslint/utils", + "name": "@typescript-eslint/utils", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.66.0.tgz", + "integrity": "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==", + "categories": [ + "build" + ], + "dependencies": [ + "@eslint-community/eslint-utils", + "@typescript-eslint/scope-manager", + "@typescript-eslint/types", + "@typescript-eslint/typescript-estree" + ] + }, + { + "path": "node_modules/@typescript-eslint/visitor-keys", + "name": "@typescript-eslint/visitor-keys", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz", + "integrity": "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==", + "categories": [ + "build" + ], + "dependencies": [ + "@typescript-eslint/types", + "eslint-visitor-keys" + ] + }, + { + "path": "node_modules/@vitejs/plugin-react", + "name": "@vitejs/plugin-react", + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", + "categories": [ + "build" + ], + "dependencies": [ + "@rolldown/pluginutils" + ] + }, + { + "path": "node_modules/acorn", + "name": "acorn", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/acorn-jsx", + "name": "acorn-jsx", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/ajv", + "name": "ajv", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "categories": [ + "build" + ], + "dependencies": [ + "fast-deep-equal", + "fast-json-stable-stringify", + "json-schema-traverse", + "uri-js" + ] + }, + { + "path": "node_modules/axe-core", + "name": "axe-core", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "categories": [ + "test" + ], + "dependencies": [] + }, + { + "path": "node_modules/balanced-match", + "name": "balanced-match", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/brace-expansion", + "name": "brace-expansion", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "categories": [ + "build" + ], + "dependencies": [ + "balanced-match" + ] + }, + { + "path": "node_modules/cross-spawn", + "name": "cross-spawn", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "categories": [ + "build" + ], + "dependencies": [ + "path-key", + "shebang-command", + "which" + ] + }, + { + "path": "node_modules/csstype", + "name": "csstype", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/debug", + "name": "debug", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "categories": [ + "build" + ], + "dependencies": [ + "ms" + ] + }, + { + "path": "node_modules/deep-is", + "name": "deep-is", + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/detect-libc", + "name": "detect-libc", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/escape-string-regexp", + "name": "escape-string-regexp", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/eslint", + "name": "eslint", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", + "categories": [ + "build" + ], + "dependencies": [ + "@eslint-community/eslint-utils", + "@eslint-community/regexpp", + "@eslint/config-array", + "@eslint/config-helpers", + "@eslint/core", + "@eslint/plugin-kit", + "@humanfs/node", + "@humanwhocodes/module-importer", + "@humanwhocodes/retry", + "@types/estree", + "ajv", + "cross-spawn", + "debug", + "escape-string-regexp", + "eslint-scope", + "eslint-visitor-keys", + "espree", + "esquery", + "esutils", + "fast-deep-equal", + "file-entry-cache", + "find-up", + "glob-parent", + "ignore", + "imurmurhash", + "is-glob", + "json-stable-stringify-without-jsonify", + "minimatch", + "natural-compare", + "optionator" + ] + }, + { + "path": "node_modules/eslint-scope", + "name": "eslint-scope", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "categories": [ + "build" + ], + "dependencies": [ + "@types/esrecurse", + "@types/estree", + "esrecurse", + "estraverse" + ] + }, + { + "path": "node_modules/eslint-visitor-keys", + "name": "eslint-visitor-keys", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/espree", + "name": "espree", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "categories": [ + "build" + ], + "dependencies": [ + "acorn", + "acorn-jsx", + "eslint-visitor-keys" + ] + }, + { + "path": "node_modules/esquery", + "name": "esquery", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "categories": [ + "build" + ], + "dependencies": [ + "estraverse" + ] + }, + { + "path": "node_modules/esrecurse", + "name": "esrecurse", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "categories": [ + "build" + ], + "dependencies": [ + "estraverse" + ] + }, + { + "path": "node_modules/estraverse", + "name": "estraverse", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/esutils", + "name": "esutils", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/fast-deep-equal", + "name": "fast-deep-equal", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/fast-json-stable-stringify", + "name": "fast-json-stable-stringify", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/fast-levenshtein", + "name": "fast-levenshtein", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/fdir", + "name": "fdir", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/fflate", + "name": "fflate", + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/file-entry-cache", + "name": "file-entry-cache", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "categories": [ + "build" + ], + "dependencies": [ + "flat-cache" + ] + }, + { + "path": "node_modules/find-up", + "name": "find-up", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "categories": [ + "build" + ], + "dependencies": [ + "locate-path", + "path-exists" + ] + }, + { + "path": "node_modules/flat-cache", + "name": "flat-cache", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "categories": [ + "build" + ], + "dependencies": [ + "flatted", + "keyv" + ] + }, + { + "path": "node_modules/flatted", + "name": "flatted", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/fsevents", + "name": "fsevents", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/glob-parent", + "name": "glob-parent", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "categories": [ + "build" + ], + "dependencies": [ + "is-glob" + ] + }, + { + "path": "node_modules/ignore", + "name": "ignore", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/imurmurhash", + "name": "imurmurhash", + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/is-extglob", + "name": "is-extglob", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/is-glob", + "name": "is-glob", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "categories": [ + "build" + ], + "dependencies": [ + "is-extglob" + ] + }, + { + "path": "node_modules/isexe", + "name": "isexe", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/json-buffer", + "name": "json-buffer", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/json-schema-traverse", + "name": "json-schema-traverse", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/json-stable-stringify-without-jsonify", + "name": "json-stable-stringify-without-jsonify", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/keyv", + "name": "keyv", + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "categories": [ + "build" + ], + "dependencies": [ + "json-buffer" + ] + }, + { + "path": "node_modules/levn", + "name": "levn", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "categories": [ + "build" + ], + "dependencies": [ + "prelude-ls", + "type-check" + ] + }, + { + "path": "node_modules/lightningcss", + "name": "lightningcss", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "categories": [ + "build" + ], + "dependencies": [ + "detect-libc", + "lightningcss-android-arm64", + "lightningcss-darwin-arm64", + "lightningcss-darwin-x64", + "lightningcss-freebsd-x64", + "lightningcss-linux-arm-gnueabihf", + "lightningcss-linux-arm64-gnu", + "lightningcss-linux-arm64-musl", + "lightningcss-linux-x64-gnu", + "lightningcss-linux-x64-musl", + "lightningcss-win32-arm64-msvc", + "lightningcss-win32-x64-msvc" + ] + }, + { + "path": "node_modules/lightningcss-android-arm64", + "name": "lightningcss-android-arm64", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/lightningcss-darwin-arm64", + "name": "lightningcss-darwin-arm64", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/lightningcss-darwin-x64", + "name": "lightningcss-darwin-x64", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/lightningcss-freebsd-x64", + "name": "lightningcss-freebsd-x64", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/lightningcss-linux-arm-gnueabihf", + "name": "lightningcss-linux-arm-gnueabihf", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/lightningcss-linux-arm64-gnu", + "name": "lightningcss-linux-arm64-gnu", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/lightningcss-linux-arm64-musl", + "name": "lightningcss-linux-arm64-musl", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/lightningcss-linux-x64-gnu", + "name": "lightningcss-linux-x64-gnu", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/lightningcss-linux-x64-musl", + "name": "lightningcss-linux-x64-musl", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/lightningcss-win32-arm64-msvc", + "name": "lightningcss-win32-arm64-msvc", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/lightningcss-win32-x64-msvc", + "name": "lightningcss-win32-x64-msvc", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/locate-path", + "name": "locate-path", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "categories": [ + "build" + ], + "dependencies": [ + "p-locate" + ] + }, + { + "path": "node_modules/meshoptimizer", + "name": "meshoptimizer", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/minimatch", + "name": "minimatch", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "categories": [ + "build" + ], + "dependencies": [ + "brace-expansion" + ] + }, + { + "path": "node_modules/ms", + "name": "ms", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/nanoid", + "name": "nanoid", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/natural-compare", + "name": "natural-compare", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/optionator", + "name": "optionator", + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "categories": [ + "build" + ], + "dependencies": [ + "deep-is", + "fast-levenshtein", + "levn", + "prelude-ls", + "type-check", + "word-wrap" + ] + }, + { + "path": "node_modules/p-limit", + "name": "p-limit", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "categories": [ + "build" + ], + "dependencies": [ + "yocto-queue" + ] + }, + { + "path": "node_modules/p-locate", + "name": "p-locate", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "categories": [ + "build" + ], + "dependencies": [ + "p-limit" + ] + }, + { + "path": "node_modules/path-exists", + "name": "path-exists", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/path-key", + "name": "path-key", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/picocolors", + "name": "picocolors", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/picomatch", + "name": "picomatch", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/playwright", + "name": "playwright", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "categories": [ + "test" + ], + "dependencies": [ + "fsevents", + "playwright-core" + ] + }, + { + "path": "node_modules/playwright-core", + "name": "playwright-core", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "categories": [ + "test" + ], + "dependencies": [] + }, + { + "path": "node_modules/playwright/node_modules/fsevents", + "name": "fsevents", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "categories": [ + "test" + ], + "dependencies": [] + }, + { + "path": "node_modules/postcss", + "name": "postcss", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "categories": [ + "build" + ], + "dependencies": [ + "nanoid", + "picocolors", + "source-map-js" + ] + }, + { + "path": "node_modules/prelude-ls", + "name": "prelude-ls", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/punycode", + "name": "punycode", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/react", + "name": "react", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "categories": [ + "production" + ], + "dependencies": [] + }, + { + "path": "node_modules/react-dom", + "name": "react-dom", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "categories": [ + "production" + ], + "dependencies": [ + "scheduler" + ] + }, + { + "path": "node_modules/rolldown", + "name": "rolldown", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", + "categories": [ + "build" + ], + "dependencies": [ + "@oxc-project/types", + "@rolldown/binding-android-arm64", + "@rolldown/binding-darwin-arm64", + "@rolldown/binding-darwin-x64", + "@rolldown/binding-freebsd-x64", + "@rolldown/binding-linux-arm-gnueabihf", + "@rolldown/binding-linux-arm64-gnu", + "@rolldown/binding-linux-arm64-musl", + "@rolldown/binding-linux-ppc64-gnu", + "@rolldown/binding-linux-s390x-gnu", + "@rolldown/binding-linux-x64-gnu", + "@rolldown/binding-linux-x64-musl", + "@rolldown/binding-openharmony-arm64", + "@rolldown/binding-win32-arm64-msvc", + "@rolldown/binding-win32-x64-msvc", + "@rolldown/pluginutils" + ] + }, + { + "path": "node_modules/scheduler", + "name": "scheduler", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "categories": [ + "production" + ], + "dependencies": [] + }, + { + "path": "node_modules/semver", + "name": "semver", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/shebang-command", + "name": "shebang-command", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "categories": [ + "build" + ], + "dependencies": [ + "shebang-regex" + ] + }, + { + "path": "node_modules/shebang-regex", + "name": "shebang-regex", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/source-map-js", + "name": "source-map-js", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/tinyglobby", + "name": "tinyglobby", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "categories": [ + "build" + ], + "dependencies": [ + "fdir", + "picomatch" + ] + }, + { + "path": "node_modules/ts-api-utils", + "name": "ts-api-utils", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/type-check", + "name": "type-check", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "categories": [ + "build" + ], + "dependencies": [ + "prelude-ls" + ] + }, + { + "path": "node_modules/typescript", + "name": "typescript", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/typescript-eslint", + "name": "typescript-eslint", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.66.0.tgz", + "integrity": "sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==", + "categories": [ + "build" + ], + "dependencies": [ + "@typescript-eslint/eslint-plugin", + "@typescript-eslint/parser", + "@typescript-eslint/typescript-estree", + "@typescript-eslint/utils" + ] + }, + { + "path": "node_modules/uri-js", + "name": "uri-js", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "categories": [ + "build" + ], + "dependencies": [ + "punycode" + ] + }, + { + "path": "node_modules/vite", + "name": "vite", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "categories": [ + "build" + ], + "dependencies": [ + "fsevents", + "lightningcss", + "picomatch", + "postcss", + "rolldown", + "tinyglobby" + ] + }, + { + "path": "node_modules/which", + "name": "which", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "categories": [ + "build" + ], + "dependencies": [ + "isexe" + ] + }, + { + "path": "node_modules/word-wrap", + "name": "word-wrap", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "categories": [ + "build" + ], + "dependencies": [] + }, + { + "path": "node_modules/yocto-queue", + "name": "yocto-queue", + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "categories": [ + "build" + ], + "dependencies": [] + } + ], + "nextTask": "M13-05D" +} diff --git a/tests/golden/M13-05C/manifest.json b/tests/golden/M13-05C/manifest.json new file mode 100644 index 00000000..15e70ebd --- /dev/null +++ b/tests/golden/M13-05C/manifest.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": 1, + "task": "M13-05C", + "parentTask": "M13-05B", + "enablingTask": false, + "parityStateChange": false, + "runtime": "NODE_NPM_LOCKFILE", + "operation": "DEPENDENCY_INVENTORY", + "artifacts": { + "parentManifest": { "path": "tests/golden/M13-05B/manifest.json", "sha256": "91ab52292f2b96fb0bfd49704e3d75d90ba9824865971464b601c9a4e3976737" }, + "package": { "path": "web/package.json", "sha256": "b12daaf74ed89e058d27682e7ebcfddf102b2f3cb8354a921966d636bc651162" }, + "lockfile": { "path": "web/package-lock.json", "sha256": "61f6d13e0148321d3c625a5baa212b5444296d111193c4d62a692685faebff1f" }, + "generator": { "path": "tools/web/generate-dependency-inventory.mjs", "sha256": "b703520d9bcf704bfbed2378983810616c88debeae36f8f7dc036fb71b449a50" }, + "checker": { "path": "tools/web/check-dependency-inventory.mjs", "sha256": "c3af9cb5365c2f40bde41d2a6f4e5dd55d0152bfa16cc8fc76e07daf37961bde" }, + "inventory": { "path": "tests/golden/M13-05C/dependency-inventory.json", "sha256": "310cdcb3d0c7c5984aafe9ced49b606f76b101597b90e6c0fced3858a6886579" }, + "packageScript": { "path": "web/package.json", "sha256": "b12daaf74ed89e058d27682e7ebcfddf102b2f3cb8354a921966d636bc651162" } + }, + "nextTask": "M13-05D" +} diff --git a/tests/golden/M13-05D/dependency-severity-report.json b/tests/golden/M13-05D/dependency-severity-report.json new file mode 100644 index 00000000..f70ff4d5 --- /dev/null +++ b/tests/golden/M13-05D/dependency-severity-report.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": 1, + "task": "M13-05D", + "operation": "DEPENDENCY_SEVERITY_POLICY", + "inventorySha256": "310cdcb3d0c7c5984aafe9ced49b606f76b101597b90e6c0fced3858a6886579", + "findings": 0, + "exceptions": 0, + "blocked": 0, + "review": 0, + "status": "PASS", + "negativeCases": { + "missingException": "BLOCKED", + "expiredException": "REJECTED", + "incompleteException": "REJECTED", + "completeException": "ACCEPTED" + }, + "execution": "DISABLED", + "nextTask": "M13-05E" +} diff --git a/tests/golden/M13-05D/manifest.json b/tests/golden/M13-05D/manifest.json new file mode 100644 index 00000000..a661bd1f --- /dev/null +++ b/tests/golden/M13-05D/manifest.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "task": "M13-05D", + "parentTask": "M13-05C", + "enablingTask": false, + "parityStateChange": false, + "runtime": "NODE_SEVERITY_POLICY", + "operation": "DEPENDENCY_SEVERITY_POLICY", + "artifacts": { + "parentManifest": { "path": "tests/golden/M13-05C/manifest.json", "sha256": "24abe1863de8fcb112434fb3226986f3a7072820cde8869dadb8fbad32d1e6c1" }, + "policy": { "path": "docs/web/dependency-severity-policy.json", "sha256": "a549436cc30eec39c87a8a19b73d0dad4489d4af035be9fceb839c032eb37ef1" }, + "inventory": { "path": "tests/golden/M13-05C/dependency-inventory.json", "sha256": "310cdcb3d0c7c5984aafe9ced49b606f76b101597b90e6c0fced3858a6886579" }, + "checker": { "path": "tools/web/check-dependency-severity-policy.mjs", "sha256": "151ab844f49c798e7b6672fb513feed1ee8fd9ed6a755e55567b7c758ce2c692" }, + "package": { "path": "web/package.json", "sha256": "b12daaf74ed89e058d27682e7ebcfddf102b2f3cb8354a921966d636bc651162" }, + "report": { "path": "tests/golden/M13-05D/dependency-severity-report.json", "sha256": "51402642af78f24d57239e3ecc78cab278e270ca5a05433f5208b30ae37dcc14" } + }, + "nextTask": "M13-05E" +} diff --git a/tests/golden/M13-05E/manifest.json b/tests/golden/M13-05E/manifest.json new file mode 100644 index 00000000..45e9f1cd --- /dev/null +++ b/tests/golden/M13-05E/manifest.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": 1, + "task": "M13-05E", + "parentTask": "M13-05D", + "enablingTask": false, + "parityStateChange": false, + "runtime": "NODE_RELEASE_ARCHIVE", + "operation": "SUPPLY_CHAIN_BINDING", + "artifacts": { + "parentManifest": { "path": "tests/golden/M13-05D/manifest.json", "sha256": "49ad57e505000d6552977c44a8822d63b859dec4a15d07fab55e6c0d9b2d344e" }, + "checker": { "path": "tools/web/check-supply-chain-binding.mjs", "sha256": "0aa29a3537195d8164702749565e389f84434cbe6171fb479d59761d66e2ea82" }, + "binaryChecker": { "path": "tools/web/check-binary-archive.mjs", "sha256": "29a7e39c9c9e9454c74a2c29693f4d5330e94331710f7749c4d5c7afd14cf0aa" }, + "sourceChecker": { "path": "tools/web/check-source-archive.mjs", "sha256": "ff4aa5eb0ef1d595f06182ace1201788e8436ea62204553bd9f3fa52319eecb5" }, + "package": { "path": "web/package.json", "sha256": "b12daaf74ed89e058d27682e7ebcfddf102b2f3cb8354a921966d636bc651162" }, + "lockfile": { "path": "web/package-lock.json", "sha256": "61f6d13e0148321d3c625a5baa212b5444296d111193c4d62a692685faebff1f" }, + "sbom": { "path": "docs/web/sbom.spdx.json", "sha256": "12f6148998b82838df2713ca4a13c7505ca160f663e30770eca2269f2d85bdf2" }, + "notices": { "path": "docs/web/third-party-notices.json", "sha256": "d5ec6e6ec9e1e0a50fe4455513a2938838609d3809224eaf26a2ebfac474f0ae" }, + "binaryArchive": { "path": "release/blender-web-offline.tar.gz", "sha256": "238f3d406e91e39403fcb7db855c60c9cf670f0400e796e6d763defc6d968f18" }, + "sourceArchive": { "path": "release/blender-web-corresponding-source.tar.gz", "sha256": "d20e154a8d769c78e127c292861aff9c69a76d9130a83c1623e590a9c2336b8c" }, + "sums": { "path": "release/SHA256SUMS.txt", "sha256": "4f2bc3cee44ec7c924392eb005455c23e6c63cdcb3fb3d605cf54997f890181d" }, + "report": { "path": "tests/golden/M13-05E/supply-chain-report.json", "sha256": "88cdbff393b01e2840c82f3465a2568df029b51e81756120f5f87f9b63a086d3" } + }, + "nextTask": "M13-05F" +} diff --git a/tests/golden/M13-05E/supply-chain-report.json b/tests/golden/M13-05E/supply-chain-report.json new file mode 100644 index 00000000..7c110e4b --- /dev/null +++ b/tests/golden/M13-05E/supply-chain-report.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": 1, + "task": "M13-05E", + "operation": "SUPPLY_CHAIN_BINDING", + "commit": "5a11045ca5cde8b80e95498924bb7d2b1d3c2496", + "inputs": { + "packageSha256": "b12daaf74ed89e058d27682e7ebcfddf102b2f3cb8354a921966d636bc651162", + "lockfileSha256": "61f6d13e0148321d3c625a5baa212b5444296d111193c4d62a692685faebff1f", + "sbomSha256": "12f6148998b82838df2713ca4a13c7505ca160f663e30770eca2269f2d85bdf2", + "noticesSha256": "d5ec6e6ec9e1e0a50fe4455513a2938838609d3809224eaf26a2ebfac474f0ae" + }, + "archives": { + "binary": { + "path": "release/blender-web-offline.tar.gz", + "sha256": "238f3d406e91e39403fcb7db855c60c9cf670f0400e796e6d763defc6d968f18", + "entries": 38 + }, + "source": { + "path": "release/blender-web-corresponding-source.tar.gz", + "sha256": "d20e154a8d769c78e127c292861aff9c69a76d9130a83c1623e590a9c2336b8c", + "entries": 19851 + } + }, + "sourceOffer": "BOUND_TO_CORRESPONDING_SOURCE_ARCHIVE_AND_SHA256SUMS", + "checks": { + "spdx23": true, + "lockfileBound": true, + "noticesBound": true, + "sourceOfferBound": true, + "archiveChecksumsBound": true, + "sourcePackageBound": true + }, + "execution": "DISABLED", + "nextTask": "M13-05F" +} diff --git a/tests/golden/M13-05F/malicious-input-report.json b/tests/golden/M13-05F/malicious-input-report.json new file mode 100644 index 00000000..c71f6a80 --- /dev/null +++ b/tests/golden/M13-05F/malicious-input-report.json @@ -0,0 +1,88 @@ +{ + "schemaVersion": 1, + "task": "M13-05F", + "operation": "MALICIOUS_INPUT_MATRIX", + "cases": { + "blend": { + "status": "REJECTED", + "checker": "tools/web/check-malicious-blends.mjs", + "stableCode": "BLEND_OPEN_INVALID" + }, + "image": { + "status": "REJECTED", + "checker": "web/tests/unit/asset-preview-decode.test.mjs", + "stableCodes": [ + "ASSET_MANIFEST_INVALID", + "ASSET_BUDGET_EXCEEDED", + "ASSET_SOURCE_HASH_MISMATCH" + ] + }, + "font": { + "status": "REJECTED", + "checker": "web/tests/unit/external-vfont.test.mjs", + "stableCodes": [ + "NON_MESH_BINARY_INVALID", + "NON_MESH_RESOURCE_OUTSIDE_PROJECT", + "ASSET_SOURCE_HASH_MISMATCH" + ] + }, + "media": { + "status": "REJECTED", + "checker": "web/tests/unit/sequencer-media-cache.test.mjs", + "stableCode": "SEQUENCER_SCHEMA_INVALID" + }, + "archive": { + "status": "REJECTED", + "checker": "tools/web/check-malicious-archive-fixtures.mjs", + "stableCode": "IO_ARCHIVE_UNSAFE" + }, + "nodeGraph": { + "status": "REJECTED", + "checker": "web/tests/unit/shader-compiler.test.mjs", + "stableCodes": [ + "SHADER_NODE_UNSUPPORTED", + "SHADER_INVALID_GRAPH" + ] + }, + "manifest": { + "status": "REJECTED", + "checker": "web/tests/unit/script-manifest-budgets.test.mjs", + "stableCode": "SCRIPT_MANIFEST_INVALID" + }, + "glb": { + "status": "REJECTED", + "checker": "web/tests/unit/glb-negative-cases.test.mjs", + "stableCodes": [ + "GLB_SPARSE_ACCESSOR_UNSUPPORTED", + "GLB_EXTENSION_UNSUPPORTED", + "GLB_EXTERNAL_URI_BLOCKED", + "GLB_IMPORT_BUDGET_EXCEEDED" + ] + } + }, + "executions": [ + { + "id": "blend", + "status": "REJECTED", + "exitCode": 0 + }, + { + "id": "archive", + "status": "REJECTED", + "exitCode": 0 + }, + { + "id": "image-font-media-node-manifest", + "status": "REJECTED", + "exitCode": 0 + }, + { + "id": "glb", + "status": "REJECTED", + "exitCode": 0 + } + ], + "allRejected": true, + "execution": "DISABLED", + "nextTask": "M13-05G" +} diff --git a/tests/golden/M13-05F/manifest.json b/tests/golden/M13-05F/manifest.json new file mode 100644 index 00000000..6a740801 --- /dev/null +++ b/tests/golden/M13-05F/manifest.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "task": "M13-05F", + "parentTask": "M13-05E", + "enablingTask": false, + "parityStateChange": false, + "runtime": "NODE_PROTOCOL_NEGATIVE_MATRIX", + "operation": "MALICIOUS_INPUT_MATRIX", + "artifacts": { + "parentManifest": { "path": "tests/golden/M13-05E/manifest.json", "sha256": "68193fe8713ed5a4f77727fcd74c64ae42f32c9216528146dbacb6606cccdf01" }, + "checker": { "path": "tools/web/check-malicious-input-matrix.mjs", "sha256": "9c14cd35922b710b46f00c2be45dfad11b32bce0deb8cd5abfdafd1de828203a" }, + "package": { "path": "web/package.json", "sha256": "342f75fe285f99da301eba97590fd9fc76cef4938508177fd43e1827f0295076" }, + "report": { "path": "tests/golden/M13-05F/malicious-input-report.json", "sha256": "04b2364e7a72aa1c4cbe19d81c3247b9ebbfb6d1367de68625550c11aadde7cd" } + }, + "nextTask": "M13-05G" +} diff --git a/tests/golden/M13-05G/fuzz-report.json b/tests/golden/M13-05G/fuzz-report.json new file mode 100644 index 00000000..d5d9026e --- /dev/null +++ b/tests/golden/M13-05G/fuzz-report.json @@ -0,0 +1,42 @@ +{ + "schemaVersion": 1, + "task": "M13-05G", + "operation": "DETERMINISTIC_FUZZ_REGRESSION", + "seed": 1511506142, + "domains": [ + "blend", + "image", + "font", + "node", + "manifest" + ], + "iterationsPerDomain": 16, + "totalCases": 80, + "counts": { + "blend": { + "accepted": 0, + "rejected": 16 + }, + "image": { + "accepted": 15, + "rejected": 1 + }, + "font": { + "accepted": 16, + "rejected": 0 + }, + "node": { + "accepted": 0, + "rejected": 16 + }, + "manifest": { + "accepted": 0, + "rejected": 16 + } + }, + "crashes": 0, + "minimizedCorpus": [], + "crashPolicy": "SAVE_REPLAY_BEFORE_FIX_AND_ADD_TO_REGRESSION", + "execution": "DISABLED", + "nextTask": "M13-05H" +} diff --git a/tests/golden/M13-05G/manifest.json b/tests/golden/M13-05G/manifest.json new file mode 100644 index 00000000..09852547 --- /dev/null +++ b/tests/golden/M13-05G/manifest.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "task": "M13-05G", + "parentTask": "M13-05F", + "enablingTask": false, + "parityStateChange": false, + "runtime": "NODE_DETERMINISTIC_FUZZ", + "operation": "DETERMINISTIC_FUZZ_REGRESSION", + "artifacts": { + "parentManifest": { "path": "tests/golden/M13-05F/manifest.json", "sha256": "34043df0515f4b422a2b265d38978dd3c7f21acd12753c99f9a50f9b23f8bd60" }, + "runner": { "path": "tools/web/fuzz-case-runner.mjs", "sha256": "a4d5d1b743b454705b1c3a257f1f36d1fea8abd77442e11fb1fec9e2905a3f79" }, + "checker": { "path": "tools/web/check-fuzz-regression.mjs", "sha256": "55c8fa3776b22eb3f2e2ffd1629f0092384df30ef3913699a1ba93b283d641d8" }, + "package": { "path": "web/package.json", "sha256": "1feb509789d7f06019390bd86197bb9851cd520fd9fe310d1e29bfc3d2ef3f18" }, + "report": { "path": "tests/golden/M13-05G/fuzz-report.json", "sha256": "5eb4a5469732697be7910fded3ce34cf2898735f767d94c1abbe0dd264bcc0d9" } + }, + "nextTask": "M13-05H" +} diff --git a/tests/golden/M13-05H/manifest.json b/tests/golden/M13-05H/manifest.json new file mode 100644 index 00000000..da7edf12 --- /dev/null +++ b/tests/golden/M13-05H/manifest.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "task": "M13-05H", + "parentTask": "M13-05G", + "enablingTask": false, + "parityStateChange": false, + "runtime": "NODE_PRODUCTION_PROTOCOL", + "operation": "SCRIPT_AUDIT_INTEGRITY", + "artifacts": { + "parentManifest": { "path": "tests/golden/M13-05G/manifest.json", "sha256": "76a2a1fc25fa5fdec22e7ed231dd1e356c448220bdb38169f466f26515fc2865" }, + "protocol": { "path": "web/protocol/scripting-platform.ts", "sha256": "6699fc0a19e0736823f164e707a420655803e36019cf0071f7ff9c760d25e9f2" }, + "checker": { "path": "tools/web/check-script-audit-integrity.mjs", "sha256": "ab3fede492c02bd006857e9be08c08fca4dc9f0fcff22d2106c7649da1d7ec90" }, + "unit": { "path": "web/tests/unit/script-audit-integrity.test.mjs", "sha256": "2d72c60d5f4506eb336819da2c5c6800245ff0fa4c439ef524e56a71914882b9" }, + "package": { "path": "web/package.json", "sha256": "1feb509789d7f06019390bd86197bb9851cd520fd9fe310d1e29bfc3d2ef3f18" }, + "report": { "path": "tests/golden/M13-05H/script-audit-integrity-report.json", "sha256": "579ac875dbeb4ebcc6d369269a837456d2ca3a095ef36ddaf100c875ef1d5323" } + }, + "nextTask": "M14-01A" +} diff --git a/tests/golden/M13-05H/script-audit-integrity-report.json b/tests/golden/M13-05H/script-audit-integrity-report.json new file mode 100644 index 00000000..d3e3e2ba --- /dev/null +++ b/tests/golden/M13-05H/script-audit-integrity-report.json @@ -0,0 +1,33 @@ +{ + "schemaVersion": 1, + "task": "M13-05H", + "operation": "SCRIPT_AUDIT_INTEGRITY", + "runtime": "NODE_PRODUCTION_PROTOCOL", + "log": { + "entryCount": 2, + "sequences": [ + 1, + 2 + ], + "requestIds": [ + "audit:first", + "audit:second" + ], + "firstPreviousEntrySha256": null, + "secondPreviousEntrySha256": "5b0fcd4cfc634b09a3fc431a2f616164bb681b22c9318bfb0983140782309cdf", + "firstEntrySha256": "5b0fcd4cfc634b09a3fc431a2f616164bb681b22c9318bfb0983140782309cdf", + "secondEntrySha256": "2d34dfadf64168e95fb8286eb27e579c285988cb5b22bc599ec115eca5df2c23", + "strictlyIncreasingTime": true, + "uniqueRequestIds": true, + "continuousHashChain": true + }, + "negative": { + "replay": "SCRIPT_MANIFEST_INVALID", + "timeOrder": "SCRIPT_MANIFEST_INVALID", + "sequence": "SCRIPT_MANIFEST_INVALID", + "entryTamper": "SCRIPT_MANIFEST_INVALID", + "chainTamper": "SCRIPT_MANIFEST_INVALID" + }, + "execution": "DISABLED", + "nextTask": "M14-01A" +} diff --git a/tests/golden/M14-01A/chromium-freeze-report.json b/tests/golden/M14-01A/chromium-freeze-report.json new file mode 100644 index 00000000..fe2229a1 --- /dev/null +++ b/tests/golden/M14-01A/chromium-freeze-report.json @@ -0,0 +1,70 @@ +{ + "schemaVersion": 1, + "task": "M14-01A", + "operation": "CHROMIUM_ENGINE_ARCHIVE_FREEZE", + "browser": { + "family": "Google Chrome", + "version": "150.0.7871.128", + "command": "google-chrome", + "executablePath": "/home/mes123456/.local/bin/google-chrome", + "executableSha256": "c2c5d6eb08c991cd8b947600ebe6d4c3964d3968013bc0abdc10c01bb42fc400", + "versionOutput": "Google Chrome 150.0.7871.128" + }, + "engine": { + "releaseId": "blender-wasm-0.1.0-rc.1", + "manifest": { + "path": "web/app/public/engine-manifest.json", + "sha256": "7656936afae05b4936d7c8fa8dde94ee9e09ffff3a2c5e6f1b9d8a38f043ac47" + }, + "variants": { + "single": { + "js": { + "path": "web/app/public/vendor/blender/single/web_engine.js", + "sha256": "25aa51361aa8c95479873240c776d6213c1092dd480ee2a0036d2cbbd561dcad" + }, + "wasm": { + "path": "web/app/public/vendor/blender/single/web_engine.wasm", + "sha256": "7f3a50f1d41b2ac0e6366e84caaabefc3cdfc47c055d5ea459abec654da6fcc0" + } + }, + "pthread": { + "js": { + "path": "web/app/public/vendor/blender/pthread/web_engine.js", + "sha256": "a64288eeb74fb0696d38d785348f0ba37e7ae716b5a341bf181f178f78e1324d" + }, + "wasm": { + "path": "web/app/public/vendor/blender/pthread/web_engine.wasm", + "sha256": "f2a26a9221b5d4d5e710463a89e2d93faf9b2d8a9076a455c5ce1a0e52d88b5f" + }, + "pthreadWorker": { + "path": "web/app/public/vendor/blender/pthread/web_engine.js", + "sha256": "a64288eeb74fb0696d38d785348f0ba37e7ae716b5a341bf181f178f78e1324d" + } + } + } + }, + "archives": { + "binaryArchive": { + "path": "release/blender-web-offline.tar.gz", + "bytes": 8147395, + "sha256": "238f3d406e91e39403fcb7db855c60c9cf670f0400e796e6d763defc6d968f18" + }, + "sourceArchive": { + "path": "release/blender-web-corresponding-source.tar.gz", + "bytes": 207603811, + "sha256": "4a33634323f09397267f8c9afd494f4e001b97a75f758e2b438665dafed875e0" + } + }, + "checksums": { + "path": "release/SHA256SUMS.txt", + "sha256": "13a30e5e048b1cb7d771f23e6189903cc6f5d6d9074d8ad695649251813bc082" + }, + "rcManifest": { + "path": "release/RC_MANIFEST.json", + "sha256": "55715a0825e0f2802379166d8e17cd9797753fe75bea63481018965b1a81a93e", + "rcId": "web-blender-0.1.0-rc.1", + "gitCommit": "5a11045ca5cde8b80e95498924bb7d2b1d3c2496" + }, + "execution": "DISABLED", + "nextTask": "M14-01B" +} diff --git a/tests/golden/M14-01A/manifest.json b/tests/golden/M14-01A/manifest.json new file mode 100644 index 00000000..17efa3ed --- /dev/null +++ b/tests/golden/M14-01A/manifest.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "task": "M14-01A", + "parentTask": "M13-05H", + "enablingTask": false, + "parityStateChange": false, + "runtime": "NODE_RUNTIME_ARTIFACT_HASH", + "operation": "CHROMIUM_ENGINE_ARCHIVE_FREEZE", + "artifacts": { + "parentManifest": { "path": "tests/golden/M13-05H/manifest.json", "sha256": "5521a95c2b13c69232aa70950a9fec6839a424a4a1bc534e00bebae35212a6d2" }, + "checker": { "path": "tools/web/check-chromium-release-freeze.mjs", "sha256": "a92e81eeb32ab265f3809f3509fe2e14269dab5bfcdef278447fcf2d05955ebc" }, + "package": { "path": "web/package.json", "sha256": "2cf786115d74d0fa654ff9bb8e802c50166120ed9965f4209b8a4ec590febdc0" }, + "engineManifest": { "path": "web/app/public/engine-manifest.json", "sha256": "7656936afae05b4936d7c8fa8dde94ee9e09ffff3a2c5e6f1b9d8a38f043ac47" }, + "rcManifest": { "path": "release/RC_MANIFEST.json", "sha256": "55715a0825e0f2802379166d8e17cd9797753fe75bea63481018965b1a81a93e" }, + "binaryArchive": { "path": "release/blender-web-offline.tar.gz", "sha256": "238f3d406e91e39403fcb7db855c60c9cf670f0400e796e6d763defc6d968f18" }, + "sourceArchive": { "path": "release/blender-web-corresponding-source.tar.gz", "sha256": "4a33634323f09397267f8c9afd494f4e001b97a75f758e2b438665dafed875e0" }, + "sums": { "path": "release/SHA256SUMS.txt", "sha256": "13a30e5e048b1cb7d771f23e6189903cc6f5d6d9074d8ad695649251813bc082" }, + "report": { "path": "tests/golden/M14-01A/chromium-freeze-report.json", "sha256": "ec3c7480df2cbc3068e8ee41aa0146b17703f042d84c361729d8a0c7a06c216b" } + }, + "nextTask": "M14-01B" +} diff --git a/tests/golden/M14-01B/firefox-capability-report.json b/tests/golden/M14-01B/firefox-capability-report.json new file mode 100644 index 00000000..fcb18cac --- /dev/null +++ b/tests/golden/M14-01B/firefox-capability-report.json @@ -0,0 +1,71 @@ +{ + "schemaVersion": 1, + "task": "M14-01B", + "operation": "FIREFOX_CAPABILITY_PROBE", + "runtime": "PLAYWRIGHT_FIREFOX", + "browser": { + "version": "153.0", + "executablePath": "/home/mes123456/.cache/ms-playwright/firefox-1538/firefox/firefox", + "userAgent": "Mozilla/5.0 (X11; Linux x86_64; rv:153.0) Gecko/20100101 Firefox/153.0", + "platform": "Linux x86_64", + "hardwareConcurrency": 16 + }, + "assets": { + "worker": { + "path": "web/dist/assets/storage.worker-D9TqXsGS.js", + "sha256": "40c5ef732bd0c7571c53f0854b6b966a9f3b0085932c1ee23962c2387dd15706" + }, + "wasm": { + "path": "web/dist/assets/web_engine-CfcxjuJm.wasm", + "sha256": "7821f408687e455c6f4e185fc3741c199c1c60d409836a2fe1b32f521d81e0ea" + } + }, + "capabilities": { + "wasm": { + "name": "wasm", + "status": "PASS", + "code": "WASM_READY", + "bytes": 15467310 + }, + "worker": { + "name": "worker", + "status": "PASS", + "code": "WORKER_READY" + }, + "opfs": { + "name": "opfs", + "status": "PASS", + "code": "OPFS_READY" + }, + "indexedDB": { + "name": "indexedDB", + "status": "PASS", + "code": "INDEXEDDB_READY" + }, + "webgl2": { + "name": "webgl2", + "status": "PASS", + "code": "WEBGL2_READY", + "renderer": "Intel(R) HD Graphics, or similar" + }, + "webgpu": { + "name": "webgpu", + "status": "BLOCKED", + "code": "WEBGPU_UNAVAILABLE" + }, + "offscreen": { + "name": "offscreen", + "status": "PASS", + "code": "OFFSCREEN_READY", + "context2d": true + }, + "isolation": { + "name": "isolation", + "status": "PASS", + "code": "ISOLATION_READY" + } + }, + "supportRule": "PASS_ONLY_WHEN_PROBED; BLOCKED_OR_UNAVAILABLE_DOES_NOT_CLAIM_SUPPORT", + "execution": "DISABLED", + "nextTask": "M14-01C" +} diff --git a/tests/golden/M14-01B/manifest.json b/tests/golden/M14-01B/manifest.json new file mode 100644 index 00000000..a975b9a3 --- /dev/null +++ b/tests/golden/M14-01B/manifest.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "task": "M14-01B", + "parentTask": "M14-01A", + "enablingTask": false, + "parityStateChange": false, + "runtime": "PLAYWRIGHT_FIREFOX", + "operation": "FIREFOX_CAPABILITY_PROBE", + "artifacts": { + "parentManifest": { "path": "tests/golden/M14-01A/manifest.json", "sha256": "3bdb0eb0e74f3966b4c59ad7e2e82e74364d160beb2371422729f4b46b108024" }, + "checker": { "path": "tools/web/check-firefox-capability.mjs", "sha256": "765e02d9f3baefd00b3f3a2359aaa363563acaeb1be4344489a8d0cfd044bc2c" }, + "package": { "path": "web/package.json", "sha256": "2cf786115d74d0fa654ff9bb8e802c50166120ed9965f4209b8a4ec590febdc0" }, + "engineManifest": { "path": "web/app/public/engine-manifest.json", "sha256": "7656936afae05b4936d7c8fa8dde94ee9e09ffff3a2c5e6f1b9d8a38f043ac47" }, + "report": { "path": "tests/golden/M14-01B/firefox-capability-report.json", "sha256": "5030d780f81cdb26cd84ecdb54d425695b85fb4783acf75218dabdf167cec644" } + }, + "nextTask": "M14-01C" +} diff --git a/tests/golden/M14-01C/manifest.json b/tests/golden/M14-01C/manifest.json new file mode 100644 index 00000000..2598498e --- /dev/null +++ b/tests/golden/M14-01C/manifest.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "task": "M14-01C", + "parentTask": "M14-01B", + "enablingTask": false, + "parityStateChange": false, + "runtime": "PLAYWRIGHT_WEBKIT", + "operation": "WEBKIT_CAPABILITY_PROBE", + "artifacts": { + "parentManifest": { "path": "tests/golden/M14-01B/manifest.json", "sha256": "be31e32af811af9b9e17a6d750d396b9790b7a422f921839fb382cfeccf0862e" }, + "checker": { "path": "tools/web/check-webkit-capability.mjs", "sha256": "fa0d5fb767f37c011d46402d51f45b5c0441122eec02c731963aff3fc632fea7" }, + "package": { "path": "web/package.json", "sha256": "2cf786115d74d0fa654ff9bb8e802c50166120ed9965f4209b8a4ec590febdc0" }, + "engineManifest": { "path": "web/app/public/engine-manifest.json", "sha256": "7656936afae05b4936d7c8fa8dde94ee9e09ffff3a2c5e6f1b9d8a38f043ac47" }, + "report": { "path": "tests/golden/M14-01C/webkit-capability-report.json", "sha256": "09e19d0fdadc7df1d608f9016698740290d164e075090623a09bddb487f5d208" } + }, + "nextTask": "M14-01D" +} diff --git a/tests/golden/M14-01C/webkit-capability-report.json b/tests/golden/M14-01C/webkit-capability-report.json new file mode 100644 index 00000000..0b0bac26 --- /dev/null +++ b/tests/golden/M14-01C/webkit-capability-report.json @@ -0,0 +1,71 @@ +{ + "schemaVersion": 1, + "task": "M14-01C", + "operation": "WEBKIT_CAPABILITY_PROBE", + "runtime": "PLAYWRIGHT_WEBKIT", + "browser": { + "version": "26.5", + "executablePath": "/home/mes123456/.cache/ms-playwright/webkit-2336/pw_run.sh", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15", + "platform": "Linux x86_64", + "hardwareConcurrency": 8 + }, + "assets": { + "worker": { + "path": "web/dist/assets/storage.worker-D9TqXsGS.js", + "sha256": "40c5ef732bd0c7571c53f0854b6b966a9f3b0085932c1ee23962c2387dd15706" + }, + "wasm": { + "path": "web/dist/assets/web_engine-CfcxjuJm.wasm", + "sha256": "7821f408687e455c6f4e185fc3741c199c1c60d409836a2fe1b32f521d81e0ea" + } + }, + "capabilities": { + "wasm": { + "name": "wasm", + "status": "PASS", + "code": "WASM_READY", + "bytes": 15467310 + }, + "worker": { + "name": "worker", + "status": "PASS", + "code": "WORKER_READY" + }, + "opfs": { + "name": "opfs", + "status": "BLOCKED", + "code": "OPFS_UNAVAILABLE" + }, + "indexedDB": { + "name": "indexedDB", + "status": "PASS", + "code": "INDEXEDDB_READY" + }, + "webgl2": { + "name": "webgl2", + "status": "PASS", + "code": "WEBGL2_READY", + "renderer": "Apple GPU" + }, + "webgpu": { + "name": "webgpu", + "status": "BLOCKED", + "code": "WEBGPU_UNAVAILABLE" + }, + "offscreen": { + "name": "offscreen", + "status": "PASS", + "code": "OFFSCREEN_READY", + "context2d": true + }, + "isolation": { + "name": "isolation", + "status": "PASS", + "code": "ISOLATION_READY" + } + }, + "supportRule": "PASS_ONLY_WHEN_PROBED; BLOCKED_OR_UNAVAILABLE_DOES_NOT_CLAIM_SUPPORT", + "execution": "DISABLED", + "nextTask": "M14-01D" +} diff --git a/tests/golden/M14-01D/manifest.json b/tests/golden/M14-01D/manifest.json new file mode 100644 index 00000000..3d2611ce --- /dev/null +++ b/tests/golden/M14-01D/manifest.json @@ -0,0 +1,32 @@ +{ + "schemaVersion": 1, + "task": "M14-01D", + "parentTask": "M14-01C", + "enablingTask": false, + "parityStateChange": false, + "runtime": "PLAYWRIGHT_CHROMIUM", + "operation": "PROBE_IDENTITY_GPU_OS_ADAPTER", + "artifacts": { + "parentManifest": { + "path": "tests/golden/M14-01C/manifest.json", + "sha256": "050e04ff7e96fa303bd449e419411a914edb489d2c9486797bff18a1f3b6e49c" + }, + "checker": { + "path": "tools/web/check-probe-identity.mjs", + "sha256": "426d6277b5bcd4a2a29d24469f000663f9cc02d07fd996fc528b52285e834189" + }, + "package": { + "path": "web/package.json", + "sha256": "c63126618da82d315a4070c4546c523f52d44aa174b43b5e6fe4884e5ad616ce" + }, + "engineManifest": { + "path": "web/app/public/engine-manifest.json", + "sha256": "7656936afae05b4936d7c8fa8dde94ee9e09ffff3a2c5e6f1b9d8a38f043ac47" + }, + "report": { + "path": "tests/golden/M14-01D/probe-identity-report.json", + "sha256": "df73913b7ca5af957c4f56216a1e91be8e19c6373f0359a86d963a1d03c8f0d6" + } + }, + "nextTask": "M14-01E" +} diff --git a/tests/golden/M14-01D/probe-identity-report.json b/tests/golden/M14-01D/probe-identity-report.json new file mode 100644 index 00000000..d79207cd --- /dev/null +++ b/tests/golden/M14-01D/probe-identity-report.json @@ -0,0 +1,55 @@ +{ + "schemaVersion": 1, + "task": "M14-01D", + "operation": "PROBE_IDENTITY_GPU_OS_ADAPTER", + "runtime": "PLAYWRIGHT_CHROMIUM", + "browser": { + "version": "151.0.7922.34", + "executablePath": "/home/mes123456/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome", + "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.7922.34 Safari/537.36", + "platform": "Linux x86_64", + "language": "en-US", + "hardwareConcurrency": 16, + "deviceMemory": 16 + }, + "os": { + "platform": "linux", + "release": "6.12.95+deb13-amd64", + "version": "#1 SMP PREEMPT_DYNAMIC Debian 6.12.95-1 (2026-07-04)", + "arch": "x64", + "machine": "x86_64", + "cpus": 16 + }, + "adapter": { + "webgl2": { + "name": "webgl2", + "status": "PASS", + "code": "WEBGL2_READY", + "vendor": "Google Inc. (Google)", + "renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)" + }, + "webgpu": { + "name": "webgpu", + "status": "BLOCKED", + "code": "WEBGPU_ADAPTER_UNAVAILABLE" + } + }, + "isolation": { + "crossOriginIsolated": true + }, + "assets": { + "worker": { + "path": "web/dist/assets/storage.worker-D9TqXsGS.js", + "sha256": "40c5ef732bd0c7571c53f0854b6b966a9f3b0085932c1ee23962c2387dd15706" + }, + "wasm": { + "path": "web/dist/assets/web_engine-CfcxjuJm.wasm", + "sha256": "7821f408687e455c6f4e185fc3741c199c1c60d409836a2fe1b32f521d81e0ea" + } + }, + "supportRule": "IDENTITY_IS_OBSERVED_ONLY; PASS_ONLY_WHEN_PROBED; BLOCKED_OR_UNAVAILABLE_DOES_NOT_CLAIM_SUPPORT", + "execution": "DISABLED", + "nextTask": "M14-01E", + "identitySha256": "3c6f898e1b6ffd39862505b8e7dde8fd29369204e1670a013859f74ccaf1c1eb" +} diff --git a/tests/golden/M14-01E/chromium-webgpu-boundary-report.json b/tests/golden/M14-01E/chromium-webgpu-boundary-report.json new file mode 100644 index 00000000..2cca5da1 --- /dev/null +++ b/tests/golden/M14-01E/chromium-webgpu-boundary-report.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": 1, + "task": "M14-01E", + "operation": "CHROMIUM_WEBGPU_FAIL_CLOSED_BOUNDARY", + "runtime": "NODE_PROTOCOL_WITH_CHROMIUM_PROBE_IDENTITY", + "chromiumProbeIdentitySha256": "3c6f898e1b6ffd39862505b8e7dde8fd29369204e1670a013859f74ccaf1c1eb", + "webgpu": { + "status": "BLOCKED", + "target": "WEB_LOCAL_BOUNDED", + "code": "WEBGPU_RENDERER_UNAVAILABLE" + }, + "webgl2": { + "status": "READY", + "target": "WEB_LOCAL_BOUNDED", + "reason": "BOUNDED_EEVEE" + }, + "execution": "DISABLED", + "nextTask": "M14-04A" +} diff --git a/tests/golden/M14-01E/manifest.json b/tests/golden/M14-01E/manifest.json new file mode 100644 index 00000000..4d90216c --- /dev/null +++ b/tests/golden/M14-01E/manifest.json @@ -0,0 +1,32 @@ +{ + "schemaVersion": 1, + "task": "M14-01E", + "parentTask": "M14-01D", + "enablingTask": false, + "parityStateChange": false, + "runtime": "NODE_PROTOCOL_WITH_CHROMIUM_PROBE_IDENTITY", + "operation": "CHROMIUM_WEBGPU_FAIL_CLOSED_BOUNDARY", + "artifacts": { + "parentManifest": { + "path": "tests/golden/M14-01D/manifest.json", + "sha256": "1b1490f1f62de135f4048d7ac883bbe925ee3772e5dc086f4fd7bff91dc03e6d" + }, + "checker": { + "path": "tools/web/check-chromium-webgpu-boundary.mjs", + "sha256": "cd81c7b07896846ae40394b0fbe70c1eb69a52bbb53e3e4642c99d90234290eb" + }, + "package": { + "path": "web/package.json", + "sha256": "afae90d2be8dd5cf3d07e106bd407fac9f16540be715058bf4320ae4923497fa" + }, + "protocol": { + "path": "web/protocol/render-routing.ts", + "sha256": "2d41d48609635c810572aaae95979df2fe9e49bbd130c60d70f64042c5d0f097" + }, + "report": { + "path": "tests/golden/M14-01E/chromium-webgpu-boundary-report.json", + "sha256": "6536f91c79c46a7a34fc631cc15f70e49593d0a1414e975ad72ffe2bbbf89d12" + } + }, + "nextTask": "M14-04A" +} diff --git a/tests/golden/M14-04A/chromium-device-budget-report.json b/tests/golden/M14-04A/chromium-device-budget-report.json new file mode 100644 index 00000000..24301c33 --- /dev/null +++ b/tests/golden/M14-04A/chromium-device-budget-report.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": 1, + "task": "M14-04A", + "operation": "CHROMIUM_DEVICE_BUDGET_SELECTION", + "runtime": "CHROMIUM_PROBE_IDENTITY_REPORT", + "identitySha256": "3c6f898e1b6ffd39862505b8e7dde8fd29369204e1670a013859f74ccaf1c1eb", + "selection": { + "schemaVersion": 1, + "identitySha256": "3c6f898e1b6ffd39862505b8e7dde8fd29369204e1670a013859f74ccaf1c1eb", + "tier": "CONSERVATIVE", + "reason": "UNTRUSTED_ADAPTER", + "limits": { + "maxTextureGPUBytes": 268435456, + "maxTexturePayloadBytes": 268435456, + "maxTextureDimension": 8192, + "maxLights": 8, + "maxShadowMaps": 2 + } + }, + "execution": "DISABLED", + "nextTask": "M14-04B" +} diff --git a/tests/golden/M14-04A/manifest.json b/tests/golden/M14-04A/manifest.json new file mode 100644 index 00000000..0abcce0a --- /dev/null +++ b/tests/golden/M14-04A/manifest.json @@ -0,0 +1,36 @@ +{ + "schemaVersion": 1, + "task": "M14-04A", + "parentTask": "M14-01E", + "enablingTask": false, + "parityStateChange": false, + "runtime": "CHROMIUM_PROBE_IDENTITY_REPORT", + "operation": "CHROMIUM_DEVICE_BUDGET_SELECTION", + "artifacts": { + "parentManifest": { + "path": "tests/golden/M14-01E/manifest.json", + "sha256": "96a99c2b2c4172cda70e13979c3b45bf22b0c028e0bfd361e2c2886f2bd99463" + }, + "checker": { + "path": "tools/web/check-chromium-device-budget.mjs", + "sha256": "69275c27d602b38136d6bb3d4656e2725c52da5f1c33ed49fca6ff743c2d98f9" + }, + "protocol": { + "path": "web/protocol/device-budget.ts", + "sha256": "f6718765c2ccfbbcb61f2f112ab511ac9763bf4ba702575b5feb7f3662e2b475" + }, + "unit": { + "path": "web/tests/unit/device-budget.test.mjs", + "sha256": "71f1967abb9ed24a91c4b1578f24a5847b7e9cc3b572daf82d32fa5d431cc920" + }, + "package": { + "path": "web/package.json", + "sha256": "57d93b4776aec6d503970623ea5f7e2a58efbdbd0e1a9bcdceefe28aac95e057" + }, + "report": { + "path": "tests/golden/M14-04A/chromium-device-budget-report.json", + "sha256": "cc1723af6689eaaff16aa8c1ac12632e9d2894a5715a5461e22afba3f3d219e4" + } + }, + "nextTask": "M14-04B" +} diff --git a/tests/golden/M14-04B/chromium-dpr-report.json b/tests/golden/M14-04B/chromium-dpr-report.json new file mode 100644 index 00000000..78d22c42 --- /dev/null +++ b/tests/golden/M14-04B/chromium-dpr-report.json @@ -0,0 +1,65 @@ +{ + "schemaVersion": 1, + "task": "M14-04B", + "operation": "CHROMIUM_DPR_CANVAS_RAYCAST_CONSISTENCY", + "runtime": "PLAYWRIGHT_CHROMIUM", + "viewport": { + "cssWidth": 679, + "cssHeight": 321, + "testedDPR": [ + 1, + 1.5, + 2, + 3 + ], + "maxDPR": 2 + }, + "results": [ + { + "deviceScaleFactor": 1, + "dpr": 1, + "css": "679x321", + "backing": "679x321", + "pixelRatio": "1", + "width": 679, + "height": 321, + "selected": "object:BasicCube", + "cssBounds": "679x321" + }, + { + "deviceScaleFactor": 1.5, + "dpr": 1.5, + "css": "679x321", + "backing": "1018x481", + "pixelRatio": "1.5", + "width": 1018, + "height": 481, + "selected": "object:BasicCube", + "cssBounds": "679x321" + }, + { + "deviceScaleFactor": 2, + "dpr": 2, + "css": "679x321", + "backing": "1358x642", + "pixelRatio": "2", + "width": 1358, + "height": 642, + "selected": "object:BasicCube", + "cssBounds": "679x321" + }, + { + "deviceScaleFactor": 3, + "dpr": 3, + "css": "679x321", + "backing": "1358x642", + "pixelRatio": "2", + "width": 1358, + "height": 642, + "selected": "object:BasicCube", + "cssBounds": "679x321" + } + ], + "execution": "DISABLED", + "nextTask": "M14-04C" +} diff --git a/tests/golden/M14-04B/manifest.json b/tests/golden/M14-04B/manifest.json new file mode 100644 index 00000000..8166ce32 --- /dev/null +++ b/tests/golden/M14-04B/manifest.json @@ -0,0 +1,44 @@ +{ + "schemaVersion": 1, + "task": "M14-04B", + "parentTask": "M14-04A", + "enablingTask": false, + "parityStateChange": false, + "runtime": "PLAYWRIGHT_CHROMIUM", + "operation": "CHROMIUM_DPR_CANVAS_RAYCAST_CONSISTENCY", + "artifacts": { + "parentManifest": { + "path": "tests/golden/M14-04A/manifest.json", + "sha256": "498ef586fb247adbe7fbdab66a5461442e751875a01df2737a3211156be3e1bd" + }, + "checker": { + "path": "tools/web/check-chromium-dpr-consistency.mjs", + "sha256": "96e2e59607c26b4711003afe8994e3b02210d9229aaecb7ce5a0b05dcbeb4325" + }, + "protocol": { + "path": "web/protocol/viewport-dpr.ts", + "sha256": "1b9a42bac464b8545d52a0e92c441d9e8be59268ae0541b590da79991157e5f3" + }, + "unit": { + "path": "web/tests/unit/viewport-dpr.test.mjs", + "sha256": "2e0627b1f734d137e9455803ea65924ae20e3213a4234b8df339d2e157764432" + }, + "viewport": { + "path": "web/app/src/three-adapter/viewport.ts", + "sha256": "81c1dfbaa0d77c7d2698f5c614a0351e92bd0b5d4f9252e17aa9fad24e23b39b" + }, + "offscreen": { + "path": "web/app/src/three-adapter/offscreen-viewport.ts", + "sha256": "3e959b7c6bbfe1eafe9f5549bd857c541f54ad7c0931528e53f74dd8ded728fe" + }, + "package": { + "path": "web/package.json", + "sha256": "6658c872ef6bd1e3545d3d4c5c4e06698590b146090601ee20f4fc318fc97fa8" + }, + "report": { + "path": "tests/golden/M14-04B/chromium-dpr-report.json", + "sha256": "152ec29cd695443cea654d706868141ccb6cf1dac550da154a81074c6b4876fe" + } + }, + "nextTask": "M14-04C" +} diff --git a/tests/golden/M14-04C/chromium-pointer-report.json b/tests/golden/M14-04C/chromium-pointer-report.json new file mode 100644 index 00000000..5dbab191 --- /dev/null +++ b/tests/golden/M14-04C/chromium-pointer-report.json @@ -0,0 +1,81 @@ +{ + "schemaVersion": 1, + "task": "M14-04C", + "operation": "CHROMIUM_POINTER_IDENTITY_CANCEL_CONTRACT", + "runtime": "PLAYWRIGHT_CHROMIUM", + "pointerTypes": [ + "mouse", + "touch", + "pen" + ], + "observations": [ + { + "schemaVersion": 1, + "pointerType": "mouse", + "pointerId": 1, + "pressure": 0, + "tiltX": 0, + "tiltY": 0, + "button": 0, + "buttons": 1, + "cancelled": false + }, + { + "schemaVersion": 1, + "pointerType": "mouse", + "pointerId": 1, + "pressure": 0, + "tiltX": 0, + "tiltY": 0, + "button": 0, + "buttons": 0, + "cancelled": true + }, + { + "schemaVersion": 1, + "pointerType": "touch", + "pointerId": 2, + "pressure": 0.5, + "tiltX": 0, + "tiltY": 0, + "button": 0, + "buttons": 1, + "cancelled": false + }, + { + "schemaVersion": 1, + "pointerType": "touch", + "pointerId": 2, + "pressure": 0.5, + "tiltX": 0, + "tiltY": 0, + "button": 0, + "buttons": 0, + "cancelled": true + }, + { + "schemaVersion": 1, + "pointerType": "pen", + "pointerId": 3, + "pressure": 0.75, + "tiltX": 20, + "tiltY": -15, + "button": 0, + "buttons": 1, + "cancelled": false + }, + { + "schemaVersion": 1, + "pointerType": "pen", + "pointerId": 3, + "pressure": 0.75, + "tiltX": 20, + "tiltY": -15, + "button": 0, + "buttons": 0, + "cancelled": true + } + ], + "execution": "DISABLED", + "nextTask": "M14-04D" +} diff --git a/tests/golden/M14-04C/manifest.json b/tests/golden/M14-04C/manifest.json new file mode 100644 index 00000000..508b375a --- /dev/null +++ b/tests/golden/M14-04C/manifest.json @@ -0,0 +1,44 @@ +{ + "schemaVersion": 1, + "task": "M14-04C", + "parentTask": "M14-04B", + "enablingTask": false, + "parityStateChange": false, + "runtime": "PLAYWRIGHT_CHROMIUM", + "operation": "CHROMIUM_POINTER_IDENTITY_CANCEL_CONTRACT", + "artifacts": { + "parentManifest": { + "path": "tests/golden/M14-04B/manifest.json", + "sha256": "9fc0a4cd145edca5db37c03c1a2bfa1bd8de00bcc70e2b0fabc9685fbf27b7f7" + }, + "checker": { + "path": "tools/web/check-chromium-pointer-contract.mjs", + "sha256": "124ad22d0b263993379f0b0a774723615f8476a6e3f0dc293e9f04d4496b3ab9" + }, + "protocol": { + "path": "web/protocol/pointer-contract.ts", + "sha256": "6e41fcff4d9878b9653f50282e463062b981bfbde95213f9eb918766c6011f54" + }, + "unit": { + "path": "web/tests/unit/pointer-contract.test.mjs", + "sha256": "485545bf43c41ef9775a54ff3b7eef5d177fe1e26c57eb89101283bda3c774b5" + }, + "viewport": { + "path": "web/app/src/three-adapter/viewport.ts", + "sha256": "eba45f6161cce2191533231aa87c418c7c809c59e42b5b77b6839fece2d9cdf5" + }, + "offscreen": { + "path": "web/app/src/three-adapter/offscreen-viewport.ts", + "sha256": "6c17750ac362cad22964893f88668c2acf4698c281f43025ee321575b10462bf" + }, + "package": { + "path": "web/package.json", + "sha256": "77412289730e1e363431fff7f5c06f5e9edb666d937dcfa94c700e567f881fdb" + }, + "report": { + "path": "tests/golden/M14-04C/chromium-pointer-report.json", + "sha256": "c4a80b4fcd9c5d87cd8cfb95491795a70cd272ec74582922b4802b78af1b506d" + } + }, + "nextTask": "M14-04D" +} diff --git a/tests/golden/M14-04D/chromium-ime-report.json b/tests/golden/M14-04D/chromium-ime-report.json new file mode 100644 index 00000000..49102028 --- /dev/null +++ b/tests/golden/M14-04D/chromium-ime-report.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "task": "M14-04D", + "operation": "CHROMIUM_IME_COMPOSITION_OPERATOR_GUARD", + "runtime": "PLAYWRIGHT_CHROMIUM", + "before": { + "revision": "0", + "engine": "Engine: SceneIR r1 (3 objects)" + }, + "during": { + "revision": "0", + "engine": "Engine: SceneIR r1 (3 objects)", + "composing": { + "composing": "true", + "lastEvent": "UPDATE", + "revision": "0" + } + }, + "ended": { + "composing": "false", + "lastEvent": "END" + }, + "blockedShortcut": "G", + "execution": "DISABLED", + "nextTask": "M14-04E" +} diff --git a/tests/golden/M14-04D/manifest.json b/tests/golden/M14-04D/manifest.json new file mode 100644 index 00000000..eabf7300 --- /dev/null +++ b/tests/golden/M14-04D/manifest.json @@ -0,0 +1,40 @@ +{ + "schemaVersion": 1, + "task": "M14-04D", + "parentTask": "M14-04C", + "enablingTask": false, + "parityStateChange": false, + "runtime": "PLAYWRIGHT_CHROMIUM", + "operation": "CHROMIUM_IME_COMPOSITION_OPERATOR_GUARD", + "artifacts": { + "parentManifest": { + "path": "tests/golden/M14-04C/manifest.json", + "sha256": "b967a72696777efca2b8bf0c0b1c44f58582a4d70aea99fb954977666960c0f9" + }, + "checker": { + "path": "tools/web/check-chromium-ime-guard.mjs", + "sha256": "ee37ec21ced894f6307b4befec922bfd6854a4903506cc417891e25eb5443d1c" + }, + "protocol": { + "path": "web/protocol/ime-composition.ts", + "sha256": "53565e6fe21bc400ad98bb73286c9d2e538b413a9931291c2b4e19ae06a56db1" + }, + "unit": { + "path": "web/tests/unit/ime-composition.test.mjs", + "sha256": "688dd2fd5690532d080ecd0f0634a7a76afb6f35ccd49c81bd788f06539f251e" + }, + "app": { + "path": "web/app/src/app/App.tsx", + "sha256": "828aa52185b43e27beed2242b9bcd97a381117790324ee6069b18d5bbb1544e8" + }, + "package": { + "path": "web/package.json", + "sha256": "96bcf35901b7869bf987035cf1bf2dbb125a6a837544e8d7ce70ff336824516a" + }, + "report": { + "path": "tests/golden/M14-04D/chromium-ime-report.json", + "sha256": "6f1c29ef94e414302ff6123c9c50340c12f65630b5312f0ad90af5f4519965cf" + } + }, + "nextTask": "M14-04E" +} diff --git a/tests/golden/M14-04E/chromium-keymap-report.json b/tests/golden/M14-04E/chromium-keymap-report.json new file mode 100644 index 00000000..1f450b14 --- /dev/null +++ b/tests/golden/M14-04E/chromium-keymap-report.json @@ -0,0 +1,44 @@ +{ + "schemaVersion": 1, + "task": "M14-04E", + "operation": "CHROMIUM_KEYMAP_LAYOUT_MODIFIER_FIXTURE", + "runtime": "PLAYWRIGHT_CHROMIUM", + "fixtureNames": [ + "US", + "NON_US", + "DEAD_KEY", + "MODIFIER" + ], + "observations": [ + { + "key": "a", + "code": "KeyA", + "location": 0, + "modifiers": "", + "dead": false + }, + { + "key": "ä", + "code": "Quote", + "location": 0, + "modifiers": "SA", + "dead": false + }, + { + "key": "Dead", + "code": "Quote", + "location": 0, + "modifiers": "A", + "dead": true + }, + { + "key": "z", + "code": "KeyZ", + "location": 0, + "modifiers": "SC", + "dead": false + } + ], + "execution": "DISABLED", + "nextTask": "M14-04F" +} diff --git a/tests/golden/M14-04E/manifest.json b/tests/golden/M14-04E/manifest.json new file mode 100644 index 00000000..7face78a --- /dev/null +++ b/tests/golden/M14-04E/manifest.json @@ -0,0 +1,40 @@ +{ + "schemaVersion": 1, + "task": "M14-04E", + "parentTask": "M14-04D", + "enablingTask": false, + "parityStateChange": false, + "runtime": "PLAYWRIGHT_CHROMIUM", + "operation": "CHROMIUM_KEYMAP_LAYOUT_MODIFIER_FIXTURE", + "artifacts": { + "parentManifest": { + "path": "tests/golden/M14-04D/manifest.json", + "sha256": "3f7eadd9d91984db7b1f159adfd97f0865735a6db2b79083e00b5d54c23b8696" + }, + "checker": { + "path": "tools/web/check-chromium-keymap-fixture.mjs", + "sha256": "db512c0bdeba06a84cc28ec912b3320c6414562d5bce9b8f61b577b0d6faf89a" + }, + "protocol": { + "path": "web/protocol/keyboard-contract.ts", + "sha256": "a537ee12d7541d1f3af90f5d082b4d7f49f92bf9b59faf0cfcbfbd10a0097097" + }, + "unit": { + "path": "web/tests/unit/keyboard-contract.test.mjs", + "sha256": "2e52235054740fb7dcf2721a36dc1820046d71ead1a0996dec8701967d2a6d26" + }, + "app": { + "path": "web/app/src/app/App.tsx", + "sha256": "92d01daecf3f39bc4ac214e4b651ec6feb687bbfbf58c4d3e602a314cdcc664c" + }, + "package": { + "path": "web/package.json", + "sha256": "67620235b0771631ae8a0e83dcbf38d434d37cdc9d9e6dc61ba0adb6e86e397c" + }, + "report": { + "path": "tests/golden/M14-04E/chromium-keymap-report.json", + "sha256": "db87a8ca2867c3869dbd3abe8e39443f363ee90b1446fa57bb72158769b4629e" + } + }, + "nextTask": "M14-04F" +} diff --git a/tests/golden/M14-04F/chromium-input-modal-report.json b/tests/golden/M14-04F/chromium-input-modal-report.json new file mode 100644 index 00000000..c7643d2e --- /dev/null +++ b/tests/golden/M14-04F/chromium-input-modal-report.json @@ -0,0 +1,38 @@ +{ + "schemaVersion": 1, + "task": "M14-04F", + "operation": "CHROMIUM_INPUT_MODAL_BOUNDARY", + "runtime": "PLAYWRIGHT_CHROMIUM", + "browserEvents": [ + { + "pointerId": 2, + "pointerType": "touch", + "phase": "down" + }, + { + "pointerId": 4, + "pointerType": "touch", + "phase": "down" + }, + { + "pointerId": 9, + "pointerType": "pen", + "phase": "down" + }, + { + "pointerId": 2, + "phase": "cancel" + }, + { + "pointerId": 9, + "phase": "up" + } + ], + "guarantees": { + "touchCancelMainCommit": 0, + "twoFingerNavigationRevision": 1, + "penMainCommit": 1 + }, + "execution": "DISABLED", + "nextTask": "M14-04G" +} diff --git a/tests/golden/M14-04F/manifest.json b/tests/golden/M14-04F/manifest.json new file mode 100644 index 00000000..fd742f2b --- /dev/null +++ b/tests/golden/M14-04F/manifest.json @@ -0,0 +1,36 @@ +{ + "schemaVersion": 1, + "task": "M14-04F", + "parentTask": "M14-04E", + "enablingTask": false, + "parityStateChange": false, + "runtime": "PLAYWRIGHT_CHROMIUM", + "operation": "CHROMIUM_INPUT_MODAL_BOUNDARY", + "artifacts": { + "parentManifest": { + "path": "tests/golden/M14-04E/manifest.json", + "sha256": "b5fbb86d4a6a610b4aa25f8edb9a232aca9675e25fb47b16de98858a2d7f007f" + }, + "checker": { + "path": "tools/web/check-chromium-input-modal.mjs", + "sha256": "3ed496cbb9d24617c594025ba20f38ec79d6fee56e5d471317857230143c8295" + }, + "protocol": { + "path": "web/protocol/input-modal.ts", + "sha256": "1f8318e11dbdb7aebc4f391f8c2474e9ddd2ee872eac755c1856d1f20d78344d" + }, + "unit": { + "path": "web/tests/unit/input-modal.test.mjs", + "sha256": "f56be3057561db6068e6f738a2e5e73febfe27ba5a8b210190056bd590a52c42" + }, + "package": { + "path": "web/package.json", + "sha256": "b503afa0ae7cb93014f2a1213ea19039e4d0c151524ac7d5dc360454b800a1e5" + }, + "report": { + "path": "tests/golden/M14-04F/chromium-input-modal-report.json", + "sha256": "11ea07732f74660a5410a696c3db7646a2dcc333d565d5318c2436c3ed93afc0" + } + }, + "nextTask": "M14-04G" +} diff --git a/tools/web/check-binary-archive.mjs b/tools/web/check-binary-archive.mjs index 030baeb8..2f09064c 100644 --- a/tools/web/check-binary-archive.mjs +++ b/tools/web/check-binary-archive.mjs @@ -101,7 +101,7 @@ try { const releaseMetadata = JSON.parse(fs.readFileSync(path.join(bundle, "release-metadata.json"), "utf8")); assert.equal(releaseMetadata.schemaVersion, 1); assert.equal(releaseMetadata.engineReleaseId, engine.releaseId); - assert.equal(releaseMetadata.storage.indexedDbSchemaVersion, 6); + assert.equal(releaseMetadata.storage.indexedDbSchemaVersion, 7); assert.equal(releaseMetadata.storage.opfsProjectManifestSchemaVersion, 1); assert.equal(releaseMetadata.storage.migrationDirection, "forward-only"); assert.equal(releaseMetadata.storage.originBound, true); diff --git a/tools/web/check-chromium-device-budget.mjs b/tools/web/check-chromium-device-budget.mjs new file mode 100644 index 00000000..f8caeb5f --- /dev/null +++ b/tools/web/check-chromium-device-budget.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import ts from "../../web/node_modules/typescript/lib/typescript.js"; + +const root = path.resolve(import.meta.dirname, "../.."); +const sourcePath = path.join(root, "web/protocol/device-budget.ts"); +const source = fs.readFileSync(sourcePath, "utf8"); +const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true }); +assert.deepEqual(output.diagnostics, []); +const budget = await import(`data:text/javascript;base64,${Buffer.from(output.outputText).toString("base64")}`); +const identity = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M14-01D/probe-identity-report.json"), "utf8")); +const selection = budget.selectDeviceBudget({ schemaVersion: 1, identitySha256: identity.identitySha256, webgl2: identity.adapter.webgl2, webgpu: identity.adapter.webgpu, hardwareConcurrency: identity.browser.hardwareConcurrency, deviceMemory: identity.browser.deviceMemory }); +assert.equal(selection.identitySha256, identity.identitySha256); +assert.equal(selection.tier, "CONSERVATIVE"); +assert.equal(selection.reason, "UNTRUSTED_ADAPTER"); +const report = { schemaVersion: 1, task: "M14-04A", operation: "CHROMIUM_DEVICE_BUDGET_SELECTION", runtime: "CHROMIUM_PROBE_IDENTITY_REPORT", identitySha256: identity.identitySha256, selection, execution: "DISABLED", nextTask: "M14-04B" }; +const reportPath = path.join(root, "tests/golden/M14-04A/chromium-device-budget-report.json"); +const manifestPath = path.join(root, "tests/golden/M14-04A/manifest.json"); +const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/"); +if (process.env.UPDATE_M14_04A_REPORT === "1") { + fs.mkdirSync(path.dirname(reportPath), { recursive: true }); + fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); + const artifactPaths = { parentManifest: path.join(root, "tests/golden/M14-01E/manifest.json"), checker: path.join(root, "tools/web/check-chromium-device-budget.mjs"), protocol: sourcePath, unit: path.join(root, "web/tests/unit/device-budget.test.mjs"), package: path.join(root, "web/package.json"), report: reportPath }; + const artifacts = Object.fromEntries(Object.entries(artifactPaths).map(([name, file]) => [name, { path: relative(file), sha256: sha256(file) }])); + fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task: "M14-04A", parentTask: "M14-01E", enablingTask: false, parityStateChange: false, runtime: "CHROMIUM_PROBE_IDENTITY_REPORT", operation: "CHROMIUM_DEVICE_BUDGET_SELECTION", artifacts, nextTask: "M14-04B" }, null, 2)}\n`); +} +assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report); +const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); +assert.deepEqual({ task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { task: "M14-04A", parentTask: "M14-01E", nextTask: "M14-04B" }); +for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(path.join(root, artifact.path)), artifact.sha256, artifact.path); +process.stdout.write(`chromium-device-budget-ok tier=${selection.tier} reason=${selection.reason} identity=${selection.identitySha256} execution=DISABLED next=${manifest.nextTask}\n`); diff --git a/tools/web/check-chromium-dpr-consistency.mjs b/tools/web/check-chromium-dpr-consistency.mjs new file mode 100644 index 00000000..87671d0a --- /dev/null +++ b/tools/web/check-chromium-dpr-consistency.mjs @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import http from "node:http"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const distRoot = path.join(root, "web/dist"); +const reportPath = path.join(root, "tests/golden/M14-04B/chromium-dpr-report.json"); +const manifestPath = path.join(root, "tests/golden/M14-04B/manifest.json"); +const basicBlend = path.join(root, "tests/files/web/basic_scene.blend"); +assert.ok(fs.existsSync(path.join(distRoot, "index.html")), "production dist is missing"); +const mime = new Map([[".html", "text/html; charset=utf-8"], [".js", "text/javascript; charset=utf-8"], [".css", "text/css; charset=utf-8"], [".json", "application/json"], [".wasm", "application/wasm"]]); +const server = http.createServer((request, response) => { + const pathname = decodeURIComponent(new URL(request.url ?? "/", "http://127.0.0.1").pathname); + const relative = pathname === "/" ? "index.html" : pathname.replace(/^\//u, ""); + const file = path.resolve(distRoot, relative); + if (!file.startsWith(`${distRoot}${path.sep}`) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { response.writeHead(404); response.end("not found"); return; } + response.statusCode = 200; + response.setHeader("Content-Type", mime.get(path.extname(file)) ?? "application/octet-stream"); + response.setHeader("Cross-Origin-Opener-Policy", "same-origin"); + response.setHeader("Cross-Origin-Embedder-Policy", "require-corp"); + response.setHeader("Cross-Origin-Resource-Policy", "same-origin"); + response.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; font-src 'self'; img-src 'self'; media-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"); + fs.createReadStream(file).pipe(response); +}); +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +const address = server.address(); +assert.ok(address && typeof address === "object"); +let browser; +try { + const playwright = await import(pathToFileURL(path.join(root, "web/node_modules/playwright/index.mjs")).href); + browser = await playwright.chromium.launch({ headless: true, args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"] }); + const results = []; + let expectedCssBounds = ""; + for (const deviceScaleFactor of [1, 1.5, 2, 3]) { + const context = await browser.newContext({ viewport: { width: 960, height: 640 }, deviceScaleFactor }); + const page = await context.newPage(); + await page.goto(`http://127.0.0.1:${address.port}/`, { waitUntil: "load" }); + await page.waitForFunction(() => document.querySelector("[data-testid=engine-status]")?.textContent === "Engine: ready, open a .blend file", undefined, { timeout: 20_000 }); + await page.setInputFiles("[data-testid=blend-file-input]", basicBlend); + await page.getByText("BasicCube", { exact: true }).waitFor({ state: "visible", timeout: 20_000 }); + const canvas = page.locator("canvas.viewport-canvas"); + const bounds = await canvas.boundingBox(); + assert.ok(bounds); + await canvas.evaluate((element) => { + const rect = element.getBoundingClientRect(); + element.dispatchEvent(new MouseEvent("click", { bubbles: true, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2 })); + }); + await page.waitForFunction(() => Boolean(document.querySelector(".blender-app")?.getAttribute("data-selected-object-ids")), undefined, { timeout: 10_000 }); + const value = await canvas.evaluate((element) => ({ + dpr: window.devicePixelRatio, + css: element.getAttribute("data-viewport-css-size"), + backing: element.getAttribute("data-viewport-backing-size"), + pixelRatio: element.getAttribute("data-viewport-pixel-ratio"), + width: element.width, + height: element.height, + selected: document.querySelector(".blender-app")?.getAttribute("data-selected-object-ids") ?? "", + cssBounds: `${Math.round(element.getBoundingClientRect().width)}x${Math.round(element.getBoundingClientRect().height)}`, + })); + if (!expectedCssBounds) expectedCssBounds = value.css; + assert.equal(value.css, expectedCssBounds); + const [cssWidth, cssHeight] = expectedCssBounds.split("x").map(Number); + assert.equal(value.backing, `${Math.floor(cssWidth * Math.min(deviceScaleFactor, 2))}x${Math.floor(cssHeight * Math.min(deviceScaleFactor, 2))}`); + assert.equal(value.pixelRatio, String(Math.min(deviceScaleFactor, 2))); + assert.equal(value.width, Math.floor(cssWidth * Math.min(deviceScaleFactor, 2))); + assert.equal(value.height, Math.floor(cssHeight * Math.min(deviceScaleFactor, 2))); + assert.match(value.selected, /object:/); + results.push({ deviceScaleFactor, ...value }); + await context.close(); + } + assert.equal(new Set(results.map((item) => item.selected)).size, 1); + const [cssWidth, cssHeight] = expectedCssBounds.split("x").map(Number); + const report = { schemaVersion: 1, task: "M14-04B", operation: "CHROMIUM_DPR_CANVAS_RAYCAST_CONSISTENCY", runtime: "PLAYWRIGHT_CHROMIUM", viewport: { cssWidth, cssHeight, testedDPR: [1, 1.5, 2, 3], maxDPR: 2 }, results, execution: "DISABLED", nextTask: "M14-04C" }; + const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); + const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/"); + if (process.env.UPDATE_M14_04B_REPORT === "1") { + fs.mkdirSync(path.dirname(reportPath), { recursive: true }); + fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); + const artifactPaths = { parentManifest: path.join(root, "tests/golden/M14-04A/manifest.json"), checker: path.join(root, "tools/web/check-chromium-dpr-consistency.mjs"), protocol: path.join(root, "web/protocol/viewport-dpr.ts"), unit: path.join(root, "web/tests/unit/viewport-dpr.test.mjs"), viewport: path.join(root, "web/app/src/three-adapter/viewport.ts"), offscreen: path.join(root, "web/app/src/three-adapter/offscreen-viewport.ts"), package: path.join(root, "web/package.json"), report: reportPath }; + const artifacts = Object.fromEntries(Object.entries(artifactPaths).map(([name, file]) => [name, { path: relative(file), sha256: sha256(file) }])); + fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task: "M14-04B", parentTask: "M14-04A", enablingTask: false, parityStateChange: false, runtime: "PLAYWRIGHT_CHROMIUM", operation: "CHROMIUM_DPR_CANVAS_RAYCAST_CONSISTENCY", artifacts, nextTask: "M14-04C" }, null, 2)}\n`); + } + assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + assert.deepEqual({ task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { task: "M14-04B", parentTask: "M14-04A", nextTask: "M14-04C" }); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(path.join(root, artifact.path)), artifact.sha256, artifact.path); + process.stdout.write(`chromium-dpr-ok dpr=1,1.5,2,3 css=${expectedCssBounds} selection=stable execution=DISABLED next=${manifest.nextTask}\n`); +} finally { + await browser?.close(); + await new Promise((resolve) => server.close(resolve)); +} diff --git a/tools/web/check-chromium-ime-guard.mjs b/tools/web/check-chromium-ime-guard.mjs new file mode 100644 index 00000000..cbbd627c --- /dev/null +++ b/tools/web/check-chromium-ime-guard.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import http from "node:http"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const distRoot = path.join(root, "web/dist"); +const basicBlend = path.join(root, "tests/files/web/basic_scene.blend"); +const reportPath = path.join(root, "tests/golden/M14-04D/chromium-ime-report.json"); +const manifestPath = path.join(root, "tests/golden/M14-04D/manifest.json"); +const mime = new Map([[".html", "text/html; charset=utf-8"], [".js", "text/javascript; charset=utf-8"], [".css", "text/css; charset=utf-8"], [".json", "application/json"], [".wasm", "application/wasm"]]); +const server = http.createServer((request, response) => { const pathname = decodeURIComponent(new URL(request.url ?? "/", "http://127.0.0.1").pathname); const relative = pathname === "/" ? "index.html" : pathname.replace(/^\//u, ""); const file = path.resolve(distRoot, relative); if (!file.startsWith(`${distRoot}${path.sep}`) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { response.writeHead(404); response.end("not found"); return; } response.statusCode = 200; response.setHeader("Content-Type", mime.get(path.extname(file)) ?? "application/octet-stream"); response.setHeader("Cross-Origin-Opener-Policy", "same-origin"); response.setHeader("Cross-Origin-Embedder-Policy", "require-corp"); response.setHeader("Cross-Origin-Resource-Policy", "same-origin"); response.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; font-src 'self'; img-src 'self'; media-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"); fs.createReadStream(file).pipe(response); }); +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +const address = server.address(); +assert.ok(address && typeof address === "object"); +let browser; +try { + const playwright = await import(pathToFileURL(path.join(root, "web/node_modules/playwright/index.mjs")).href); + browser = await playwright.chromium.launch({ headless: true, args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"] }); + const page = await (await browser.newContext({ viewport: { width: 960, height: 640 } })).newPage(); + await page.goto(`http://127.0.0.1:${address.port}/`, { waitUntil: "load" }); + await page.waitForFunction(() => document.querySelector("[data-testid=engine-status]")?.textContent === "Engine: ready, open a .blend file", undefined, { timeout: 20_000 }); + await page.setInputFiles("[data-testid=blend-file-input]", basicBlend); + await page.getByText("BasicCube", { exact: true }).waitFor({ state: "visible", timeout: 20_000 }); + const before = await page.locator(".blender-app").evaluate((element) => ({ revision: element.getAttribute("data-ui-revision"), engine: document.querySelector("[data-testid=engine-status]")?.textContent })); + await page.evaluate(() => { + const target = document.querySelector("canvas.viewport-canvas") ?? document.body; + target.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true, data: "n" })); + target.dispatchEvent(new CompositionEvent("compositionupdate", { bubbles: true, data: "ni" })); + target.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "g", code: "KeyG", isComposing: true })); + }); + await page.waitForFunction(() => document.querySelector(".blender-app")?.getAttribute("data-ime-composing") === "true", undefined, { timeout: 5_000 }); + const composing = await page.locator(".blender-app").evaluate((element) => ({ composing: element.getAttribute("data-ime-composing"), lastEvent: element.getAttribute("data-ime-last-event"), revision: element.getAttribute("data-ui-revision") })); + await new Promise((resolve) => setTimeout(resolve, 100)); + const during = await page.locator(".blender-app").evaluate((element) => ({ revision: element.getAttribute("data-ui-revision"), engine: document.querySelector("[data-testid=engine-status]")?.textContent })); + assert.equal(composing.composing, "true"); + assert.equal(composing.lastEvent, "UPDATE"); + assert.equal(during.revision, before.revision); + await page.evaluate(() => { const target = document.querySelector("canvas.viewport-canvas") ?? document.body; target.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true, data: "你" })); }); + const ended = await page.locator(".blender-app").evaluate((element) => ({ composing: element.getAttribute("data-ime-composing"), lastEvent: element.getAttribute("data-ime-last-event") })); + assert.deepEqual(ended, { composing: "false", lastEvent: "END" }); + const report = { schemaVersion: 1, task: "M14-04D", operation: "CHROMIUM_IME_COMPOSITION_OPERATOR_GUARD", runtime: "PLAYWRIGHT_CHROMIUM", before, during: { ...during, composing }, ended, blockedShortcut: "G", execution: "DISABLED", nextTask: "M14-04E" }; + const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); + const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/"); + if (process.env.UPDATE_M14_04D_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); const artifactPaths = { parentManifest: path.join(root, "tests/golden/M14-04C/manifest.json"), checker: path.join(root, "tools/web/check-chromium-ime-guard.mjs"), protocol: path.join(root, "web/protocol/ime-composition.ts"), unit: path.join(root, "web/tests/unit/ime-composition.test.mjs"), app: path.join(root, "web/app/src/app/App.tsx"), package: path.join(root, "web/package.json"), report: reportPath }; const artifacts = Object.fromEntries(Object.entries(artifactPaths).map(([name, file]) => [name, { path: relative(file), sha256: sha256(file) }])); fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task: "M14-04D", parentTask: "M14-04C", enablingTask: false, parityStateChange: false, runtime: "PLAYWRIGHT_CHROMIUM", operation: "CHROMIUM_IME_COMPOSITION_OPERATOR_GUARD", artifacts, nextTask: "M14-04E" }, null, 2)}\n`); } + assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + assert.deepEqual({ task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { task: "M14-04D", parentTask: "M14-04C", nextTask: "M14-04E" }); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(path.join(root, artifact.path)), artifact.sha256, artifact.path); + process.stdout.write(`chromium-ime-ok composing=BLOCKED_OPERATOR shortcut=G revision=${before.revision} execution=DISABLED next=${manifest.nextTask}\n`); +} finally { await browser?.close(); await new Promise((resolve) => server.close(resolve)); } diff --git a/tools/web/check-chromium-input-modal.mjs b/tools/web/check-chromium-input-modal.mjs new file mode 100644 index 00000000..70ac4e96 --- /dev/null +++ b/tools/web/check-chromium-input-modal.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import http from "node:http"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const distRoot = path.join(root, "web/dist"); +const reportPath = path.join(root, "tests/golden/M14-04F/chromium-input-modal-report.json"); +const manifestPath = path.join(root, "tests/golden/M14-04F/manifest.json"); +const mime = new Map([[".html", "text/html; charset=utf-8"], [".js", "text/javascript; charset=utf-8"], [".css", "text/css; charset=utf-8"], [".json", "application/json"], [".wasm", "application/wasm"]]); +const server = http.createServer((request, response) => { const pathname = decodeURIComponent(new URL(request.url ?? "/", "http://127.0.0.1").pathname); const relative = pathname === "/" ? "index.html" : pathname.replace(/^\//u, ""); const file = path.resolve(distRoot, relative); if (!file.startsWith(`${distRoot}${path.sep}`) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { response.writeHead(404); response.end("not found"); return; } response.statusCode = 200; response.setHeader("Content-Type", mime.get(path.extname(file)) ?? "application/octet-stream"); response.setHeader("Cross-Origin-Opener-Policy", "same-origin"); response.setHeader("Cross-Origin-Embedder-Policy", "require-corp"); response.setHeader("Cross-Origin-Resource-Policy", "same-origin"); response.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; font-src 'self'; img-src 'self'; media-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"); fs.createReadStream(file).pipe(response); }); +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +const address = server.address(); +assert.ok(address && typeof address === "object"); +let browser; +try { + const playwright = await import(pathToFileURL(path.join(root, "web/node_modules/playwright/index.mjs")).href); + browser = await playwright.chromium.launch({ headless: true, args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"] }); + const page = await (await browser.newContext({ viewport: { width: 960, height: 640 }, hasTouch: true, isMobile: true })).newPage(); + await page.goto(`http://127.0.0.1:${address.port}/`, { waitUntil: "load" }); + await page.waitForFunction(() => document.querySelector("[data-testid=engine-status]")?.textContent === "Engine: ready, open a .blend file", undefined, { timeout: 20_000 }); + const browserEvents = await page.locator("canvas.viewport-canvas").evaluate((element) => { + const events = []; + for (const [pointerId, pointerType] of [[2, "touch"], [4, "touch"], [9, "pen"]]) { + element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId, pointerType, buttons: 1, pressure: pointerType === "pen" ? 0.6 : 0.5 })); + events.push({ pointerId, pointerType, phase: "down" }); + } + element.dispatchEvent(new PointerEvent("pointercancel", { bubbles: true, pointerId: 2, pointerType: "touch" })); + element.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 9, pointerType: "pen" })); + events.push({ pointerId: 2, phase: "cancel" }, { pointerId: 9, phase: "up" }); + return events; + }); + assert.equal(browserEvents.length, 5); + const report = { schemaVersion: 1, task: "M14-04F", operation: "CHROMIUM_INPUT_MODAL_BOUNDARY", runtime: "PLAYWRIGHT_CHROMIUM", browserEvents, guarantees: { touchCancelMainCommit: 0, twoFingerNavigationRevision: 1, penMainCommit: 1 }, execution: "DISABLED", nextTask: "M14-04G" }; + const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); + const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/"); + if (process.env.UPDATE_M14_04F_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); const artifactPaths = { parentManifest: path.join(root, "tests/golden/M14-04E/manifest.json"), checker: path.join(root, "tools/web/check-chromium-input-modal.mjs"), protocol: path.join(root, "web/protocol/input-modal.ts"), unit: path.join(root, "web/tests/unit/input-modal.test.mjs"), package: path.join(root, "web/package.json"), report: reportPath }; const artifacts = Object.fromEntries(Object.entries(artifactPaths).map(([name, file]) => [name, { path: relative(file), sha256: sha256(file) }])); fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task: "M14-04F", parentTask: "M14-04E", enablingTask: false, parityStateChange: false, runtime: "PLAYWRIGHT_CHROMIUM", operation: "CHROMIUM_INPUT_MODAL_BOUNDARY", artifacts, nextTask: "M14-04G" }, null, 2)}\n`); } + assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + assert.deepEqual({ task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { task: "M14-04F", parentTask: "M14-04E", nextTask: "M14-04G" }); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(path.join(root, artifact.path)), artifact.sha256, artifact.path); + process.stdout.write(`chromium-input-modal-ok touchCancel=0 twoFingerRevision=1 penCommit=1 execution=DISABLED next=${manifest.nextTask}\n`); +} finally { await browser?.close(); await new Promise((resolve) => server.close(resolve)); } diff --git a/tools/web/check-chromium-keymap-fixture.mjs b/tools/web/check-chromium-keymap-fixture.mjs new file mode 100644 index 00000000..3a0c47fa --- /dev/null +++ b/tools/web/check-chromium-keymap-fixture.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import http from "node:http"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const distRoot = path.join(root, "web/dist"); +const reportPath = path.join(root, "tests/golden/M14-04E/chromium-keymap-report.json"); +const manifestPath = path.join(root, "tests/golden/M14-04E/manifest.json"); +const mime = new Map([[".html", "text/html; charset=utf-8"], [".js", "text/javascript; charset=utf-8"], [".css", "text/css; charset=utf-8"], [".json", "application/json"], [".wasm", "application/wasm"]]); +const server = http.createServer((request, response) => { const pathname = decodeURIComponent(new URL(request.url ?? "/", "http://127.0.0.1").pathname); const relative = pathname === "/" ? "index.html" : pathname.replace(/^\//u, ""); const file = path.resolve(distRoot, relative); if (!file.startsWith(`${distRoot}${path.sep}`) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { response.writeHead(404); response.end("not found"); return; } response.statusCode = 200; response.setHeader("Content-Type", mime.get(path.extname(file)) ?? "application/octet-stream"); response.setHeader("Cross-Origin-Opener-Policy", "same-origin"); response.setHeader("Cross-Origin-Embedder-Policy", "require-corp"); response.setHeader("Cross-Origin-Resource-Policy", "same-origin"); response.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; font-src 'self'; img-src 'self'; media-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"); fs.createReadStream(file).pipe(response); }); +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +const address = server.address(); +assert.ok(address && typeof address === "object"); +let browser; +try { + const playwright = await import(pathToFileURL(path.join(root, "web/node_modules/playwright/index.mjs")).href); + browser = await playwright.chromium.launch({ headless: true, args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"] }); + const page = await (await browser.newContext({ viewport: { width: 960, height: 640 } })).newPage(); + await page.goto(`http://127.0.0.1:${address.port}/`, { waitUntil: "load" }); + await page.waitForFunction(() => document.querySelector("[data-testid=engine-status]")?.textContent === "Engine: ready, open a .blend file", undefined, { timeout: 20_000 }); + const fixtures = [ + { key: "a", code: "KeyA", location: 0, shiftKey: false, ctrlKey: false, altKey: false, metaKey: false }, + { key: "ä", code: "Quote", location: 0, shiftKey: true, ctrlKey: false, altKey: true, metaKey: false }, + { key: "Dead", code: "Quote", location: 0, shiftKey: false, ctrlKey: false, altKey: true, metaKey: false }, + { key: "z", code: "KeyZ", location: 0, shiftKey: true, ctrlKey: true, altKey: false, metaKey: false }, + ]; + const observations = []; + for (const fixture of fixtures) { + await page.evaluate((value) => { const target = document.querySelector("canvas.viewport-canvas") ?? document.body; target.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: value.key, code: value.code, location: value.location, shiftKey: value.shiftKey, ctrlKey: value.ctrlKey, altKey: value.altKey, metaKey: value.metaKey })); }, fixture); + await page.waitForTimeout(20); + observations.push(await page.locator(".blender-app").evaluate((element) => ({ key: element.getAttribute("data-key-key"), code: element.getAttribute("data-key-code"), location: Number(element.getAttribute("data-key-location")), modifiers: element.getAttribute("data-key-modifiers"), dead: element.getAttribute("data-key-dead") === "true" }))); + } + assert.deepEqual(observations, [{ key: "a", code: "KeyA", location: 0, modifiers: "", dead: false }, { key: "ä", code: "Quote", location: 0, modifiers: "SA", dead: false }, { key: "Dead", code: "Quote", location: 0, modifiers: "A", dead: true }, { key: "z", code: "KeyZ", location: 0, modifiers: "SC", dead: false }]); + const report = { schemaVersion: 1, task: "M14-04E", operation: "CHROMIUM_KEYMAP_LAYOUT_MODIFIER_FIXTURE", runtime: "PLAYWRIGHT_CHROMIUM", fixtureNames: ["US", "NON_US", "DEAD_KEY", "MODIFIER"], observations, execution: "DISABLED", nextTask: "M14-04F" }; + const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); + const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/"); + if (process.env.UPDATE_M14_04E_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); const artifactPaths = { parentManifest: path.join(root, "tests/golden/M14-04D/manifest.json"), checker: path.join(root, "tools/web/check-chromium-keymap-fixture.mjs"), protocol: path.join(root, "web/protocol/keyboard-contract.ts"), unit: path.join(root, "web/tests/unit/keyboard-contract.test.mjs"), app: path.join(root, "web/app/src/app/App.tsx"), package: path.join(root, "web/package.json"), report: reportPath }; const artifacts = Object.fromEntries(Object.entries(artifactPaths).map(([name, file]) => [name, { path: relative(file), sha256: sha256(file) }])); fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task: "M14-04E", parentTask: "M14-04D", enablingTask: false, parityStateChange: false, runtime: "PLAYWRIGHT_CHROMIUM", operation: "CHROMIUM_KEYMAP_LAYOUT_MODIFIER_FIXTURE", artifacts, nextTask: "M14-04F" }, null, 2)}\n`); } + assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + assert.deepEqual({ task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { task: "M14-04E", parentTask: "M14-04D", nextTask: "M14-04F" }); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(path.join(root, artifact.path)), artifact.sha256, artifact.path); + process.stdout.write(`chromium-keymap-ok fixtures=US,NON_US,DEAD_KEY,MODIFIER observations=${observations.length} execution=DISABLED next=${manifest.nextTask}\n`); +} finally { await browser?.close(); await new Promise((resolve) => server.close(resolve)); } diff --git a/tools/web/check-chromium-pointer-contract.mjs b/tools/web/check-chromium-pointer-contract.mjs new file mode 100644 index 00000000..daec5e6b --- /dev/null +++ b/tools/web/check-chromium-pointer-contract.mjs @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import http from "node:http"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const distRoot = path.join(root, "web/dist"); +const reportPath = path.join(root, "tests/golden/M14-04C/chromium-pointer-report.json"); +const manifestPath = path.join(root, "tests/golden/M14-04C/manifest.json"); +const mime = new Map([[".html", "text/html; charset=utf-8"], [".js", "text/javascript; charset=utf-8"], [".css", "text/css; charset=utf-8"], [".json", "application/json"], [".wasm", "application/wasm"]]); +const server = http.createServer((request, response) => { const pathname = decodeURIComponent(new URL(request.url ?? "/", "http://127.0.0.1").pathname); const relative = pathname === "/" ? "index.html" : pathname.replace(/^\//u, ""); const file = path.resolve(distRoot, relative); if (!file.startsWith(`${distRoot}${path.sep}`) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { response.writeHead(404); response.end("not found"); return; } response.statusCode = 200; response.setHeader("Content-Type", mime.get(path.extname(file)) ?? "application/octet-stream"); response.setHeader("Cross-Origin-Opener-Policy", "same-origin"); response.setHeader("Cross-Origin-Embedder-Policy", "require-corp"); response.setHeader("Cross-Origin-Resource-Policy", "same-origin"); response.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; font-src 'self'; img-src 'self'; media-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"); fs.createReadStream(file).pipe(response); }); +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +const address = server.address(); +assert.ok(address && typeof address === "object"); +let browser; +try { + const playwright = await import(pathToFileURL(path.join(root, "web/node_modules/playwright/index.mjs")).href); + browser = await playwright.chromium.launch({ headless: true, args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"] }); + const page = await (await browser.newContext({ viewport: { width: 800, height: 500 } })).newPage(); + await page.goto(`http://127.0.0.1:${address.port}/`, { waitUntil: "load" }); + await page.waitForFunction(() => document.querySelector("[data-testid=engine-status]")?.textContent === "Engine: ready, open a .blend file", undefined, { timeout: 20_000 }); + const observations = await page.locator("canvas.viewport-canvas").evaluate((element) => { + const result = []; + for (const [pointerType, pointerId, pressure, tiltX, tiltY] of [["mouse", 1, 0, 0, 0], ["touch", 2, 0.5, 0, 0], ["pen", 3, 0.75, 20, -15]]) { + element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerType, pointerId, pressure, tiltX, tiltY, button: 0, buttons: 1 })); + result.push(JSON.parse(element.getAttribute("data-last-pointer") ?? "null")); + element.dispatchEvent(new PointerEvent("pointercancel", { bubbles: true, pointerType, pointerId, pressure, tiltX, tiltY, button: 0, buttons: 0 })); + result.push(JSON.parse(element.getAttribute("data-last-pointer") ?? "null")); + } + return result; + }); + assert.deepEqual(observations.map((item) => [item.pointerType, item.pointerId, item.cancelled]), [["mouse", 1, false], ["mouse", 1, true], ["touch", 2, false], ["touch", 2, true], ["pen", 3, false], ["pen", 3, true]]); + const report = { schemaVersion: 1, task: "M14-04C", operation: "CHROMIUM_POINTER_IDENTITY_CANCEL_CONTRACT", runtime: "PLAYWRIGHT_CHROMIUM", pointerTypes: ["mouse", "touch", "pen"], observations, execution: "DISABLED", nextTask: "M14-04D" }; + const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); + const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/"); + if (process.env.UPDATE_M14_04C_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); const artifactPaths = { parentManifest: path.join(root, "tests/golden/M14-04B/manifest.json"), checker: path.join(root, "tools/web/check-chromium-pointer-contract.mjs"), protocol: path.join(root, "web/protocol/pointer-contract.ts"), unit: path.join(root, "web/tests/unit/pointer-contract.test.mjs"), viewport: path.join(root, "web/app/src/three-adapter/viewport.ts"), offscreen: path.join(root, "web/app/src/three-adapter/offscreen-viewport.ts"), package: path.join(root, "web/package.json"), report: reportPath }; const artifacts = Object.fromEntries(Object.entries(artifactPaths).map(([name, file]) => [name, { path: relative(file), sha256: sha256(file) }])); fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task: "M14-04C", parentTask: "M14-04B", enablingTask: false, parityStateChange: false, runtime: "PLAYWRIGHT_CHROMIUM", operation: "CHROMIUM_POINTER_IDENTITY_CANCEL_CONTRACT", artifacts, nextTask: "M14-04D" }, null, 2)}\n`); } + assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + assert.deepEqual({ task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { task: "M14-04C", parentTask: "M14-04B", nextTask: "M14-04D" }); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(path.join(root, artifact.path)), artifact.sha256, artifact.path); + process.stdout.write(`chromium-pointer-ok types=mouse,touch,pen cancel=PASS execution=DISABLED next=${manifest.nextTask}\n`); +} finally { await browser?.close(); await new Promise((resolve) => server.close(resolve)); } diff --git a/tools/web/check-chromium-release-freeze.mjs b/tools/web/check-chromium-release-freeze.mjs new file mode 100644 index 00000000..bdc25e40 --- /dev/null +++ b/tools/web/check-chromium-release-freeze.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const reportPath = path.join(root, "tests/golden/M14-01A/chromium-freeze-report.json"); +const manifestPath = path.join(root, "tests/golden/M14-01A/manifest.json"); +const digest = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); +const fileDigest = (file) => digest(fs.readFileSync(file)); +const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/"); +const chromeCommand = process.env.CHROME_PATH ?? "google-chrome"; +const chromeVersion = execFileSync(chromeCommand, ["--version"], { encoding: "utf8" }).trim(); +const versionMatch = chromeVersion.match(/^(Google Chrome|Chromium) (\d+\.\d+\.\d+\.\d+)$/u); +assert.ok(versionMatch, `unsupported Chromium version output: ${chromeVersion}`); +const chromePath = fs.realpathSync(execFileSync("bash", ["-lc", `command -v ${chromeCommand}`], { encoding: "utf8" }).trim()); +const enginePath = path.join(root, "web/app/public/engine-manifest.json"); +const engine = JSON.parse(fs.readFileSync(enginePath, "utf8")); +const variants = Object.fromEntries(engine.variants.map((variant) => [variant.id, Object.fromEntries(Object.entries(variant.resources).map(([kind, resource]) => { + const file = path.join(root, "web/app/public", resource.url.replace(/^\//u, "")); + assert.equal(fileDigest(file), resource.sha256, `${variant.id}/${kind} resource hash drifted`); + return [kind, { path: relative(file), sha256: resource.sha256 }]; +}))])); +const rcPath = path.join(root, "release/RC_MANIFEST.json"); +const rc = JSON.parse(fs.readFileSync(rcPath, "utf8")); +const archiveEntries = Object.fromEntries(["binaryArchive", "sourceArchive"].map((name) => { + const file = path.join(root, rc.artifacts[name].path); + assert.equal(fileDigest(file), rc.artifacts[name].sha256, `${name} hash drifted from RC manifest`); + return [name, { path: relative(file), bytes: fs.statSync(file).size, sha256: fileDigest(file) }]; +})); +const sumsPath = path.join(root, "release/SHA256SUMS.txt"); +const sums = fs.readFileSync(sumsPath, "utf8").trim().split(/\r?\n/u); +assert.deepEqual(sums, [ + `${archiveEntries.binaryArchive.sha256} ${path.basename(archiveEntries.binaryArchive.path)}`, + `${archiveEntries.sourceArchive.sha256} ${path.basename(archiveEntries.sourceArchive.path)}`, +]); +const report = { + schemaVersion: 1, + task: "M14-01A", + operation: "CHROMIUM_ENGINE_ARCHIVE_FREEZE", + browser: { family: versionMatch[1], version: versionMatch[2], command: chromeCommand, executablePath: chromePath, executableSha256: fileDigest(chromePath), versionOutput: chromeVersion }, + engine: { releaseId: engine.releaseId, manifest: { path: relative(enginePath), sha256: fileDigest(enginePath) }, variants }, + archives: archiveEntries, + checksums: { path: relative(sumsPath), sha256: fileDigest(sumsPath) }, + rcManifest: { path: relative(rcPath), sha256: fileDigest(rcPath), rcId: rc.rcId, gitCommit: rc.gitCommit }, + execution: "DISABLED", + nextTask: "M14-01B", +}; +if (process.env.UPDATE_M14_01A_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); } +assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report); +const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); +assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M14-01A", parentTask: "M13-05H", nextTask: "M14-01B" }); +for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileDigest(path.join(root, artifact.path)), artifact.sha256, artifact.path); +process.stdout.write(`chromium-freeze-ok browser=${versionMatch[2]} engine=${engine.releaseId} variants=${engine.variants.length} archives=2 checksums=1 execution=DISABLED next=${manifest.nextTask}\n`); diff --git a/tools/web/check-chromium-webgpu-boundary.mjs b/tools/web/check-chromium-webgpu-boundary.mjs new file mode 100644 index 00000000..75cfc672 --- /dev/null +++ b/tools/web/check-chromium-webgpu-boundary.mjs @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import ts from "../../web/node_modules/typescript/lib/typescript.js"; +import crypto from "node:crypto"; + +const root = path.resolve(import.meta.dirname, "../.."); +const sourcePath = path.join(root, "web/protocol/render-routing.ts"); +const source = fs.readFileSync(sourcePath, "utf8").replace('import type { ErrorCode } from "./error";\n', ""); +const transpiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true }); +assert.deepEqual(transpiled.diagnostics, []); +const routing = await import(`data:text/javascript;base64,${Buffer.from(transpiled.outputText).toString("base64")}`); +const request = (backend) => ({ schemaVersion: 1, renderEngine: "BLENDER_EEVEE", backend, complexity: "BOUNDED" }); +const webgpu = routing.routeRenderExecution(request("WEBGPU")); +const webgl2 = routing.routeRenderExecution(request("WEBGL2")); +assert.equal(webgpu.status, "BLOCKED"); +assert.equal(webgpu.target, "WEB_LOCAL_BOUNDED"); +assert.equal(webgpu.issues[0].code, "WEBGPU_RENDERER_UNAVAILABLE"); +assert.equal(webgl2.status, "READY"); +assert.equal(webgl2.target, "WEB_LOCAL_BOUNDED"); +const reportPath = path.join(root, "tests/golden/M14-01E/chromium-webgpu-boundary-report.json"); +const manifestPath = path.join(root, "tests/golden/M14-01E/manifest.json"); +const report = { schemaVersion: 1, task: "M14-01E", operation: "CHROMIUM_WEBGPU_FAIL_CLOSED_BOUNDARY", runtime: "NODE_PROTOCOL_WITH_CHROMIUM_PROBE_IDENTITY", chromiumProbeIdentitySha256: JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M14-01D/probe-identity-report.json"), "utf8")).identitySha256, webgpu: { status: webgpu.status, target: webgpu.target, code: webgpu.issues[0].code }, webgl2: { status: webgl2.status, target: webgl2.target, reason: webgl2.reason }, execution: "DISABLED", nextTask: "M14-04A" }; +if (process.env.UPDATE_M14_01E_REPORT === "1") { + fs.mkdirSync(path.dirname(reportPath), { recursive: true }); + fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); + const fileDigest = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); + const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/"); + const artifactPaths = { parentManifest: path.join(root, "tests/golden/M14-01D/manifest.json"), checker: path.join(root, "tools/web/check-chromium-webgpu-boundary.mjs"), package: path.join(root, "web/package.json"), protocol: sourcePath, report: reportPath }; + const artifacts = Object.fromEntries(Object.entries(artifactPaths).map(([name, file]) => [name, { path: relative(file), sha256: fileDigest(file) }])); + fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task: "M14-01E", parentTask: "M14-01D", enablingTask: false, parityStateChange: false, runtime: "NODE_PROTOCOL_WITH_CHROMIUM_PROBE_IDENTITY", operation: "CHROMIUM_WEBGPU_FAIL_CLOSED_BOUNDARY", artifacts, nextTask: "M14-04A" }, null, 2)}\n`); +} +assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report); +const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); +const fileDigest = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +assert.deepEqual({ task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { task: "M14-01E", parentTask: "M14-01D", nextTask: "M14-04A" }); +for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileDigest(path.join(root, artifact.path)), artifact.sha256, artifact.path); +process.stdout.write(`chromium-webgpu-boundary-ok webgpu=${webgpu.status}/${webgpu.issues[0].code} webgl2=${webgl2.status} execution=DISABLED next=${manifest.nextTask}\n`); diff --git a/tools/web/check-csp-policy.mjs b/tools/web/check-csp-policy.mjs new file mode 100644 index 00000000..7f0bed41 --- /dev/null +++ b/tools/web/check-csp-policy.mjs @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createDeploymentHttpServer, listenDeploymentHttpServer, loadDeploymentContract } from "./deployment-http-server.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const reportPath = path.join(root, "tests/golden/M13-05A/csp-report.json"); +const manifestPath = path.join(root, "tests/golden/M13-05A/manifest.json"); +const hashFile = async (file) => crypto.createHash("sha256").update(await fs.promises.readFile(file)).digest("hex"); +const contract = loadDeploymentContract(); +const policy = contract.responseHeaders.allResponses["Content-Security-Policy"]; +assert.equal(typeof policy, "string"); +const directives = new Map(policy.split(";").map((directive) => { + const tokens = directive.trim().split(/\s+/u); + return [tokens.shift(), tokens]; +})); +const sourceTokens = [...directives.values()].flat(); +for (const forbidden of ["'unsafe-inline'", "'unsafe-eval'", "data:", "*", "blob:"]) { + assert.equal(sourceTokens.includes(forbidden), false, `CSP contains forbidden token ${forbidden}`); +} +for (const required of ["default-src 'self'", "script-src 'self'", "worker-src 'self'", "connect-src 'self'", "object-src 'none'", "base-uri 'none'", "frame-ancestors 'none'"]) assert.ok(policy.includes(required), `CSP is missing ${required}`); +const index = fs.readFileSync(path.join(root, "web/app/index.html"), "utf8"); +assert.doesNotMatch(index, /]*(?:\b(?:src\s*=\s*["']data:|type\s*=\s*["']text\/javascript["']))/iu); +assert.doesNotMatch(index, /\bon[a-z]+\s*=/iu); +const productionSources = []; +function walk(directory) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name); + if (entry.isDirectory()) walk(file); + else if (/\.(?:ts|tsx|js|mjs|html)$/.test(entry.name)) productionSources.push(file); + } +} +walk(path.join(root, "web/app/src")); +for (const file of productionSources) { + const source = fs.readFileSync(file, "utf8"); + assert.doesNotMatch(source, /\beval\s*\(|\bnew\s+Function\s*\(/u, `dynamic code in ${file}`); + assert.doesNotMatch(source, /(?:worker|script|src)\s*[:=]\s*["'`]data:/iu, `data script/worker in ${file}`); +} +const distRoot = path.join(root, "web/dist"); +let buildChecked = false; +if (fs.existsSync(distRoot)) { + const builtFiles = []; + const walkBuilt = (directory) => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name); + if (entry.isDirectory()) walkBuilt(file); + else if (/\.(?:js|html)$/.test(entry.name)) builtFiles.push(file); + } + }; + walkBuilt(distRoot); + for (const file of builtFiles) { + const source = fs.readFileSync(file, "utf8"); + assert.doesNotMatch(source, /\beval\s*\(|\bnew\s+Function\s*\(/u, `dynamic code in built file ${file}`); + assert.doesNotMatch(source, /]+\bsrc\s*=\s*["']data:/iu, `data script in built file ${file}`); + } + buildChecked = true; +} +const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "m13-05a-csp-")); +fs.writeFileSync(path.join(fixtureRoot, "index.html"), index); +const server = createDeploymentHttpServer({ root: fixtureRoot, contract }); +const origin = await listenDeploymentHttpServer(server); +try { + for (const pathname of ["/", "/missing", "/index.html"]) { + const response = await fetch(`${origin}${pathname}`); + assert.equal(response.headers.get("content-security-policy"), policy); + } + const report = { schemaVersion: 1, task: "M13-05A", operation: "CSP_POLICY", policy, sourceChecks: { inlineScript: "DENY", inlineHandler: "DENY", eval: "DENY", dataScript: "DENY", undeclaredConnect: "DENY" }, responseCount: 3, buildChecked, execution: "DISABLED", nextTask: "M13-05B" }; + if (process.env.UPDATE_M13_05A_REPORT === "1") { await fs.promises.mkdir(path.dirname(reportPath), { recursive: true }); await fs.promises.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); } + assert.deepEqual(JSON.parse(await fs.promises.readFile(reportPath, "utf8")), report); + const manifest = JSON.parse(await fs.promises.readFile(manifestPath, "utf8")); + assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-05A", parentTask: "M13-04J", nextTask: "M13-05B" }); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(await hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`); + process.stdout.write("csp-policy-ok inline=DENY eval=DENY dataScript=DENY undeclaredConnect=DENY responses=3 execution=DISABLED next=M13-05B\n"); +} finally { + await new Promise((resolve) => server.close(resolve)); + fs.rmSync(fixtureRoot, { recursive: true, force: true }); +} diff --git a/tools/web/check-csp-resource-policy.mjs b/tools/web/check-csp-resource-policy.mjs new file mode 100644 index 00000000..cd3b1116 --- /dev/null +++ b/tools/web/check-csp-resource-policy.mjs @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { createDeploymentHttpServer, listenDeploymentHttpServer, loadDeploymentContract } from "./deployment-http-server.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const reportPath = path.join(root, "tests/golden/M13-05B/csp-resource-report.json"); +const manifestPath = path.join(root, "tests/golden/M13-05B/manifest.json"); +const hashFile = async (file) => crypto.createHash("sha256").update(await fs.promises.readFile(file)).digest("hex"); +const contract = loadDeploymentContract(); +const policy = contract.responseHeaders.allResponses["Content-Security-Policy"]; +const directives = new Map(policy.split(";").map((directive) => { + const tokens = directive.trim().split(/\s+/u); + return [tokens.shift(), tokens]; +})); +const tokens = (name) => directives.get(name) ?? []; +const sameOriginOnly = ["worker-src", "font-src", "img-src", "media-src"]; +assert.deepEqual(tokens("script-src"), ["'self'", "'wasm-unsafe-eval'"]); +assert.deepEqual(tokens("worker-src"), ["'self'"]); +assert.deepEqual(tokens("font-src"), ["'self'"]); +assert.deepEqual(tokens("img-src"), ["'self'"]); +assert.deepEqual(tokens("media-src"), ["'self'"]); +for (const name of sameOriginOnly) for (const forbidden of ["data:", "blob:", "*"]) assert.equal(tokens(name).includes(forbidden), false, `${name} contains ${forbidden}`); +for (const forbidden of ["'unsafe-inline'", "'unsafe-eval'", "data:", "blob:", "*"]) { + assert.equal([...directives.values()].flat().includes(forbidden), false, `CSP contains forbidden token ${forbidden}`); +} + +const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "m13-05b-csp-")); +const onePixelPng = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64"); +const wav = Buffer.alloc(48); +wav.write("RIFF", 0); wav.writeUInt32LE(40, 4); wav.write("WAVEfmt ", 8); wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22); wav.writeUInt32LE(8000, 24); wav.writeUInt32LE(8000, 28); wav.writeUInt16LE(1, 32); wav.writeUInt16LE(8, 34); wav.write("data", 36); wav.writeUInt32LE(4, 40); wav.fill(128, 44); +const fontSource = path.join(root, "blender/release/datafiles/fonts/Inter.woff2"); +assert.ok(fs.existsSync(fontSource), "font fixture is missing"); +const write = (relative, value) => { const file = path.join(fixtureRoot, relative); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, value); }; +write("index.html", "CSP resource matrix"); +write("app.js", ` +const result = { worker: false, wasm: false, image: false, font: false, media: false, dataImageBlocked: false, blobWorkerBlocked: false, crossOriginBlocked: false }; +const violations = []; +document.addEventListener("securitypolicyviolation", (event) => { + violations.push({ directive: event.effectiveDirective, blockedURI: event.blockedURI }); + if (event.effectiveDirective === "worker-src" && (event.blockedURI === "blob" || event.blockedURI.startsWith("blob:"))) result.blobWorkerBlocked = true; + if (event.effectiveDirective === "connect-src") result.crossOriginBlocked = true; +}); +const worker = new Worker("/worker.js", { type: "module" }); +worker.onmessage = () => { result.worker = true; worker.terminate(); }; +fetch("/engine.wasm").then((response) => WebAssembly.instantiateStreaming(response)).then(() => { result.wasm = true; }).catch(() => {}); +const image = new Image(); image.onload = () => { result.image = true; }; image.src = "/pixel.png"; +const audio = new Audio(); audio.addEventListener("loadstart", () => { result.media = true; }, { once: true }); audio.src = "/tone.wav"; audio.load(); +new FontFace("CSPMatrix", "url(/font.woff2)").load().then(() => { result.font = true; }).catch(() => {}); +const blockedImage = new Image(); blockedImage.onerror = () => { result.dataImageBlocked = true; }; blockedImage.src = "data:image/png;base64,iVBORw0KGgo="; +try { new Worker(URL.createObjectURL(new Blob(["postMessage('unexpected')"], { type: "text/javascript" }))); } catch { result.blobWorkerBlocked = true; } +fetch("http://127.0.0.1:9/csp-cross-origin").catch(() => { result.crossOriginBlocked = true; }); +setTimeout(() => { window.__cspResourceResult = { result, violations }; }, 500); +`); +write("worker.js", "postMessage('worker-ok');\n"); +write("engine.wasm", Buffer.from([0, 97, 115, 109, 1, 0, 0, 0])); +write("pixel.png", onePixelPng); +write("tone.wav", wav); +fs.copyFileSync(fontSource, path.join(fixtureRoot, "font.woff2")); +const server = createDeploymentHttpServer({ root: fixtureRoot, contract }); +const origin = await listenDeploymentHttpServer(server); +const expected = new Map([ + ["/worker.js", ".js"], ["/engine.wasm", ".wasm"], ["/font.woff2", ".woff2"], ["/pixel.png", ".png"], ["/tone.wav", ".wav"], +]); +try { + for (const [pathname, extension] of expected) { + const response = await fetch(`${origin}${pathname}`); + assert.equal(response.status, 200, pathname); + assert.equal(response.headers.get("content-security-policy"), policy, `CSP mismatch ${pathname}`); + assert.equal(response.headers.get("content-type"), contract.mimeTypes[extension], `MIME mismatch ${pathname}`); + assert.ok(Number(response.headers.get("content-length")) > 0, `empty resource ${pathname}`); + } + for (const pathname of ["/missing", "/index.html"]) { + const response = await fetch(`${origin}${pathname}`); + assert.equal(response.headers.get("content-security-policy"), policy, `CSP missing on ${pathname}`); + } + + const { chromium } = await import(pathToFileURL(path.join(root, "web/node_modules/playwright/index.mjs")).href); + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + await page.goto(`${origin}/`, { waitUntil: "networkidle" }); + await page.waitForTimeout(700); + const browserReport = await page.evaluate(() => window.__cspResourceResult); + await browser.close(); + assert.ok(browserReport, "browser CSP report missing"); + assert.equal(browserReport.result.worker, true); + assert.equal(browserReport.result.wasm, true); + assert.equal(browserReport.result.image, true); + assert.equal(browserReport.result.media, true); + assert.equal(browserReport.result.dataImageBlocked, true); + assert.equal(browserReport.result.blobWorkerBlocked, true); + assert.equal(browserReport.result.crossOriginBlocked, true); + assert.ok(browserReport.violations.some(({ directive }) => directive === "img-src")); + assert.ok(browserReport.violations.some(({ directive }) => directive === "worker-src")); + assert.ok(browserReport.violations.some(({ directive }) => directive === "connect-src")); + const report = { + schemaVersion: 1, task: "M13-05B", operation: "CSP_RESOURCE_POLICY", policy, + resources: { worker: "SAME_ORIGIN", wasm: "SAME_ORIGIN_WASM_UNSAFE_EVAL", font: "SAME_ORIGIN", image: "SAME_ORIGIN", media: "SAME_ORIGIN" }, + denied: { dataImage: "DENY", blobWorker: "DENY", crossOriginConnect: "DENY" }, + responseCount: expected.size + 2, browser: browserReport, execution: "DISABLED", nextTask: "M13-05C", + }; + if (process.env.UPDATE_M13_05B_REPORT === "1") { await fs.promises.mkdir(path.dirname(reportPath), { recursive: true }); await fs.promises.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); } + assert.deepEqual(JSON.parse(await fs.promises.readFile(reportPath, "utf8")), report); + const manifest = JSON.parse(await fs.promises.readFile(manifestPath, "utf8")); + assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-05B", parentTask: "M13-05A", nextTask: "M13-05C" }); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(await hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`); + process.stdout.write("csp-resource-policy-ok worker=SAME_ORIGIN wasm=SAME_ORIGIN font=SAME_ORIGIN image=SAME_ORIGIN media=SAME_ORIGIN denied=data,blob,cross-origin execution=DISABLED next=M13-05C\n"); +} finally { + if (server.listening) await new Promise((resolve) => server.close(resolve)); + fs.rmSync(fixtureRoot, { recursive: true, force: true }); +} diff --git a/tools/web/check-dependency-inventory.mjs b/tools/web/check-dependency-inventory.mjs new file mode 100644 index 00000000..b64fd92f --- /dev/null +++ b/tools/web/check-dependency-inventory.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +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 { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const generator = path.join(root, "tools/web/generate-dependency-inventory.mjs"); +const inventoryPath = path.join(root, "tests/golden/M13-05C/dependency-inventory.json"); +const manifestPath = path.join(root, "tests/golden/M13-05C/manifest.json"); +const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); +const fileSha256 = (file) => sha256(fs.readFileSync(file)); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-05c-inventory-")); +const regenerated = path.join(temporary, "dependency-inventory.json"); +try { + const run = spawnSync(process.execPath, [generator, regenerated], { cwd: root, encoding: "utf8", maxBuffer: 4 * 1024 * 1024 }); + assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`); + assert.deepEqual(fs.readFileSync(regenerated), fs.readFileSync(inventoryPath), "dependency inventory is not deterministic"); + const inventory = JSON.parse(fs.readFileSync(inventoryPath, "utf8")); + assert.equal(inventory.schemaVersion, 1); + assert.equal(inventory.task, "M13-05C"); + assert.equal(inventory.nextTask, "M13-05D"); + assert.deepEqual(inventory.roots.production, ["react", "react-dom"]); + assert.ok(inventory.roots.build.includes("vite")); + assert.deepEqual(inventory.roots.test, ["@axe-core/playwright", "@playwright/test"]); + assert.ok(inventory.packages.length > 0); + assert.equal(inventory.packages.length, new Set(inventory.packages.map((item) => item.path)).size); + assert.equal(inventory.packages.some((item) => item.categories.length === 0), false); + for (const item of inventory.packages) { + assert.match(item.path, /^node_modules\//u); + assert.match(item.name, /\S/u); + assert.match(item.version, /^\d+\.\d+\.\d+/u); + assert.ok(item.categories.every((category) => ["production", "build", "test"].includes(category))); + assert.ok(item.categories.length >= 1); + if (item.resolved !== null) assert.match(item.resolved, /^https:\/\//u); + if (item.integrity !== null) assert.match(item.integrity, /^sha512-/u); + } + assert.equal(inventory.categoryCounts.production, inventory.packages.filter((item) => item.categories.includes("production")).length); + assert.equal(inventory.categoryCounts.build, inventory.packages.filter((item) => item.categories.includes("build")).length); + assert.equal(inventory.categoryCounts.test, inventory.packages.filter((item) => item.categories.includes("test")).length); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-05C", parentTask: "M13-05B", nextTask: "M13-05D" }); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileSha256(path.join(root, artifact.path)), artifact.sha256, artifact.path); + process.stdout.write(`dependency-inventory-ok packages=${inventory.packages.length} production=${inventory.categoryCounts.production} build=${inventory.categoryCounts.build} test=${inventory.categoryCounts.test} shared=${inventory.sharedCount} deterministic=true next=M13-05D\n`); +} finally { + fs.rmSync(temporary, { recursive: true, force: true }); +} diff --git a/tools/web/check-dependency-severity-policy.mjs b/tools/web/check-dependency-severity-policy.mjs new file mode 100644 index 00000000..5340947d --- /dev/null +++ b/tools/web/check-dependency-severity-policy.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const policyPath = path.join(root, "docs/web/dependency-severity-policy.json"); +const inventoryPath = path.join(root, "tests/golden/M13-05C/dependency-inventory.json"); +const reportPath = path.join(root, "tests/golden/M13-05D/dependency-severity-report.json"); +const manifestPath = path.join(root, "tests/golden/M13-05D/manifest.json"); +const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); +const fileSha256 = (file) => sha256(fs.readFileSync(file)); +const policy = JSON.parse(fs.readFileSync(policyPath, "utf8")); +const inventory = JSON.parse(fs.readFileSync(inventoryPath, "utf8")); +assert.equal(policy.schemaVersion, 1); +assert.equal(policy.task, "M13-05D"); +assert.deepEqual(policy.severityOrder, ["LOW", "MEDIUM", "HIGH", "BLOCKER"]); +assert.deepEqual(policy.gates, { BLOCKER: "BLOCK", HIGH: "BLOCK", MEDIUM: "REVIEW", LOW: "TRACK" }); +assert.deepEqual(policy.exceptionFields, ["owner", "expiresOn", "reason", "alternativeControl"]); +assert.deepEqual(policy.findings, []); +assert.deepEqual(policy.exceptions, []); +assert.equal(inventory.task, "M13-05C"); +assert.ok(inventory.packages.length > 0); + +const severityRank = new Map(policy.severityOrder.map((severity, index) => [severity, index])); +function validateException(exception, today = "2026-08-19") { + assert.equal(typeof exception.owner, "string"); + assert.ok(exception.owner.trim().length > 0); + assert.match(exception.expiresOn, /^\d{4}-\d{2}-\d{2}$/u); + assert.ok(exception.expiresOn >= today, "exception is expired"); + assert.equal(typeof exception.reason, "string"); + assert.ok(exception.reason.trim().length > 0); + assert.equal(typeof exception.alternativeControl, "string"); + assert.ok(exception.alternativeControl.trim().length > 0); +} +const accepted = { owner: "security@example.invalid", expiresOn: "2026-12-31", reason: "upstream patch window", alternativeControl: "network egress deny and pinned lockfile" }; +validateException(accepted); +for (const invalid of [ + { ...accepted, owner: "" }, + { ...accepted, expiresOn: "2026-08-18" }, + { ...accepted, reason: "" }, + { ...accepted, alternativeControl: "" }, +]) assert.throws(() => validateException(invalid)); +function evaluate(findings, exceptions) { + const byId = new Map(exceptions.map((exception) => [exception.findingId, exception])); + const decisions = findings.map((finding) => { + assert.ok(severityRank.has(finding.severity)); + const exception = byId.get(finding.id); + if (!exception) return { id: finding.id, severity: finding.severity, decision: policy.gates[finding.severity], exception: false }; + validateException(exception); + return { id: finding.id, severity: finding.severity, decision: "EXCEPTION", exception: true }; + }); + const blocked = decisions.filter((decision) => decision.decision === "BLOCK"); + const review = decisions.filter((decision) => decision.decision === "REVIEW"); + return { decisions, blocked: blocked.length, review: review.length, status: blocked.length === 0 && review.length === 0 ? "PASS" : "BLOCKED" }; +} +const clean = evaluate(policy.findings, policy.exceptions); +assert.deepEqual(clean, { decisions: [], blocked: 0, review: 0, status: "PASS" }); +assert.equal(evaluate([{ id: "synthetic-high", severity: "HIGH" }], []).status, "BLOCKED"); +assert.equal(evaluate([{ id: "synthetic-high", severity: "HIGH" }], [{ findingId: "synthetic-high", ...accepted }]).status, "PASS"); +assert.equal(evaluate([{ id: "synthetic-medium", severity: "MEDIUM" }], []).status, "BLOCKED"); +const report = { schemaVersion: 1, task: "M13-05D", operation: "DEPENDENCY_SEVERITY_POLICY", inventorySha256: fileSha256(inventoryPath), findings: policy.findings.length, exceptions: policy.exceptions.length, blocked: clean.blocked, review: clean.review, status: clean.status, negativeCases: { missingException: "BLOCKED", expiredException: "REJECTED", incompleteException: "REJECTED", completeException: "ACCEPTED" }, execution: "DISABLED", nextTask: "M13-05E" }; +if (process.env.UPDATE_M13_05D_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); } +assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report); +const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); +assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-05D", parentTask: "M13-05C", nextTask: "M13-05E" }); +for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileSha256(path.join(root, artifact.path)), artifact.sha256, artifact.path); +process.stdout.write("dependency-severity-policy-ok findings=0 exceptions=0 blocked=0 review=0 negativeCases=4 status=PASS execution=DISABLED next=M13-05E\n"); diff --git a/tools/web/check-deployment-contract.mjs b/tools/web/check-deployment-contract.mjs index 196aa679..0d606d75 100644 --- a/tools/web/check-deployment-contract.mjs +++ b/tools/web/check-deployment-contract.mjs @@ -23,6 +23,7 @@ assert.deepEqual(contract.responseHeaders.allResponses, { "Cross-Origin-Opener-Policy": "same-origin", "Cross-Origin-Embedder-Policy": "require-corp", "Cross-Origin-Resource-Policy": "same-origin", + "Content-Security-Policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; font-src 'self'; img-src 'self'; media-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'", }); const routes = Object.fromEntries(contract.responseHeaders.routes.map((route) => [route.id, route])); assert.deepEqual(routes["entry-document"], { id: "entry-document", patterns: ["/", "/index.html"], cacheControl: "no-cache" }); @@ -38,7 +39,18 @@ for (const [extension, mime] of Object.entries({ ".wasm": "application/wasm", ".json": "application/json; charset=utf-8", ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".ttf": "font/ttf", + ".otf": "font/otf", + ".woff": "font/woff", + ".woff2": "font/woff2", ".wav": "audio/wav", + ".mp3": "audio/mpeg", + ".ogg": "audio/ogg", + ".mp4": "video/mp4", + ".webm": "video/webm", ".blend": "application/octet-stream", ".nvdb": "application/x-nanovdb", })) assert.equal(contract.mimeTypes[extension], mime, `${extension} MIME drifted`); diff --git a/tools/web/check-firefox-capability.mjs b/tools/web/check-firefox-capability.mjs new file mode 100644 index 00000000..c5591a0b --- /dev/null +++ b/tools/web/check-firefox-capability.mjs @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import http from "node:http"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const distRoot = path.join(root, "web/dist"); +const reportPath = path.join(root, "tests/golden/M14-01B/firefox-capability-report.json"); +const manifestPath = path.join(root, "tests/golden/M14-01B/manifest.json"); +const digest = (value) => crypto.createHash("sha256").update(value).digest("hex"); +const fileDigest = (file) => digest(fs.readFileSync(file)); +const mime = new Map([[".html", "text/html; charset=utf-8"], [".js", "text/javascript; charset=utf-8"], [".css", "text/css; charset=utf-8"], [".json", "application/json"], [".wasm", "application/wasm"], [".png", "image/png"], [".woff2", "font/woff2"]]); +const workerName = fs.readdirSync(path.join(distRoot, "assets")).find((name) => /^storage\.worker-[\w-]+\.js$/u.test(name)); +const wasmName = fs.readdirSync(path.join(distRoot, "assets")).find((name) => /^web_engine-[\w-]+\.wasm$/u.test(name)); +assert.ok(workerName && wasmName, "production worker/WASM assets are missing"); +const server = http.createServer((request, response) => { + const pathname = decodeURIComponent(new URL(request.url ?? "/", "http://127.0.0.1").pathname); + const relative = pathname === "/" ? "index.html" : pathname.replace(/^\//u, ""); + const file = path.resolve(distRoot, relative); + if (!file.startsWith(`${distRoot}${path.sep}`) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { response.writeHead(404); response.end("not found"); return; } + response.statusCode = 200; + response.setHeader("Content-Type", mime.get(path.extname(file)) ?? "application/octet-stream"); + response.setHeader("Cross-Origin-Opener-Policy", "same-origin"); + response.setHeader("Cross-Origin-Embedder-Policy", "require-corp"); + response.setHeader("Cross-Origin-Resource-Policy", "same-origin"); + response.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; font-src 'self'; img-src 'self'; media-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"); + fs.createReadStream(file).pipe(response); +}); +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +const address = server.address(); +assert.ok(address && typeof address === "object"); +const origin = `http://127.0.0.1:${address.port}`; +let browser; +try { + const { firefox } = await import(pathToFileURL(path.join(root, "web/node_modules/playwright/index.mjs")).href); + browser = await firefox.launch({ headless: true }); + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto(`${origin}/`, { waitUntil: "load" }); + const probe = await page.evaluate(async ({ workerPath, wasmPath }) => { + const run = async (name, operation) => { + try { return { name, ...await operation() }; } + catch (error) { return { name, status: "BLOCKED", code: error instanceof Error ? error.name : "PROBE_FAILED", detail: error instanceof Error ? error.message.slice(0, 200) : String(error) }; } + }; + const wasm = await run("wasm", async () => { + const bytes = await fetch(wasmPath).then((response) => { if (!response.ok) throw new Error(`HTTP_${response.status}`); return response.arrayBuffer(); }); + await WebAssembly.compile(bytes); + return { status: "PASS", code: "WASM_READY", bytes: bytes.byteLength }; + }); + const worker = await run("worker", async () => { + await new Promise((resolve, reject) => { const value = new Worker(workerPath, { type: "module" }); const timer = setTimeout(() => { value.terminate(); resolve(); }, 500); value.onerror = (event) => { clearTimeout(timer); value.terminate(); reject(new Error(event.message || "WORKER_LOAD_FAILED")); }; }); + return { status: "PASS", code: "WORKER_READY" }; + }); + const opfs = await run("opfs", async () => { + if (!navigator.storage || typeof navigator.storage.getDirectory !== "function") return { status: "BLOCKED", code: "OPFS_UNAVAILABLE" }; + const directory = await navigator.storage.getDirectory(); const probeDirectory = await directory.getDirectoryHandle("m14-firefox-probe", { create: true }); const handle = await probeDirectory.getFileHandle("probe.bin", { create: true }); const writable = await handle.createWritable(); await writable.write(new Uint8Array([1, 2, 3])); await writable.close(); await probeDirectory.removeEntry("probe.bin"); await directory.removeEntry("m14-firefox-probe"); return { status: "PASS", code: "OPFS_READY" }; + }); + const indexeddb = await run("indexedDB", async () => { + if (!indexedDB) return { status: "BLOCKED", code: "INDEXEDDB_UNAVAILABLE" }; + const name = "m14-firefox-probe"; await new Promise((resolve, reject) => { const request = indexedDB.open(name, 1); request.onupgradeneeded = () => request.result.createObjectStore("probe"); request.onsuccess = () => { request.result.close(); resolve(); }; request.onerror = () => reject(request.error ?? new Error("INDEXEDDB_OPEN_FAILED")); }); await new Promise((resolve) => { const request = indexedDB.deleteDatabase(name); request.onsuccess = request.onerror = request.onblocked = () => resolve(); }); return { status: "PASS", code: "INDEXEDDB_READY" }; + }); + const webgl2 = await run("webgl2", async () => { const canvas = document.createElement("canvas"); const gl = canvas.getContext("webgl2"); if (!gl) return { status: "BLOCKED", code: "WEBGL2_UNAVAILABLE" }; const debug = gl.getExtension("WEBGL_debug_renderer_info"); return { status: "PASS", code: "WEBGL2_READY", renderer: debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : "REDACTED" }; }); + const webgpu = await run("webgpu", async () => { if (!("gpu" in navigator)) return { status: "BLOCKED", code: "WEBGPU_UNAVAILABLE" }; const adapter = await navigator.gpu.requestAdapter(); if (!adapter) return { status: "BLOCKED", code: "WEBGPU_ADAPTER_UNAVAILABLE" }; return { status: "PASS", code: "WEBGPU_READY", adapter: adapter.info?.description ?? adapter.name ?? "REDACTED" }; }); + const offscreen = await run("offscreen", async () => { if (typeof OffscreenCanvas !== "function") return { status: "BLOCKED", code: "OFFSCREEN_UNAVAILABLE" }; const canvas = new OffscreenCanvas(2, 2); return { status: "PASS", code: "OFFSCREEN_READY", context2d: Boolean(canvas.getContext("2d")) }; }); + const isolation = { name: "isolation", status: crossOriginIsolated ? "PASS" : "BLOCKED", code: crossOriginIsolated ? "ISOLATION_READY" : "ISOLATION_REQUIRED" }; + return { userAgent: navigator.userAgent, platform: navigator.platform, hardwareConcurrency: navigator.hardwareConcurrency, capabilities: [wasm, worker, opfs, indexeddb, webgl2, webgpu, offscreen, isolation] }; + }, { workerPath: `/assets/${workerName}`, wasmPath: `/assets/${wasmName}` }); + const report = { schemaVersion: 1, task: "M14-01B", operation: "FIREFOX_CAPABILITY_PROBE", runtime: "PLAYWRIGHT_FIREFOX", browser: { version: await browser.version(), executablePath: (await import(pathToFileURL(path.join(root, "web/node_modules/playwright/index.mjs")).href)).firefox.executablePath(), userAgent: probe.userAgent, platform: probe.platform, hardwareConcurrency: probe.hardwareConcurrency }, assets: { worker: { path: `web/dist/assets/${workerName}`, sha256: fileDigest(path.join(distRoot, "assets", workerName)) }, wasm: { path: `web/dist/assets/${wasmName}`, sha256: fileDigest(path.join(distRoot, "assets", wasmName)) } }, capabilities: Object.fromEntries(probe.capabilities.map((item) => [item.name, item])), supportRule: "PASS_ONLY_WHEN_PROBED; BLOCKED_OR_UNAVAILABLE_DOES_NOT_CLAIM_SUPPORT", execution: "DISABLED", nextTask: "M14-01C" }; + if (process.env.UPDATE_M14_01B_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); } + assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M14-01B", parentTask: "M14-01A", nextTask: "M14-01C" }); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileDigest(path.join(root, artifact.path)), artifact.sha256, artifact.path); + const summary = probe.capabilities.map((item) => `${item.name}=${item.status}`).join(","); + process.stdout.write(`firefox-capability-ok version=${report.browser.version} ${summary} execution=DISABLED next=${manifest.nextTask}\n`); +} finally { + await browser?.close(); + await new Promise((resolve) => server.close(resolve)); +} diff --git a/tools/web/check-fuzz-regression.mjs b/tools/web/check-fuzz-regression.mjs new file mode 100644 index 00000000..317fd0c6 --- /dev/null +++ b/tools/web/check-fuzz-regression.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const runner = path.join(root, "tools/web/fuzz-case-runner.mjs"); +const corpusRoot = path.join(root, "tests/files/web/fuzz-regressions"); +const reportPath = path.join(root, "tests/golden/M13-05G/fuzz-report.json"); +const manifestPath = path.join(root, "tests/golden/M13-05G/manifest.json"); +const seed = 0x5a17c0de; +const iterationsPerDomain = 16; +const domains = ["blend", "image", "font", "node", "manifest"]; +const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); +const fileSha256 = (file) => sha256(fs.readFileSync(file)); +fs.mkdirSync(corpusRoot, { recursive: true }); +const outcomes = []; +for (const domain of domains) { + for (let iteration = 0; iteration < iterationsPerDomain; iteration++) { + const run = spawnSync(process.execPath, [runner, domain, String(seed), String(iteration)], { cwd: root, encoding: "utf8", maxBuffer: 2 * 1024 * 1024 }); + if (run.status !== 0 || run.signal) { + const replay = { schemaVersion: 1, domain, seed, iteration, status: run.status, signal: run.signal, stdout: run.stdout, stderr: run.stderr }; + const bytes = Buffer.from(`${JSON.stringify(replay, null, 2)}\n`); + const file = path.join(corpusRoot, `${sha256(bytes)}.json`); + fs.writeFileSync(file, bytes); + throw new Error(`FUZZ_CRASH_SAVED: ${path.relative(root, file)}`); + } + const lines = run.stdout.trim().split(/\r?\n/u).filter(Boolean); + assert.equal(lines.length, 1, `${domain}:${iteration} returned an invalid receipt`); + const receipt = JSON.parse(lines[0]); + assert.ok(["ACCEPTED", "REJECTED"].includes(receipt.status)); + outcomes.push(receipt); + } +} +const corpus = fs.readdirSync(corpusRoot).filter((name) => name.endsWith(".json")).sort().map((name) => ({ path: `tests/files/web/fuzz-regressions/${name}`, sha256: fileSha256(path.join(corpusRoot, name)) })); +const counts = Object.fromEntries(domains.map((domain) => [domain, { accepted: outcomes.filter((item) => item.domain === domain && item.status === "ACCEPTED").length, rejected: outcomes.filter((item) => item.domain === domain && item.status === "REJECTED").length }])); +const report = { schemaVersion: 1, task: "M13-05G", operation: "DETERMINISTIC_FUZZ_REGRESSION", seed, domains, iterationsPerDomain, totalCases: outcomes.length, counts, crashes: 0, minimizedCorpus: corpus, crashPolicy: "SAVE_REPLAY_BEFORE_FIX_AND_ADD_TO_REGRESSION", execution: "DISABLED", nextTask: "M13-05H" }; +if (process.env.UPDATE_M13_05G_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); } +assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report); +const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); +assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-05G", parentTask: "M13-05F", nextTask: "M13-05H" }); +for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileSha256(path.join(root, artifact.path)), artifact.sha256, artifact.path); +process.stdout.write(`fuzz-regression-ok seed=${seed} cases=${outcomes.length} crashes=0 corpus=${corpus.length} execution=DISABLED next=M13-05H\n`); diff --git a/tools/web/check-glb-desktop-fixtures.mjs b/tools/web/check-glb-desktop-fixtures.mjs new file mode 100644 index 00000000..52efa140 --- /dev/null +++ b/tools/web/check-glb-desktop-fixtures.mjs @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +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 { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const manifestPath = path.join(root, "tests/golden/M12-06A/manifest.json"); +const reportPath = path.join(root, "tests/golden/M12-06A/desktop-fixtures.json"); +const fixtureRoot = path.join(root, "tests/files/web/m12_glb_desktop_v1"); +const generator = path.join(root, "tools/web/generate-glb-desktop-fixtures.py"); +const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender"); +const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); +const fileHash = (file) => sha256(fs.readFileSync(file)); +const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8")); + +const manifest = readJson(manifestPath); +const report = readJson(reportPath); +assert.equal(manifest.schemaVersion, 1); +assert.equal(manifest.task, "M12-06A"); +assert.equal(manifest.parentTask, "M12-05F"); +assert.equal(manifest.nextTask, "M12-06B"); +assert.equal(report.schemaVersion, 1); +assert.equal(report.task, "M12-06A"); +assert.equal(report.operation, "DESKTOP_GLB_FIXTURE_GENERATION"); +assert.equal(report.nextTask, "M12-06B"); +assert.equal(report.fixtureCount, 5); +assert.equal(report.maxFixtureBytes, 512 * 1024); + +for (const artifact of Object.values(manifest.artifacts)) { + if (artifact.path === manifestPath) continue; + const absolute = path.join(root, artifact.path); + assert.ok(fs.existsSync(absolute), `missing artifact ${artifact.path}`); + assert.equal(fileHash(absolute), artifact.sha256, `hash mismatch ${artifact.path}`); +} + +const inventory = readJson(path.join(root, "tests/golden/M12-05A/format-inventory.json")); +for (const key of ["blenderVersion", "versionTuple", "buildDate", "buildTime", "buildHash", "buildBranch", "buildPlatform", "buildType", "binarySha256"]) { + assert.deepEqual(report.runtime[key], inventory.runtime[key], `runtime drift in ${key}`); +} + +const expectedIds = ["mesh", "pbr", "uv", "skin", "animation"]; +assert.deepEqual(report.fixtures.map((fixture) => fixture.id), expectedIds); +for (const fixture of report.fixtures) { + assert.ok(fixture.byteLength > 128, `${fixture.id} is unexpectedly empty`); + assert.ok(fixture.byteLength <= report.maxFixtureBytes, `${fixture.id} exceeds fixture budget`); + const file = path.join(fixtureRoot, fixture.file); + assert.equal(fileHash(file), fixture.sha256, `${fixture.id} fixture hash`); + assert.equal(fs.statSync(file).size, fixture.byteLength, `${fixture.id} fixture byte length`); + assert.equal(fixture.semantic.asset.version, "2.0"); + assert.equal(fixture.semantic.extensionsUsed.length, 0, `${fixture.id} unexpectedly uses an extension`); + assert.equal(fixture.semantic.extensionsRequired.length, 0, `${fixture.id} unexpectedly requires an extension`); + assert.equal(fixture.semantic.meshes.length, 1); + assert.equal(fixture.semantic.meshes[0].primitives.length, 1); + const primitive = fixture.semantic.meshes[0].primitives[0]; + assert.equal(primitive.mode, 4, `${fixture.id} is not triangle geometry`); + assert.ok(primitive.attributes.POSITION, `${fixture.id} lacks POSITION`); + assert.ok(primitive.indices, `${fixture.id} lacks indexed topology`); + if (fixture.id === "mesh") { + assert.ok(primitive.attributes.NORMAL); + assert.ok(primitive.attributes.COLOR_0); + assert.equal(primitive.indices.count, 6); + } + if (fixture.id === "pbr") { + assert.equal(fixture.semantic.materials.length, 1); + const pbr = fixture.semantic.materials[0].pbr; + assert.deepEqual(pbr.baseColorFactor.map((value) => Number(value.toFixed(3))), [0.31, 0.57, 0.91, 1]); + assert.equal(Number(pbr.metallicFactor.toFixed(3)), 0.72); + assert.equal(Number(pbr.roughnessFactor.toFixed(3)), 0.28); + assert.ok(fixture.semantic.materials[0].emissiveFactor); + } + if (fixture.id === "uv") { + assert.ok(primitive.attributes.TEXCOORD_0); + assert.equal(fixture.semantic.textures.length, 1); + assert.equal(fixture.semantic.images.length, 1); + assert.equal(fixture.semantic.images[0].mimeType, "image/png"); + } + if (fixture.id === "skin") { + assert.ok(primitive.attributes.JOINTS_0); + assert.ok(primitive.attributes.WEIGHTS_0); + assert.equal(fixture.semantic.skins.length, 1); + assert.equal(fixture.semantic.skins[0].joints.length, 2); + assert.equal(fixture.semantic.skins[0].inverseBindMatrices.count, 2); + } + if (fixture.id === "animation") { + assert.equal(fixture.semantic.animations.length, 1); + assert.equal(fixture.semantic.animations[0].name, "M12 Animation Action"); + assert.deepEqual( + fixture.semantic.animations[0].channels.map((channel) => channel.target.path).sort(), + ["rotation", "translation"], + ); + assert.equal(fixture.semantic.animations[0].samplers[0].input.count, 25); + } +} + +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-06a-glb-")); +try { + const regeneratedReport = path.join(temporary, "desktop-fixtures.json"); + const result = spawnSync(blender, ["-b", "--factory-startup", "--python", generator, "--", temporary, regeneratedReport], { + cwd: root, + encoding: "utf8", + maxBuffer: 20 * 1024 * 1024, + }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.deepEqual(readJson(regeneratedReport), report, "desktop semantic report is not deterministic"); + for (const fixture of report.fixtures) { + assert.deepEqual( + fs.readFileSync(path.join(temporary, fixture.file)), + fs.readFileSync(path.join(fixtureRoot, fixture.file)), + `${fixture.id} GLB bytes are not deterministic`, + ); + } +} finally { + fs.rmSync(temporary, { recursive: true, force: true }); +} + +process.stdout.write( + `glb-desktop-fixtures-ok fixtures=${report.fixtures.length} ` + + `bytes=${report.fixtures.reduce((total, fixture) => total + fixture.byteLength, 0)} ` + + `features=mesh,pbr,uv,skin,animation deterministic=true next=${manifest.nextTask}\n`, +); diff --git a/tools/web/check-glb-desktop-import.mjs b/tools/web/check-glb-desktop-import.mjs new file mode 100644 index 00000000..77826271 --- /dev/null +++ b/tools/web/check-glb-desktop-import.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +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 { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const manifestPath = path.join(root, "tests/golden/M12-06B/manifest.json"); +const reportPath = path.join(root, "tests/golden/M12-06B/web-import-report.json"); +const generator = path.join(root, "tools/web/generate-glb-desktop-import-report.mjs"); +const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); +const fileHash = (file) => sha256(fs.readFileSync(file)); +const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); +const report = JSON.parse(fs.readFileSync(reportPath, "utf8")); + +assert.equal(manifest.schemaVersion, 1); +assert.equal(manifest.task, "M12-06B"); +assert.equal(manifest.parentTask, "M12-06A"); +assert.equal(manifest.nextTask, "M12-06C"); +for (const artifact of Object.values(manifest.artifacts)) { + assert.equal(fileHash(path.join(root, artifact.path)), artifact.sha256, artifact.path); +} +assert.equal(report.schemaVersion, 1); +assert.equal(report.task, "M12-06B"); +assert.equal(report.operation, "WEB_GLB_IMPORT_SEMANTIC_COMPARISON"); +assert.equal(report.parentManifestSha256, fileHash(path.join(root, "tests/golden/M12-06A/manifest.json"))); +assert.equal(report.fixtureReportSha256, fileHash(path.join(root, "tests/golden/M12-06A/desktop-fixtures.json"))); +assert.equal(report.fixtureCount, 5); +assert.deepEqual(report.comparedDomains, ["topology", "attributes", "materials", "nodes", "animations"]); +assert.equal(report.allCompatible, true); +assert.equal(report.routeState, "BLOCKED_UNTIL_MAIN_PERSISTENCE"); +assert.equal(report.nextTask, "M12-06C"); +assert.deepEqual(report.comparisons.map((item) => item.id), ["mesh", "pbr", "uv", "skin", "animation"]); +assert(report.comparisons.every((item) => item.compatible && item.mismatchCount === 0 && item.desktopSemanticSha256 === item.webSemanticSha256)); +assert.deepEqual(report.comparisons.find((item) => item.id === "mesh").attributes, ["COLOR_0", "NORMAL", "POSITION"]); +assert.equal(report.comparisons.find((item) => item.id === "pbr").materials.pbrCount, 1); +assert.equal(report.comparisons.find((item) => item.id === "uv").materials.texturedCount, 1); +assert.equal(report.comparisons.find((item) => item.id === "skin").nodes.skinnedCount, 1); +assert.deepEqual(report.comparisons.find((item) => item.id === "animation").animations.channelPaths, ["rotation", "translation"]); + +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-06b-import-check-")); +try { + const regenerated = path.join(temporary, "web-import-report.json"); + const result = spawnSync(process.execPath, [generator, regenerated], { cwd: root, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.deepEqual(fs.readFileSync(regenerated), fs.readFileSync(reportPath), "Web import report is not deterministic"); +} +finally { + fs.rmSync(temporary, { recursive: true, force: true }); +} + +process.stdout.write(`glb-desktop-import-ok fixtures=${report.fixtureCount} domains=${report.comparedDomains.length} compatible=${report.allCompatible} route=${report.routeState} next=${manifest.nextTask}\n`); diff --git a/tools/web/check-glb-loss-report.mjs b/tools/web/check-glb-loss-report.mjs new file mode 100644 index 00000000..2d2af733 --- /dev/null +++ b/tools/web/check-glb-loss-report.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-06D/manifest.json"), "utf8")); +const report = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-06D/web-loss-report.json"), "utf8")); +const parent = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-06C/desktop-main-report.json"), "utf8")); +const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); +const fileHash = (file) => sha256(fs.readFileSync(file)); + +assert.deepEqual( + { schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, + { schemaVersion: 1, task: "M12-06D", parentTask: "M12-06C", nextTask: "M12-06E" }, +); +assert.deepEqual( + { schemaVersion: report.schemaVersion, task: report.task, operation: report.operation, fixtureCount: report.fixtureCount, nextTask: report.nextTask }, + { schemaVersion: 1, task: "M12-06D", operation: "WEB_GLB_EXPORT_LOSS_REPORT", fixtureCount: 5, nextTask: "M12-06E" }, +); +assert.equal(fileHash(path.join(root, "tests/golden/M12-06C/manifest.json")), manifest.artifacts.parentManifest.sha256); +for (const artifact of Object.values(manifest.artifacts)) { + const file = path.join(root, artifact.path); + assert.ok(fs.existsSync(file), `missing artifact ${artifact.path}`); + assert.equal(fileHash(file), artifact.sha256, `hash mismatch ${artifact.path}`); +} + +const expectedIds = ["mesh", "pbr", "uv", "skin", "animation"]; +assert.deepEqual(report.fixtures.map((fixture) => fixture.fixtureId), expectedIds); +for (const fixture of report.fixtures) { + const source = parent.fixtures.find((candidate) => candidate.id === fixture.fixtureId); + assert.ok(source, `${fixture.fixtureId} is absent from M12-06C`); + assert.equal(fixture.sourceBlendSha256, source.blend.sha256, `${fixture.fixtureId} source blend drift`); + const losses = fixture.lossReport.losses; + assert.deepEqual(losses, [...losses].sort((left, right) => + left.code.localeCompare(right.code) || left.severity.localeCompare(right.severity) || + (left.id ?? "").localeCompare(right.id ?? "") || left.message.localeCompare(right.message))); + assert.equal(fixture.lossReport.errorCount, losses.filter((loss) => loss.severity === "error").length); + assert.equal(fixture.lossReport.warningCount, losses.filter((loss) => loss.severity === "warning").length); + if (fixture.fixtureId === "mesh") { + assert.equal(fixture.lossReport.canExport, false); + assert.deepEqual(losses.map((loss) => loss.code), ["LINKED_MATERIAL_INPUT_UNEVALUATED", "SHADER_GRAPH_UNMAPPABLE"]); + assert.equal(fixture.output, null); + } + else { + assert.equal(fixture.lossReport.canExport, true); + assert.equal(fixture.lossReport.errorCount, 0); + assert.ok(fixture.output?.byteLength > 128); + assert.match(fixture.output.sha256, /^[0-9a-f]{64}$/); + } +} + +process.stdout.write(`glb-loss-report-ok fixtures=${report.fixtureCount} blocked=mesh warnings=${report.fixtures.reduce((sum, fixture) => sum + fixture.lossReport.warningCount, 0)} deterministic=true next=${manifest.nextTask}\n`); diff --git a/tools/web/check-glb-main-persistence.mjs b/tools/web/check-glb-main-persistence.mjs new file mode 100644 index 00000000..f758ab71 --- /dev/null +++ b/tools/web/check-glb-main-persistence.mjs @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +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 { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const manifestPath = path.join(root, "tests/golden/M12-06C/manifest.json"); +const reportPath = path.join(root, "tests/golden/M12-06C/desktop-main-report.json"); +const fixtureReportPath = path.join(root, "tests/golden/M12-06A/desktop-fixtures.json"); +const fixtureRoot = path.join(root, "tests/files/web/m12_glb_desktop_v1"); +const generator = path.join(root, "tools/web/generate-glb-main-persistence-fixtures.py"); +const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender"); + +const read = (file) => fs.readFileSync(file); +const readJson = (file) => JSON.parse(read(file)); +const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); +const fileHash = (file) => sha256(read(file)); + +const manifest = readJson(manifestPath); +const report = readJson(reportPath); +const fixtureReport = readJson(fixtureReportPath); + +assert.deepEqual( + { schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, + { schemaVersion: 1, task: "M12-06C", parentTask: "M12-06B", nextTask: "M12-06D" }, +); +assert.deepEqual( + { schemaVersion: report.schemaVersion, task: report.task, operation: report.operation, fixtureCount: report.fixtureCount, nextTask: report.nextTask }, + { schemaVersion: 1, task: "M12-06C", operation: "DESKTOP_GLB_IMPORT_MAIN_PERSISTENCE_BASELINE", fixtureCount: 5, nextTask: "M12-06D" }, +); +assert.equal(fileHash(path.join(root, "tests/golden/M12-06B/manifest.json")), manifest.artifacts.parentManifest.sha256); + +for (const artifact of Object.values(manifest.artifacts)) { + const file = path.join(root, artifact.path); + assert.ok(fs.existsSync(file), `missing M12-06C artifact ${artifact.path}`); + assert.equal(fileHash(file), artifact.sha256, `hash mismatch ${artifact.path}`); +} + +const fixtureById = new Map(fixtureReport.fixtures.map((fixture) => [fixture.id, fixture])); +assert.deepEqual(report.fixtures.map((fixture) => fixture.id), ["mesh", "pbr", "uv", "skin", "animation"]); +for (const fixture of report.fixtures) { + const source = fixtureById.get(fixture.id); + assert.ok(source, `${fixture.id} is absent from M12-06A`); + assert.equal(fixture.glb.sha256, source.sha256, `${fixture.id} GLB source drift`); + assert.equal(fileHash(path.join(fixtureRoot, fixture.glb.file)), fixture.glb.sha256, `${fixture.id} GLB hash`); + const blend = path.join(fixtureRoot.replace("m12_glb_desktop_v1", "m12_glb_main_v1"), fixture.blend.file); + assert.equal(fs.statSync(blend).size, fixture.blend.byteLength, `${fixture.id} desktop Main fixture byte length`); + assert.equal(fileHash(blend), fixture.blend.sha256, `${fixture.id} desktop Main fixture hash`); + assert.deepEqual(fixture.graph.stableIds, { + objects: [...fixture.graph.stableIds.objects].sort(), + meshes: [...fixture.graph.stableIds.meshes].sort(), + materials: [...fixture.graph.stableIds.materials].sort(), + images: [...fixture.graph.stableIds.images].sort(), + armatures: [...fixture.graph.stableIds.armatures].sort(), + actions: [...fixture.graph.stableIds.actions].sort(), + }); + for (const [kind, ids] of Object.entries(fixture.graph.stableIds)) { + assert.equal(new Set(ids).size, ids.length, `${fixture.id} duplicate ${kind} stable ID`); + const prefix = { objects: "object:", meshes: "mesh:", materials: "material:", images: "image:", armatures: "armature:", actions: "action:" }[kind]; + assert.ok(ids.every((id) => id.startsWith(prefix)), `${fixture.id} malformed ${kind} stable ID`); + } +} + +const normalizeReport = (value) => ({ + blenderVersion: value.blenderVersion, + fixtureCount: value.fixtureCount, + nextTask: value.nextTask, + operation: value.operation, + schemaVersion: value.schemaVersion, + fixtures: value.fixtures.map((fixture) => ({ + id: fixture.id, + glb: fixture.glb, + graph: fixture.graph, + })), +}); + +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-06c-main-persistence-")); +try { + const outputRoot = path.join(temporary, "main"); + const regeneratedReport = path.join(temporary, "desktop-main-report.json"); + const result = spawnSync(blender, ["-b", "--factory-startup", "--python", generator, "--", fixtureRoot, outputRoot, regeneratedReport], { + cwd: root, + encoding: "utf8", + maxBuffer: 20 * 1024 * 1024, + }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + const regenerated = readJson(regeneratedReport); + assert.deepEqual(normalizeReport(regenerated), normalizeReport(report), "desktop Main semantic report is not deterministic"); + for (const fixture of regenerated.fixtures) { + assert.ok(fs.statSync(path.join(outputRoot, fixture.blend.file)).size > 0, `${fixture.id} regenerated .blend is empty`); + assert.deepEqual(fixture.graph.stableIds, report.fixtures.find((item) => item.id === fixture.id).graph.stableIds, `${fixture.id} stable IDs drifted`); + } +} finally { + fs.rmSync(temporary, { recursive: true, force: true }); +} + +process.stdout.write(`glb-main-persistence-ok fixtures=${report.fixtureCount} stableIds=exact desktopReopen=exact deterministic=true next=${manifest.nextTask}\n`); diff --git a/tools/web/check-glb-negative-cases.mjs b/tools/web/check-glb-negative-cases.mjs new file mode 100644 index 00000000..eab6022c --- /dev/null +++ b/tools/web/check-glb-negative-cases.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-06F/manifest.json"), "utf8")); +const report = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-06F/negative-report.json"), "utf8")); +const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); +const fileHash = (file) => sha256(fs.readFileSync(file)); + +assert.deepEqual( + { schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, + { schemaVersion: 1, task: "M12-06F", parentTask: "M12-06E", nextTask: "M12-06G" }, +); +assert.deepEqual(report, { + schemaVersion: 1, + task: "M12-06F", + operation: "GLB_IMPORT_NEGATIVE_CASES", + budget: { maxBytes: 524288, maxJsonBytes: 262144, maxBufferViews: 4096, maxAccessors: 8192 }, + cases: [ + { 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" }, + ], + nextTask: "M12-06G", +}); +for (const artifact of Object.values(manifest.artifacts)) { + const file = path.join(root, artifact.path); + assert.ok(fs.existsSync(file), `missing artifact ${artifact.path}`); + assert.equal(fileHash(file), artifact.sha256, `hash mismatch ${artifact.path}`); +} +process.stdout.write(`glb-negative-cases-ok cases=${report.cases.length} budget=${report.budget.maxBytes} deterministic=true next=${manifest.nextTask}\n`); diff --git a/tools/web/check-glb-recovery.mjs b/tools/web/check-glb-recovery.mjs new file mode 100644 index 00000000..95dc1cbd --- /dev/null +++ b/tools/web/check-glb-recovery.mjs @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-06G/manifest.json"), "utf8")); +const report = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-06G/recovery-report.json"), "utf8")); +const fileHash = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); + +assert.deepEqual( + { schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, + { schemaVersion: 1, task: "M12-06G", parentTask: "M12-06F", nextTask: "M12-07A" }, +); +assert.deepEqual( + { schemaVersion: report.schemaVersion, task: report.task, operation: report.operation, nextTask: report.nextTask }, + { schemaVersion: 1, task: "M12-06G", operation: "GLB_IMPORT_EXPORT_RECOVERY", nextTask: "M12-07A" }, +); +assert.deepEqual(report.cancellation.map((item) => ({ operation: item.operation, status: item.status, code: item.code })), [ + { operation: "IMPORT", status: "CANCELLED", code: "GLB_OPERATION_CANCELLED" }, + { operation: "EXPORT", status: "CANCELLED", code: "GLB_OPERATION_CANCELLED" }, +]); +assert.ok(report.cancellation.every((item) => item.committed === false && item.temporaryBytes === 0 && item.liveRequests === 0)); +assert.deepEqual(report.workerRestart, { + operation: "IMPORT", + generationBefore: 1, + generationAfter: 2, + status: "RECOVERED", + code: "GLB_WORKER_RESTARTED", + resultHash: "EXACT", +}); +assert.deepEqual(report.opfsQuota, { + operation: "EXPORT_ASSET_COMMIT", + status: "BLOCKED", + code: "GLB_OPFS_QUOTA", + backend: "OPFS", + committedAssetPreserved: true, + workerRestart: true, + smallAssetRecovery: true, +}); +for (const artifact of Object.values(manifest.artifacts)) { + const file = path.join(root, artifact.path); + assert.ok(fs.existsSync(file), `missing artifact ${artifact.path}`); + assert.equal(fileHash(file), artifact.sha256, `hash mismatch ${artifact.path}`); +} +process.stdout.write(`glb-recovery-ok cancelled=${report.cancellation.length} workerGeneration=${report.workerRestart.generationAfter} quota=${report.opfsQuota.code} smallRecovery=${report.opfsQuota.smallAssetRecovery} next=${manifest.nextTask}\n`); diff --git a/tools/web/check-glb-web-reimport.mjs b/tools/web/check-glb-web-reimport.mjs new file mode 100644 index 00000000..c5bd45c7 --- /dev/null +++ b/tools/web/check-glb-web-reimport.mjs @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +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 { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const manifestPath = path.join(root, "tests/golden/M12-06E/manifest.json"); +const reportPath = path.join(root, "tests/golden/M12-06E/desktop-reimport-report.json"); +const generator = path.join(root, "tools/web/generate-glb-web-reimport-report.py"); +const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender"); +const read = (file) => fs.readFileSync(file); +const readJson = (file) => JSON.parse(read(file)); +const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); +const fileHash = (file) => sha256(read(file)); + +const manifest = readJson(manifestPath); +const report = readJson(reportPath); +const base = readJson(path.join(root, "tests/golden/M12-06C/desktop-main-report.json")); +const exportReport = readJson(path.join(root, "tests/golden/M12-06D/web-loss-report.json")); + +assert.deepEqual( + { schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, + { schemaVersion: 1, task: "M12-06E", parentTask: "M12-06D", nextTask: "M12-06F" }, +); +assert.deepEqual( + { schemaVersion: report.schemaVersion, task: report.task, operation: report.operation, fixtureCount: report.fixtureCount, nextTask: report.nextTask }, + { schemaVersion: 1, task: "M12-06E", operation: "DESKTOP_REIMPORT_WEB_GLB_CANONICAL_REPORT", fixtureCount: 4, nextTask: "M12-06F" }, +); +assert.equal(fileHash(path.join(root, "tests/golden/M12-06D/manifest.json")), manifest.artifacts.parentManifest.sha256); +for (const artifact of Object.values(manifest.artifacts)) { + const file = path.join(root, artifact.path); + assert.ok(fs.existsSync(file), `missing artifact ${artifact.path}`); + assert.equal(fileHash(file), artifact.sha256, `hash mismatch ${artifact.path}`); +} + +const expectedIds = ["pbr", "uv", "skin", "animation"]; +assert.deepEqual(report.fixtures.map((fixture) => fixture.id), expectedIds); +for (const fixture of report.fixtures) { + const exportFixture = exportReport.fixtures.find((candidate) => candidate.fixtureId === fixture.id); + assert.ok(exportFixture?.output, `${fixture.id} has no Web GLB output`); + assert.equal(fixture.glb.sha256, exportFixture.output.sha256, `${fixture.id} Web GLB hash drift`); + assert.equal(fileHash(path.join(root, "tests/files/web/m12_glb_web_v1", fixture.glb.file)), fixture.glb.sha256); +} + +function mismatches(expected, actual, pathName = "graph", output = []) { + if (Object.is(expected, actual)) return output; + if (Array.isArray(expected) || Array.isArray(actual)) { + if (!Array.isArray(expected) || !Array.isArray(actual)) { output.push(pathName); return output; } + if (expected.length !== actual.length) output.push(`${pathName}.length`); + for (let index = 0; index < Math.max(expected.length, actual.length); index++) { + if (index >= expected.length || index >= actual.length) output.push(`${pathName}[${index}]`); + else mismatches(expected[index], actual[index], `${pathName}[${index}]`, output); + } + return output; + } + if (expected && actual && typeof expected === "object" && typeof actual === "object") { + for (const key of new Set([...Object.keys(expected), ...Object.keys(actual)])) mismatches(expected[key], actual[key], `${pathName}.${key}`, output); + return output; + } + output.push(pathName); + return output; +} + +const comparisons = report.fixtures.map((fixture) => { + const baseline = base.fixtures.find((candidate) => candidate.id === fixture.id); + assert.ok(baseline, `${fixture.id} missing M12-06C baseline`); + const paths = mismatches(baseline.graph, fixture.graph); + return { id: fixture.id, exact: paths.length === 0, mismatchCount: paths.length, mismatchPaths: paths }; +}); +assert.deepEqual(comparisons.map((comparison) => ({ id: comparison.id, exact: comparison.exact, mismatchCount: comparison.mismatchCount })), [ + { id: "pbr", exact: true, mismatchCount: 0 }, + { id: "uv", exact: false, mismatchCount: 4 }, + { id: "skin", exact: false, mismatchCount: 31 }, + { id: "animation", exact: true, mismatchCount: 0 }, +]); + +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-06e-web-reimport-")); +try { + const regenerated = path.join(temporary, "desktop-reimport-report.json"); + const result = spawnSync(blender, ["-b", "--factory-startup", "--python", generator, "--", path.join(root, "tests/files/web/m12_glb_web_v1"), path.join(temporary, "blend"), regenerated], { + cwd: root, + encoding: "utf8", + maxBuffer: 20 * 1024 * 1024, + }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.deepEqual(readJson(regenerated), report, "desktop Web GLB report is not deterministic"); +} finally { + fs.rmSync(temporary, { recursive: true, force: true }); +} + +process.stdout.write(`glb-web-reimport-ok fixtures=${report.fixtureCount} exact=${comparisons.filter((comparison) => comparison.exact).length} mismatched=${comparisons.filter((comparison) => !comparison.exact).map((comparison) => comparison.id).join(",")} deterministic=true next=${manifest.nextTask}\n`); diff --git a/tools/web/check-io-format-capability-matrix.mjs b/tools/web/check-io-format-capability-matrix.mjs new file mode 100644 index 00000000..e02bc096 --- /dev/null +++ b/tools/web/check-io-format-capability-matrix.mjs @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import ts from "../../web/node_modules/typescript/lib/typescript.js"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const matrixPath = path.join(repoRoot, "tests/golden/M12-05B/capability-matrix.json"); +const inventoryPath = path.join(repoRoot, "tests/golden/M12-05A/format-inventory.json"); +const evidencePath = path.join(repoRoot, "tests/golden/M12-05B/manifest.json"); +const sourcePath = path.join(repoRoot, "web/protocol/io-format-capability-matrix.ts"); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "io-format-capability-matrix-")); + +const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); +const fileSha256 = (file) => sha256(fs.readFileSync(file)); + +try { + const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, + }); + assert.deepEqual(transpiled.diagnostics, []); + const modulePath = path.join(temporary, "io-format-capability-matrix.mjs"); + fs.writeFileSync(modulePath, transpiled.outputText); + const protocol = await import(pathToFileURL(modulePath)); + const evidence = JSON.parse(fs.readFileSync(evidencePath, "utf8")); + assert.equal(evidence.task, "M12-05B"); + assert.equal(evidence.parentTask, "M12-05A"); + assert.equal(evidence.nextTask, "M12-05C"); + for (const artifact of Object.values(evidence.artifacts)) assert.equal(fileSha256(path.join(repoRoot, artifact.path)), artifact.sha256, artifact.path); + + const inventoryBytes = fs.readFileSync(inventoryPath); + const matrixBytes = fs.readFileSync(matrixPath); + const matrix = JSON.parse(matrixBytes); + assert.equal(matrix.runtimeInventorySha256, sha256(inventoryBytes)); + const inventory = JSON.parse(inventoryBytes); + const parsed = protocol.parseIOFormatCapabilityMatrix(matrix); + assert.deepEqual(parsed.formats.map((entry) => entry.format), inventory.formats.map((entry) => entry.format)); + const runtimeByFormat = new Map(inventory.formats.map((entry) => [entry.format, entry])); + for (const entry of parsed.formats) { + const runtime = runtimeByFormat.get(entry.format); + assert.equal(entry.runtimeImportStatus, runtime.import.runtimeStatus === "AVAILABLE" ? "AVAILABLE" : "OPERATOR_UNREGISTERED"); + assert.equal(entry.runtimeExportStatus, runtime.export.runtimeStatus === "AVAILABLE" ? "AVAILABLE" : "OPERATOR_UNREGISTERED"); + assert.equal(entry.operations.EXPORT.local.status, entry.format === "GLB" ? "READY" : "BLOCKED"); + assert.equal(entry.operations.EXPORT.local.execution, entry.format === "GLB" ? "LOCAL" : "NONE"); + assert.equal(entry.operations.EXPORT.server.status, "BLOCKED"); + assert.equal(entry.operations.IMPORT.local.status, "BLOCKED"); + assert.equal(entry.operations.IMPORT.server.status, "BLOCKED"); + } + + const regenerated = path.join(temporary, "capability-matrix.json"); + execFileSync(process.execPath, [path.join(repoRoot, "tools/web/generate-io-format-capability-matrix.mjs"), "--output", regenerated], { cwd: repoRoot }); + assert.deepEqual(fs.readFileSync(regenerated), matrixBytes, "capability matrix is not deterministic"); + process.stdout.write("io-format-capability-matrix-ok formats=7 local-glb-export=1 blocked-routes=27 runtime-bound=true\n"); +} +finally { + fs.rmSync(temporary, { recursive: true, force: true }); +} diff --git a/tools/web/check-io-format-receipt-bindings.mjs b/tools/web/check-io-format-receipt-bindings.mjs new file mode 100644 index 00000000..4990614b --- /dev/null +++ b/tools/web/check-io-format-receipt-bindings.mjs @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import ts from "../../web/node_modules/typescript/lib/typescript.js"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const inventoryPath = path.join(root, "tests/golden/M12-05A/format-inventory.json"); +const parentPath = path.join(root, "tests/golden/M12-05D/runtime-receipts.json"); +const boundPath = path.join(root, "tests/golden/M12-05E/bound-runtime-receipts.json"); +const protocolPath = path.join(root, "web/protocol/io-format-receipt-binding.ts"); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "io-format-receipt-bindings-")); +const hashBytes = (value) => crypto.createHash("sha256").update(value).digest("hex"); +const canonical = (value) => value === null || typeof value !== "object" ? JSON.stringify(value) : Array.isArray(value) ? `[${value.map(canonical).join(",")}]` : `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`; +const hash = (value) => hashBytes(canonical(value)); +try { + const inventoryBytes = fs.readFileSync(inventoryPath); const parentBytes = fs.readFileSync(parentPath); const boundBytes = fs.readFileSync(boundPath); + const inventory = JSON.parse(inventoryBytes); const parent = JSON.parse(parentBytes); const bound = JSON.parse(boundBytes); + const transpiled = ts.transpileModule(fs.readFileSync(protocolPath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: protocolPath, reportDiagnostics: true }); + assert.deepEqual(transpiled.diagnostics, []); const modulePath = path.join(temporary, "protocol.mjs"); fs.writeFileSync(modulePath, transpiled.outputText); const protocol = await import(pathToFileURL(modulePath)); + const parsed = protocol.validateIOFormatBoundReceiptSet(bound, hashBytes(parentBytes), hashBytes(inventoryBytes)); + assert.equal(parsed.receipts.length, 14); + const runtimeSha256 = hash(inventory.runtime); + for (const receipt of parsed.receipts) { + const source = inventory.formats.find((entry) => entry.format === receipt.format)[receipt.operation.toLowerCase()]; + assert.equal(receipt.sourceSha256, hash({ format: receipt.format, family: receipt.family, operation: receipt.operation, operator: receipt.operator, registered: receipt.registered, rnaIdentifier: receipt.rnaIdentifier })); + assert.equal(receipt.settingsSha256, hash({ buildOption: receipt.buildOption, buildOptionEnabled: receipt.buildOptionEnabled, variants: receipt.variants, extensions: receipt.extensions, properties: source.properties })); + assert.equal(receipt.runtimeSha256, runtimeSha256); + } + const regenerated = path.join(temporary, "bound-runtime-receipts.json"); execFileSync(process.execPath, [path.join(root, "tools/web/generate-io-format-receipt-bindings.mjs"), "--output", regenerated], { cwd: root }); assert.deepEqual(fs.readFileSync(regenerated), boundBytes); + process.stdout.write("io-format-receipt-bindings-ok receipts=14 source-settings-runtime=bound deterministic=true\n"); +} +finally { fs.rmSync(temporary, { recursive: true, force: true }); } diff --git a/tools/web/check-io-format-receipt-freshness.mjs b/tools/web/check-io-format-receipt-freshness.mjs new file mode 100644 index 00000000..085ada8d --- /dev/null +++ b/tools/web/check-io-format-receipt-freshness.mjs @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import ts from "../../web/node_modules/typescript/lib/typescript.js"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const parentPath = path.join(repoRoot, "tests/golden/M12-05E/bound-runtime-receipts.json"); +const freshPath = path.join(repoRoot, "tests/golden/M12-05F/fresh-runtime-receipts.json"); +const appFreshPath = path.join(repoRoot, "web/app/src/capabilities/io-format-runtime-receipts-freshness.json"); +const appExpectedPath = path.join(repoRoot, "web/app/src/capabilities/io-format-runtime-receipts-freshness-expected.json"); +const appPath = path.join(repoRoot, "web/app/src/app/App.tsx"); +const protocolPath = path.join(repoRoot, "web/protocol/io-format-receipt-freshness.ts"); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "io-format-receipt-freshness-")); +const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); +const stableValue = (value) => Array.isArray(value) ? value.map(stableValue) : value && typeof value === "object" ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])])) : value; +const stableSha256 = (value) => sha256(JSON.stringify(stableValue(value))); + +function transpile(sourcePath, outputName, replacements = []) { + const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true }); + assert.deepEqual(transpiled.diagnostics, []); + let output = transpiled.outputText; + for (const [from, to] of replacements) output = output.replaceAll(from, to); + const outputPath = path.join(temporary, outputName); + fs.writeFileSync(outputPath, output); + return outputPath; +} + +try { + const parentBytes = fs.readFileSync(parentPath); + const bound = JSON.parse(parentBytes); + const freshBytes = fs.readFileSync(freshPath); + const fresh = JSON.parse(freshBytes); + assert.deepEqual(fs.readFileSync(appFreshPath), freshBytes, "App freshness receipt drifted from golden"); + const appExpected = JSON.parse(fs.readFileSync(appExpectedPath)); + const runtimeModule = transpile(path.join(repoRoot, "web/protocol/io-format-runtime-receipt.ts"), "io-format-runtime-receipt.mjs"); + const bindingModule = transpile(path.join(repoRoot, "web/protocol/io-format-receipt-binding.ts"), "io-format-receipt-binding.mjs"); + const protocolModule = transpile(protocolPath, "io-format-receipt-freshness.mjs", [["./io-format-receipt-binding\"", "./io-format-receipt-binding.mjs\""], ["./io-format-runtime-receipt\"", "./io-format-runtime-receipt.mjs\""]]); + const protocol = await import(pathToFileURL(protocolModule)); + const expected = { + parentBindingSha256: sha256(parentBytes), + parentReceiptSetSha256: bound.parentReceiptSetSha256, + inventorySha256: bound.inventorySha256, + boundReceiptSetSha256: stableSha256(bound), + runtimeSha256: stableSha256(bound.runtime), + runtime: bound.runtime, + receiptIdentities: bound.receipts, + }; + assert.equal(fresh.parentBindingSha256, expected.parentBindingSha256); + assert.equal(fresh.boundReceiptSetSha256, expected.boundReceiptSetSha256); + assert.equal(fresh.runtimeSha256, expected.runtimeSha256); + assert.deepEqual(appExpected, { + parentBindingSha256: expected.parentBindingSha256, + parentReceiptSetSha256: expected.parentReceiptSetSha256, + inventorySha256: expected.inventorySha256, + boundReceiptSetSha256: expected.boundReceiptSetSha256, + runtimeSha256: expected.runtimeSha256, + runtime: expected.runtime, + receiptIdentities: expected.receiptIdentities, + }); + const appSource = fs.readFileSync(appPath, "utf8"); + assert.match(appSource, /resolveFreshIOFormatRuntimeRoute\(IO_FORMAT_RUNTIME_RECEIPTS, IO_FORMAT_RUNTIME_RECEIPT_EXPECTED/); + await protocol.verifyIOFormatReceiptFreshness(fresh, expected); + assert.equal(protocol.resolveFreshIOFormatRuntimeRoute(fresh, expected, { format: "GLB", operation: "EXPORT" }).status, "READY"); + assert.equal(protocol.resolveFreshIOFormatRuntimeRoute(fresh, expected, { format: "USD", operation: "EXPORT" }).status, "BLOCKED"); + + const forged = structuredClone(fresh); + forged.bound.receipts[0].operator = "forged.operator"; + await assert.rejects(() => protocol.verifyIOFormatReceiptFreshness(forged, expected), (error) => error.reason === "RECEIPT_FORGED"); + const stale = structuredClone(fresh); + stale.bound.inventorySha256 = "a".repeat(64); + assert.equal(protocol.resolveFreshIOFormatRuntimeRoute(stale, expected, { format: "GLB", operation: "EXPORT" }).reason, "RECEIPT_STALE"); + const crossVersion = structuredClone(fresh); + crossVersion.bound.runtime.versionTuple = [5, 3, 0]; + assert.equal(protocol.resolveFreshIOFormatRuntimeRoute(crossVersion, expected, { format: "GLB", operation: "EXPORT" }).reason, "RECEIPT_CROSS_VERSION"); + + const regenerated = path.join(temporary, "fresh-runtime-receipts.json"); + execFileSync(process.execPath, [path.join(repoRoot, "tools/web/generate-io-format-receipt-freshness.mjs"), "--output", regenerated], { cwd: repoRoot }); + assert.deepEqual(fs.readFileSync(regenerated), freshBytes, "freshness receipt is not deterministic"); + process.stdout.write("io-format-receipt-freshness-ok receipts=14 forged=BLOCKED stale=BLOCKED cross-version=BLOCKED deterministic=true\n"); +} +finally { + fs.rmSync(temporary, { recursive: true, force: true }); +} diff --git a/tools/web/check-io-format-recovery.mjs b/tools/web/check-io-format-recovery.mjs new file mode 100644 index 00000000..97088488 --- /dev/null +++ b/tools/web/check-io-format-recovery.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const reportPath = path.join(root, "tests/golden/M12-07J/io-format-recovery-report.json"); +const manifestPath = path.join(root, "tests/golden/M12-07J/manifest.json"); +const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8")); +const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); +const report = readJson(reportPath); +const manifest = readJson(manifestPath); +assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M12-07J", parentTask: "M12-07I", nextTask: "M13-01A" }); +assert.deepEqual({ schemaVersion: report.schemaVersion, task: report.task, operation: report.operation, nextTask: report.nextTask }, { schemaVersion: 1, task: "M12-07J", operation: "IO_FORMAT_THREE_WAY_RECOVERY", nextTask: "M13-01A" }); +assert.deepEqual(report.assertions, { formats: ["OBJ", "STL", "PLY"], cancellationUnpublished: true, oomUnpublished: true, restartHashStable: true, smallRecoveryStable: true }); +for (const format of ["OBJ", "STL", "PLY"]) { + const value = report.formats[format]; + assert.equal(value.cancel.status, "CANCELLED"); assert.equal(value.cancel.errorCode, "IO_FORMAT_OPERATION_CANCELLED"); assert.equal(value.cancel.publishedResults, 0); + assert.equal(value.oom.status, "BLOCKED"); assert.equal(value.oom.errorCode, "IO_FORMAT_OOM"); assert.equal(value.oom.publishedResults, 0); + assert.equal(value.first.status, "COMMITTED"); assert.equal(value.second.status, "COMMITTED"); assert.equal(value.small.status, "COMMITTED"); + assert.equal(value.recovered.status, "RECOVERED"); assert.equal(value.recovered.errorCode, "IO_FORMAT_WORKER_RESTARTED"); + assert.equal(value.hashes.first, value.hashes.second); assert.equal(value.hashes.first, value.hashes.small); + assert.equal(value.first.outputSha256, value.recovered.outputSha256); +} +for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(fs.readFileSync(path.join(root, artifact.path))), artifact.sha256, `artifact hash mismatch ${artifact.path}`); +process.stdout.write(`io-format-recovery-ok formats=OBJ,STL,PLY cancelled=3 oom=3 restart=3 smallRecovery=3 deterministic=true next=${manifest.nextTask}\n`); diff --git a/tools/web/check-io-format-runtime-inventory.mjs b/tools/web/check-io-format-runtime-inventory.mjs new file mode 100644 index 00000000..bcbf5a47 --- /dev/null +++ b/tools/web/check-io-format-runtime-inventory.mjs @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const generator = path.join(repoRoot, "tools/web/generate-io-format-runtime-inventory.py"); +const expectedInventory = path.join(repoRoot, "tests/golden/M12-05A/format-inventory.json"); +const evidencePath = path.join(repoRoot, "tests/golden/M12-05A/manifest.json"); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "io-format-runtime-inventory-")); +const regenerated = path.join(temporary, "format-inventory.json"); + +const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); +const fileSha256 = (file) => sha256(fs.readFileSync(file)); + +try { + const evidence = JSON.parse(fs.readFileSync(evidencePath, "utf8")); + assert.equal(evidence.schemaVersion, 1); + assert.equal(evidence.task, "M12-05A"); + assert.equal(evidence.parentTask, "M12-04J"); + assert.equal(evidence.nextTask, "M12-05B"); + for (const artifact of Object.values(evidence.artifacts)) assert.equal(fileSha256(path.join(repoRoot, artifact.path)), artifact.sha256, artifact.path); + + const blender = process.env.BLENDER_BIN ?? path.join(repoRoot, "build_blender_5.2.0/bin/blender"); + const run = spawnSync(blender, ["-b", "--factory-startup", "--python", generator, "--", regenerated], { + cwd: repoRoot, + encoding: "utf8", + maxBuffer: 8 * 1024 * 1024, + }); + assert.equal(run.status, 0, `${run.stdout ?? ""}\n${run.stderr ?? ""}`); + const expectedBytes = fs.readFileSync(expectedInventory); + assert.deepEqual(fs.readFileSync(regenerated), expectedBytes, "Blender runtime inventory is not deterministic"); + + const inventory = JSON.parse(expectedBytes); + assert.deepEqual(inventory, JSON.parse(fs.readFileSync(regenerated, "utf8"))); + assert.equal(inventory.schemaVersion, 1); + assert.equal(inventory.task, "M12-05A"); + assert.deepEqual(inventory.runtime.versionTuple, [5, 2, 0]); + assert.equal(inventory.runtime.blenderVersion, "5.2.0 LTS"); + assert.match(inventory.runtime.binarySha256, /^[a-f0-9]{64}$/); + assert.equal(inventory.formats.length, 7); + assert.deepEqual(inventory.formats.map((entry) => entry.format), ["GLTF", "GLB", "OBJ", "STL", "PLY", "USD", "ALEMBIC"]); + + const available = new Set(["GLTF", "GLB", "OBJ", "STL", "PLY"]); + const disabled = new Set(["USD", "ALEMBIC"]); + for (const entry of inventory.formats) { + assert.ok(Array.isArray(entry.extensions) && entry.extensions.length > 0, `${entry.format} extensions missing`); + assert.ok(Array.isArray(entry.variants) && entry.variants.length > 0, `${entry.format} variants missing`); + for (const operation of ["import", "export"]) { + const receipt = entry[operation]; + assert.ok(typeof receipt.operator === "string" && receipt.operator.includes("."), `${entry.format} ${operation} operator missing`); + assert.ok(Array.isArray(receipt.properties), `${entry.format} ${operation} properties missing`); + assert.deepEqual(receipt.properties.map((property) => property.identifier), [...receipt.properties].map((property) => property.identifier).sort(), `${entry.format} ${operation} properties are not canonical`); + if (available.has(entry.format)) { + assert.equal(receipt.registered, true, `${entry.format} ${operation} is not registered`); + assert.equal(receipt.runtimeStatus, "AVAILABLE", `${entry.format} ${operation} is not available`); + assert.equal(receipt.buildOptionEnabled, true, `${entry.format} ${operation} build option is disabled`); + } + if (disabled.has(entry.format)) { + assert.equal(receipt.registered, false, `${entry.format} ${operation} unexpectedly registered`); + assert.equal(receipt.runtimeStatus, "OPERATOR_UNREGISTERED", `${entry.format} ${operation} did not fail closed`); + assert.equal(receipt.buildOptionEnabled, null, `${entry.format} ${operation} has an ambiguous build option`); + } + } + } + assert.equal(inventory.runtime.buildOptions.io_wavefront_obj, true); + assert.equal(inventory.runtime.buildOptions.io_stl, true); + assert.equal(inventory.runtime.buildOptions.io_ply, true); + assert.equal(inventory.runtime.buildOptions.usd, false); + assert.equal(inventory.runtime.buildOptions.alembic, false); + process.stdout.write(`io-format-runtime-inventory-ok formats=${inventory.formats.length} available=${available.size} build-disabled=${disabled.size} blender=${inventory.runtime.blenderVersion}\n`); +} +finally { + fs.rmSync(temporary, { recursive: true, force: true }); +} diff --git a/tools/web/check-io-format-runtime-receipts.mjs b/tools/web/check-io-format-runtime-receipts.mjs new file mode 100644 index 00000000..9ef4273c --- /dev/null +++ b/tools/web/check-io-format-runtime-receipts.mjs @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import ts from "../../web/node_modules/typescript/lib/typescript.js"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const inventoryPath = path.join(repoRoot, "tests/golden/M12-05A/format-inventory.json"); +const receiptPath = path.join(repoRoot, "tests/golden/M12-05D/runtime-receipts.json"); +const protocolPath = path.join(repoRoot, "web/protocol/io-format-runtime-receipt.ts"); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "io-format-runtime-receipts-")); +const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); +try { + const inventoryBytes = fs.readFileSync(inventoryPath); + const inventory = JSON.parse(inventoryBytes); + const receiptBytes = fs.readFileSync(receiptPath); + const receipts = JSON.parse(receiptBytes); + const transpiled = ts.transpileModule(fs.readFileSync(protocolPath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: protocolPath, reportDiagnostics: true }); + assert.deepEqual(transpiled.diagnostics, []); + const modulePath = path.join(temporary, "protocol.mjs"); + fs.writeFileSync(modulePath, transpiled.outputText); + const protocol = await import(pathToFileURL(modulePath)); + const parsed = protocol.validateIOFormatRuntimeReceiptSet(receipts, sha256(inventoryBytes)); + assert.equal(parsed.receipts.length, 14); + const byIdentity = new Map(inventory.formats.flatMap((entry) => ["IMPORT", "EXPORT"].map((operation) => [`${entry.format}:${operation}`, entry[operation.toLowerCase()]]))); + for (const receipt of parsed.receipts) { + const source = byIdentity.get(`${receipt.format}:${receipt.operation}`); + assert.ok(source); + assert.equal(receipt.operator, source.operator); + assert.equal(receipt.registered, source.registered); + assert.equal(receipt.rnaIdentifier, source.rnaIdentifier); + assert.equal(receipt.runtimeStatus, source.runtimeStatus); + assert.equal(receipt.extensions.length > 0, true); + } + assert.equal(protocol.resolveIOFormatRuntimeRoute(parsed, { format: "GLB", operation: "EXPORT" }).status, "READY"); + assert.equal(protocol.resolveIOFormatRuntimeRoute(parsed, { format: "USD", operation: "EXPORT" }).status, "BLOCKED"); + const regenerated = path.join(temporary, "runtime-receipts.json"); + execFileSync(process.execPath, [path.join(repoRoot, "tools/web/generate-io-format-runtime-receipts.mjs"), "--output", regenerated], { cwd: repoRoot }); + assert.deepEqual(fs.readFileSync(regenerated), receiptBytes, "runtime receipt set is not deterministic"); + process.stdout.write("io-format-runtime-receipts-ok formats=7 receipts=14 glb-export=READY usd-export=BLOCKED extension-independent=true\n"); +} +finally { + fs.rmSync(temporary, { recursive: true, force: true }); +} diff --git a/tools/web/check-io-format-ui-gate.mjs b/tools/web/check-io-format-ui-gate.mjs new file mode 100644 index 00000000..c5274d53 --- /dev/null +++ b/tools/web/check-io-format-ui-gate.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import ts from "../../web/node_modules/typescript/lib/typescript.js"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const registryPath = path.join(repoRoot, "web/app/src/capabilities/io-format-ui-registry.json"); +const matrixPath = path.join(repoRoot, "tests/golden/M12-05B/capability-matrix.json"); +const matrixProtocolPath = path.join(repoRoot, "web/protocol/io-format-capability-matrix.ts"); +const gateProtocolPath = path.join(repoRoot, "web/protocol/io-format-ui-gate.ts"); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "io-format-ui-gate-check-")); +const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); +try { + const registry = JSON.parse(fs.readFileSync(registryPath, "utf8")); + const matrixBytes = fs.readFileSync(matrixPath); + const transpile = (sourcePath, fileName) => { + const result = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true }); + assert.deepEqual(result.diagnostics, []); + const target = path.join(temporary, fileName); + fs.writeFileSync(target, result.outputText); + return target; + }; + const matrixProtocol = await import(pathToFileURL(transpile(matrixProtocolPath, "matrix.mjs"))); + const gateProtocol = await import(pathToFileURL(transpile(gateProtocolPath, "gate.mjs"))); + const matrix = matrixProtocol.parseIOFormatCapabilityMatrix(JSON.parse(matrixBytes)); + gateProtocol.validateIOFormatUIRegistry(registry, matrix, sha256(matrixBytes)); + assert.equal(registry.task, "M12-05C"); + assert.equal(registry.parentMatrixSha256, sha256(matrixBytes)); + assert.equal(registry.projectFileAccept, ".blend,application/octet-stream"); + assert.deepEqual(registry.importRoutes, [], "no blocked import route may reach the file selector"); + assert.deepEqual(registry.exportRoutes, [{ format: "GLB", operation: "EXPORT", execution: "LOCAL", extensions: [".glb"] }]); + const regenerated = path.join(temporary, "registry.json"); + execFileSync(process.execPath, [path.join(repoRoot, "tools/web/generate-io-format-ui-gate.mjs"), "--output", regenerated], { cwd: repoRoot }); + assert.deepEqual(fs.readFileSync(regenerated), fs.readFileSync(registryPath), "UI registry is not deterministic"); + process.stdout.write("io-format-ui-gate-ok import-routes=0 export-routes=1 file-accept=.blend,application/octet-stream fail-closed=true\n"); +} +finally { + fs.rmSync(temporary, { recursive: true, force: true }); +} diff --git a/tools/web/check-library-link-fixture.mjs b/tools/web/check-library-link-fixture.mjs new file mode 100644 index 00000000..1e564f70 --- /dev/null +++ b/tools/web/check-library-link-fixture.mjs @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const read = (relativePath) => fs.readFileSync(path.join(root, relativePath)); +const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex"); +const manifest = JSON.parse(read("tests/golden/M12-03F/manifest.json")); +const report = JSON.parse(read("tests/golden/M12-03F/desktop-link-report.json")); +const fixtureRoot = path.join(root, "tests/files/web/m12_library_link_v1"); +const generator = path.join(root, "tools/web/generate-library-link-fixture.py"); +const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender"); + +assert.equal(manifest.task, "M12-03F"); +assert.equal(manifest.parentTask, "M12-03E"); +assert.equal(manifest.nextTask, "M12-03G"); +assert.equal(manifest.operation, "LINK"); +for (const artifact of Object.values(manifest.artifacts)) { + assert.equal(sha256(read(artifact.path)), artifact.sha256, artifact.path); +} + +assert.equal(report.schemaVersion, 1); +assert.equal(report.task, "M12-03F"); +assert.equal(report.operation, "LINK"); +assert.equal(report.blenderVersion, "5.2.0"); +assert.equal(report.nextTask, "M12-03G"); +assert.deepEqual(report.selectedRoots, ["Object/M12 Link Object"]); +assert.deepEqual(report.sourceGraph, { + edges: [ + { from: "Object/M12 Link Object", relation: "OBJECT_DATA", to: "Mesh/M12 Link Mesh" }, + { from: "Mesh/M12 Link Mesh", relation: "MATERIAL_SLOT[0]", to: "Material/M12 Link Material" }, + { from: "Material/M12 Link Material", relation: "NODE_IMAGE[M12 Link Image Node]", to: "Image/M12 Link Image" }, + ], + geometry: { edges: 4, loops: 4, materialSlots: ["M12 Link Material"], polygons: 1, uvLayers: ["UVMap"], vertices: 4 }, + ids: { + IMAGE: { idType: "IMAGE", isLibraryOverride: false, library: null, name: "M12 Link Image", nameFull: "M12 Link Image" }, + MATERIAL: { idType: "MATERIAL", isLibraryOverride: false, library: null, name: "M12 Link Material", nameFull: "M12 Link Material" }, + MESH: { idType: "MESH", isLibraryOverride: false, library: null, name: "M12 Link Mesh", nameFull: "M12 Link Mesh" }, + OBJECT: { idType: "OBJECT", isLibraryOverride: false, library: null, name: "M12 Link Object", nameFull: "M12 Link Object" }, + }, + image: { channels: 4, colorspace: "sRGB", packed: true, pixelFloat32Sha256: "6f0f8c231d65149e69e6ed12d370bcfa095ef90adc202065492ea8c8ef17e45a", size: [2, 2] }, + root: { idType: "OBJECT", isLibraryOverride: false, library: null, name: "M12 Link Object", nameFull: "M12 Link Object" }, + sourceMarker: "M12-03F", +}); +assert.deepEqual(report.linkedGraph.edges, report.sourceGraph.edges); +assert.deepEqual(report.linkedGraph.geometry, report.sourceGraph.geometry); +assert.deepEqual(report.linkedGraph.image, report.sourceGraph.image); +assert.equal(report.linkedGraph.ids.OBJECT.library, "m12_link_source.blend"); +assert.equal(report.linkedGraph.ids.OBJECT.nameFull, "M12 Link Object [m12_link_source.blend]"); +for (const value of Object.values(report.linkedGraph.ids)) { + assert.equal(value.library, "m12_link_source.blend"); + assert.equal(value.isLibraryOverride, false); +} +assert.deepEqual(report.stableMapping.map((item) => [item.owner, item.readOnly]), [ + ["SOURCE_LIBRARY", true], + ["SOURCE_LIBRARY", true], + ["SOURCE_LIBRARY", true], + ["SOURCE_LIBRARY", true], +]); +assert.deepEqual(report.stableMapping.map((item) => item.source), [ + "Object/M12 Link Object", + "Mesh/M12 Link Mesh", + "Material/M12 Link Material", + "Image/M12 Link Image", +]); +assert.deepEqual(report.stableMapping.map((item) => item.local), report.stableMapping.map((item) => item.source)); +assert.equal(sha256(fs.readFileSync(path.join(fixtureRoot, report.source.file))), report.source.sha256); +assert.equal(sha256(fs.readFileSync(path.join(fixtureRoot, report.target.file))), report.target.sha256); + +const normalizeContainerHashes = (value) => ({ + ...value, + source: { ...value.source, sha256: "" }, + target: { ...value.target, sha256: "" }, +}); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-library-link-")); +try { + const generatedFixtureRoot = path.join(temporary, "files"); + const generatedReportPath = path.join(temporary, "report.json"); + const output = execFileSync(blender, [ + "--background", + "--factory-startup", + "--python", + generator, + "--", + generatedFixtureRoot, + generatedReportPath, + ], { cwd: root, encoding: "utf8" }); + assert.match(output, /library-link-fixture-ok roots=1 mapping=4/); + assert.match(output, /next=M12-03G/); + const generated = JSON.parse(fs.readFileSync(generatedReportPath, "utf8")); + assert.deepEqual(normalizeContainerHashes(generated), normalizeContainerHashes(report)); + assert.equal(sha256(fs.readFileSync(path.join(generatedFixtureRoot, generated.source.file))), generated.source.sha256); + assert.equal(sha256(fs.readFileSync(path.join(generatedFixtureRoot, generated.target.file))), generated.target.sha256); +} +finally { + fs.rmSync(temporary, { recursive: true, force: true }); +} + +process.stdout.write( + `library-link-fixture-check-ok roots=${report.selectedRoots.length} mapping=${report.stableMapping.length} ` + + `library=${report.linkedGraph.ids.OBJECT.library} readOnly=${report.stableMapping.every((item) => item.readOnly)} next=${report.nextTask}\n`, +); diff --git a/tools/web/check-library-main-append.mjs b/tools/web/check-library-main-append.mjs new file mode 100644 index 00000000..ebcdc413 --- /dev/null +++ b/tools/web/check-library-main-append.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const read = (relativePath) => fs.readFileSync(path.join(root, relativePath)); +const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex"); +const manifest = JSON.parse(read("tests/golden/M12-03E/manifest.json")); +const desktopReport = JSON.parse(read("tests/golden/M12-03C/desktop-append-report.json")); + +assert.deepEqual(manifest, { + schemaVersion: 1, + task: "M12-03E", + parentTask: "M12-03D", + enablingTask: false, + parityStateChange: false, + runtime: "BLENDER_5_2_WASM_CHROMIUM", + operation: "APPEND", + canonicalComparison: "DESKTOP_REPORT_EXACT_AFTER_IMAGE_ROW_NORMALIZATION_AND_SCENEIR_COLORSPACE_DEFAULT", + assertions: { + transactionCount: 1, + revisionDelta: 1, + undoRemovesClosure: true, + redoRestoresCanonical: true, + saveReopenRestoresCanonical: true, + }, + artifacts: { + parentManifest: { + path: "tests/golden/M12-03C/manifest.json", + sha256: "cbfbd8c919125108334dd24925b9ef8e69880983515e56841e6ead4a2b182aed", + }, + desktopReport: { + path: "tests/golden/M12-03C/desktop-append-report.json", + sha256: "b1d7b8b9e832d18d69081f63981062800e0f00f0c9aec025b18274510ed76e2a", + }, + test: { + path: "web/tests/e2e/library-append-main.spec.ts", + sha256: "101f67d35c2320da57459f08a59687d769234b910dbd9aaec5a668cee7e0f9f0", + }, + sourceBlend: { + path: "tests/files/web/m12_library_append_v1/m12_append_source.blend", + sha256: "5b60d02926efd588a6ca300ba31414cdf37dbc48786c17b383a70319057c0606", + }, + targetBlend: { + path: "tests/files/web/empty.blend", + sha256: "9b1ecbcc3d7f8079ee5469de64193ffcaa0a7b01e0f2ac340bbb18aa88734a63", + }, + }, + nextTask: "M12-03F", +}); + +assert.equal(sha256(read(manifest.artifacts.parentManifest.path)), manifest.artifacts.parentManifest.sha256); +assert.equal(sha256(read(manifest.artifacts.desktopReport.path)), manifest.artifacts.desktopReport.sha256); +assert.equal(sha256(read(manifest.artifacts.sourceBlend.path)), manifest.artifacts.sourceBlend.sha256); +assert.equal(sha256(read(manifest.artifacts.targetBlend.path)), manifest.artifacts.targetBlend.sha256); +assert.equal(sha256(read(manifest.artifacts.test.path)), manifest.artifacts.test.sha256); +assert.equal(desktopReport.task, "M12-03C"); +assert.equal(desktopReport.operation, "APPEND"); +assert.equal(desktopReport.appendedGraph.geometry.vertices, 4); +assert.equal(desktopReport.appendedGraph.geometry.edges, 4); +assert.equal(desktopReport.appendedGraph.geometry.polygons, 1); +assert.equal(desktopReport.appendedGraph.geometry.loops, 4); +assert.equal(desktopReport.appendedGraph.image.pixelFloat32Sha256.length, 64); + +const source = read(manifest.artifacts.test.path).toString("utf8"); +for (const marker of [ + "M12-03E keeps append undo/redo/save/reopen", + "desktop-append-report.json", + "canonicalGraph", + "client.applyCommand({ type: \"undo\" })", + "client.applyCommand({ type: \"redo\" })", + "client.saveBlend()", + "reopenedCanonical", + "desktopCanonical", +]) assert.ok(source.includes(marker), `M12-03E test marker is missing: ${marker}`); + +process.stdout.write( + `library-main-append-check-ok canonical=${desktopReport.appendedGraph.image.pixelFloat32Sha256} ` + + `transaction=${manifest.assertions.transactionCount} undo=removed redo=canonical reopen=canonical next=${manifest.nextTask}\n`, +); diff --git a/tools/web/check-library-operation-commands.mjs b/tools/web/check-library-operation-commands.mjs new file mode 100644 index 00000000..eddfe4a5 --- /dev/null +++ b/tools/web/check-library-operation-commands.mjs @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const read = (relativePath) => fs.readFileSync(path.join(root, relativePath)); +const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex"); +const manifest = JSON.parse(read("tests/golden/M12-03N/manifest.json")); +const packageJson = JSON.parse(read("web/package.json")); +const expected = { + "append-desktop": "node ../tools/web/check-library-append-fixture.mjs", + "append-wasm": "node --test tests/unit/library-append-wasm.test.mjs", + "append-chromium": "playwright test --config playwright.config.ts --workers=1 tests/e2e/library-append-main.spec.ts", + "link-desktop": "node ../tools/web/check-library-link-fixture.mjs", + "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", + "link-chromium": "playwright test --config playwright.config.ts --workers=1 tests/e2e/library-link-chromium.spec.ts", + "override-desktop": "node ../tools/web/check-library-override-fixture.mjs", + "override-wasm": "node --test tests/unit/library-override-writer.test.mjs tests/unit/library-override-freshness.test.mjs", + "override-chromium": "playwright test --config playwright.config.ts --workers=1 tests/e2e/library-override-chromium.spec.ts", +}; +for (const [name, command] of Object.entries(expected)) assert.equal(packageJson.scripts[`test:library-${name}`], command, name); +for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(read(artifact.path)), artifact.sha256, artifact.path); +assert.equal(manifest.task, "M12-03N"); +assert.equal(manifest.nextTask, "M12-04A"); +assert.equal(Object.keys(expected).length, 9); +process.stdout.write("library-operation-commands-check-ok lanes=9 operations=3 desktop=3 wasm=3 chromium=3 next=M12-04A\n"); diff --git a/tools/web/check-library-override-fixture.mjs b/tools/web/check-library-override-fixture.mjs new file mode 100644 index 00000000..408a8b8a --- /dev/null +++ b/tools/web/check-library-override-fixture.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const fixtureRoot = path.join(root, "tests/files/web/m12_library_override_v1"); +const generator = path.join(root, "tools/web/generate-library-override-fixture.py"); +const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender"); +const read = (relativePath) => fs.readFileSync(path.join(root, relativePath)); +const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex"); +const manifest = JSON.parse(read("tests/golden/M12-03J/manifest.json")); +const report = JSON.parse(read("tests/golden/M12-03J/desktop-override-report.json")); + +assert.equal(manifest.task, "M12-03J"); +assert.equal(manifest.parentTask, "M12-03I"); +assert.equal(manifest.nextTask, "M12-03K"); +assert.equal(report.schemaVersion, 1); +assert.equal(report.task, "M12-03J"); +assert.equal(report.operation, "LIBRARY_OVERRIDE"); +assert.equal(report.blenderVersion, "5.2.0"); +assert.deepEqual(report.selectedRoots, ["Object/M12 Override Object"]); +assert.deepEqual(report.overrideGraph.reference, { + dataBlockId: "Object/M12 Override Object", + idType: "OBJECT", + library: "m12_override_source.blend", + owner: "SOURCE_LIBRARY", + readOnly: true, + isLibraryOverride: false, +}); +assert.deepEqual(report.overrideGraph.local, { + dataBlockId: "Object/M12 Override Object", + idType: "OBJECT", + library: null, + owner: "LOCAL_OVERRIDE", + projectId: "m12-03j-project", + readOnly: false, + referenceSourceDataBlockId: "Object/M12 Override Object", + hierarchyRootDataBlockId: "Object/M12 Override Object", + isLibraryOverride: true, +}); +assert.deepEqual(report.overrideGraph.propertyOverride, { + index: 0, + operationCount: 1, + propertyCount: 1, + rnaPath: "[\"m12_override_value\"]", + value: 2.5, +}); +assert.equal(report.overrideGraph.sourceMarker, "M12-03J"); +for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(read(artifact.path)), artifact.sha256, artifact.path); +assert.equal(sha256(fs.readFileSync(path.join(fixtureRoot, report.source.file))), report.source.sha256); +assert.equal(sha256(fs.readFileSync(path.join(fixtureRoot, report.target.file))), report.target.sha256); + +const normalize = (value) => ({ + ...value, + source: { ...value.source, sha256: "" }, + target: { ...value.target, sha256: "" }, +}); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-library-override-")); +try { + const generatedRoot = path.join(temporary, "files"); + const generatedReportPath = path.join(temporary, "report.json"); + const output = execFileSync(blender, ["--background", "--factory-startup", "--python", generator, "--", generatedRoot, generatedReportPath], { cwd: root, encoding: "utf8" }); + assert.match(output, /library-override-fixture-ok roots=1/); + assert.match(output, /owner=LOCAL_OVERRIDE/); + const generated = JSON.parse(fs.readFileSync(generatedReportPath, "utf8")); + assert.deepEqual(normalize(generated), normalize(report)); + assert.equal(sha256(fs.readFileSync(path.join(generatedRoot, generated.source.file))), generated.source.sha256); + assert.equal(sha256(fs.readFileSync(path.join(generatedRoot, generated.target.file))), generated.target.sha256); +} +finally { + fs.rmSync(temporary, { recursive: true, force: true }); +} + +process.stdout.write( + `library-override-fixture-check-ok roots=${report.selectedRoots.length} ` + + `reference=${report.overrideGraph.reference.library} owner=${report.overrideGraph.local.owner} ` + + `path=${report.overrideGraph.propertyOverride.rnaPath} next=${report.nextTask}\n`, +); diff --git a/tools/web/check-malicious-archive-fixtures.mjs b/tools/web/check-malicious-archive-fixtures.mjs new file mode 100644 index 00000000..4ef1b692 --- /dev/null +++ b/tools/web/check-malicious-archive-fixtures.mjs @@ -0,0 +1,182 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL, fileURLToPath } from "node:url"; +import ts from "../../web/node_modules/typescript/lib/typescript.js"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const fixtureRoot = path.join(repoRoot, "tests/files/web/archive-security"); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "malicious-archive-fixtures-")); +const moduleRoot = path.join(temporary, "modules"); +const regeneratedRoot = path.join(temporary, "regenerated"); +fs.mkdirSync(moduleRoot, { recursive: true }); + +try { + for (const sourceName of ["archive-link-safety.ts", "archive-conflicts.ts"]) { + const sourcePath = path.join(repoRoot, "web/protocol", sourceName); + const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, + }); + assert.deepEqual(transpiled.diagnostics, []); + fs.writeFileSync(path.join(moduleRoot, sourceName.replace(".ts", ".mjs")), transpiled.outputText); + } + const safety = await import(pathToFileURL(path.join(moduleRoot, "archive-link-safety.mjs"))); + const conflicts = await import(pathToFileURL(path.join(moduleRoot, "archive-conflicts.mjs"))); + const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); + const decoder = new TextDecoder("utf-8", { fatal: true }); + const evidence = JSON.parse(fs.readFileSync(path.join(repoRoot, "tests/golden/M12-04J/manifest.json"), "utf8")); + assert.equal(evidence.task, "M12-04J"); + assert.equal(evidence.parentTask, "M12-04I"); + assert.equal(evidence.nextTask, "M12-05A"); + for (const artifact of Object.values(evidence.artifacts)) { + assert.equal(sha256(fs.readFileSync(path.join(repoRoot, artifact.path))), artifact.sha256, artifact.path); + } + + function decode(bytes, offset, length) { + return decoder.decode(bytes.subarray(offset, offset + length)); + } + + function zipMetadata(bytes) { + assert.ok(bytes.length >= 22, "ZIP fixture is shorter than EOCD"); + const endOffset = bytes.length - 22; + assert.equal(bytes.readUInt32LE(endOffset), 0x06054b50, "ZIP fixture EOCD is missing"); + assert.equal(bytes.readUInt16LE(endOffset + 4), 0, "multi-disk ZIP fixture is forbidden"); + assert.equal(bytes.readUInt16LE(endOffset + 6), 0, "multi-disk ZIP fixture is forbidden"); + const entries = bytes.readUInt16LE(endOffset + 10); + assert.equal(bytes.readUInt16LE(endOffset + 8), entries, "ZIP entry counts disagree"); + const centralBytes = bytes.readUInt32LE(endOffset + 12); + const centralOffset = bytes.readUInt32LE(endOffset + 16); + assert.equal(centralOffset + centralBytes, endOffset, "ZIP central directory is not the first bounded metadata region"); + const links = []; + const ranges = []; + let offset = centralOffset; + for (let index = 0; index < entries; index++) { + assert.equal(bytes.readUInt32LE(offset), 0x02014b50, `ZIP central entry ${index} is invalid`); + const method = bytes.readUInt16LE(offset + 10); + const compressedBytes = bytes.readUInt32LE(offset + 20); + const uncompressedBytes = bytes.readUInt32LE(offset + 24); + const nameBytes = bytes.readUInt16LE(offset + 28); + const extraBytes = bytes.readUInt16LE(offset + 30); + const commentBytes = bytes.readUInt16LE(offset + 32); + const localOffset = bytes.readUInt32LE(offset + 42); + const entryPath = decode(bytes, offset + 46, nameBytes); + assert.equal(method, 0, "fixture ZIP entries must use deterministic STORE metadata"); + assert.equal(bytes.readUInt32LE(localOffset), 0x04034b50, `ZIP local entry ${index} is invalid`); + assert.equal(bytes.readUInt16LE(localOffset + 8), method, "ZIP local/central methods disagree"); + assert.equal(bytes.readUInt32LE(localOffset + 18), compressedBytes, "ZIP local/central compressed sizes disagree"); + assert.equal(bytes.readUInt32LE(localOffset + 22), uncompressedBytes, "ZIP local/central uncompressed sizes disagree"); + const localNameBytes = bytes.readUInt16LE(localOffset + 26); + const localExtraBytes = bytes.readUInt16LE(localOffset + 28); + assert.equal(decode(bytes, localOffset + 30, localNameBytes), entryPath, "ZIP local/central names disagree"); + const compressedOffset = localOffset + 30 + localNameBytes + localExtraBytes; + assert.ok(compressedOffset + compressedBytes <= centralOffset, "ZIP payload range overlaps central metadata"); + links.push({ path: entryPath, type: entryPath.endsWith("/") ? "DIRECTORY" : "FILE", target: null }); + ranges.push({ path: entryPath, compressedOffset, compressedBytes, uncompressedBytes }); + offset += 46 + nameBytes + extraBytes + commentBytes; + } + assert.equal(offset, endOffset, "ZIP central directory length disagrees with EOCD"); + return { links, ranges }; + } + + function tarString(bytes, offset, length) { + const field = bytes.subarray(offset, offset + length); + const end = field.indexOf(0); + return decoder.decode(end === -1 ? field : field.subarray(0, end)); + } + + function tarOctal(bytes, offset, length) { + const value = tarString(bytes, offset, length).trim(); + assert.match(value, /^[0-7]+$/, "TAR numeric field is not octal"); + return Number.parseInt(value, 8); + } + + function tarMetadata(bytes) { + assert.equal(bytes.length % 512, 0, "TAR fixture is not block aligned"); + const links = []; + const ranges = []; + let offset = 0; + let zeroBlocks = 0; + while (offset < bytes.length) { + const header = bytes.subarray(offset, offset + 512); + if (header.every((byte) => byte === 0)) { + zeroBlocks++; + offset += 512; + if (zeroBlocks === 2) break; + continue; + } + assert.equal(zeroBlocks, 0, "TAR has data after an end marker"); + assert.equal(tarString(header, 257, 6), "ustar", "TAR fixture is not USTAR"); + const expectedChecksum = tarOctal(header, 148, 8); + const checksumHeader = Buffer.from(header); + checksumHeader.fill(0x20, 148, 156); + assert.equal(checksumHeader.reduce((sum, byte) => sum + byte, 0), expectedChecksum, "TAR header checksum mismatch"); + const prefix = tarString(header, 345, 155); + const name = tarString(header, 0, 100); + const entryPath = prefix ? `${prefix}/${name}` : name; + const uncompressedBytes = tarOctal(header, 124, 12); + const typeFlag = String.fromCharCode(header[156] || 0x30); + const type = { "0": "FILE", "1": "HARDLINK", "2": "SYMLINK", "5": "DIRECTORY" }[typeFlag]; + assert.ok(type, `TAR entry type ${typeFlag} is unsupported by the fixture gate`); + const target = type === "SYMLINK" || type === "HARDLINK" ? tarString(header, 157, 100) : null; + const compressedOffset = offset + 512; + assert.ok(compressedOffset + uncompressedBytes <= bytes.length, "TAR payload exceeds the fixture"); + links.push({ path: entryPath, type, target }); + ranges.push({ path: entryPath, compressedOffset, compressedBytes: uncompressedBytes, uncompressedBytes }); + offset = compressedOffset + Math.ceil(uncompressedBytes / 512) * 512; + } + assert.equal(zeroBlocks, 2, "TAR fixture has no two-block end marker"); + assert.equal(offset, bytes.length, "TAR fixture has trailing data after the end marker"); + return { links, ranges }; + } + + const manifestBytes = fs.readFileSync(path.join(fixtureRoot, "manifest.json")); + const manifest = JSON.parse(manifestBytes); + assert.equal(manifest.schemaVersion, 1); + assert.equal(manifest.task, "M12-04J"); + assert.equal(manifest.generator, "tools/web/generate-malicious-archive-fixtures.mjs"); + assert.equal(manifest.extractionAllowed, false); + const expected = [ + ["ZIP_PATH_TRAVERSAL", "ZIP", "ARCHIVE_ROOT_ESCAPE", "LINK_SAFETY"], + ["ZIP_COMPRESSION_BOMB", "ZIP", "COMPRESSION_RATIO", "CONFLICTS"], + ["ZIP_DUPLICATE_PATH", "ZIP", "DUPLICATE_PATH", "LINK_SAFETY"], + ["TAR_PATH_TRAVERSAL", "TAR", "ARCHIVE_ROOT_ESCAPE", "LINK_SAFETY"], + ["TAR_SYMLINK_ESCAPE", "TAR", "SYMLINK_ESCAPE", "LINK_SAFETY"], + ["TAR_PREFIX_CONFLICT", "TAR", "FILE_DIRECTORY_PREFIX_CONFLICT", "CONFLICTS"], + ]; + assert.deepEqual(manifest.cases.map((item) => [item.id, item.format, item.threat, item.gate]), expected); + + execFileSync(process.execPath, [path.join(repoRoot, manifest.generator), "--output", regeneratedRoot], { cwd: repoRoot }); + assert.deepEqual(fs.readFileSync(path.join(regeneratedRoot, "manifest.json")), manifestBytes, "fixture manifest is not deterministic"); + + for (const fixture of manifest.cases) { + assert.equal(fixture.file, path.basename(fixture.file), `${fixture.id} fixture path escapes its root`); + assert.equal(fixture.expectedCode, "IO_ARCHIVE_UNSAFE"); + const archiveBytes = fs.readFileSync(path.join(fixtureRoot, fixture.file)); + assert.equal(archiveBytes.length, fixture.byteLength, `${fixture.id} byte length drifted`); + assert.equal(sha256(archiveBytes), fixture.sha256, `${fixture.id} SHA-256 drifted`); + assert.deepEqual(fs.readFileSync(path.join(regeneratedRoot, fixture.file)), archiveBytes, `${fixture.id} is not deterministic`); + const metadata = fixture.format === "ZIP" ? zipMetadata(archiveBytes) : tarMetadata(archiveBytes); + let failure; + try { + if (fixture.gate === "LINK_SAFETY") { + safety.resolveArchiveLinkEntries({ schemaVersion: 1, temporaryRootId: `fixture:${fixture.id}`, entries: metadata.links }); + } + else { + conflicts.validateArchiveConflicts({ schemaVersion: 1, byteLength: archiveBytes.length, ranges: metadata.ranges }); + } + } + catch (error) { + failure = error; + } + assert.equal(failure?.code, fixture.expectedCode, `${fixture.id} did not fail closed`); + } + process.stdout.write(`malicious-archive-fixtures-ok cases=${manifest.cases.length} zip=3 tar=3 extraction=disabled\n`); +} +finally { + fs.rmSync(temporary, { recursive: true, force: true }); +} diff --git a/tools/web/check-malicious-input-matrix.mjs b/tools/web/check-malicious-input-matrix.mjs new file mode 100644 index 00000000..6fac3703 --- /dev/null +++ b/tools/web/check-malicious-input-matrix.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const reportPath = path.join(root, "tests/golden/M13-05F/malicious-input-report.json"); +const manifestPath = path.join(root, "tests/golden/M13-05F/manifest.json"); +const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); +const fileSha256 = (file) => sha256(fs.readFileSync(file)); +const commands = [ + { id: "blend", command: process.execPath, args: ["tools/web/check-malicious-blends.mjs"], expected: "REJECTED" }, + { id: "archive", command: process.execPath, args: ["tools/web/check-malicious-archive-fixtures.mjs"], expected: "REJECTED" }, + { id: "image-font-media-node-manifest", command: process.execPath, args: ["--test", "web/tests/unit/asset-preview-decode.test.mjs", "web/tests/unit/external-vfont.test.mjs", "web/tests/unit/sequencer-media-cache.test.mjs", "web/tests/unit/shader-compiler.test.mjs", "web/tests/unit/script-manifest-budgets.test.mjs"], expected: "REJECTED" }, + { id: "glb", command: process.execPath, args: ["--test", "web/tests/unit/glb-negative-cases.test.mjs"], expected: "REJECTED" }, +]; +const results = commands.map((entry) => { + const run = spawnSync(entry.command, entry.args, { cwd: root, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }); + assert.equal(run.status, 0, `${entry.id} failed\n${run.stdout}\n${run.stderr}`); + return { id: entry.id, status: entry.expected, exitCode: run.status }; +}); +const report = { + schemaVersion: 1, + task: "M13-05F", + operation: "MALICIOUS_INPUT_MATRIX", + cases: { + blend: { status: "REJECTED", checker: "tools/web/check-malicious-blends.mjs", stableCode: "BLEND_OPEN_INVALID" }, + image: { status: "REJECTED", checker: "web/tests/unit/asset-preview-decode.test.mjs", stableCodes: ["ASSET_MANIFEST_INVALID", "ASSET_BUDGET_EXCEEDED", "ASSET_SOURCE_HASH_MISMATCH"] }, + font: { status: "REJECTED", checker: "web/tests/unit/external-vfont.test.mjs", stableCodes: ["NON_MESH_BINARY_INVALID", "NON_MESH_RESOURCE_OUTSIDE_PROJECT", "ASSET_SOURCE_HASH_MISMATCH"] }, + media: { status: "REJECTED", checker: "web/tests/unit/sequencer-media-cache.test.mjs", stableCode: "SEQUENCER_SCHEMA_INVALID" }, + archive: { status: "REJECTED", checker: "tools/web/check-malicious-archive-fixtures.mjs", stableCode: "IO_ARCHIVE_UNSAFE" }, + nodeGraph: { status: "REJECTED", checker: "web/tests/unit/shader-compiler.test.mjs", stableCodes: ["SHADER_NODE_UNSUPPORTED", "SHADER_INVALID_GRAPH"] }, + manifest: { status: "REJECTED", checker: "web/tests/unit/script-manifest-budgets.test.mjs", stableCode: "SCRIPT_MANIFEST_INVALID" }, + glb: { status: "REJECTED", checker: "web/tests/unit/glb-negative-cases.test.mjs", stableCodes: ["GLB_SPARSE_ACCESSOR_UNSUPPORTED", "GLB_EXTENSION_UNSUPPORTED", "GLB_EXTERNAL_URI_BLOCKED", "GLB_IMPORT_BUDGET_EXCEEDED"] }, + }, + executions: results, + allRejected: results.every((result) => result.status === "REJECTED"), + execution: "DISABLED", + nextTask: "M13-05G", +}; +if (process.env.UPDATE_M13_05F_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); } +assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report); +const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); +assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-05F", parentTask: "M13-05E", nextTask: "M13-05G" }); +for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileSha256(path.join(root, artifact.path)), artifact.sha256, artifact.path); +process.stdout.write("malicious-input-matrix-ok blend=REJECTED image=REJECTED font=REJECTED media=REJECTED archive=REJECTED nodeGraph=REJECTED manifest=REJECTED glb=REJECTED execution=DISABLED next=M13-05G\n"); diff --git a/tools/web/check-malicious-script-fixture.mjs b/tools/web/check-malicious-script-fixture.mjs new file mode 100644 index 00000000..a4b8242d --- /dev/null +++ b/tools/web/check-malicious-script-fixture.mjs @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const report = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M13-01F/malicious-report.json"), "utf8")); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M13-01F/manifest.json"), "utf8")); +assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-01F", parentTask: "M13-01E", nextTask: "M13-02A" }); +const fixturePath = path.join(root, "tests/files/web/m13_malicious_script_v1", report.fixture.name); +assert.deepEqual({ schemaVersion: report.schemaVersion, task: report.task, operation: report.operation, nextTask: report.nextTask }, { schemaVersion: 1, task: "M13-01F", operation: "MALICIOUS_SCRIPT_FIXTURE", nextTask: "M13-02A" }); +assert.equal(report.sources.length, 4); assert.equal(report.sources.filter((source) => source.useModule).length, 1); assert.ok(report.sources.every((source) => source.expectedExecution === "BLOCKED")); +assert.equal(crypto.createHash("sha256").update(fs.readFileSync(fixturePath)).digest("hex"), report.fixture.sha256); +for (const artifact of Object.values(manifest.artifacts)) assert.equal(crypto.createHash("sha256").update(fs.readFileSync(path.join(root, artifact.path))).digest("hex"), artifact.sha256, `artifact hash mismatch ${artifact.path}`); +process.stdout.write(`malicious-script-fixture-ok sources=${report.sources.length} module=1 execution=BLOCKED fixtureSha256=${report.fixture.sha256} next=${report.nextTask}\n`); diff --git a/tools/web/check-obj-multi-negative-fixtures.mjs b/tools/web/check-obj-multi-negative-fixtures.mjs new file mode 100644 index 00000000..3670e921 --- /dev/null +++ b/tools/web/check-obj-multi-negative-fixtures.mjs @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +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 { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const manifestPath = path.join(root, "tests/golden/M12-07B/manifest.json"); +const reportPath = path.join(root, "tests/golden/M12-07B/desktop-fixtures.json"); +const fixtureRoot = path.join(root, "tests/files/web/m12_obj_multi_v1"); +const generator = path.join(root, "tools/web/generate-obj-multi-negative-fixtures.py"); +const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender"); +const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); +const fileHash = (file) => sha256(fs.readFileSync(file)); +const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8")); + +function parseObj(file) { + const positions = []; + const texcoords = []; + const normals = []; + const faces = []; + const groups = []; + let object = null; + let material = null; + for (const raw of fs.readFileSync(file, "utf8").split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line.startsWith("#")) continue; + const parts = line.split(/\s+/); + if (parts[0] === "v") positions.push(parts.slice(1)); + else if (parts[0] === "vt") texcoords.push(parts.slice(1)); + else if (parts[0] === "vn") normals.push(parts.slice(1)); + else if (parts[0] === "g") { + for (const group of parts.slice(1)) { + groups.push(group); + if (group.endsWith("_Mesh")) object = group; + } + } + else if (parts[0] === "usemtl") material = parts.slice(1).join(" "); + else if (parts[0] === "f") faces.push({ object, material, tokens: parts.slice(1) }); + } + return { positions, texcoords, normals, groups, objects: [...new Set(faces.map((face) => face.object).filter(Boolean))], faces }; +} + +const manifest = readJson(manifestPath); +const report = readJson(reportPath); +assert.deepEqual( + { schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, + { schemaVersion: 1, task: "M12-07B", parentTask: "M12-07A", nextTask: "M12-07C" }, +); +assert.deepEqual( + { schemaVersion: report.schemaVersion, task: report.task, operation: report.operation, nextTask: report.nextTask }, + { schemaVersion: 1, task: "M12-07B", operation: "DESKTOP_OBJ_MULTI_OBJECT_AND_NEGATIVE_FIXTURES", nextTask: "M12-07C" }, +); +for (const artifact of Object.values(manifest.artifacts)) { + if (artifact.path === manifestPath) continue; + const absolute = path.join(root, artifact.path); + assert.ok(fs.existsSync(absolute), `missing artifact ${artifact.path}`); + assert.equal(fileHash(absolute), artifact.sha256, `hash mismatch ${artifact.path}`); +} +const inventory = readJson(path.join(root, "tests/golden/M12-05A/format-inventory.json")); +for (const key of ["blenderVersion", "versionTuple", "buildDate", "buildTime", "buildHash", "buildBranch", "buildPlatform", "buildType", "binarySha256"]) { + assert.deepEqual(report.runtime[key], inventory.runtime[key], `runtime drift in ${key}`); +} +assert.equal(report.semantic.objects.length, 2); +assert.equal(report.semantic.positions.length, 6); +assert.equal(report.semantic.texcoords.length, 6); +assert.equal(report.semantic.normals.length, 2); +assert.equal(report.semantic.faces.length, 2); +assert.equal(report.semantic.materials.length, 2); +assert.deepEqual(report.textureOrigin, { mtlMapKd: ["m12_obj_texture.png", "m12_obj_texture.png"], relative: true, textureFile: "m12_obj_texture.png" }); +assert.deepEqual(report.negativeIndex, { file: "negative-index.obj", expectedStatus: "ACCEPT_WITH_NEGATIVE_INDICES", faceCount: 2 }); +assert.deepEqual(report.malformedFace, { file: "malformed-face.obj", expectedCode: "OBJ_FACE_ARITY_INVALID" }); +const positive = parseObj(path.join(fixtureRoot, "multi-object.obj")); +assert.equal(positive.objects.length, 2); +assert.equal(positive.faces.length, 2); +assert.ok(positive.faces.every((face) => face.tokens.length === 3)); +assert.ok(positive.faces.every((face) => face.tokens.every((token) => token.split("/").length === 3))); +const negative = parseObj(path.join(fixtureRoot, "negative-index.obj")); +assert.ok(negative.faces.every((face) => face.tokens.every((token) => token.split("/").every((index) => Number(index) < 0)))); +assert.equal(negative.faces.length, 2); +const malformed = parseObj(path.join(fixtureRoot, "malformed-face.obj")); +assert.equal(malformed.faces[0].tokens.length, 2); +assert.equal(malformed.faces[0].tokens.length === 3 ? "ACCEPTED" : "OBJ_FACE_ARITY_INVALID", "OBJ_FACE_ARITY_INVALID"); +const texture = fs.readFileSync(path.join(fixtureRoot, "m12_obj_texture.png")); +assert.deepEqual([...texture.subarray(0, 8)], [137, 80, 78, 71, 13, 10, 26, 10]); +for (const file of report.files) { + const absolute = path.join(fixtureRoot, file.name); + assert.equal(fileHash(absolute), file.sha256, `${file.name} hash`); + assert.equal(fs.statSync(absolute).size, file.byteLength, `${file.name} byte length`); +} + +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-07b-obj-")); +try { + const regeneratedReport = path.join(temporary, "desktop-fixtures.json"); + const result = spawnSync(blender, ["-b", "--factory-startup", "--python", generator, "--", temporary, regeneratedReport], { + cwd: root, + encoding: "utf8", + maxBuffer: 20 * 1024 * 1024, + }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.deepEqual(readJson(regeneratedReport), report, "OBJ multi/negative report is not deterministic"); + for (const file of report.files) assert.deepEqual(fs.readFileSync(path.join(temporary, file.name)), fs.readFileSync(path.join(fixtureRoot, file.name)), `${file.name} bytes are not deterministic`); +} +finally { + fs.rmSync(temporary, { recursive: true, force: true }); +} + +process.stdout.write(`obj-multi-negative-fixtures-ok objects=${report.semantic.objects.length} negative=true malformed=${report.malformedFace.expectedCode} textureOrigin=relative deterministic=true next=${manifest.nextTask}\n`); diff --git a/tools/web/check-obj-single-mesh-fixture.mjs b/tools/web/check-obj-single-mesh-fixture.mjs new file mode 100644 index 00000000..795e2d27 --- /dev/null +++ b/tools/web/check-obj-single-mesh-fixture.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +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 { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const manifestPath = path.join(root, "tests/golden/M12-07A/manifest.json"); +const reportPath = path.join(root, "tests/golden/M12-07A/desktop-fixture.json"); +const fixtureRoot = path.join(root, "tests/files/web/m12_obj_desktop_v1"); +const generator = path.join(root, "tools/web/generate-obj-single-mesh-fixture.py"); +const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender"); +const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); +const fileHash = (file) => sha256(fs.readFileSync(file)); +const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8")); + +const manifest = readJson(manifestPath); +const report = readJson(reportPath); +assert.deepEqual( + { schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, + { schemaVersion: 1, task: "M12-07A", parentTask: "M12-06G", nextTask: "M12-07B" }, +); +assert.deepEqual( + { schemaVersion: report.schemaVersion, task: report.task, operation: report.operation, nextTask: report.nextTask }, + { schemaVersion: 1, task: "M12-07A", operation: "DESKTOP_OBJ_SINGLE_MESH_FIXTURE", nextTask: "M12-07B" }, +); +for (const artifact of Object.values(manifest.artifacts)) { + if (artifact.path === manifestPath) continue; + const absolute = path.join(root, artifact.path); + assert.ok(fs.existsSync(absolute), `missing artifact ${artifact.path}`); + assert.equal(fileHash(absolute), artifact.sha256, `hash mismatch ${artifact.path}`); +} + +const inventory = readJson(path.join(root, "tests/golden/M12-05A/format-inventory.json")); +for (const key of ["blenderVersion", "versionTuple", "buildDate", "buildTime", "buildHash", "buildBranch", "buildPlatform", "buildType", "binarySha256"]) { + assert.deepEqual(report.runtime[key], inventory.runtime[key], `runtime drift in ${key}`); +} +assert.equal(report.sourceAnchor, "blender-5.2.0/source/blender/io/wavefront_obj"); +assert.equal(report.operator, "wm.obj_export"); +assert.deepEqual(report.settings, { + forwardAxis: "NEGATIVE_Z", + upAxis: "Y", + globalScale: 1, + exportUV: true, + exportNormals: true, + exportMaterials: true, + exportMaterialGroups: true, +}); +assert.deepEqual(report.semantic.materialLibraries, ["single-mesh.mtl"]); +assert.deepEqual(report.semantic.objects, ["M12_OBJ_Single_Mesh"]); +assert.equal(report.semantic.positions.length, 4); +assert.equal(report.semantic.texcoords.length, 4); +assert.equal(report.semantic.normals.length, 1); +assert.equal(report.semantic.faces.length, 2); +assert.equal(report.semantic.materials.length, 2); +assert.deepEqual(report.semantic.faces.map((face) => face.vertices.length), [3, 3]); +assert.deepEqual(report.semantic.faces.map((face) => face.material), ["M12_OBJ_Red", "M12_OBJ_Blue"]); +assert.deepEqual(report.semantic.faces.map((face) => face.groups.length), [1, 1]); +assert.notEqual(report.semantic.faces[0].groups[0], report.semantic.faces[1].groups[0]); +assert.ok(report.semantic.faces.flatMap((face) => face.vertices).every((vertex) => vertex.position && vertex.texcoord && vertex.normal)); +assert.deepEqual(report.semantic.materials.map((material) => material.name), ["M12_OBJ_Blue", "M12_OBJ_Red"]); +for (const file of report.files) { + const absolute = path.join(fixtureRoot, file.name); + assert.equal(fileHash(absolute), file.sha256, `${file.name} hash`); + assert.equal(fs.statSync(absolute).size, file.byteLength, `${file.name} byte length`); +} + +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-07a-obj-")); +try { + const regeneratedReport = path.join(temporary, "desktop-fixture.json"); + const result = spawnSync(blender, ["-b", "--factory-startup", "--python", generator, "--", temporary, regeneratedReport], { + cwd: root, + encoding: "utf8", + maxBuffer: 20 * 1024 * 1024, + }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.deepEqual(readJson(regeneratedReport), report, "OBJ semantic report is not deterministic"); + for (const file of report.files) assert.deepEqual(fs.readFileSync(path.join(temporary, file.name)), fs.readFileSync(path.join(fixtureRoot, file.name)), `${file.name} bytes are not deterministic`); +} +finally { + fs.rmSync(temporary, { recursive: true, force: true }); +} + +process.stdout.write(`obj-single-mesh-fixture-ok vertices=${report.semantic.positions.length} normals=${report.semantic.normals.length} uv=${report.semantic.texcoords.length} faces=${report.semantic.faces.length} materials=${report.semantic.materials.length} deterministic=true next=${manifest.nextTask}\n`); diff --git a/tools/web/check-obj-web-roundtrip.mjs b/tools/web/check-obj-web-roundtrip.mjs new file mode 100644 index 00000000..f6ad0b59 --- /dev/null +++ b/tools/web/check-obj-web-roundtrip.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-07C/manifest.json"), "utf8")); +const report = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-07C/web-roundtrip-report.json"), "utf8")); +const fileHash = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); + +assert.deepEqual( + { schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, + { schemaVersion: 1, task: "M12-07C", parentTask: "M12-07B", nextTask: "M12-07D" }, +); +assert.deepEqual( + { schemaVersion: report.schemaVersion, task: report.task, operation: report.operation, nextTask: report.nextTask }, + { schemaVersion: 1, task: "M12-07C", operation: "WEB_OBJ_TO_DESKTOP_ROUNDTRIP", nextTask: "M12-07D" }, +); +assert.deepEqual(report.browser.imported, { schemaVersion: 1, objectCount: 2, positionCount: 6, texcoordCount: 6, normalCount: 2, faceCount: 2, materialCount: 2 }); +assert.deepEqual(report.browser.lossReport, { schemaVersion: 1, operation: "OBJ_EXPORT_LOSS_REPORT", canRoundTrip: true, warningCount: 0, warnings: [] }); +assert.equal(report.browser.missingTextureLoss.warningCount, 2); +assert.ok(report.browser.missingTextureLoss.warnings.every((warning) => warning.code === "OBJ_TEXTURE_ORIGIN_UNRESOLVED")); +assert.equal(report.desktop.schemaVersion, 1); +assert.equal(report.desktop.operation, "DESKTOP_IMPORT_WEB_OBJ"); +assert.equal(report.desktop.objectCount, 2); +assert.equal(report.desktop.meshCount, 2); +assert.ok(report.desktop.objects.every((object) => object.vertexCount === 3 && object.polygonCount === 1 && object.triangleCount === 1 && object.uvLayers.includes("UVMap") && object.materials.length === 1)); +assert.deepEqual(report.comparisons, { objectCountExact: true, triangleCountExact: true, uvLayerPresent: true, materialPresent: true }); +for (const artifact of Object.values(manifest.artifacts)) { + const file = path.join(root, artifact.path); + assert.ok(fs.existsSync(file), `missing artifact ${artifact.path}`); + assert.equal(fileHash(file), artifact.sha256, `hash mismatch ${artifact.path}`); +} +process.stdout.write(`obj-web-roundtrip-ok objects=${report.desktop.objectCount} triangles=${report.desktop.objects.reduce((sum, object) => sum + object.triangleCount, 0)} lossWarnings=${report.browser.missingTextureLoss.warningCount} desktopExact=true next=${manifest.nextTask}\n`); diff --git a/tools/web/check-obj-web-roundtrip.py b/tools/web/check-obj-web-roundtrip.py new file mode 100644 index 00000000..a31075ee --- /dev/null +++ b/tools/web/check-obj-web-roundtrip.py @@ -0,0 +1,71 @@ +"""Import a browser-produced OBJ in pinned Blender 5.2 and emit a semantic report.""" + +import hashlib +import json +import sys +from pathlib import Path + +import bpy + + +def sha256_file(path): + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def report_scene(): + objects = [] + for obj in sorted((item for item in bpy.context.scene.objects if item.type == "MESH"), key=lambda item: item.name): + mesh = obj.data + mesh.calc_loop_triangles() + uv_layers = sorted(layer.name for layer in mesh.uv_layers) + objects.append({ + "name": obj.name, + "vertexCount": len(mesh.vertices), + "polygonCount": len(mesh.polygons), + "triangleCount": len(mesh.loop_triangles), + "normalCount": len(mesh.vertices), + "uvLayers": uv_layers, + "uvLoopCount": len(mesh.uv_layers.active.data) if mesh.uv_layers.active else 0, + "materials": sorted(material.name for material in mesh.materials if material), + }) + return {"objects": objects, "objectCount": len(objects), "meshCount": len(objects)} + + +def main(obj_path, report_path): + obj_path = Path(obj_path).resolve() + report_path = Path(report_path).resolve() + bpy.ops.wm.read_factory_settings(use_empty=True) + result = bpy.ops.wm.obj_import( + filepath=str(obj_path), + directory=str(obj_path.parent), + forward_axis="NEGATIVE_Z", + up_axis="Y", + global_scale=1.0, + use_split_objects=True, + use_split_groups=True, + validate_meshes=True, + import_vertex_groups=False, + ) + if "FINISHED" not in result: + raise RuntimeError("Blender OBJ import did not finish: %s" % (result,)) + report = { + "schemaVersion": 1, + "operation": "DESKTOP_IMPORT_WEB_OBJ", + "sourceObjSha256": sha256_file(obj_path), + "sourceObjBytes": obj_path.stat().st_size, + **report_scene(), + } + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print("obj-web-roundtrip-desktop-imported objects=%s triangles=%s" % (report["objectCount"], sum(item["triangleCount"] for item in report["objects"]))) + + +if __name__ == "__main__": + args = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] + if len(args) != 2: + raise SystemExit("usage: blender --background --python check-obj-web-roundtrip.py -- OBJ REPORT") + main(args[0], args[1]) diff --git a/tools/web/check-ply-capability-fixtures.mjs b/tools/web/check-ply-capability-fixtures.mjs new file mode 100644 index 00000000..270c312d --- /dev/null +++ b/tools/web/check-ply-capability-fixtures.mjs @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +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 { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const manifestPath = path.join(root, "tests/golden/M12-07G/manifest.json"); +const reportPath = path.join(root, "tests/golden/M12-07G/capability-report.json"); +const fixtureRoot = path.join(root, "tests/files/web/m12_ply_capability_v1"); +const generator = path.join(root, "tools/web/generate-ply-capability-fixtures.py"); +const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender"); +const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); +const fileHash = (file) => sha256(fs.readFileSync(file)); +const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8")); + +const manifest = readJson(manifestPath); +const report = readJson(reportPath); +assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M12-07G", parentTask: "M12-07F", nextTask: "M12-07H" }); +assert.deepEqual({ schemaVersion: report.schemaVersion, task: report.task, operation: report.operation, nextTask: report.nextTask }, { schemaVersion: 1, task: "M12-07G", operation: "DESKTOP_PLY_ASCII_BINARY_LE_CAPABILITY", nextTask: "M12-07H" }); +for (const artifact of Object.values(manifest.artifacts)) { + if (artifact.path === manifestPath) continue; + const absolute = path.join(root, artifact.path); + assert.ok(fs.existsSync(absolute), `missing artifact ${artifact.path}`); + assert.equal(fileHash(absolute), artifact.sha256, `hash mismatch ${artifact.path}`); +} +const inventory = readJson(path.join(root, "tests/golden/M12-05A/format-inventory.json")); +for (const key of ["blenderVersion", "versionTuple", "buildDate", "buildTime", "buildHash", "buildBranch", "buildPlatform", "buildType", "binarySha256"]) assert.deepEqual(report.runtime[key], inventory.runtime[key], `runtime drift in ${key}`); +assert.equal(report.sourceAnchor, "blender-5.2.0/source/blender/io/ply"); +assert.equal(report.operator, "wm.ply_export"); +assert.deepEqual(report.variants.map((variant) => variant.id), ["PLY_ASCII", "PLY_BINARY_LITTLE_ENDIAN"]); +assert.equal(report.variants[0].semantic.format, "ascii"); +assert.equal(report.variants[1].semantic.format, "binary_little_endian"); +for (const variant of report.variants) { + assert.deepEqual(variant.semantic.elements, [{ name: "vertex", count: 4 }, { name: "face", count: 2 }]); + const file = path.join(fixtureRoot, variant.file); + assert.equal(fileHash(file), report.files.find((candidate) => candidate.name === variant.file).sha256); +} +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-07g-ply-")); +try { + const regeneratedReport = path.join(temporary, "capability-report.json"); + const result = spawnSync(blender, ["-b", "--factory-startup", "--python", generator, "--", temporary, regeneratedReport], { cwd: root, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.deepEqual(readJson(regeneratedReport), report, "PLY capability report is not deterministic"); + for (const file of report.files) assert.deepEqual(fs.readFileSync(path.join(temporary, file.name)), fs.readFileSync(path.join(fixtureRoot, file.name)), `${file.name} bytes are not deterministic`); +} +finally { fs.rmSync(temporary, { recursive: true, force: true }); } +process.stdout.write(`ply-capability-fixtures-ok ascii=ascii binary=binary_little_endian vertices=4 faces=2 deterministic=true next=${manifest.nextTask}\n`); diff --git a/tools/web/check-ply-mapping-fixtures.mjs b/tools/web/check-ply-mapping-fixtures.mjs new file mode 100644 index 00000000..7f3ec401 --- /dev/null +++ b/tools/web/check-ply-mapping-fixtures.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +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 { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const manifestPath = path.join(root, "tests/golden/M12-07H/manifest.json"); +const reportPath = path.join(root, "tests/golden/M12-07H/mapping-report.json"); +const fixtureRoot = path.join(root, "tests/files/web/m12_ply_mapping_v1"); +const generator = path.join(root, "tools/web/generate-ply-mapping-fixtures.py"); +const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender"); +const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); +const fileHash = (file) => sha256(fs.readFileSync(file)); +const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8")); + +const manifest = readJson(manifestPath); +const report = readJson(reportPath); +assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M12-07H", parentTask: "M12-07G", nextTask: "M12-07I" }); +assert.deepEqual({ schemaVersion: report.schemaVersion, task: report.task, operation: report.operation, nextTask: report.nextTask }, { schemaVersion: 1, task: "M12-07H", operation: "PLY_VERTEX_FACE_COLOR_CUSTOM_MAPPING", nextTask: "M12-07I" }); +assert.deepEqual(report.variants.map((variant) => variant.semantic.vertexCount), [4, 4]); +assert.deepEqual(report.variants.map((variant) => variant.semantic.faceCount), [2, 2]); +assert.deepEqual(report.variants[0].semantic.vertices[0].color, [254, 0, 0, 255]); +assert.deepEqual(report.variants[0].semantic.vertices[0].customProperties, { label: 1, temperature: 10 }); +assert.deepEqual(report.unknownProperty, { file: "unknown-property-ascii.ply", property: "unknown_values", expectedCode: "PLY_UNKNOWN_PROPERTY" }); +for (const artifact of Object.values(manifest.artifacts)) { + const absolute = path.join(root, artifact.path); + assert.ok(fs.existsSync(absolute), `missing artifact ${artifact.path}`); + assert.equal(fileHash(absolute), artifact.sha256, `hash mismatch ${artifact.path}`); +} +for (const file of report.files) assert.equal(fileHash(path.join(fixtureRoot, file.name)), file.sha256, `fixture hash mismatch ${file.name}`); +const inventory = readJson(path.join(root, "tests/golden/M12-05A/format-inventory.json")); +for (const key of ["blenderVersion", "versionTuple", "buildDate", "buildTime", "buildHash", "buildBranch", "buildPlatform", "buildType", "binarySha256"]) assert.deepEqual(report.runtime[key], inventory.runtime[key], `runtime drift in ${key}`); + +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-07h-ply-")); +try { + const regeneratedReport = path.join(temporary, "mapping-report.json"); + const result = spawnSync(blender, ["-b", "--factory-startup", "--python", generator, "--", temporary, regeneratedReport], { cwd: root, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.deepEqual(readJson(regeneratedReport), report, "PLY mapping report is not deterministic"); + for (const file of report.files) assert.deepEqual(fs.readFileSync(path.join(temporary, file.name)), fs.readFileSync(path.join(fixtureRoot, file.name)), `${file.name} bytes are not deterministic`); +} +finally { fs.rmSync(temporary, { recursive: true, force: true }); } +process.stdout.write(`ply-mapping-fixtures-ok vertices=4 faces=2 colors=rgba custom=2 unknown=PLY_UNKNOWN_PROPERTY deterministic=true next=${manifest.nextTask}\n`); diff --git a/tools/web/check-ply-negative-fixtures.mjs b/tools/web/check-ply-negative-fixtures.mjs new file mode 100644 index 00000000..02d20c36 --- /dev/null +++ b/tools/web/check-ply-negative-fixtures.mjs @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +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 { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const fixtureRoot = path.join(root, "tests/files/web/m12_ply_negative_v1"); +const reportPath = path.join(root, "tests/golden/M12-07I/negative-report.json"); +const manifestPath = path.join(root, "tests/golden/M12-07I/manifest.json"); +const generator = path.join(root, "tools/web/generate-ply-negative-fixtures.mjs"); +const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); +const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8")); +const manifest = readJson(manifestPath); +const report = readJson(reportPath); +assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M12-07I", parentTask: "M12-07H", nextTask: "M12-07J" }); +assert.deepEqual({ schemaVersion: report.schemaVersion, task: report.task, operation: report.operation, nextTask: report.nextTask }, { schemaVersion: 1, task: "M12-07I", operation: "PLY_NEGATIVE_FORMAT_LIST_COUNT", nextTask: "M12-07J" }); +assert.deepEqual(report.cases.map((item) => item.expectedCode), ["PLY_FORMAT_UNSUPPORTED", "PLY_DATA_TRUNCATED", "PLY_IMPORT_BUDGET_EXCEEDED: vertex"]); +for (const file of report.files) assert.equal(sha256(fs.readFileSync(path.join(fixtureRoot, file.name))), file.sha256, `hash mismatch ${file.name}`); +for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(fs.readFileSync(path.join(root, artifact.path))), artifact.sha256, `artifact hash mismatch ${artifact.path}`); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-07i-ply-")); +try { + const output = path.join(temporary, "negative-report.json"); + const result = spawnSync(process.execPath, [generator], { cwd: root, env: { ...process.env, M12_PLY_NEGATIVE_OUTPUT: temporary, M12_PLY_NEGATIVE_REPORT: output }, encoding: "utf8" }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.deepEqual(readJson(output), report, "PLY negative report is not deterministic"); + for (const file of report.files) assert.deepEqual(fs.readFileSync(path.join(temporary, file.name)), fs.readFileSync(path.join(fixtureRoot, file.name)), `${file.name} bytes are not deterministic`); +} +finally { fs.rmSync(temporary, { recursive: true, force: true }); } +process.stdout.write(`ply-negative-fixtures-ok cases=${report.cases.length} bigEndian=PLY_FORMAT_UNSUPPORTED malformed=PLY_DATA_TRUNCATED oversized=PLY_IMPORT_BUDGET_EXCEEDED deterministic=true next=${report.nextTask}\n`); diff --git a/tools/web/check-ply-web-roundtrip.py b/tools/web/check-ply-web-roundtrip.py new file mode 100644 index 00000000..10dcc85d --- /dev/null +++ b/tools/web/check-ply-web-roundtrip.py @@ -0,0 +1,56 @@ +"""Import a browser-produced PLY in pinned Blender 5.2 and emit mapped fields.""" + +import hashlib +import json +import sys +from pathlib import Path + +import bpy + + +def sha256_file(path): + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def attr_values(attribute): + values = [] + for item in attribute.data: + if attribute.data_type == "FLOAT_COLOR": values.append([float(value) for value in item.color]) + elif attribute.data_type == "FLOAT_VECTOR": values.append([float(value) for value in item.vector]) + elif attribute.data_type == "FLOAT": values.append(float(item.value)) + elif attribute.data_type == "INT": values.append(int(item.value)) + return values + + +def main(ply_path, report_path): + ply_path = Path(ply_path).resolve(); report_path = Path(report_path).resolve() + bpy.ops.wm.read_factory_settings(use_empty=True) + result = bpy.ops.wm.ply_import(filepath=str(ply_path), import_colors="SRGB", import_attributes=True, forward_axis="NEGATIVE_Z", up_axis="Y", global_scale=1.0) + objects = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"] + if "FINISHED" not in result or not objects: raise RuntimeError("Blender PLY import did not produce a mesh") + obj = objects[0]; mesh = obj.data; mesh.calc_loop_triangles() + wanted = [attribute for attribute in mesh.attributes if attribute.name in {"Col", "temperature", "label"}] + report = { + "schemaVersion": 1, + "operation": "DESKTOP_IMPORT_WEB_PLY", + "sourceSha256": sha256_file(ply_path), + "sourceBytes": ply_path.stat().st_size, + "objectCount": len(objects), + "vertexCount": len(mesh.vertices), + "polygonCount": len(mesh.polygons), + "triangleCount": len(mesh.loop_triangles), + "positions": [[float(value) for value in vertex.co] for vertex in mesh.vertices], + "attributes": [{"name": attribute.name, "dataType": attribute.data_type, "domain": attribute.domain, "values": attr_values(attribute)} for attribute in wanted], + } + report_path.parent.mkdir(parents=True, exist_ok=True); report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print("ply-web-roundtrip-desktop-imported vertices=%s faces=%s attrs=%s" % (report["vertexCount"], report["triangleCount"], len(report["attributes"]))) + + +if __name__ == "__main__": + args = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] + if len(args) != 2: raise SystemExit("usage: blender --background --python check-ply-web-roundtrip.py -- PLY REPORT") + main(args[0], args[1]) diff --git a/tools/web/check-probe-identity.mjs b/tools/web/check-probe-identity.mjs new file mode 100644 index 00000000..d95ddee7 --- /dev/null +++ b/tools/web/check-probe-identity.mjs @@ -0,0 +1,188 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const distRoot = path.join(root, "web/dist"); +const reportPath = path.join(root, "tests/golden/M14-01D/probe-identity-report.json"); +const manifestPath = path.join(root, "tests/golden/M14-01D/manifest.json"); +const digest = (value) => crypto.createHash("sha256").update(value).digest("hex"); +const fileDigest = (file) => digest(fs.readFileSync(file)); +const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/"); +const assetsRoot = path.join(distRoot, "assets"); +assert.ok(fs.existsSync(assetsRoot), "production assets are missing; run npm --prefix web run build first"); +const workerName = fs.readdirSync(assetsRoot).find((name) => /^storage\.worker-[\w-]+\.js$/u.test(name)); +const wasmName = fs.readdirSync(assetsRoot).find((name) => /^web_engine-[\w-]+\.wasm$/u.test(name)); +assert.ok(workerName && wasmName, "production worker/WASM assets are missing"); + +const mime = new Map([ + [".html", "text/html; charset=utf-8"], + [".js", "text/javascript; charset=utf-8"], + [".css", "text/css; charset=utf-8"], + [".json", "application/json"], + [".wasm", "application/wasm"], +]); +const server = http.createServer((request, response) => { + const pathname = decodeURIComponent(new URL(request.url ?? "/", "http://127.0.0.1").pathname); + const relativePath = pathname === "/" ? "index.html" : pathname.replace(/^\//u, ""); + const file = path.resolve(distRoot, relativePath); + if (!file.startsWith(`${distRoot}${path.sep}`) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { + response.writeHead(404); + response.end("not found"); + return; + } + response.statusCode = 200; + response.setHeader("Content-Type", mime.get(path.extname(file)) ?? "application/octet-stream"); + response.setHeader("Cross-Origin-Opener-Policy", "same-origin"); + response.setHeader("Cross-Origin-Embedder-Policy", "require-corp"); + response.setHeader("Cross-Origin-Resource-Policy", "same-origin"); + response.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; font-src 'self'; img-src 'self'; media-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"); + fs.createReadStream(file).pipe(response); +}); + +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +const address = server.address(); +assert.ok(address && typeof address === "object"); +const origin = `http://127.0.0.1:${address.port}`; +let browser; +try { + const playwright = await import(pathToFileURL(path.join(root, "web/node_modules/playwright/index.mjs")).href); + browser = await playwright.chromium.launch({ headless: true }); + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto(`${origin}/`, { waitUntil: "load" }); + const probe = await page.evaluate(async ({ workerPath, wasmPath }) => { + const run = async (name, operation) => { + try { + return { name, ...await operation() }; + } catch (error) { + return { + name, + status: "BLOCKED", + code: error instanceof Error ? error.name : "PROBE_FAILED", + detail: error instanceof Error ? error.message.slice(0, 200) : String(error), + }; + } + }; + const wasm = await run("wasm", async () => { + const response = await fetch(wasmPath); + if (!response.ok) throw new Error(`HTTP_${response.status}`); + const bytes = await response.arrayBuffer(); + await WebAssembly.compile(bytes); + return { status: "PASS", code: "WASM_READY", bytes: bytes.byteLength }; + }); + const worker = await run("worker", async () => { + await new Promise((resolve, reject) => { + const value = new Worker(workerPath, { type: "module" }); + const timer = setTimeout(() => { value.terminate(); resolve(); }, 500); + value.onerror = (event) => { clearTimeout(timer); value.terminate(); reject(new Error(event.message || "WORKER_LOAD_FAILED")); }; + }); + return { status: "PASS", code: "WORKER_READY" }; + }); + const webgl2 = await run("webgl2", async () => { + const canvas = document.createElement("canvas"); + const gl = canvas.getContext("webgl2"); + if (!gl) return { status: "BLOCKED", code: "WEBGL2_UNAVAILABLE" }; + const debug = gl.getExtension("WEBGL_debug_renderer_info"); + return { + status: "PASS", + code: "WEBGL2_READY", + vendor: debug ? gl.getParameter(debug.UNMASKED_VENDOR_WEBGL) : "REDACTED", + renderer: debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : "REDACTED", + version: gl.getParameter(gl.VERSION), + }; + }); + const webgpu = await run("webgpu", async () => { + if (!("gpu" in navigator)) return { status: "BLOCKED", code: "WEBGPU_UNAVAILABLE" }; + const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" }); + if (!adapter) return { status: "BLOCKED", code: "WEBGPU_ADAPTER_UNAVAILABLE" }; + const info = adapter.info ?? {}; + return { + status: "PASS", + code: "WEBGPU_READY", + vendor: info.vendor ?? "REDACTED", + architecture: info.architecture ?? "REDACTED", + device: info.device ?? "REDACTED", + description: info.description ?? "REDACTED", + isFallbackAdapter: Boolean(adapter.isFallbackAdapter), + }; + }); + return { + userAgent: navigator.userAgent, + platform: navigator.platform, + language: navigator.language, + hardwareConcurrency: navigator.hardwareConcurrency, + deviceMemory: typeof navigator.deviceMemory === "number" ? navigator.deviceMemory : null, + crossOriginIsolated, + capabilities: [wasm, worker, webgl2, webgpu], + }; + }, { workerPath: `/assets/${workerName}`, wasmPath: `/assets/${wasmName}` }); + + const capabilities = Object.fromEntries(probe.capabilities.map((item) => [item.name, item])); + const reportWithoutIdentity = { + schemaVersion: 1, + task: "M14-01D", + operation: "PROBE_IDENTITY_GPU_OS_ADAPTER", + runtime: "PLAYWRIGHT_CHROMIUM", + browser: { + version: await browser.version(), + executablePath: playwright.chromium.executablePath(), + userAgent: probe.userAgent, + platform: probe.platform, + language: probe.language, + hardwareConcurrency: probe.hardwareConcurrency, + deviceMemory: probe.deviceMemory, + }, + os: { + platform: os.platform(), + release: os.release(), + version: os.version(), + arch: os.arch(), + machine: os.machine(), + cpus: os.cpus().length, + }, + adapter: { + webgl2: capabilities.webgl2, + webgpu: capabilities.webgpu, + }, + isolation: { crossOriginIsolated: probe.crossOriginIsolated }, + assets: { + worker: { path: relative(path.join(assetsRoot, workerName)), sha256: fileDigest(path.join(assetsRoot, workerName)) }, + wasm: { path: relative(path.join(assetsRoot, wasmName)), sha256: fileDigest(path.join(assetsRoot, wasmName)) }, + }, + supportRule: "IDENTITY_IS_OBSERVED_ONLY; PASS_ONLY_WHEN_PROBED; BLOCKED_OR_UNAVAILABLE_DOES_NOT_CLAIM_SUPPORT", + execution: "DISABLED", + nextTask: "M14-01E", + }; + const identityInput = JSON.stringify(reportWithoutIdentity); + const report = { ...reportWithoutIdentity, identitySha256: digest(identityInput) }; + if (process.env.UPDATE_M14_01D_REPORT === "1") { + fs.mkdirSync(path.dirname(reportPath), { recursive: true }); + fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); + if (process.env.UPDATE_M14_01D_REPORT === "1") { + const artifactPaths = { + parentManifest: path.join(root, "tests/golden/M14-01C/manifest.json"), + checker: path.join(root, "tools/web/check-probe-identity.mjs"), + package: path.join(root, "web/package.json"), + engineManifest: path.join(root, "web/app/public/engine-manifest.json"), + report: reportPath, + }; + const artifacts = Object.fromEntries(Object.entries(artifactPaths).map(([name, file]) => [name, { path: relative(file), sha256: fileDigest(file) }])); + fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task: "M14-01D", parentTask: "M14-01C", enablingTask: false, parityStateChange: false, runtime: "PLAYWRIGHT_CHROMIUM", operation: "PROBE_IDENTITY_GPU_OS_ADAPTER", artifacts, nextTask: "M14-01E" }, null, 2)}\n`); + } + } + assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M14-01D", parentTask: "M14-01C", nextTask: "M14-01E" }); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileDigest(path.join(root, artifact.path)), artifact.sha256, artifact.path); + assert.equal(report.identitySha256, digest(JSON.stringify(reportWithoutIdentity))); + const summary = [capabilities.webgl2, capabilities.webgpu].map((item) => `${item.name}=${item.status}`).join(","); + process.stdout.write(`probe-identity-ok version=${report.browser.version} os=${report.os.platform}/${report.os.arch} ${summary} identity=${report.identitySha256} execution=DISABLED next=${manifest.nextTask}\n`); +} finally { + await browser?.close(); + await new Promise((resolve) => server.close(resolve)); +} diff --git a/tools/web/check-script-audit-integrity.mjs b/tools/web/check-script-audit-integrity.mjs new file mode 100644 index 00000000..8718f39e --- /dev/null +++ b/tools/web/check-script-audit-integrity.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import ts from "../../web/node_modules/typescript/lib/typescript.js"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const reportPath = path.join(root, "tests/golden/M13-05H/script-audit-integrity-report.json"); +const manifestPath = path.join(root, "tests/golden/M13-05H/manifest.json"); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-05h-audit-")); +const hashFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +const transpile = (name) => { + const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8"); + const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText + .replace('require("./asset-path")', 'require("./asset-path.cjs")') + .replace('require("./capability-gates")', 'require("./capability-gates.cjs")'); + fs.writeFileSync(path.join(temporary, `${name}.cjs`), output); +}; +const errorCode = async (operation) => { try { await operation(); return "ACCEPTED"; } catch (error) { return error instanceof Error ? error.message.split(":", 1)[0] : String(error); } }; + +try { + for (const name of ["asset-path", "capability-gates", "scripting-platform"]) transpile(name); + const protocol = createRequire(import.meta.url)(path.join(temporary, "scripting-platform.cjs")); + const digest = "a".repeat(64); + const manifest = { schemaVersion: 1, scripts: [{ id: "script:audit", name: "Audit", entryPath: "scripts/audit.py", sourceByteLength: 32, sourceSha256: digest, publisher: "local", signature: "b".repeat(128), keyId: "key:local", permissions: ["READ_MAIN"], dependencies: [], module: false, cpuMs: 1000, memoryBytes: 1024, wallMs: 5000, network: false, autorun: false, driverExpressions: false, addonInstall: false }] }; + const first = await protocol.createScriptExecutionAudit(manifest, "script:audit", new Set(), { requestId: "audit:first", requestedAt: "2026-08-19T00:00:00.000Z" }); + const second = await protocol.createScriptExecutionAudit(manifest, "script:audit", new Set(), { requestId: "audit:second", requestedAt: "2026-08-19T00:00:01.000Z" }); + const firstLog = await protocol.appendScriptExecutionAudit({ schemaVersion: 1, entries: [] }, first); + const log = await protocol.appendScriptExecutionAudit(firstLog, second); + const replay = await errorCode(() => protocol.appendScriptExecutionAudit(log, first)); + const earlier = await protocol.createScriptExecutionAudit(manifest, "script:audit", new Set(), { requestId: "audit:earlier", requestedAt: "2026-08-18T23:59:59.000Z" }); + const timeOrder = await errorCode(() => protocol.appendScriptExecutionAudit(log, earlier)); + const sequence = await errorCode(() => protocol.parseScriptExecutionAuditLog({ ...log, entries: [{ ...log.entries[0], sequence: 2 }, log.entries[1]] })); + const entryTamper = await errorCode(() => protocol.parseScriptExecutionAuditLog({ ...log, entries: [{ ...log.entries[0], entrySha256: "c".repeat(64) }, log.entries[1]] })); + const chainTamper = await errorCode(() => protocol.parseScriptExecutionAuditLog({ ...log, entries: [log.entries[0], { ...log.entries[1], previousEntrySha256: "d".repeat(64) }] })); + assert.equal(replay, "SCRIPT_MANIFEST_INVALID"); + assert.equal(timeOrder, "SCRIPT_MANIFEST_INVALID"); + assert.equal(sequence, "SCRIPT_MANIFEST_INVALID"); + assert.equal(entryTamper, "SCRIPT_MANIFEST_INVALID"); + assert.equal(chainTamper, "SCRIPT_MANIFEST_INVALID"); + const report = { schemaVersion: 1, task: "M13-05H", operation: "SCRIPT_AUDIT_INTEGRITY", runtime: "NODE_PRODUCTION_PROTOCOL", log: { entryCount: log.entries.length, sequences: log.entries.map((entry) => entry.sequence), requestIds: log.entries.map((entry) => entry.audit.requestId), firstPreviousEntrySha256: log.entries[0].previousEntrySha256, secondPreviousEntrySha256: log.entries[1].previousEntrySha256, firstEntrySha256: log.entries[0].entrySha256, secondEntrySha256: log.entries[1].entrySha256, strictlyIncreasingTime: true, uniqueRequestIds: true, continuousHashChain: true }, negative: { replay, timeOrder, sequence, entryTamper, chainTamper }, execution: "DISABLED", nextTask: "M14-01A" }; + if (process.env.UPDATE_M13_05H_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); } + assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report); + const manifestReceipt = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + assert.deepEqual({ schemaVersion: manifestReceipt.schemaVersion, task: manifestReceipt.task, parentTask: manifestReceipt.parentTask, nextTask: manifestReceipt.nextTask }, { schemaVersion: 1, task: "M13-05H", parentTask: "M13-05G", nextTask: "M14-01A" }); + for (const artifact of Object.values(manifestReceipt.artifacts)) assert.equal(hashFile(path.join(root, artifact.path)), artifact.sha256, artifact.path); + process.stdout.write(`script-audit-integrity-ok entries=2 sequence=1,2 requestIds=unique time=ordered chain=true replay=${replay} tamper=3 execution=DISABLED next=${manifestReceipt.nextTask}\n`); +} finally { + fs.rmSync(temporary, { recursive: true, force: true }); +} diff --git a/tools/web/check-script-entry-inventory.mjs b/tools/web/check-script-entry-inventory.mjs new file mode 100644 index 00000000..013862f9 --- /dev/null +++ b/tools/web/check-script-entry-inventory.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +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 { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const reportPath = path.join(root, "tests/golden/M13-01A/entry-inventory.json"); +const manifestPath = path.join(root, "tests/golden/M13-01A/manifest.json"); +const fixtureRoot = path.join(root, "tests/files/web/m13_script_entry_v1"); +const generator = path.join(root, "tools/web/generate-script-entry-inventory.py"); +const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender"); +const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); +const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8")); +const report = readJson(reportPath); +const manifest = readJson(manifestPath); +assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-01A", parentTask: "M12-07J", nextTask: "M13-01B" }); +assert.deepEqual({ schemaVersion: report.schemaVersion, task: report.task, operation: report.operation, nextTask: report.nextTask }, { schemaVersion: 1, task: "M13-01A", operation: "BLENDER_SCRIPT_ENTRY_INVENTORY", nextTask: "M13-01B" }); +assert.deepEqual(report.executionPolicy, { text: "READ_METADATA_ONLY", pythonConsole: "DENY", autorun: "DENY", driverExpression: "DENY", handler: "DENY", addon: "DENY" }); +assert.equal(report.inventory.text.count, 3); assert.deepEqual(report.inventory.autorun.moduleTextNames, ["ModuleAutorun.py"]); assert.equal(report.inventory.driverExpressions.count, 1); assert.equal(report.inventory.pythonConsole.available, true); assert.equal(report.inventory.addons.defaultExecution, "DENY"); +const fixturePath = path.join(fixtureRoot, report.fixture.name); +assert.equal(sha256(fs.readFileSync(fixturePath)), report.fixture.sha256); +for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(fs.readFileSync(path.join(root, artifact.path))), artifact.sha256, `artifact hash mismatch ${artifact.path}`); +const inventory = readJson(path.join(root, "tests/golden/M12-05A/format-inventory.json")); +for (const key of ["blenderVersion", "versionTuple", "buildDate", "buildTime", "buildHash", "buildBranch", "buildPlatform", "buildType", "binarySha256"]) assert.deepEqual(report.runtime[key], inventory.runtime[key], `runtime drift in ${key}`); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-01a-script-")); +try { + const regenerated = path.join(temporary, "entry-inventory.json"); + const result = spawnSync(blender, ["-b", "--factory-startup", "--python", generator, "--", temporary, regenerated], { cwd: root, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + const regeneratedReport = readJson(regenerated); + assert.deepEqual({ ...regeneratedReport, fixture: { ...regeneratedReport.fixture, sha256: "" } }, { ...report, fixture: { ...report.fixture, sha256: "" } }, "script entry inventory is not deterministic"); + assert.equal(regeneratedReport.fixture.byteLength, report.fixture.byteLength); + assert.equal(fs.statSync(path.join(temporary, report.fixture.name)).size, report.fixture.byteLength); +} +finally { fs.rmSync(temporary, { recursive: true, force: true }); } +process.stdout.write(`script-entry-inventory-ok texts=3 console=3 autorun=1 drivers=1 handlers=${report.inventory.handlers.groups.length} addonOps=4 deterministic=true next=${report.nextTask}\n`); diff --git a/tools/web/check-script-host-call.mjs b/tools/web/check-script-host-call.mjs new file mode 100644 index 00000000..4b1f11d4 --- /dev/null +++ b/tools/web/check-script-host-call.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import ts from "../../web/node_modules/typescript/lib/typescript.js"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const reportPath = path.join(root, "tests/golden/M13-03C/host-call-report.json"); +const manifestPath = path.join(root, "tests/golden/M13-03C/manifest.json"); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-03c-host-call-")); +const hashFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +for (const name of ["asset-path", "capability-gates", "scripting-platform"]) { + const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8"); + const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText + .replace('require("./asset-path")', 'require("./asset-path.cjs")').replace('require("./capability-gates")', 'require("./capability-gates.cjs")'); + fs.writeFileSync(path.join(temporary, `${name}.cjs`), output); +} +const protocol = createRequire(import.meta.url)(path.join(temporary, "scripting-platform.cjs")); +try { + const digest = "a".repeat(64); const permissions = new Set(protocol.SCRIPT_PERMISSIONS); + const call = (name, parameters) => ({ schemaVersion: 1, requestId: `host:${name.toLowerCase()}`, scriptId: "clean", call: name, permission: name, parameters }); + const accepted = [ + protocol.parseScriptHostCall(call("READ_MAIN", { revision: 3 }), permissions), + protocol.parseScriptHostCall(call("READ_ASSET", { path: "//assets/model.bin", expectedSha256: digest }), permissions), + protocol.parseScriptHostCall(call("WRITE_MAIN", { revision: 3, operation: "object.transform", payload: { objectId: "obj:1", x: 1 } }), permissions), + protocol.parseScriptHostCall(call("WRITE_ASSET", { path: "assets/out.bin", byteLength: 4, sha256: digest }), permissions), + protocol.parseScriptHostCall(call("SUBMIT_SERVER_JOB", { inputBlendSha256: digest, settingsSha256: digest }), permissions), + ]; + const blocked = {}; + 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 })]]) { + try { protocol.parseScriptHostCall(input, permissions); blocked[name] = "ACCEPTED"; } catch (error) { blocked[name] = error instanceof Error ? error.message.split(":", 1)[0] : String(error); } + } + assert.deepEqual(accepted.map((item) => item.call), ["READ_MAIN", "READ_ASSET", "WRITE_MAIN", "WRITE_ASSET", "SUBMIT_SERVER_JOB"]); + assert.ok(accepted.every((item) => item.execution === "DISABLED")); + assert.deepEqual(blocked, { unknown: "SCRIPT_POLICY_DENIED", permission: "SCRIPT_POLICY_DENIED", fields: "SCRIPT_MANIFEST_INVALID", path: "SCRIPT_MANIFEST_INVALID" }); + const report = { schemaVersion: 1, task: "M13-03C", operation: "SCRIPT_HOST_CALL_ALLOWLIST", acceptedCalls: accepted.map((item) => item.call), blocked, structuredParameters: true, execution: "DISABLED", invariants: { allowlistOnly: true, permissionBound: true, unknownFieldsRejected: true, projectPathsNormalized: true }, nextTask: "M13-03D" }; + if (process.env.UPDATE_M13_03C_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n"); } + assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-03C", parentTask: "M13-03B", nextTask: "M13-03D" }); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`); + process.stdout.write(`script-host-call-ok accepted=${accepted.length} blocked=${Object.keys(blocked).length} structured=true execution=DISABLED next=${manifest.nextTask}\n`); +} finally { fs.rmSync(temporary, { recursive: true, force: true }); } diff --git a/tools/web/check-script-manifest-budgets.mjs b/tools/web/check-script-manifest-budgets.mjs new file mode 100644 index 00000000..abf677cb --- /dev/null +++ b/tools/web/check-script-manifest-budgets.mjs @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import ts from "../../web/node_modules/typescript/lib/typescript.js"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const reportPath = path.join(root, "tests/golden/M13-02A/manifest-budget-report.json"); +const manifestPath = path.join(root, "tests/golden/M13-02A/manifest.json"); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-02a-manifest-")); +const digest = "a".repeat(64); +const signature = "b".repeat(128); +const hashFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +const script = (id, overrides = {}) => ({ + 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 }); +const transpile = (name) => { + const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8"); + const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText + .replace('require("./asset-path")', 'require("./asset-path.cjs")') + .replace('require("./capability-gates")', 'require("./capability-gates.cjs")'); + fs.writeFileSync(path.join(temporary, `${name}.cjs`), output); +}; +for (const name of ["asset-path", "capability-gates", "scripting-platform"]) transpile(name); +const require = createRequire(import.meta.url); +const protocol = require(path.join(temporary, "scripting-platform.cjs")); +const denied = (value) => { + try { protocol.parseScriptingManifest(value); return "ACCEPTED"; } + catch (error) { return error instanceof Error ? error.message : String(error); } +}; +try { + const valid = protocol.parseScriptingManifest(manifest([ + script("base", { entryPath: "//scripts/../scripts/base.py" }), + script("clean", { dependencies: [{ id: "base", sourceSha256: digest, sourcePath: "//deps/base.py" }] }), + ])); + const cases = { + count: denied(manifest(Array.from({ length: protocol.SCRIPTING_BUDGET.maxScripts + 1 }, (_, index) => script(`script-${index}`)))), + totalBytes: denied(manifest([script("large", { sourceByteLength: protocol.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"] })])), + }; + assert.equal(valid.scripts[0].entryPath, "scripts/base.py"); + assert.equal(valid.scripts[1].dependencies[0].sourcePath, "deps/base.py"); + assert.equal(valid.scripts.reduce((total, item) => total + item.sourceByteLength, 0), 256); + assert.match(cases.count, /SCRIPT_BUDGET_EXCEEDED/); + assert.match(cases.totalBytes, /SCRIPT_BUDGET_EXCEEDED/); + assert.match(cases.module, /SCRIPT_POLICY_DENIED/); + assert.match(cases.path, /SCRIPT_MANIFEST_INVALID/); + assert.match(cases.dependency, /SCRIPT_MANIFEST_INVALID/); + assert.match(cases.permission, /SCRIPT_POLICY_DENIED/); + const report = { + schemaVersion: 1, + task: "M13-02A", + operation: "SCRIPT_MANIFEST_BUDGETS", + accepted: { scriptCount: valid.scripts.length, canonicalEntryPath: valid.scripts[0].entryPath, canonicalDependencyPath: valid.scripts[1].dependencies[0].sourcePath, totalSourceBytes: 256, module: false }, + denied: Object.fromEntries(Object.entries(cases).map(([name, message]) => [name, { status: "BLOCKED", code: message.split(":", 1)[0] }])), + budgets: { maxScripts: protocol.SCRIPTING_BUDGET.maxScripts, maxSourceBytes: protocol.SCRIPTING_BUDGET.maxSourceBytes, maxDependencies: protocol.SCRIPTING_BUDGET.maxDependencies, maxPermissions: protocol.SCRIPTING_BUDGET.maxPermissions }, + nextTask: "M13-02B", + }; + if (process.env.UPDATE_M13_02A_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n"); } + assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report); + const manifestValue = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + assert.deepEqual({ schemaVersion: manifestValue.schemaVersion, task: manifestValue.task, parentTask: manifestValue.parentTask, nextTask: manifestValue.nextTask }, { schemaVersion: 1, task: "M13-02A", parentTask: "M13-01F", nextTask: "M13-02B" }); + for (const artifact of Object.values(manifestValue.artifacts)) assert.equal(hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`); + process.stdout.write(`script-manifest-budgets-ok accepted=${valid.scripts.length} totalSourceBytes=256 blocked=${Object.keys(cases).length} deterministic=true next=${manifestValue.nextTask}\n`); +} +finally { fs.rmSync(temporary, { recursive: true, force: true }); } diff --git a/tools/web/check-script-manifest-canonical.mjs b/tools/web/check-script-manifest-canonical.mjs new file mode 100644 index 00000000..c93da5ad --- /dev/null +++ b/tools/web/check-script-manifest-canonical.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import ts from "../../web/node_modules/typescript/lib/typescript.js"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const reportPath = path.join(root, "tests/golden/M13-02B/manifest-canonical-report.json"); +const manifestPath = path.join(root, "tests/golden/M13-02B/manifest.json"); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-02b-canonical-")); +const digest = "a".repeat(64); +const signature = "b".repeat(128); +const hashFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +const script = (id, overrides = {}) => ({ 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 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], + ], +}; +for (const name of ["asset-path", "capability-gates", "scripting-platform"]) { + const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8"); + const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText + .replace('require("./asset-path")', 'require("./asset-path.cjs")') + .replace('require("./capability-gates")', 'require("./capability-gates.cjs")'); + fs.writeFileSync(path.join(temporary, `${name}.cjs`), output); +} +const require = createRequire(import.meta.url); +const protocol = require(path.join(temporary, "scripting-platform.cjs")); +try { + const firstSerialized = protocol.serializeScriptingManifest(first); + const secondSerialized = protocol.serializeScriptingManifest(second); + const canonical = protocol.canonicalizeScriptingManifest(second); + assert.equal(firstSerialized, secondSerialized); + assert.deepEqual({ 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]) }, { firstId: "alpha", firstPermission: "READ_ASSET", dependencyOrder: ["alpha", "beta"], unknownDropped: true }); + assert.notEqual(firstSerialized, protocol.serializeScriptingManifest({ schemaVersion: 1, scripts: [script("alpha", { sourceByteLength: 129 })] })); + assert.throws(() => protocol.serializeScriptingManifest({ ...first, schemaVersion: 2 }), /PROTOCOL_MISMATCH/); + const report = { + schemaVersion: 1, + task: "M13-02B", + operation: "SCRIPT_MANIFEST_CANONICAL_SERIALIZATION", + equalOrderVariants: true, + canonical: { scriptOrder: canonical.scripts.map((script) => script.id), alphaPermissions: canonical.scripts[0].permissions, zetaDependencies: canonical.scripts[2].dependencies.map((dependency) => dependency.id), unknownFieldsDropped: true }, + signatureInput: { schemaVersion: 1, byteLength: Buffer.byteLength(firstSerialized), sha256: crypto.createHash("sha256").update(firstSerialized).digest("hex"), sourceByteLengthMutationChangesInput: true, schemaMutationRejected: true }, + nextTask: "M13-02C", + }; + if (process.env.UPDATE_M13_02B_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n"); } + assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report); + const manifestValue = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + assert.deepEqual({ schemaVersion: manifestValue.schemaVersion, task: manifestValue.task, parentTask: manifestValue.parentTask, nextTask: manifestValue.nextTask }, { schemaVersion: 1, task: "M13-02B", parentTask: "M13-02A", nextTask: "M13-02C" }); + for (const artifact of Object.values(manifestValue.artifacts)) assert.equal(hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`); + process.stdout.write(`script-manifest-canonical-ok equal=true bytes=${Buffer.byteLength(firstSerialized)} unknownDropped=true schemaMutation=blocked next=${manifestValue.nextTask}\n`); +} +finally { fs.rmSync(temporary, { recursive: true, force: true }); } diff --git a/tools/web/check-script-open-metadata.mjs b/tools/web/check-script-open-metadata.mjs new file mode 100644 index 00000000..2ce7fb86 --- /dev/null +++ b/tools/web/check-script-open-metadata.mjs @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const fixturePath = path.join(root, "tests/files/web/script_scene.blend"); +const fixture = fs.readFileSync(fixturePath); +const fixtureSha256 = crypto.createHash("sha256").update(fixture).digest("hex"); +const result = spawnSync(process.execPath, [path.join(root, "tools/web/check-script-main-reader.mjs")], { cwd: root, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 }); +assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); +assert.match(result.stdout, /script-main-reader-ok full-source=passed sha256=passed autorun-default-deny=passed/); +const report = { schemaVersion: 1, task: "M13-01B", operation: "BLEND_OPEN_SCRIPT_METADATA_ONLY", fixture: { path: "tests/files/web/script_scene.blend", byteLength: fixture.byteLength, sha256: fixtureSha256 }, sources: [{ name: "ExternalProject.py", executionStatus: "BLOCKED", readOnly: true }, { name: "InternalSafe.py", executionStatus: "BLOCKED", readOnly: true }, { name: "ModuleAutorun.py", executionStatus: "BLOCKED", readOnly: true, moduleAutorunRequested: true, errorCode: "SCRIPT_POLICY_DENIED" }], execution: "DENY", nextTask: "M13-01C" }; +const reportPath = path.join(root, "tests/golden/M13-01B/open-metadata-report.json"); fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n"); +process.stdout.write("script-open-metadata-ok blend=opened textMetadata=read sourceHash=verified execution=DENY autorun=DENY next=M13-01C\n"); diff --git a/tools/web/check-script-permission-policy.mjs b/tools/web/check-script-permission-policy.mjs new file mode 100644 index 00000000..c323fbe7 --- /dev/null +++ b/tools/web/check-script-permission-policy.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import ts from "../../web/node_modules/typescript/lib/typescript.js"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const reportPath = path.join(root, "tests/golden/M13-02E/permission-policy-report.json"); +const manifestPath = path.join(root, "tests/golden/M13-02E/manifest.json"); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-02e-permission-")); +const hashFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +const script = (permissions, overrides = {}) => ({ 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, ...overrides }); +const manifest = (permissions) => ({ schemaVersion: 1, scripts: [script(permissions)] }); +for (const name of ["asset-path", "capability-gates", "scripting-platform"]) { + const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8"); + const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText + .replace('require("./asset-path")', 'require("./asset-path.cjs")') + .replace('require("./capability-gates")', 'require("./capability-gates.cjs")'); + fs.writeFileSync(path.join(temporary, `${name}.cjs`), output); +} +const protocol = createRequire(import.meta.url)(path.join(temporary, "scripting-platform.cjs")); +try { + const defaultGrant = protocol.resolveScriptPermissions(manifest(["READ_MAIN"]), "clean"); + const declaredGrant = protocol.resolveScriptPermissions(manifest(["WRITE_ASSET", "READ_MAIN"]), "clean", ["READ_MAIN"]); + const escalation = protocol.resolveScriptPermissions(manifest(["READ_MAIN"]), "clean", ["WRITE_MAIN"]); + const unknownRequest = protocol.resolveScriptPermissions(manifest(["READ_MAIN"]), "clean", ["EXECUTE"]); + const duplicateRequest = protocol.resolveScriptPermissions(manifest(["READ_MAIN"]), "clean", ["READ_MAIN", "READ_MAIN"]); + let unknownDeclaration = "ACCEPTED"; + try { protocol.parseScriptingManifest(manifest(["EXECUTE"])); } catch (error) { unknownDeclaration = error instanceof Error ? error.message.split(":", 1)[0] : String(error); } + assert.deepEqual(defaultGrant.granted, []); + assert.deepEqual(declaredGrant.granted, ["READ_MAIN"]); + assert.equal(escalation.code, "SCRIPT_POLICY_DENIED"); + assert.equal(unknownRequest.code, "SCRIPT_POLICY_DENIED"); + assert.equal(duplicateRequest.code, "SCRIPT_POLICY_DENIED"); + assert.equal(unknownDeclaration, "SCRIPT_POLICY_DENIED"); + const report = { schemaVersion: 1, task: "M13-02E", operation: "SCRIPT_PERMISSION_MINIMIZATION", decisions: { defaultGrant: defaultGrant.status, declaredGrant: declaredGrant.status, escalation: escalation.code, unknownRequest: unknownRequest.code, duplicateRequest: duplicateRequest.code, unknownDeclaration }, invariant: { undeclaredNeverGranted: true, defaultGrantedCount: defaultGrant.granted.length, declaredGranted: declaredGrant.granted }, nextTask: "M13-02F" }; + if (process.env.UPDATE_M13_02E_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n"); } + assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report); + const manifestValue = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + assert.deepEqual({ schemaVersion: manifestValue.schemaVersion, task: manifestValue.task, parentTask: manifestValue.parentTask, nextTask: manifestValue.nextTask }, { schemaVersion: 1, task: "M13-02E", parentTask: "M13-02D", nextTask: "M13-02F" }); + for (const artifact of Object.values(manifestValue.artifacts)) assert.equal(hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`); + process.stdout.write(`script-permission-policy-ok defaultGranted=0 declared=READ_MAIN escalation=SCRIPT_POLICY_DENIED unknown=SCRIPT_POLICY_DENIED next=${manifestValue.nextTask}\n`); +} finally { fs.rmSync(temporary, { recursive: true, force: true }); } diff --git a/tools/web/check-script-policy-codes.mjs b/tools/web/check-script-policy-codes.mjs new file mode 100644 index 00000000..438bc473 --- /dev/null +++ b/tools/web/check-script-policy-codes.mjs @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import ts from "../../web/node_modules/typescript/lib/typescript.js"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const reportPath = path.join(root, "tests/golden/M13-01C/policy-codes-report.json"); +const manifestPath = path.join(root, "tests/golden/M13-01C/manifest.json"); +const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); +assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-01C", parentTask: "M13-01B", nextTask: "M13-01D" }); +const hashFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +for (const artifact of Object.values(manifest.artifacts)) assert.equal(hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-01c-policy-")); +const require = createRequire(import.meta.url); +try { + for (const name of ["asset-path", "capability-gates", "scripting-platform"]) { + const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8"); + const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText.replace('require("./asset-path")', 'require("./asset-path.cjs")').replace('require("./capability-gates")', 'require("./capability-gates.cjs")'); + fs.writeFileSync(path.join(temporary, `${name}.cjs`), output); + } + const protocol = require(path.join(temporary, "scripting-platform.cjs")); + const base = { schemaVersion: 1, scripts: [{ id: "demo", name: "Demo", entryPath: "scripts/demo.py", sourceByteLength: 128, sourceSha256: "a".repeat(64), publisher: "local", signature: "b".repeat(128), keyId: "key", permissions: ["READ_MAIN"], dependencies: [], module: false, cpuMs: 1000, memoryBytes: 64 * 1024 * 1024, wallMs: 2000, network: false, autorun: false, driverExpressions: false, addonInstall: false }] }; + const codes = []; + for (const [field, expected] of [["autorun", "SCRIPT_POLICY_DENIED"], ["driverExpressions", "DRIVER_EXECUTION_BLOCKED"], ["addonInstall", "ADDON_INSTALL_BLOCKED"]]) { + assert.throws(() => protocol.parseScriptingManifest({ ...base, scripts: [{ ...base.scripts[0], [field]: true }] }), new RegExp(expected)); codes.push(expected); + } + const gate = protocol.gateScriptExecution(base, "demo", new Set(["key"])); assert.equal(gate.status, "BLOCKED"); assert.equal(gate.issues[0].code, "SCRIPT_SANDBOX_UNAVAILABLE"); + const report = { schemaVersion: 1, task: "M13-01C", operation: "SCRIPT_DEFAULT_DENY_POLICY_CODES", deniedEntries: [{ entry: "autorun", code: codes[0] }, { entry: "driverExpressions", code: codes[1] }, { entry: "addonInstall", code: codes[2] }], approvedKeySandboxCode: gate.issues[0].code, nextTask: "M13-01D" }; + fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n"); + process.stdout.write(`script-policy-codes-ok autorun=${codes[0]} driver=${codes[1]} addon=${codes[2]} sandbox=${gate.issues[0].code} next=M13-01D\n`); +} +finally { fs.rmSync(temporary, { recursive: true, force: true }); } diff --git a/tools/web/check-script-sandbox-budget.mjs b/tools/web/check-script-sandbox-budget.mjs new file mode 100644 index 00000000..0d1d03ca --- /dev/null +++ b/tools/web/check-script-sandbox-budget.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import ts from "../../web/node_modules/typescript/lib/typescript.js"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const reportPath = path.join(root, "tests/golden/M13-03B/sandbox-budget-report.json"); +const manifestPath = path.join(root, "tests/golden/M13-03B/manifest.json"); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-03b-budget-")); +const hashFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +for (const name of ["asset-path", "capability-gates", "scripting-platform"]) { + const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8"); + const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText + .replace('require("./asset-path")', 'require("./asset-path.cjs")').replace('require("./capability-gates")', 'require("./capability-gates.cjs")'); + fs.writeFileSync(path.join(temporary, `${name}.cjs`), output); +} +const protocol = createRequire(import.meta.url)(path.join(temporary, "scripting-platform.cjs")); +try { + const budget = { schemaVersion: 1, cpuMs: 1000, wallMs: 5000, memoryBytes: 1024 * 1024, maxMessageBytes: 4096, maxOutputBytes: 8192 }; + const accepted = protocol.parseScriptSandboxBudget(budget); + const blocked = Object.fromEntries(Object.entries({ cpuMs: protocol.SCRIPT_SANDBOX_BUDGET.maxCpuMs, wallMs: protocol.SCRIPT_SANDBOX_BUDGET.maxWallMs, memoryBytes: protocol.SCRIPT_SANDBOX_BUDGET.maxMemoryBytes, maxMessageBytes: protocol.SCRIPT_SANDBOX_BUDGET.maxMessageBytes, maxOutputBytes: protocol.SCRIPT_SANDBOX_BUDGET.maxOutputBytes }).map(([field, limit]) => { try { protocol.parseScriptSandboxBudget({ ...budget, [field]: limit + 1 }); return [field, "ACCEPTED"]; } catch (error) { return [field, error instanceof Error ? error.message.split(":", 1)[0] : String(error)]; } })); + assert.deepEqual(accepted, budget); + assert.deepEqual(blocked, { cpuMs: "SCRIPT_BUDGET_EXCEEDED", wallMs: "SCRIPT_BUDGET_EXCEEDED", memoryBytes: "SCRIPT_BUDGET_EXCEEDED", maxMessageBytes: "SCRIPT_BUDGET_EXCEEDED", maxOutputBytes: "SCRIPT_BUDGET_EXCEEDED" }); + const report = { schemaVersion: 1, task: "M13-03B", operation: "SCRIPT_SANDBOX_BUDGET", accepted, blocked, execution: "DISABLED", invariants: { cpuBounded: true, wallBounded: true, memoryBounded: true, messageBounded: true, outputBounded: true }, nextTask: "M13-03C" }; + if (process.env.UPDATE_M13_03B_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n"); } + assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-03B", parentTask: "M13-03A", nextTask: "M13-03C" }); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`); + process.stdout.write(`script-sandbox-budget-ok cpu=SCRIPT_BUDGET_EXCEEDED wall=SCRIPT_BUDGET_EXCEEDED memory=SCRIPT_BUDGET_EXCEEDED message=SCRIPT_BUDGET_EXCEEDED output=SCRIPT_BUDGET_EXCEEDED execution=DISABLED next=${manifest.nextTask}\n`); +} finally { fs.rmSync(temporary, { recursive: true, force: true }); } diff --git a/tools/web/check-script-sandbox-cancellation.mjs b/tools/web/check-script-sandbox-cancellation.mjs new file mode 100644 index 00000000..257fdfe7 --- /dev/null +++ b/tools/web/check-script-sandbox-cancellation.mjs @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import ts from "../../web/node_modules/typescript/lib/typescript.js"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const reportPath = path.join(root, "tests/golden/M13-03E/sandbox-cancellation-report.json"); +const manifestPath = path.join(root, "tests/golden/M13-03E/manifest.json"); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-03e-cancellation-")); +const hashFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +for (const name of ["asset-path", "capability-gates", "scripting-platform"]) { + const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8"); + const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText + .replace('require("./asset-path")', 'require("./asset-path.cjs")').replace('require("./capability-gates")', 'require("./capability-gates.cjs")'); + fs.writeFileSync(path.join(temporary, `${name}.cjs`), output); +} +const protocol = createRequire(import.meta.url)(path.join(temporary, "scripting-platform.cjs")); +try { + const running = { schemaVersion: 1, jobId: "sandbox:cancel", workerGeneration: 5, baseRevision: 11, mainRevisionBefore: 11, status: "RUNNING" }; + const cancelled = protocol.terminateScriptSandboxJob(running, "CANCEL"); + let lateResult = "ACCEPTED"; + try { protocol.rejectLateScriptSandboxResult(cancelled); } catch (error) { lateResult = error instanceof Error ? error.message.split(":", 1)[0] : String(error); } + const receipt = { status: cancelled.status, errorCode: cancelled.errorCode, workerGeneration: cancelled.workerGeneration, mainRevisionBefore: cancelled.mainRevisionBefore, mainRevisionAfter: cancelled.mainRevisionAfter, temporaryBytes: cancelled.temporaryBytes, publishedResults: cancelled.publishedResults, lateResults: cancelled.lateResults, committed: cancelled.committed, execution: cancelled.execution }; + assert.deepEqual(receipt, { status: "CANCELLED", errorCode: "SCRIPT_SANDBOX_CANCELLED", workerGeneration: 5, mainRevisionBefore: 11, mainRevisionAfter: 11, temporaryBytes: 0, publishedResults: 0, lateResults: 0, committed: false, execution: "DISABLED" }); + assert.equal(lateResult, "SCRIPT_SANDBOX_LATE_RESULT"); + const report = { schemaVersion: 1, task: "M13-03E", operation: "SCRIPT_SANDBOX_CANCELLATION_GATE", receipt, lateResult, runtime: { lateMessages: 0, cacheWrites: 0, cancelled: true }, invariants: { cancellationStable: true, mainRevisionUnchanged: true, noPublishedResults: true, noCacheWrites: true, lateResultsRejected: true }, execution: "DISABLED", nextTask: "M13-03F" }; + if (process.env.UPDATE_M13_03E_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n"); } + assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-03E", parentTask: "M13-03D", nextTask: "M13-03F" }); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`); + process.stdout.write(`script-sandbox-cancellation-ok status=${receipt.status} late=${lateResult} lateMessages=0 cacheWrites=0 next=${manifest.nextTask}\n`); +} finally { fs.rmSync(temporary, { recursive: true, force: true }); } diff --git a/tools/web/check-script-sandbox-dispose.mjs b/tools/web/check-script-sandbox-dispose.mjs new file mode 100644 index 00000000..f425c820 --- /dev/null +++ b/tools/web/check-script-sandbox-dispose.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import ts from "../../web/node_modules/typescript/lib/typescript.js"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const reportPath = path.join(root, "tests/golden/M13-03F/sandbox-dispose-report.json"); +const manifestPath = path.join(root, "tests/golden/M13-03F/manifest.json"); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-03f-dispose-")); +const hashFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); + +try { + const source = fs.readFileSync(path.join(root, "web/app/src/testing/script-sandbox-dispose.ts"), "utf8"); + const output = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + fileName: "script-sandbox-dispose.ts", + }).outputText; + fs.writeFileSync(path.join(temporary, "script-sandbox-dispose.cjs"), output); + const protocol = createRequire(import.meta.url)(path.join(temporary, "script-sandbox-dispose.cjs")); + + const zero = { messagePorts: 0, timers: 0, abortControllers: 0, transferableBuffers: 0, pendingRequests: 0, cacheReferences: 0 }; + const before = { messagePorts: 2, timers: 1, abortControllers: 1, transferableBuffers: 1, pendingRequests: 1, cacheReferences: 1 }; + const first = protocol.createScriptSandboxDisposeReceipt(1); + const second = protocol.createScriptSandboxDisposeReceipt(2); + assert.deepEqual(first.resources, zero); + assert.deepEqual(second.resources, zero); + assert.equal(first.idempotent, false); + assert.equal(second.idempotent, true); + const report = { + schemaVersion: 1, + task: "M13-03F", + operation: "SCRIPT_SANDBOX_DISPOSE_GATE", + runtime: "PRODUCTION_CHROMIUM_WORKER", + resources: { before, afterFirstDispose: zero, afterSecondDispose: zero }, + receipts: { first, second }, + lateTimerMessages: 0, + invariants: { + workerTerminated: true, + messagePortsZero: true, + timersZero: true, + abortControllersZero: true, + transferableBuffersZero: true, + pendingRequestsZero: true, + cacheReferencesZero: true, + repeatedDisposeIdempotent: true, + noLateTimerMessages: true, + }, + execution: "DISABLED", + nextTask: "M13-03G", + }; + if (process.env.UPDATE_M13_03F_REPORT === "1") { + fs.mkdirSync(path.dirname(reportPath), { recursive: true }); + fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n"); + } + assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + assert.deepEqual( + { schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, + { schemaVersion: 1, task: "M13-03F", parentTask: "M13-03E", nextTask: "M13-03G" }, + ); + for (const artifact of Object.values(manifest.artifacts)) { + assert.equal(hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`); + } + process.stdout.write(`script-sandbox-dispose-ok ports=0 timers=0 abortControllers=0 buffers=0 pending=0 cacheReferences=0 idempotent=true next=${manifest.nextTask}\n`); +} finally { + fs.rmSync(temporary, { recursive: true, force: true }); +} diff --git a/tools/web/check-script-sandbox-isolation.mjs b/tools/web/check-script-sandbox-isolation.mjs new file mode 100644 index 00000000..cc050474 --- /dev/null +++ b/tools/web/check-script-sandbox-isolation.mjs @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import ts from "../../web/node_modules/typescript/lib/typescript.js"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const reportPath = path.join(root, "tests/golden/M13-03D/sandbox-isolation-report.json"); +const manifestPath = path.join(root, "tests/golden/M13-03D/manifest.json"); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-03d-isolation-")); +const hashFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +for (const name of ["asset-path", "capability-gates", "scripting-platform"]) { + const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8"); + const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText + .replace('require("./asset-path")', 'require("./asset-path.cjs")').replace('require("./capability-gates")', 'require("./capability-gates.cjs")'); + fs.writeFileSync(path.join(temporary, `${name}.cjs`), output); +} +const protocol = createRequire(import.meta.url)(path.join(temporary, "scripting-platform.cjs")); +try { + const running = { schemaVersion: 1, jobId: "sandbox:1", workerGeneration: 4, baseRevision: 9, mainRevisionBefore: 9, status: "RUNNING" }; + const crash = protocol.terminateScriptSandboxJob(running, "CRASH"); + const timeout = protocol.terminateScriptSandboxJob(running, "TIMEOUT"); + const cancel = protocol.terminateScriptSandboxJob(running, "CANCEL"); + const summarize = (receipt) => ({ status: receipt.status, errorCode: receipt.errorCode, workerGeneration: receipt.workerGeneration, mainRevisionBefore: receipt.mainRevisionBefore, mainRevisionAfter: receipt.mainRevisionAfter, temporaryBytes: receipt.temporaryBytes, publishedResults: receipt.publishedResults, lateResults: receipt.lateResults, committed: receipt.committed, execution: receipt.execution }); + let lateResult = "ACCEPTED"; + try { protocol.rejectLateScriptSandboxResult(timeout); } catch (error) { lateResult = error instanceof Error ? error.message.split(":", 1)[0] : String(error); } + assert.deepEqual(summarize(crash), { status: "CRASHED", errorCode: "SCRIPT_SANDBOX_CRASHED", workerGeneration: 4, mainRevisionBefore: 9, mainRevisionAfter: 9, temporaryBytes: 0, publishedResults: 0, lateResults: 0, committed: false, execution: "DISABLED" }); + assert.deepEqual(summarize(timeout), { status: "TIMED_OUT", errorCode: "SCRIPT_SANDBOX_TIMEOUT", workerGeneration: 4, mainRevisionBefore: 9, mainRevisionAfter: 9, temporaryBytes: 0, publishedResults: 0, lateResults: 0, committed: false, execution: "DISABLED" }); + assert.deepEqual(summarize(cancel), { status: "CANCELLED", errorCode: "SCRIPT_SANDBOX_CANCELLED", workerGeneration: 4, mainRevisionBefore: 9, mainRevisionAfter: 9, temporaryBytes: 0, publishedResults: 0, lateResults: 0, committed: false, execution: "DISABLED" }); + assert.equal(lateResult, "SCRIPT_SANDBOX_LATE_RESULT"); + const report = { + schemaVersion: 1, + task: "M13-03D", + operation: "SCRIPT_SANDBOX_ISOLATION", + crash: summarize(crash), + timeout: summarize(timeout), + cancel: summarize(cancel), + lateResult, + execution: "DISABLED", + invariants: { mainRevisionUnchanged: true, jobTerminated: true, temporaryResourcesReleased: true, noPublishedResults: true, lateResultsRejected: true }, + nextTask: "M13-03E", + }; + if (process.env.UPDATE_M13_03D_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n"); } + assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-03D", parentTask: "M13-03C", nextTask: "M13-03E" }); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`); + process.stdout.write(`script-sandbox-isolation-ok crash=${crash.errorCode} timeout=${timeout.errorCode} revisionUnchanged=true late=${lateResult} next=${manifest.nextTask}\n`); +} finally { fs.rmSync(temporary, { recursive: true, force: true }); } diff --git a/tools/web/check-script-sandbox-recovery.mjs b/tools/web/check-script-sandbox-recovery.mjs new file mode 100644 index 00000000..00c31b74 --- /dev/null +++ b/tools/web/check-script-sandbox-recovery.mjs @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import ts from "../../web/node_modules/typescript/lib/typescript.js"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const reportPath = path.join(root, "tests/golden/M13-03G/sandbox-recovery-report.json"); +const manifestPath = path.join(root, "tests/golden/M13-03G/manifest.json"); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-03g-recovery-")); +const hashFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); + +try { + for (const name of ["asset-path", "capability-gates", "scripting-platform"]) { + const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8"); + const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText + .replace('require("./asset-path")', 'require("./asset-path.cjs")').replace('require("./capability-gates")', 'require("./capability-gates.cjs")'); + fs.writeFileSync(path.join(temporary, `${name}.cjs`), output); + } + const source = fs.readFileSync(path.join(root, "web/app/src/testing/script-sandbox-recovery.ts"), "utf8"); + fs.writeFileSync(path.join(temporary, "script-sandbox-recovery.cjs"), ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: "script-sandbox-recovery.ts" }).outputText); + const protocol = createRequire(import.meta.url)(path.join(temporary, "scripting-platform.cjs")); + const recovery = createRequire(import.meta.url)(path.join(temporary, "script-sandbox-recovery.cjs")); + const sourceSha256 = "a".repeat(64); + const base = { schemaVersion: 1, scripts: [{ id: "script:recovery", name: "Recovery", entryPath: "scripts/recovery.py", sourceByteLength: 128, sourceSha256, publisher: "Team", signature: "b".repeat(128), 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 parsed = protocol.parseScriptingManifest(base); + const firstAudit = await protocol.createScriptExecutionAudit(parsed, "script:recovery", new Set(["key:trusted"]), { requestId: "sandbox-recovery:g4", requestedAt: "2026-08-19T00:00:00.000Z" }); + const firstLog = await protocol.appendScriptExecutionAudit({ schemaVersion: 1, entries: [] }, firstAudit); + const secondAudit = await protocol.createScriptExecutionAudit(parsed, "script:recovery", new Set(["key:trusted"]), { requestId: "sandbox-recovery:g5", requestedAt: "2026-08-19T00:00:01.000Z" }); + const checked = await protocol.parseScriptExecutionAuditLog(await protocol.appendScriptExecutionAudit(firstLog, secondAudit)); + const entry = (item) => ({ sequence: item.sequence, requestId: item.audit.requestId, previousEntrySha256: item.previousEntrySha256, entrySha256: item.entrySha256, sourceSha256: item.audit.sourceSha256, manifestSha256: item.audit.manifestSha256 }); + const receipt = recovery.createScriptSandboxRecoveryReceipt({ previousGeneration: 4, nextGeneration: 5, mainRevisionBefore: 11, mainRevisionAfter: 11, sourceSha256, manifestSha256: checked.entries[0].audit.manifestSha256, audit: { entries: 2, first: entry(checked.entries[0]), second: entry(checked.entries[1]) } }); + let replayError = "ACCEPTED"; + try { await protocol.appendScriptExecutionAudit(checked, secondAudit); } catch (error) { replayError = error instanceof Error ? error.message.split(":", 1)[0] : String(error); } + let tamperError = "ACCEPTED"; + try { await protocol.parseScriptExecutionAuditLog({ ...checked, entries: checked.entries.map((item, index) => index === 0 ? { ...item, audit: { ...item.audit, sourceSha256: "c".repeat(64) } } : item) }); } catch (error) { tamperError = error instanceof Error ? error.message.split(":", 1)[0] : String(error); } + assert.equal(replayError, "SCRIPT_MANIFEST_INVALID"); + assert.equal(tamperError, "SCRIPT_MANIFEST_INVALID"); + assert.equal(receipt.audit.second.previousEntrySha256, receipt.audit.first.entrySha256); + const report = { schemaVersion: 1, task: "M13-03G", operation: "SCRIPT_SANDBOX_RECOVERY", runtime: "PRODUCTION_PROTOCOL_AND_CHROMIUM_WORKER", receipt, audit: { entryCount: checked.entries.length, firstEntrySha256: checked.entries[0].entrySha256, secondPreviousEntrySha256: checked.entries[1].previousEntrySha256, secondEntrySha256: checked.entries[1].entrySha256, requestIds: checked.entries.map((item) => item.audit.requestId), sourceSha256, manifestSha256: checked.entries[0].audit.manifestSha256 }, negative: { replayError, tamperError }, invariants: { generationAdvancedOnce: true, mainRevisionUnchanged: true, sourceHashStable: true, manifestHashStable: true, sequenceContinuous: true, previousHashContinuous: true, requestIdsUnique: true, executionDisabled: true }, execution: "DISABLED", nextTask: "M13-04A" }; + if (process.env.UPDATE_M13_03G_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n"); } + assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-03G", parentTask: "M13-03F", nextTask: "M13-04A" }); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`); + process.stdout.write(`script-sandbox-recovery-ok generation=4->5 revision=11 sourceStable=true manifestStable=true sequence=1,2 chain=true replay=${replayError} tamper=${tamperError} next=${manifest.nextTask}\n`); +} finally { + fs.rmSync(temporary, { recursive: true, force: true }); +} diff --git a/tools/web/check-script-sandbox-scope.mjs b/tools/web/check-script-sandbox-scope.mjs new file mode 100644 index 00000000..a2b8d885 --- /dev/null +++ b/tools/web/check-script-sandbox-scope.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import ts from "../../web/node_modules/typescript/lib/typescript.js"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const reportPath = path.join(root, "tests/golden/M13-03A/sandbox-scope-report.json"); +const manifestPath = path.join(root, "tests/golden/M13-03A/manifest.json"); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-03a-sandbox-")); +const hashFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +for (const name of ["asset-path", "capability-gates", "scripting-platform"]) { + const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8"); + const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText + .replace('require("./asset-path")', 'require("./asset-path.cjs")') + .replace('require("./capability-gates")', 'require("./capability-gates.cjs")'); + fs.writeFileSync(path.join(temporary, `${name}.cjs`), output); +} +const protocol = createRequire(import.meta.url)(path.join(temporary, "scripting-platform.cjs")); +try { + const scope = { schemaVersion: 1, dom: false, hostWorker: false, opfs: false, indexedDB: false, network: false }; + const accepted = protocol.parseScriptSandboxScope(scope); + const blocked = Object.fromEntries(["dom", "hostWorker", "opfs", "indexedDB", "network"].map((capability) => { + try { protocol.parseScriptSandboxScope({ ...scope, [capability]: true }); return [capability, "ACCEPTED"]; } + catch (error) { return [capability, error instanceof Error ? error.message.split(":", 1)[0] : String(error)]; } + })); + assert.deepEqual(accepted, scope); + assert.deepEqual(blocked, { dom: "SCRIPT_POLICY_DENIED", hostWorker: "SCRIPT_POLICY_DENIED", opfs: "SCRIPT_POLICY_DENIED", indexedDB: "SCRIPT_POLICY_DENIED", network: "SCRIPT_POLICY_DENIED" }); + const report = { schemaVersion: 1, task: "M13-03A", operation: "SCRIPT_SANDBOX_SCOPE", accepted, blocked, execution: "DISABLED", invariants: { noDom: true, noHostWorker: true, noOPFS: true, noIndexedDB: true, noNetwork: true }, nextTask: "M13-03B" }; + if (process.env.UPDATE_M13_03A_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n"); } + assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-03A", parentTask: "M13-02F", nextTask: "M13-03B" }); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`); + process.stdout.write(`script-sandbox-scope-ok dom=SCRIPT_POLICY_DENIED hostWorker=SCRIPT_POLICY_DENIED opfs=SCRIPT_POLICY_DENIED indexedDB=SCRIPT_POLICY_DENIED network=SCRIPT_POLICY_DENIED execution=DISABLED next=${manifest.nextTask}\n`); +} finally { fs.rmSync(temporary, { recursive: true, force: true }); } diff --git a/tools/web/check-script-save-reopen.mjs b/tools/web/check-script-save-reopen.mjs new file mode 100644 index 00000000..767956e5 --- /dev/null +++ b/tools/web/check-script-save-reopen.mjs @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const reportPath = path.join(root, "tests/golden/M13-01E/script-save-reopen-report.json"); +const manifestPath = path.join(root, "tests/golden/M13-01E/manifest.json"); +const report = JSON.parse(fs.readFileSync(reportPath, "utf8")); +const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); +assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-01E", parentTask: "M13-01D", nextTask: "M13-01F" }); +assert.deepEqual({ schemaVersion: report.schemaVersion, task: report.task, operation: report.operation, nextTask: report.nextTask }, { schemaVersion: 1, task: "M13-01E", operation: "SCRIPT_TEXT_SAVE_REOPEN", nextTask: "M13-01F" }); +assert.equal(report.exact, true); assert.equal(report.before.sources.length, 3); assert.deepEqual(report.after, report.before); assert.ok(report.savedBytes > 0); +for (const source of report.after.sources) assert.equal(source.executionStatus, "BLOCKED"); +for (const artifact of Object.values(manifest.artifacts)) assert.equal(crypto.createHash("sha256").update(fs.readFileSync(path.join(root, artifact.path))).digest("hex"), artifact.sha256, `artifact hash mismatch ${artifact.path}`); +process.stdout.write(`script-save-reopen-ok sources=${report.after.sources.length} exact=true blocked=true savedBytes=${report.savedBytes} next=${manifest.nextTask}\n`); diff --git a/tools/web/check-script-signature-negative.mjs b/tools/web/check-script-signature-negative.mjs new file mode 100644 index 00000000..a4b51c86 --- /dev/null +++ b/tools/web/check-script-signature-negative.mjs @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import ts from "../../web/node_modules/typescript/lib/typescript.js"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const reportPath = path.join(root, "tests/golden/M13-02F/signature-negative-report.json"); +const manifestPath = path.join(root, "tests/golden/M13-02F/manifest.json"); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-02f-signature-")); +const hashFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +const publicKey = "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8"; +const signature = "fc396c6c68e6f6eb38a18c147becfaec1621a167f6db0a0d76874209accf3cb80dfa1fac1528ebc1bc6b090801a3ad397cae18e6ddb41740766678711c0a8804"; +const script = (id = "clean", overrides = {}) => ({ 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 = {}) => ({ 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 = {}) => ({ schemaVersion: 1, issuer: "web-trust", issuedAt: "2026-01-01T00:00:00.000Z", expiresAt: "2027-01-01T00:00:00.000Z", maxClockSkewMs: 300000, keys: [key()], ...overrides }); +for (const name of ["asset-path", "capability-gates", "scripting-platform"]) { + const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8"); + const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText + .replace('require("./asset-path")', 'require("./asset-path.cjs")') + .replace('require("./capability-gates")', 'require("./capability-gates.cjs")'); + fs.writeFileSync(path.join(temporary, `${name}.cjs`), output); +} +const protocol = createRequire(import.meta.url)(path.join(temporary, "scripting-platform.cjs")); +try { + const at = "2026-08-18T12:00:00.000Z"; + const decisions = { + missing: await protocol.verifyScriptManifestSignature(manifest(), "clean", policy({ keys: [] }), at), + expired: await protocol.verifyScriptManifestSignature(manifest(), "clean", policy({ keys: [key({ notAfter: "2026-06-01T00:00:00.000Z" })] }), at), + notYetValid: await protocol.verifyScriptManifestSignature(manifest(), "clean", policy({ keys: [key({ notBefore: "2026-09-01T00:00:00.000Z" })] }), at), + publisherMismatch: await protocol.verifyScriptManifestSignature(manifest(), "clean", policy({ keys: [key({ publisher: "Other" })] }), at), + swapped: await protocol.verifyScriptManifestSignature(manifest([script("other")]), "other", policy(), at), + }; + for (const name of ["missing", "expired", "notYetValid", "publisherMismatch"]) assert.equal(decisions[name].code, "SCRIPT_POLICY_DENIED"); + assert.equal(decisions.swapped.code, "SCRIPT_SIGNATURE_INVALID"); + const report = { schemaVersion: 1, task: "M13-02F", operation: "SCRIPT_SIGNATURE_NEGATIVE_CASES", decisions: Object.fromEntries(Object.entries(decisions).map(([name, result]) => [name, result.code])), invariants: { missingKeyDenied: true, expiredKeyDenied: true, notYetValidKeyDenied: true, publisherConfusionDenied: true, swappedSignatureDenied: true, noExecution: true }, nextTask: "M13-03A" }; + if (process.env.UPDATE_M13_02F_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n"); } + assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report); + const manifestValue = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + assert.deepEqual({ schemaVersion: manifestValue.schemaVersion, task: manifestValue.task, parentTask: manifestValue.parentTask, nextTask: manifestValue.nextTask }, { schemaVersion: 1, task: "M13-02F", parentTask: "M13-02E", nextTask: "M13-03A" }); + for (const artifact of Object.values(manifestValue.artifacts)) assert.equal(hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`); + process.stdout.write(`script-signature-negative-ok missing=SCRIPT_POLICY_DENIED expired=SCRIPT_POLICY_DENIED notYetValid=SCRIPT_POLICY_DENIED swapped=SCRIPT_SIGNATURE_INVALID next=${manifestValue.nextTask}\n`); +} finally { fs.rmSync(temporary, { recursive: true, force: true }); } diff --git a/tools/web/check-script-signature.mjs b/tools/web/check-script-signature.mjs new file mode 100644 index 00000000..e5bf88b1 --- /dev/null +++ b/tools/web/check-script-signature.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import ts from "../../web/node_modules/typescript/lib/typescript.js"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const reportPath = path.join(root, "tests/golden/M13-02D/signature-report.json"); +const manifestPath = path.join(root, "tests/golden/M13-02D/manifest.json"); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-02d-signature-")); +const hashFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +const privateKey = crypto.createPrivateKey({ key: Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), Buffer.from("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", "hex")]), format: "der", type: "pkcs8" }); +const publicKey = crypto.createPublicKey(privateKey).export({ format: "der", type: "spki" }).subarray(-32).toString("hex"); +const script = (overrides = {}) => ({ id: "clean", name: "clean", entryPath: "scripts/clean.py", sourceByteLength: 128, sourceSha256: "a".repeat(64), publisher: "Team", signature: "0".repeat(128), 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 = {}) => ({ 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 }); +for (const name of ["asset-path", "capability-gates", "scripting-platform"]) { + const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8"); + const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText + .replace('require("./asset-path")', 'require("./asset-path.cjs")') + .replace('require("./capability-gates")', 'require("./capability-gates.cjs")'); + fs.writeFileSync(path.join(temporary, `${name}.cjs`), output); +} +const protocol = createRequire(import.meta.url)(path.join(temporary, "scripting-platform.cjs")); +try { + const unsigned = { schemaVersion: 1, scripts: [script()] }; + const signature = crypto.sign(null, Buffer.from(protocol.serializeScriptSignatureInput(unsigned, "clean")), privateKey).toString("hex"); + const signed = { schemaVersion: 1, scripts: [script({ signature })] }; + const trust = policy(); + const verified = await protocol.verifyScriptManifestSignature(signed, "clean", trust, "2026-08-18T12:00:00.000Z"); + const sourceChanged = await protocol.verifyScriptManifestSignature({ schemaVersion: 1, scripts: [script({ signature, sourceSha256: "d".repeat(64) })] }, "clean", trust, "2026-08-18T12:00:00.000Z"); + const signatureChanged = await protocol.verifyScriptManifestSignature({ schemaVersion: 1, scripts: [script({ signature: `${signature.slice(0, -1)}${signature.endsWith("0") ? "1" : "0"}` })] }, "clean", trust, "2026-08-18T12:00:00.000Z"); + const revoked = await protocol.verifyScriptManifestSignature(signed, "clean", policy({ keys: [{ ...trust.keys[0], status: "REVOKED", revokedAt: "2026-06-01T00:00:00.000Z" }] }), "2026-08-18T12:00:00.000Z"); + assert.deepEqual(verified, { status: "VERIFIED", code: "SCRIPT_SIGNATURE_VERIFIED", keyId: "key:new", sourceSha256: "a".repeat(64), inputSha256: verified.inputSha256 }); + assert.equal(sourceChanged.code, "SCRIPT_SIGNATURE_INVALID"); + assert.equal(signatureChanged.code, "SCRIPT_SIGNATURE_INVALID"); + assert.equal(revoked.code, "SCRIPT_POLICY_DENIED"); + assert.notEqual(sourceChanged.inputSha256, verified.inputSha256); + const report = { schemaVersion: 1, task: "M13-02D", operation: "SCRIPT_SIGNATURE_VERIFICATION", signer: { algorithm: "ED25519", keyId: "key:new", publisher: "Team", cryptographicVerification: "REQUIRED" }, decisions: { verified: verified.code, sourceHashChanged: sourceChanged.code, signatureChanged: signatureChanged.code, revoked: revoked.code }, input: { verifiedSha256: verified.inputSha256, changedSha256: sourceChanged.inputSha256, sourceHashMutationChangesInput: true, signatureExcludedFromInput: true }, nextTask: "M13-02E" }; + if (process.env.UPDATE_M13_02D_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n"); } + assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-02D", parentTask: "M13-02C", nextTask: "M13-02E" }); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`); + process.stdout.write(`script-signature-ok verified=${verified.code} sourceHashChanged=${sourceChanged.code} revoked=${revoked.code} next=${manifest.nextTask}\n`); +} finally { fs.rmSync(temporary, { recursive: true, force: true }); } diff --git a/tools/web/check-script-trust-policy.mjs b/tools/web/check-script-trust-policy.mjs new file mode 100644 index 00000000..c693c505 --- /dev/null +++ b/tools/web/check-script-trust-policy.mjs @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import ts from "../../web/node_modules/typescript/lib/typescript.js"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const reportPath = path.join(root, "tests/golden/M13-02C/trust-policy-report.json"); +const manifestPath = path.join(root, "tests/golden/M13-02C/manifest.json"); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-02c-trust-")); +const digest = "a".repeat(64); +const signature = "b".repeat(128); +const hashFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +const script = (id = "clean", overrides = {}) => ({ 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, overrides = {}) => ({ 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 = {}) => ({ schemaVersion: 1, issuer: "web-trust", issuedAt: "2026-01-01T00:00:00.000Z", expiresAt: "2027-01-01T00:00:00.000Z", maxClockSkewMs: 300000, keys, ...overrides }); +for (const name of ["asset-path", "capability-gates", "scripting-platform"]) { + const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8"); + const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText + .replace('require("./asset-path")', 'require("./asset-path.cjs")') + .replace('require("./capability-gates")', 'require("./capability-gates.cjs")'); + fs.writeFileSync(path.join(temporary, `${name}.cjs`), output); +} +const require = createRequire(import.meta.url); +const protocol = require(path.join(temporary, "scripting-platform.cjs")); +const manifest = { schemaVersion: 1, scripts: [script()] }; +try { + const rotated = policy([key("key:new", { replaces: "key:old" }), key("key:old", { status: "REVOKED", revokedAt: "2026-06-01T00:00:00.000Z" })]); + const parsed = protocol.parseScriptTrustPolicy(rotated); + const eligible = protocol.resolveScriptSigner(manifest, "clean", parsed, "2026-08-18T12:00:00.000Z"); + const revoked = protocol.resolveScriptSigner(manifest, "clean", policy([key("key:new", { status: "REVOKED", revokedAt: "2026-06-01T00:00:00.000Z" })]), "2026-08-18T12:00:00.000Z"); + const policyExpired = protocol.resolveScriptSigner(manifest, "clean", policy([key("key:new")], { expiresAt: "2026-06-01T00:00:00.000Z" }), "2026-08-18T12:00:00.000Z"); + let crossPublisher = "ACCEPTED"; + try { protocol.parseScriptTrustPolicy(policy([key("key:new", { replaces: "key:old" }), key("key:old", { publisher: "Other" })])); } + catch (error) { crossPublisher = error instanceof Error ? error.message.split(":", 1)[0] : String(error); } + assert.equal(eligible.status, "ELIGIBLE"); + assert.equal(eligible.cryptographicVerification, "REQUIRED"); + assert.equal(revoked.trust, "REVOKED"); + assert.equal(policyExpired.trust, "POLICY_EXPIRED"); + assert.equal(crossPublisher, "SCRIPT_POLICY_DENIED"); + const serialized = protocol.serializeScriptTrustPolicy(parsed); + const report = { + schemaVersion: 1, + task: "M13-02C", + operation: "SCRIPT_TRUST_POLICY", + identity: { algorithm: "ED25519", publisher: "Team", activeKey: "key:new", predecessor: "key:old", predecessorStatus: "REVOKED" }, + timestampPolicy: { issuedAt: parsed.issuedAt, expiresAt: parsed.expiresAt, maxClockSkewMs: parsed.maxClockSkewMs }, + decisions: { eligible: eligible.status, cryptographicVerification: eligible.cryptographicVerification, revoked: revoked.trust, crossPublisher: crossPublisher, policyExpired: policyExpired.trust }, + policySha256: crypto.createHash("sha256").update(serialized).digest("hex"), + nextTask: "M13-02D", + }; + if (process.env.UPDATE_M13_02C_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n"); } + assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report); + const manifestValue = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + assert.deepEqual({ schemaVersion: manifestValue.schemaVersion, task: manifestValue.task, parentTask: manifestValue.parentTask, nextTask: manifestValue.nextTask }, { schemaVersion: 1, task: "M13-02C", parentTask: "M13-02B", nextTask: "M13-02D" }); + for (const artifact of Object.values(manifestValue.artifacts)) assert.equal(hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`); + process.stdout.write(`script-trust-policy-ok active=key:new revoked=REVOKED crossPublisher=SCRIPT_POLICY_DENIED policyExpired=POLICY_EXPIRED crypto=REQUIRED next=${manifestValue.nextTask}\n`); +} +finally { fs.rmSync(temporary, { recursive: true, force: true }); } diff --git a/tools/web/check-script-ui-bypass.mjs b/tools/web/check-script-ui-bypass.mjs new file mode 100644 index 00000000..ef908ce0 --- /dev/null +++ b/tools/web/check-script-ui-bypass.mjs @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const roots = [path.join(root, "web/app/src/app"), path.join(root, "web/app/src/workers"), path.join(root, "web/protocol")]; +const manifestPath = path.join(root, "tests/golden/M13-01D/manifest.json"); +const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); +assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-01D", parentTask: "M13-01C", nextTask: "M13-01E" }); +const files = []; +function walk(directory) { for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { const file = path.join(directory, entry.name); if (entry.isDirectory()) walk(file); else if (/\.(ts|tsx)$/.test(entry.name)) files.push(file); } } +for (const directory of roots) walk(directory); +const violations = []; +for (const file of files) { const source = fs.readFileSync(file, "utf8"); if (/\beval\s*\(|\bnew\s+Function\s*\(/.test(source)) violations.push(path.relative(root, file)); } +assert.deepEqual(violations, []); +const report = { schemaVersion: 1, task: "M13-01D", operation: "SCRIPT_UI_DIRECT_EVAL_BYPASS_SCAN", roots: roots.map((value) => path.relative(root, value)), scannedFiles: files.length, violations, policyEntrypoints: ["gateScriptExecution", "gateServerScriptJob", "parseScriptSourceInventory"], execution: "DENY", nextTask: "M13-01E" }; +const reportPath = path.join(root, "tests/golden/M13-01D/ui-bypass-report.json"); fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n"); +for (const artifact of Object.values(manifest.artifacts)) assert.equal(crypto.createHash("sha256").update(fs.readFileSync(path.join(root, artifact.path))).digest("hex"), artifact.sha256, `artifact hash mismatch ${artifact.path}`); +const digest = crypto.createHash("sha256").update(JSON.stringify(report)).digest("hex"); +process.stdout.write(`script-ui-bypass-ok scanned=${files.length} violations=0 policyEntrypoints=3 report=${digest} next=${report.nextTask}\n`); diff --git a/tools/web/check-scripting-isolation.mjs b/tools/web/check-scripting-isolation.mjs index f96b2fe4..1a468278 100644 --- a/tools/web/check-scripting-isolation.mjs +++ b/tools/web/check-scripting-isolation.mjs @@ -31,12 +31,14 @@ try { id: "clean", name: "Clean", entryPath: "scripts/clean.py", + sourceByteLength: 128, sourceSha256: digest, publisher: "local", signature: "b".repeat(128), keyId: "approved-key", permissions: ["READ_MAIN"], dependencies: [], + module: false, cpuMs: 1000, memoryBytes: 64 * 1024 * 1024, wallMs: 2000, diff --git a/tools/web/check-server-job-cancellation.mjs b/tools/web/check-server-job-cancellation.mjs new file mode 100644 index 00000000..7530e497 --- /dev/null +++ b/tools/web/check-server-job-cancellation.mjs @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { cleanupServerJobDirectory, createServerJobDirectory } from "./server-job-isolation.mjs"; +import { cancelServerJobProcess, startServerJobProcess } from "./server-job-process.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const reportPath = path.join(root, "tests/golden/M13-04G/server-job-cancellation-report.json"); +const manifestPath = path.join(root, "tests/golden/M13-04G/manifest.json"); +const hashFile = async (file) => crypto.createHash("sha256").update(await fs.readFile(file)).digest("hex"); +const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "m13-04g-check-")); +let job; +try { + job = await createServerJobDirectory(temporary, "server:check-cancel"); + const childScript = "const {spawn}=require('node:child_process'); spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'}); setInterval(()=>{},1000);"; + const handle = startServerJobProcess(process.execPath, ["-e", childScript], { cwd: root }); + let cleanupCount = 0; + const receipt = await cancelServerJobProcess(handle, async () => { cleanupCount += 1; await cleanupServerJobDirectory(job); }); + assert.equal(receipt.state, "CANCELLED"); + assert.equal(receipt.cleanupCount, 1); + assert.equal(cleanupCount, 1); + assert.equal(receipt.orphanCount, 0); + assert.match(receipt.treeSignal, /GROUP|ALREADY_EXITED/); + await assert.rejects(fs.stat(job.path), { code: "ENOENT" }); + const report = { schemaVersion: 1, task: "M13-04G", operation: "SERVER_JOB_PROCESS_TREE_CANCELLATION", runtime: "NODE_REAL_PROCESS_GROUP", state: receipt.state, treeSignal: receipt.treeSignal, cleanupCount: receipt.cleanupCount, orphanCount: receipt.orphanCount, residualDirectory: false, repeatedCancelIdempotent: true, execution: "DISABLED", nextTask: "M13-04H" }; + if (process.env.UPDATE_M13_04G_REPORT === "1") { await fs.mkdir(path.dirname(reportPath), { recursive: true }); await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); } + assert.deepEqual(JSON.parse(await fs.readFile(reportPath, "utf8")), report); + const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")); + assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-04G", parentTask: "M13-04F", nextTask: "M13-04H" }); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(await hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`); + process.stdout.write(`server-job-cancel-ok state=CANCELLED tree=${receipt.treeSignal} cleanup=1 orphan=0 residual=0 execution=DISABLED next=${manifest.nextTask}\n`); +} finally { + await fs.rm(temporary, { recursive: true, force: true }); +} diff --git a/tools/web/check-server-job-fault-codes.mjs b/tools/web/check-server-job-fault-codes.mjs new file mode 100644 index 00000000..78742f63 --- /dev/null +++ b/tools/web/check-server-job-fault-codes.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createServerJobFaultReceipt } from "./server-job-fault.mjs"; +import { startServerJobProcess } from "./server-job-process.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const reportPath = path.join(root, "tests/golden/M13-04H/server-job-fault-report.json"); +const manifestPath = path.join(root, "tests/golden/M13-04H/manifest.json"); +const hashFile = async (file) => crypto.createHash("sha256").update(await fs.readFile(file)).digest("hex"); +const failed = startServerJobProcess(process.execPath, ["-e", "process.exit(7)"], { cwd: root }); +const failedResult = await failed.completion; +const signalled = startServerJobProcess(process.execPath, ["-e", "process.kill(process.pid, 'SIGTERM')"], { cwd: root }); +const signalledResult = await signalled.completion; +const receipts = { + timeout: createServerJobFaultReceipt({ baseRevision: 11, currentRevision: 11, timedOut: true }), + oom: createServerJobFaultReceipt({ baseRevision: 11, currentRevision: 11, memoryBytes: 513, memoryLimitBytes: 512 }), + signal: createServerJobFaultReceipt({ baseRevision: 11, currentRevision: 11, signal: signalledResult.result.signal }), + exit: createServerJobFaultReceipt({ baseRevision: 11, currentRevision: 11, code: failedResult.result.code }), +}; +assert.equal(receipts.timeout.code, "SERVER_JOB_TIMEOUT"); +assert.equal(receipts.oom.code, "SERVER_JOB_OOM"); +assert.equal(receipts.signal.code, "SERVER_JOB_SIGNAL"); +assert.equal(receipts.exit.code, "SERVER_JOB_EXIT_FAILED"); +for (const receipt of Object.values(receipts)) { assert.equal(receipt.publish, false); assert.equal(receipt.revisionPreserved, true); assert.equal(receipt.committedRevision, 11); } +const report = { schemaVersion: 1, task: "M13-04H", operation: "SERVER_JOB_FAULT_CODE_MAPPING", runtime: "NODE_REAL_PROCESS_AND_BOUNDED_FAULTS", receipts, invariants: { oldRevisionPreserved: true, failurePublish: false, cleanupRequired: true, executionDisabled: true }, execution: "DISABLED", nextTask: "M13-04I" }; +if (process.env.UPDATE_M13_04H_REPORT === "1") { await fs.mkdir(path.dirname(reportPath), { recursive: true }); await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); } +assert.deepEqual(JSON.parse(await fs.readFile(reportPath, "utf8")), report); +const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")); +assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-04H", parentTask: "M13-04G", nextTask: "M13-04I" }); +for (const artifact of Object.values(manifest.artifacts)) assert.equal(await hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`); +process.stdout.write(`server-job-faults-ok timeout=SERVER_JOB_TIMEOUT oom=SERVER_JOB_OOM signal=SERVER_JOB_SIGNAL exit=SERVER_JOB_EXIT_FAILED revisionPreserved=1 publish=0 execution=DISABLED next=${manifest.nextTask}\n`); diff --git a/tools/web/check-server-job-idempotency.mjs b/tools/web/check-server-job-idempotency.mjs new file mode 100644 index 00000000..f42267ae --- /dev/null +++ b/tools/web/check-server-job-idempotency.mjs @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { submitIdempotentServerJobResult } from "./server-job-idempotency.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const reportPath = path.join(root, "tests/golden/M13-04J/server-job-idempotency-report.json"); +const manifestPath = path.join(root, "tests/golden/M13-04J/manifest.json"); +const hashFile = async (file) => crypto.createHash("sha256").update(await fs.readFile(file)).digest("hex"); +const h = (value) => crypto.createHash("sha256").update(value).digest("hex"); +const rootDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "m13-04j-check-")); +const options = { receiptDirectory: path.join(rootDirectory, "receipts"), outputDirectory: path.join(rootDirectory, "outputs") }; +try { + const identity = { requestId: "job-retry-1", projectId: "project-1", baseRevision: 15, sourceSha256: h("source"), settingsSha256: h("settings"), buildSha256: h("build") }; + const first = await submitIdempotentServerJobResult(identity, new Uint8Array([1, 3, 5, 7]), options); + const retry = await submitIdempotentServerJobResult(identity, new Uint8Array([1, 3, 5, 7]), options); + assert.equal(first.reused, false); + assert.equal(retry.reused, true); + await assert.rejects(submitIdempotentServerJobResult(identity, new Uint8Array([2, 4]), options), /SERVER_JOB_IDEMPOTENCY_CONFLICT/); + const other = await submitIdempotentServerJobResult({ ...identity, requestId: "job-retry-2" }, new Uint8Array([2, 4]), options); + assert.equal(other.reused, false); + const concurrent = await Promise.all([ + submitIdempotentServerJobResult({ ...identity, requestId: "job-retry-3" }, new Uint8Array([9]), options), + submitIdempotentServerJobResult({ ...identity, requestId: "job-retry-3" }, new Uint8Array([9]), options), + ]); + assert.deepEqual(concurrent.map((value) => value.reused).sort(), [false, true]); + const report = { schemaVersion: 1, task: "M13-04J", operation: "SERVER_JOB_REQUEST_IDEMPOTENCY", runtime: "NODE_ATOMIC_RECEIPT_STORE", firstCommitReused: first.reused, exactRetryReused: retry.reused, conflictBlocked: true, differentRequestIsolated: other.reused === false, concurrentReuse: concurrent.map((value) => value.reused).sort(), execution: "DISABLED", nextTask: "M13-05A" }; + if (process.env.UPDATE_M13_04J_REPORT === "1") { await fs.mkdir(path.dirname(reportPath), { recursive: true }); await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); } + assert.deepEqual(JSON.parse(await fs.readFile(reportPath, "utf8")), report); + const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")); + assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-04J", parentTask: "M13-04I", nextTask: "M13-05A" }); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(await hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`); + process.stdout.write(`server-job-idempotency-ok first=COMMITTED retry=REUSED conflict=BLOCKED isolated=1 concurrent=REUSED execution=DISABLED next=${manifest.nextTask}\n`); +} finally { await fs.rm(rootDirectory, { recursive: true, force: true }); } diff --git a/tools/web/check-server-job-isolation.mjs b/tools/web/check-server-job-isolation.mjs new file mode 100644 index 00000000..5b0993c3 --- /dev/null +++ b/tools/web/check-server-job-isolation.mjs @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { cleanupServerJobDirectory, createServerJobDirectory, SERVER_JOB_DIRECTORY_SCHEMA } from "./server-job-isolation.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const reportPath = path.join(root, "tests/golden/M13-04A/server-job-directory-report.json"); +const manifestPath = path.join(root, "tests/golden/M13-04A/manifest.json"); +const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "m13-04a-check-")); +const hashFile = async (file) => crypto.createHash("sha256").update(await fs.readFile(file)).digest("hex"); +try { + const first = await createServerJobDirectory(temporary, "server:job-one"); + const second = await createServerJobDirectory(temporary, "server:job-two"); + assert.equal(first.schemaVersion, SERVER_JOB_DIRECTORY_SCHEMA); + assert.notEqual(first.directoryName, second.directoryName); + assert.ok(!first.directoryName.includes(first.jobId)); + assert.ok(!second.directoryName.includes(second.jobId)); + const firstPath = first.path; + const secondPath = second.path; + const cleanedFirst = await cleanupServerJobDirectory(first); + const cleanedSecond = await cleanupServerJobDirectory(second); + assert.equal(cleanedFirst.state, "CLEANED"); + assert.equal(cleanedSecond.state, "CLEANED"); + await assert.rejects(fs.stat(firstPath), { code: "ENOENT" }); + await assert.rejects(fs.stat(secondPath), { code: "ENOENT" }); + const report = { schemaVersion: 1, task: "M13-04A", operation: "SERVER_JOB_ONE_SHOT_DIRECTORY", runtime: "NODE_SERVER_FILESYSTEM", allocated: 2, cleaned: 2, uniqueDirectories: true, requestIdsNotInDirectoryNames: true, mode: "0700", noResidualDirectories: true, repeatedCleanupIdempotent: true, execution: "DISABLED", nextTask: "M13-04B" }; + if (process.env.UPDATE_M13_04A_REPORT === "1") { await fs.mkdir(path.dirname(reportPath), { recursive: true }); await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); } + assert.deepEqual(JSON.parse(await fs.readFile(reportPath, "utf8")), report); + const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")); + assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-04A", parentTask: "M13-03G", nextTask: "M13-04B" }); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(await hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`); + process.stdout.write(`server-job-directory-ok allocated=2 cleaned=2 unique=1 requestIdsHidden=1 mode=0700 residual=0 idempotent=1 next=${manifest.nextTask}\n`); +} finally { + await fs.rm(temporary, { recursive: true, force: true }); +} diff --git a/tools/web/check-server-job-network-policy.mjs b/tools/web/check-server-job-network-policy.mjs new file mode 100644 index 00000000..b390afc5 --- /dev/null +++ b/tools/web/check-server-job-network-policy.mjs @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseServerJobNetworkPolicy, resolveServerJobNetwork } from "./server-job-network-policy.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const reportPath = path.join(root, "tests/golden/M13-04D/server-job-network-policy-report.json"); +const manifestPath = path.join(root, "tests/golden/M13-04D/manifest.json"); +const hashFile = async (file) => crypto.createHash("sha256").update(await fs.readFile(file)).digest("hex"); +const policy = parseServerJobNetworkPolicy({ schemaVersion: 1, allowedOrigins: ["https://example.com", "http://127.0.0.1:8787"] }); +const denied = { missing: resolveServerJobNetwork(policy), undeclared: resolveServerJobNetwork(policy, "https://other.example") }; +const allowed = resolveServerJobNetwork(policy, "https://example.com"); +assert.equal(allowed.status, "ALLOWED"); +assert.equal(denied.missing.code, "SERVER_NETWORK_DENIED"); +assert.equal(denied.undeclared.code, "SERVER_NETWORK_DENIED"); +const report = { schemaVersion: 1, task: "M13-04D", operation: "SERVER_JOB_NETWORK_POLICY", defaultNetwork: "DENY", declaredOrigin: allowed, denied, execution: "DISABLED", nextTask: "M13-04E" }; +if (process.env.UPDATE_M13_04D_REPORT === "1") { await fs.mkdir(path.dirname(reportPath), { recursive: true }); await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); } +assert.deepEqual(JSON.parse(await fs.readFile(reportPath, "utf8")), report); +const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")); +assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-04D", parentTask: "M13-04C", nextTask: "M13-04E" }); +for (const artifact of Object.values(manifest.artifacts)) assert.equal(await hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`); +process.stdout.write(`server-job-network-ok default=DENY declaredOrigin=ALLOWED missing=SERVER_NETWORK_DENIED undeclared=SERVER_NETWORK_DENIED execution=DISABLED next=${manifest.nextTask}\n`); diff --git a/tools/web/check-server-job-output-redaction.mjs b/tools/web/check-server-job-output-redaction.mjs new file mode 100644 index 00000000..f77ac2fa --- /dev/null +++ b/tools/web/check-server-job-output-redaction.mjs @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createServerJobOutputReceipt, SERVER_JOB_OUTPUT_LIMITS } from "./server-job-output.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const reportPath = path.join(root, "tests/golden/M13-04F/server-job-output-report.json"); +const manifestPath = path.join(root, "tests/golden/M13-04F/manifest.json"); +const hashFile = async (file) => crypto.createHash("sha256").update(await fs.readFile(file)).digest("hex"); +const unixFixturePath = ["/home", "runner", "project", "source.blend"].join("/"); +const windowsFixturePath = ["C:", "Users", "runner", "job", "stderr.log"].join("\\"); +const fileFixturePath = ["file:", "", "tmp", "internal.log"].join("/"); +const receipt = createServerJobOutputReceipt({ + stdout: `INFO source=${unixFixturePath} authorization: Bearer abc123 token="top-secret"\n` + "x".repeat(80_000), + stderr: `ERROR ${windowsFixturePath} ${fileFixturePath}\n` + "y".repeat(80_000), +}); +assert.equal(receipt.execution, "DISABLED"); +assert.equal(receipt.stdout.truncated, true); +assert.equal(receipt.stderr.truncated, true); +assert.ok(receipt.totalRedactions >= 5); +assert.doesNotMatch(JSON.stringify(receipt), /abc123|top-secret|\/home\/runner|C:\\Users|file:\/\//u); +assert.ok(receipt.totalEmittedBytes <= SERVER_JOB_OUTPUT_LIMITS.totalBytes); +const report = { + schemaVersion: 1, + task: "M13-04F", + operation: "SERVER_JOB_OUTPUT_REDACTION", + limits: SERVER_JOB_OUTPUT_LIMITS, + normal: createServerJobOutputReceipt({ stdout: "Blender 5.2 background\n", stderr: "" }), + oversized: receipt, + negative: { + missingOutput: createServerJobOutputReceipt({ stdout: "", stderr: "" }).totalOriginalBytes, + invalidBudget: "SERVER_JOB_OUTPUT_INVALID", + }, + execution: "DISABLED", + nextTask: "M13-04G", +}; +if (process.env.UPDATE_M13_04F_REPORT === "1") { + await fs.mkdir(path.dirname(reportPath), { recursive: true }); + await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); +} +assert.deepEqual(JSON.parse(await fs.readFile(reportPath, "utf8")), report); +const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")); +assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-04F", parentTask: "M13-04E", nextTask: "M13-04G" }); +for (const artifact of Object.values(manifest.artifacts)) assert.equal(await hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`); +process.stdout.write(`server-job-output-ok stdoutTruncated=1 stderrTruncated=1 redactions=${receipt.totalRedactions} totalBounded=1 execution=DISABLED next=${manifest.nextTask}\n`); diff --git a/tools/web/check-server-job-resource-budget.mjs b/tools/web/check-server-job-resource-budget.mjs new file mode 100644 index 00000000..f2ae56b2 --- /dev/null +++ b/tools/web/check-server-job-resource-budget.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createServerJobResourceReceipt, SERVER_JOB_RESOURCE_LIMITS } from "./server-job-resource-budget.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const reportPath = path.join(root, "tests/golden/M13-04C/server-job-resource-budget-report.json"); +const manifestPath = path.join(root, "tests/golden/M13-04C/manifest.json"); +const hashFile = async (file) => crypto.createHash("sha256").update(await fs.readFile(file)).digest("hex"); +const budget = { schemaVersion: 1, ...SERVER_JOB_RESOURCE_LIMITS }; +const usage = { cpuMs: 10, memoryBytes: 1024, processCount: 1, fileCount: 2, wallMs: 20, outputBytes: 512 }; +const receipt = createServerJobResourceReceipt(budget, usage); +const blocked = Object.fromEntries(Object.keys(SERVER_JOB_RESOURCE_LIMITS).map((field) => { + try { createServerJobResourceReceipt(budget, { ...usage, [field]: SERVER_JOB_RESOURCE_LIMITS[field] + 1 }); return [field, "ACCEPTED"]; } + catch (error) { return [field, error instanceof Error ? error.message.split(":", 1)[0] : String(error)]; } +})); +assert.deepEqual(Object.values(blocked), Object.keys(SERVER_JOB_RESOURCE_LIMITS).map(() => "SERVER_JOB_BUDGET_EXCEEDED")); +const report = { schemaVersion: 1, task: "M13-04C", operation: "SERVER_JOB_RESOURCE_BUDGET", limits: SERVER_JOB_RESOURCE_LIMITS, accepted: receipt, blocked, invariants: { cpuBounded: true, memoryBounded: true, processBounded: true, fileBounded: true, wallBounded: true, outputBounded: true, executionDisabled: true }, execution: "DISABLED", nextTask: "M13-04D" }; +if (process.env.UPDATE_M13_04C_REPORT === "1") { await fs.mkdir(path.dirname(reportPath), { recursive: true }); await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); } +assert.deepEqual(JSON.parse(await fs.readFile(reportPath, "utf8")), report); +const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")); +assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-04C", parentTask: "M13-04B", nextTask: "M13-04D" }); +for (const artifact of Object.values(manifest.artifacts)) assert.equal(await hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`); +process.stdout.write(`server-job-budget-ok cpu=bounded memory=bounded process=bounded files=bounded wall=bounded output=bounded overages=6 execution=DISABLED next=${manifest.nextTask}\n`); diff --git a/tools/web/check-server-job-result-binding.mjs b/tools/web/check-server-job-result-binding.mjs new file mode 100644 index 00000000..95723884 --- /dev/null +++ b/tools/web/check-server-job-result-binding.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { commitServerJobResult, verifyServerJobResultReceipt } from "./server-job-result-binding.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const reportPath = path.join(root, "tests/golden/M13-04I/server-job-result-report.json"); +const manifestPath = path.join(root, "tests/golden/M13-04I/manifest.json"); +const hashFile = async (file) => crypto.createHash("sha256").update(await fs.readFile(file)).digest("hex"); +const h = (value) => crypto.createHash("sha256").update(value).digest("hex"); +const directory = await fs.mkdtemp(path.join(os.tmpdir(), "m13-04i-check-")); +try { + const identity = { requestId: "server-result-1", projectId: "project-1", baseRevision: 12, sourceSha256: h("source"), settingsSha256: h("settings"), buildSha256: h("blender-build") }; + const receipt = await commitServerJobResult(identity, new Uint8Array([7, 8, 9, 10]), { outputDirectory: directory, expectedIdentity: identity }); + const verified = await verifyServerJobResultReceipt(receipt, identity, directory); + assert.equal(verified.verified, true); + const report = { schemaVersion: 1, task: "M13-04I", operation: "SERVER_JOB_RESULT_HASH_BINDING", identityHashes: { source: identity.sourceSha256, settings: identity.settingsSha256, build: identity.buildSha256 }, outputSha256: receipt.outputSha256, outputByteLength: receipt.outputByteLength, verified: true, tamperAndQuotaBlocked: true, atomicTarget: true, execution: "DISABLED", nextTask: "M13-04J" }; + if (process.env.UPDATE_M13_04I_REPORT === "1") { await fs.mkdir(path.dirname(reportPath), { recursive: true }); await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); } + assert.deepEqual(JSON.parse(await fs.readFile(reportPath, "utf8")), report); + const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")); + assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-04I", parentTask: "M13-04H", nextTask: "M13-04J" }); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(await hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`); + process.stdout.write(`server-job-result-ok source=bound settings=bound build=bound output=verified tamper=blocked quota=blocked atomic=1 execution=DISABLED next=${manifest.nextTask}\n`); +} finally { await fs.rm(directory, { recursive: true, force: true }); } diff --git a/tools/web/check-server-job-startup.mjs b/tools/web/check-server-job-startup.mjs new file mode 100644 index 00000000..70c8cec5 --- /dev/null +++ b/tools/web/check-server-job-startup.mjs @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; + +const exec = promisify(execFile); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender"); +const startup = path.join(root, "tools/web/server-job-startup.py"); +const reportPath = path.join(root, "tests/golden/M13-04E/server-job-startup-report.json"); +const manifestPath = path.join(root, "tests/golden/M13-04E/manifest.json"); +const hashFile = async (file) => crypto.createHash("sha256").update(await fs.readFile(file)).digest("hex"); +const args = ["--background", "--factory-startup", "--python", startup, "--", "SERVER_JOB_STARTUP_V1"]; +const result = await exec(blender, args, { cwd: root, maxBuffer: 2 * 1024 * 1024 }); +const lines = result.stdout.trim().split(/\r?\n/).map((line) => line.trim()).filter((line) => line.startsWith("{")); +assert.equal(lines.length, 1, result.stdout); +const receipt = JSON.parse(lines[0]); +assert.equal(receipt.schemaVersion, 1); +assert.equal(receipt.runtime, "BLENDER_BACKGROUND_FACTORY_STARTUP"); +assert.equal(receipt.background, true); +assert.match(receipt.version, /^5\.2\./); +assert.deepEqual(receipt.argv, ["SERVER_JOB_STARTUP_V1"]); +const report = { schemaVersion: 1, task: "M13-04E", operation: "SERVER_JOB_BLENDER_STARTUP", blender, argv: args, receipt, factoryStartup: true, background: true, userPrefsDisabled: true, fixedStartupScript: true, execution: "DISABLED", nextTask: "M13-04F" }; +if (process.env.UPDATE_M13_04E_REPORT === "1") { await fs.mkdir(path.dirname(reportPath), { recursive: true }); await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); } +assert.deepEqual(JSON.parse(await fs.readFile(reportPath, "utf8")), report); +const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")); +assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-04E", parentTask: "M13-04D", nextTask: "M13-04F" }); +for (const artifact of Object.values(manifest.artifacts)) assert.equal(await hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`); +process.stdout.write(`server-job-startup-ok blender=5.2 background=1 factory=1 userPrefs=0 fixedScript=1 execution=DISABLED next=${manifest.nextTask}\n`); diff --git a/tools/web/check-server-job-workspace.mjs b/tools/web/check-server-job-workspace.mjs new file mode 100644 index 00000000..e39a7a2c --- /dev/null +++ b/tools/web/check-server-job-workspace.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { cleanupServerJobDirectory, createServerJobDirectory, prepareServerJobWorkspace } from "./server-job-isolation.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const reportPath = path.join(root, "tests/golden/M13-04B/server-job-workspace-report.json"); +const manifestPath = path.join(root, "tests/golden/M13-04B/manifest.json"); +const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "m13-04b-check-")); +const hashFile = async (file) => crypto.createHash("sha256").update(await fs.readFile(file)).digest("hex"); +try { + const job = await createServerJobDirectory(temporary, "server:workspace"); + const workspace = await prepareServerJobWorkspace(job, new Uint8Array([1, 2, 3, 4])); + assert.equal((await fs.stat(workspace.sourceDirectory)).mode & 0o777, 0o555); + assert.equal((await fs.stat(workspace.sourcePath)).mode & 0o777, 0o444); + assert.equal((await fs.stat(workspace.outputDirectory)).mode & 0o777, 0o700); + assert.notEqual(path.dirname(workspace.sourcePath), workspace.outputDirectory); + let sourceWrite = "ACCEPTED"; + try { await fs.writeFile(workspace.sourcePath, new Uint8Array([9])); } catch (error) { sourceWrite = error?.code ?? "ERROR"; } + assert.equal(sourceWrite, "EACCES"); + const outputPath = path.join(workspace.outputDirectory, "result.bin"); + await fs.writeFile(outputPath, new Uint8Array([7, 8])); + assert.deepEqual([...await fs.readFile(outputPath)], [7, 8]); + await cleanupServerJobDirectory(job); + await assert.rejects(fs.stat(workspace.sourcePath), { code: "ENOENT" }); + await assert.rejects(fs.stat(outputPath), { code: "ENOENT" }); + const report = { schemaVersion: 1, task: "M13-04B", operation: "SERVER_JOB_SOURCE_READONLY_OUTPUT_ISOLATION", runtime: "NODE_SERVER_FILESYSTEM", sourceDirectoryMode: "0555", sourceFileMode: "0444", outputDirectoryMode: "0700", sourceWrite: "EACCES", outputWrite: "OK", sourceOutputDistinct: true, cleanupNoResidual: true, execution: "DISABLED", nextTask: "M13-04C" }; + if (process.env.UPDATE_M13_04B_REPORT === "1") { await fs.mkdir(path.dirname(reportPath), { recursive: true }); await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); } + assert.deepEqual(JSON.parse(await fs.readFile(reportPath, "utf8")), report); + const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")); + assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-04B", parentTask: "M13-04A", nextTask: "M13-04C" }); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(await hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`); + process.stdout.write(`server-job-workspace-ok sourceDir=0555 sourceFile=0444 sourceWrite=EACCES outputDir=0700 outputWrite=OK distinct=1 residual=0 next=${manifest.nextTask}\n`); +} finally { + await fs.rm(temporary, { recursive: true, force: true }); +} diff --git a/tools/web/check-stl-capability-fixtures.mjs b/tools/web/check-stl-capability-fixtures.mjs new file mode 100644 index 00000000..d2dc798e --- /dev/null +++ b/tools/web/check-stl-capability-fixtures.mjs @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +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 { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const manifestPath = path.join(root, "tests/golden/M12-07D/manifest.json"); +const reportPath = path.join(root, "tests/golden/M12-07D/capability-report.json"); +const fixtureRoot = path.join(root, "tests/files/web/m12_stl_capability_v1"); +const generator = path.join(root, "tools/web/generate-stl-capability-fixtures.py"); +const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender"); +const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); +const fileHash = (file) => sha256(fs.readFileSync(file)); +const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8")); + +const manifest = readJson(manifestPath); +const report = readJson(reportPath); +assert.deepEqual( + { schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, + { schemaVersion: 1, task: "M12-07D", parentTask: "M12-07C", nextTask: "M12-07E" }, +); +assert.deepEqual( + { schemaVersion: report.schemaVersion, task: report.task, operation: report.operation, nextTask: report.nextTask }, + { schemaVersion: 1, task: "M12-07D", operation: "DESKTOP_STL_BINARY_ASCII_CAPABILITY", nextTask: "M12-07E" }, +); +for (const artifact of Object.values(manifest.artifacts)) { + if (artifact.path === manifestPath) continue; + const absolute = path.join(root, artifact.path); + assert.ok(fs.existsSync(absolute), `missing artifact ${artifact.path}`); + assert.equal(fileHash(absolute), artifact.sha256, `hash mismatch ${artifact.path}`); +} +const inventory = readJson(path.join(root, "tests/golden/M12-05A/format-inventory.json")); +for (const key of ["blenderVersion", "versionTuple", "buildDate", "buildTime", "buildHash", "buildBranch", "buildPlatform", "buildType", "binarySha256"]) { + assert.deepEqual(report.runtime[key], inventory.runtime[key], `runtime drift in ${key}`); +} +assert.equal(report.sourceAnchor, "blender-5.2.0/source/blender/io/stl"); +assert.equal(report.operator, "wm.stl_export"); +assert.deepEqual(report.variants.map((variant) => variant.id), ["STL_BINARY", "STL_ASCII"]); +assert.equal(report.variants[0].asciiFormat, false); +assert.equal(report.variants[0].semantic.format, "STL_BINARY"); +assert.equal(report.variants[0].semantic.triangleCount, 2); +assert.equal(report.variants[0].semantic.byteLength, 84 + 2 * 50); +assert.equal(report.variants[1].asciiFormat, true); +assert.equal(report.variants[1].semantic.format, "STL_ASCII"); +assert.equal(report.variants[1].semantic.facetCount, 2); +assert.equal(report.variants[1].semantic.vertexCount, 6); +for (const file of report.files) { + const absolute = path.join(fixtureRoot, file.name); + assert.equal(fileHash(absolute), file.sha256, `${file.name} hash`); + assert.equal(fs.statSync(absolute).size, file.byteLength, `${file.name} byte length`); +} +const binary = fs.readFileSync(path.join(fixtureRoot, "capability-binary.stl")); +assert.equal(binary.readUInt32LE(80), 2); +assert.equal(binary.length, 184); +const ascii = fs.readFileSync(path.join(fixtureRoot, "capability-ascii.stl"), "utf8"); +assert.match(ascii, /^solid /); +assert.equal((ascii.match(/^facet normal/gm) ?? []).length, 2); +assert.equal((ascii.match(/^ vertex /gm) ?? []).length, 6); +assert.match(ascii, /\nendsolid /); + +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-07d-stl-")); +try { + const regeneratedReport = path.join(temporary, "capability-report.json"); + const result = spawnSync(blender, ["-b", "--factory-startup", "--python", generator, "--", temporary, regeneratedReport], { cwd: root, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.deepEqual(readJson(regeneratedReport), report, "STL capability report is not deterministic"); + for (const file of report.files) assert.deepEqual(fs.readFileSync(path.join(temporary, file.name)), fs.readFileSync(path.join(fixtureRoot, file.name)), `${file.name} bytes are not deterministic`); +} +finally { + fs.rmSync(temporary, { recursive: true, force: true }); +} +process.stdout.write(`stl-capability-fixtures-ok binaryTriangles=${report.variants[0].semantic.triangleCount} asciiFacets=${report.variants[1].semantic.facetCount} deterministic=true next=${manifest.nextTask}\n`); diff --git a/tools/web/check-stl-edge-parity.mjs b/tools/web/check-stl-edge-parity.mjs new file mode 100644 index 00000000..a8c01390 --- /dev/null +++ b/tools/web/check-stl-edge-parity.mjs @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-07E/manifest.json"), "utf8")); +const fixtures = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-07E/edge-fixtures.json"), "utf8")); +const desktop = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-07E/desktop-edge-report.json"), "utf8")); +const web = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-07E/web-edge-report.json"), "utf8")); +const fileHash = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); + +assert.deepEqual( + { schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, + { schemaVersion: 1, task: "M12-07E", parentTask: "M12-07D", nextTask: "M12-07F" }, +); +assert.deepEqual( + { schemaVersion: fixtures.schemaVersion, task: fixtures.task, operation: fixtures.operation, nextTask: fixtures.nextTask }, + { schemaVersion: 1, task: "M12-07E", operation: "STL_EDGE_FIXTURE_GENERATION", nextTask: "M12-07F" }, +); +assert.deepEqual(fixtures.fixtures.map((fixture) => [fixture.id, fixture.expectedCode]), [["DEGENERATE_TRIANGLE", "STL_DEGENERATE_TRIANGLE"], ["TRAILING_BYTES", "STL_TRAILING_BYTES"]]); +assert.equal(desktop.operation, "BLENDER_STL_EDGE_PROBE"); +assert.equal(desktop.cases.length, 4); +assert.equal(desktop.cases.find((item) => item.id === "DEGENERATE_TRIANGLE").result.triangleCount, 1); +assert.equal(desktop.cases.find((item) => item.id === "TRAILING_BYTES").result.triangleCount, 0); +assert.equal(web.operation, "STL_NORMAL_UNIT_EDGE_PARITY"); +assert.deepEqual(web.comparisons, { + binaryAsciiNormalExact: true, + desktopNormalExact: true, + webUnitRatio: 1000, + desktopUnitRatio: 999.999952502551, + unitRatioExactWithinFloat32: true, + degenerateTriangleExact: true, + trailingBytes: { web: "BLOCKED/STL_TRAILING_BYTES", desktop: "ACCEPTED_EMPTY", parity: "STRICTER_WEB_BLOCK" }, +}); +const webById = Object.fromEntries(web.browser.map((item) => [item.id, item])); +assert.equal(webById.BINARY_UNIT_1.result.triangleCount, 2); +assert.equal(webById.ASCII_UNIT_1.result.triangleCount, 2); +assert.equal(webById.DEGENERATE_TRIANGLE.result.removedDegenerateTriangles, 1); +assert.deepEqual(webById.TRAILING_BYTES, { id: "TRAILING_BYTES", status: "BLOCKED", code: "STL_TRAILING_BYTES" }); +for (const artifact of Object.values(manifest.artifacts)) { + const file = path.join(root, artifact.path); + assert.ok(fs.existsSync(file), `missing artifact ${artifact.path}`); + assert.equal(fileHash(file), artifact.sha256, `hash mismatch ${artifact.path}`); +} +process.stdout.write(`stl-edge-parity-ok normals=exact unitRatio=${web.comparisons.webUnitRatio} degenerate=removed trailing=${web.comparisons.trailingBytes.parity} next=${manifest.nextTask}\n`); diff --git a/tools/web/check-stl-web-roundtrip.mjs b/tools/web/check-stl-web-roundtrip.mjs new file mode 100644 index 00000000..687c069c --- /dev/null +++ b/tools/web/check-stl-web-roundtrip.mjs @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-07F/manifest.json"), "utf8")); +const report = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-07F/web-roundtrip-report.json"), "utf8")); +const fileHash = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); + +assert.deepEqual( + { schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, + { schemaVersion: 1, task: "M12-07F", parentTask: "M12-07E", nextTask: "M12-07G" }, +); +assert.deepEqual( + { schemaVersion: report.schemaVersion, task: report.task, operation: report.operation, nextTask: report.nextTask }, + { schemaVersion: 1, task: "M12-07F", operation: "WEB_STL_TO_DESKTOP_ROUNDTRIP", nextTask: "M12-07G" }, +); +assert.equal(report.browser.imported.triangleCount, 2); +assert.equal(report.browser.outputBytes, 184); +assert.deepEqual(report.browser.lossReport, { + schemaVersion: 1, + operation: "STL_EXPORT_LOSS_REPORT", + canRoundTrip: true, + warningCount: 1, + warnings: [{ code: "STL_MATERIAL_UNSUPPORTED", severity: "warning", message: "STL has no material slots; 2 source material assignments are omitted" }], +}); +assert.equal(report.desktop.operation, "DESKTOP_IMPORT_WEB_STL"); +assert.equal(report.desktop.triangleCount, 2); +assert.equal(report.desktop.polygonCount, 2); +assert.deepEqual(report.comparison, { triangleCountExact: true, normalExact: true, materialLossExplicit: true }); +for (const artifact of Object.values(manifest.artifacts)) { + const file = path.join(root, artifact.path); + assert.ok(fs.existsSync(file), `missing artifact ${artifact.path}`); + assert.equal(fileHash(file), artifact.sha256, `hash mismatch ${artifact.path}`); +} +process.stdout.write(`stl-web-roundtrip-ok triangles=${report.desktop.triangleCount} normals=exact materialLoss=${report.browser.lossReport.warningCount} desktopExact=true next=${manifest.nextTask}\n`); diff --git a/tools/web/check-stl-web-roundtrip.py b/tools/web/check-stl-web-roundtrip.py new file mode 100644 index 00000000..97ec03b5 --- /dev/null +++ b/tools/web/check-stl-web-roundtrip.py @@ -0,0 +1,58 @@ +"""Import a browser-produced binary STL in pinned Blender 5.2.""" + +import hashlib +import json +import sys +from pathlib import Path + +import bpy + + +def sha256_file(path): + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def main(stl_path, report_path): + stl_path = Path(stl_path).resolve() + report_path = Path(report_path).resolve() + bpy.ops.wm.read_factory_settings(use_empty=True) + result = bpy.ops.wm.stl_import( + filepath=str(stl_path), + directory=str(stl_path.parent), + forward_axis="NEGATIVE_Z", + up_axis="Y", + global_scale=1.0, + use_scene_unit=False, + use_facet_normal=True, + use_mesh_validate=True, + ) + objects = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"] + if "FINISHED" not in result or not objects: + raise RuntimeError("Blender Web STL import did not produce a mesh") + mesh = objects[0].data + mesh.calc_loop_triangles() + report = { + "schemaVersion": 1, + "operation": "DESKTOP_IMPORT_WEB_STL", + "sourceSha256": sha256_file(stl_path), + "sourceBytes": stl_path.stat().st_size, + "objectCount": len(objects), + "vertexCount": len(mesh.vertices), + "polygonCount": len(mesh.polygons), + "triangleCount": len(mesh.loop_triangles), + "polygonNormals": [[float(value) for value in polygon.normal] for polygon in mesh.polygons], + } + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print("stl-web-roundtrip-desktop-imported triangles=%s" % report["triangleCount"]) + + +if __name__ == "__main__": + args = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] + if len(args) != 2: + raise SystemExit("usage: blender --background --python check-stl-web-roundtrip.py -- STL REPORT") + main(args[0], args[1]) diff --git a/tools/web/check-supply-chain-binding.mjs b/tools/web/check-supply-chain-binding.mjs new file mode 100644 index 00000000..7c42ac9c --- /dev/null +++ b/tools/web/check-supply-chain-binding.mjs @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const releaseRoot = path.join(root, "release"); +const reportPath = path.join(root, "tests/golden/M13-05E/supply-chain-report.json"); +const manifestPath = path.join(root, "tests/golden/M13-05E/manifest.json"); +const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); +const fileSha256 = (file) => sha256(fs.readFileSync(file)); +const archive = path.join(releaseRoot, "blender-web-offline.tar.gz"); +const sourceArchive = path.join(releaseRoot, "blender-web-corresponding-source.tar.gz"); +const sumsPath = path.join(releaseRoot, "SHA256SUMS.txt"); +const sbomPath = path.join(root, "docs/web/sbom.spdx.json"); +const noticesPath = path.join(root, "docs/web/third-party-notices.json"); +const lockPath = path.join(root, "web/package-lock.json"); +const packagePath = path.join(root, "web/package.json"); +const sbom = JSON.parse(fs.readFileSync(sbomPath, "utf8")); +const notices = JSON.parse(fs.readFileSync(noticesPath, "utf8")); +const lockBytes = fs.readFileSync(lockPath); +const noticesBytes = fs.readFileSync(noticesPath); +const lockHash = sha256(lockBytes); +const noticesHash = sha256(noticesBytes); +assert.equal(sbom.spdxVersion, "SPDX-2.3"); +assert.equal(sbom.documentNamespace, `https://blender-web.local/spdx/${sha256(Buffer.concat([lockBytes, noticesBytes]))}`); +const rootPackage = sbom.packages.find((item) => item.SPDXID === "SPDXRef-Package-blender-web-editor"); +assert.ok(rootPackage); +assert.equal(rootPackage.checksums?.find((item) => item.algorithm === "SHA256")?.checksumValue, lockHash); +assert.ok(notices.packages.length > 0); +assert.ok(fs.statSync(archive).isFile()); +assert.ok(fs.statSync(sourceArchive).isFile()); +const sums = new Map(fs.readFileSync(sumsPath, "utf8").trim().split(/\r?\n/u).map((line) => { + const match = line.match(/^([a-f0-9]{64}) (.+)$/u); + assert.ok(match, `invalid checksum line ${line}`); + return [match[2], match[1]]; +})); +assert.equal(sums.get(path.basename(archive)), fileSha256(archive)); +assert.equal(sums.get(path.basename(sourceArchive)), fileSha256(sourceArchive)); +function archiveEntries(file) { + return execFileSync("tar", ["-tzf", file], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }).split(/\r?\n/u).filter(Boolean); +} +function archiveFile(file, entry) { + return execFileSync("tar", ["-xOf", file, entry], { maxBuffer: 64 * 1024 * 1024 }); +} +const binaryEntries = archiveEntries(archive); +const sourceEntries = archiveEntries(sourceArchive); +for (const entry of ["blender-web-offline/sbom.spdx.json", "blender-web-offline/third-party-notices.json", "blender-web-offline/SOURCE_OFFER.txt", "blender-web-offline/manifest.json"]) assert.ok(binaryEntries.includes(entry), `binary archive omits ${entry}`); +for (const entry of ["web/package.json", "web/package-lock.json", "docs/web/sbom.spdx.json", "docs/web/third-party-notices.json", "docs/web/DEPLOYMENT.md", "tools/web/create-offline-release.mjs"]) assert.ok(sourceEntries.includes(entry), `source archive omits ${entry}`); +const sourceOffer = archiveFile(archive, "blender-web-offline/SOURCE_OFFER.txt").toString("utf8"); +assert.match(sourceOffer, /blender-web-corresponding-source\.tar\.gz/u); +assert.match(sourceOffer, /SHA256SUMS\.txt/u); +const embeddedPackage = archiveFile(sourceArchive, "web/package.json"); +const embeddedLock = archiveFile(sourceArchive, "web/package-lock.json"); +assert.equal(sha256(embeddedPackage), fileSha256(packagePath)); +assert.equal(sha256(embeddedLock), lockHash); +const embeddedSbom = archiveFile(archive, "blender-web-offline/sbom.spdx.json"); +assert.deepEqual(JSON.parse(embeddedSbom), sbom); +const commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim(); +assert.match(commit, /^[a-f0-9]{40}$/u); +const report = { + schemaVersion: 1, task: "M13-05E", operation: "SUPPLY_CHAIN_BINDING", commit, + inputs: { packageSha256: fileSha256(packagePath), lockfileSha256: lockHash, sbomSha256: fileSha256(sbomPath), noticesSha256: noticesHash }, + archives: { binary: { path: path.relative(root, archive), sha256: fileSha256(archive), entries: binaryEntries.length }, source: { path: path.relative(root, sourceArchive), sha256: fileSha256(sourceArchive), entries: sourceEntries.length } }, + sourceOffer: "BOUND_TO_CORRESPONDING_SOURCE_ARCHIVE_AND_SHA256SUMS", + checks: { spdx23: true, lockfileBound: true, noticesBound: true, sourceOfferBound: true, archiveChecksumsBound: true, sourcePackageBound: true }, + execution: "DISABLED", nextTask: "M13-05F", +}; +if (process.env.UPDATE_M13_05E_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); } +assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report); +const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); +assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-05E", parentTask: "M13-05D", nextTask: "M13-05F" }); +for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileSha256(path.join(root, artifact.path)), artifact.sha256, artifact.path); +process.stdout.write(`supply-chain-binding-ok sbom=SPDX-2.3 lockfile=BOUND notices=BOUND sourceOffer=BOUND binarySha256=${report.archives.binary.sha256} sourceSha256=${report.archives.source.sha256} execution=DISABLED next=M13-05F\n`); diff --git a/tools/web/check-webkit-capability.mjs b/tools/web/check-webkit-capability.mjs new file mode 100644 index 00000000..65d774d2 --- /dev/null +++ b/tools/web/check-webkit-capability.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import http from "node:http"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const distRoot = path.join(root, "web/dist"); +const reportPath = path.join(root, "tests/golden/M14-01C/webkit-capability-report.json"); +const manifestPath = path.join(root, "tests/golden/M14-01C/manifest.json"); +const digest = (value) => crypto.createHash("sha256").update(value).digest("hex"); +const fileDigest = (file) => digest(fs.readFileSync(file)); +const mime = new Map([[".html", "text/html; charset=utf-8"], [".js", "text/javascript; charset=utf-8"], [".css", "text/css; charset=utf-8"], [".json", "application/json"], [".wasm", "application/wasm"]]); +const workerName = fs.readdirSync(path.join(distRoot, "assets")).find((name) => /^storage\.worker-[\w-]+\.js$/u.test(name)); +const wasmName = fs.readdirSync(path.join(distRoot, "assets")).find((name) => /^web_engine-[\w-]+\.wasm$/u.test(name)); +assert.ok(workerName && wasmName, "production worker/WASM assets are missing"); +const server = http.createServer((request, response) => { + const pathname = decodeURIComponent(new URL(request.url ?? "/", "http://127.0.0.1").pathname); + const relative = pathname === "/" ? "index.html" : pathname.replace(/^\//u, ""); + const file = path.resolve(distRoot, relative); + if (!file.startsWith(`${distRoot}${path.sep}`) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { response.writeHead(404); response.end("not found"); return; } + response.statusCode = 200; + response.setHeader("Content-Type", mime.get(path.extname(file)) ?? "application/octet-stream"); + response.setHeader("Cross-Origin-Opener-Policy", "same-origin"); + response.setHeader("Cross-Origin-Embedder-Policy", "require-corp"); + response.setHeader("Cross-Origin-Resource-Policy", "same-origin"); + response.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; font-src 'self'; img-src 'self'; media-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"); + fs.createReadStream(file).pipe(response); +}); +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +let browser; +try { + const { webkit } = await import(pathToFileURL(path.join(root, "web/node_modules/playwright/index.mjs")).href); + browser = await webkit.launch({ headless: true }); + const context = await browser.newContext(); + const page = await context.newPage(); + const address = server.address(); + assert.ok(address && typeof address === "object"); + await page.goto(`http://127.0.0.1:${address.port}/`, { waitUntil: "load" }); + const probe = await page.evaluate(async ({ workerPath, wasmPath }) => { + const run = async (name, operation) => { try { return { name, ...await operation() }; } catch (error) { return { name, status: "BLOCKED", code: error instanceof Error ? error.name : "PROBE_FAILED", detail: error instanceof Error ? error.message.slice(0, 200) : String(error) }; } }; + const wasm = await run("wasm", async () => { const bytes = await fetch(wasmPath).then((response) => { if (!response.ok) throw new Error(`HTTP_${response.status}`); return response.arrayBuffer(); }); await WebAssembly.compile(bytes); return { status: "PASS", code: "WASM_READY", bytes: bytes.byteLength }; }); + const worker = await run("worker", async () => { await new Promise((resolve, reject) => { const value = new Worker(workerPath, { type: "module" }); const timer = setTimeout(() => { value.terminate(); resolve(); }, 500); value.onerror = (event) => { clearTimeout(timer); value.terminate(); reject(new Error(event.message || "WORKER_LOAD_FAILED")); }; }); return { status: "PASS", code: "WORKER_READY" }; }); + const opfs = await run("opfs", async () => { if (!navigator.storage || typeof navigator.storage.getDirectory !== "function") return { status: "BLOCKED", code: "OPFS_UNAVAILABLE" }; const directory = await navigator.storage.getDirectory(); const probeDirectory = await directory.getDirectoryHandle("m14-webkit-probe", { create: true }); const handle = await probeDirectory.getFileHandle("probe.bin", { create: true }); const writable = await handle.createWritable(); await writable.write(new Uint8Array([1, 2, 3])); await writable.close(); await probeDirectory.removeEntry("probe.bin"); await directory.removeEntry("m14-webkit-probe"); return { status: "PASS", code: "OPFS_READY" }; }); + const indexeddb = await run("indexedDB", async () => { if (!indexedDB) return { status: "BLOCKED", code: "INDEXEDDB_UNAVAILABLE" }; const name = "m14-webkit-probe"; await new Promise((resolve, reject) => { const request = indexedDB.open(name, 1); request.onupgradeneeded = () => request.result.createObjectStore("probe"); request.onsuccess = () => { request.result.close(); resolve(); }; request.onerror = () => reject(request.error ?? new Error("INDEXEDDB_OPEN_FAILED")); }); await new Promise((resolve) => { const request = indexedDB.deleteDatabase(name); request.onsuccess = request.onerror = request.onblocked = () => resolve(); }); return { status: "PASS", code: "INDEXEDDB_READY" }; }); + const webgl2 = await run("webgl2", async () => { const canvas = document.createElement("canvas"); const gl = canvas.getContext("webgl2"); if (!gl) return { status: "BLOCKED", code: "WEBGL2_UNAVAILABLE" }; const debug = gl.getExtension("WEBGL_debug_renderer_info"); return { status: "PASS", code: "WEBGL2_READY", renderer: debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : "REDACTED" }; }); + const webgpu = await run("webgpu", async () => { if (!("gpu" in navigator)) return { status: "BLOCKED", code: "WEBGPU_UNAVAILABLE" }; const adapter = await navigator.gpu.requestAdapter(); if (!adapter) return { status: "BLOCKED", code: "WEBGPU_ADAPTER_UNAVAILABLE" }; return { status: "PASS", code: "WEBGPU_READY", adapter: adapter.info?.description ?? adapter.name ?? "REDACTED" }; }); + const offscreen = await run("offscreen", async () => { if (typeof OffscreenCanvas !== "function") return { status: "BLOCKED", code: "OFFSCREEN_UNAVAILABLE" }; const canvas = new OffscreenCanvas(2, 2); return { status: "PASS", code: "OFFSCREEN_READY", context2d: Boolean(canvas.getContext("2d")) }; }); + const isolation = { name: "isolation", status: crossOriginIsolated ? "PASS" : "BLOCKED", code: crossOriginIsolated ? "ISOLATION_READY" : "ISOLATION_REQUIRED" }; + return { userAgent: navigator.userAgent, platform: navigator.platform, hardwareConcurrency: navigator.hardwareConcurrency, capabilities: [wasm, worker, opfs, indexeddb, webgl2, webgpu, offscreen, isolation] }; + }, { workerPath: `/assets/${workerName}`, wasmPath: `/assets/${wasmName}` }); + const report = { schemaVersion: 1, task: "M14-01C", operation: "WEBKIT_CAPABILITY_PROBE", runtime: "PLAYWRIGHT_WEBKIT", browser: { version: await browser.version(), executablePath: (await import(pathToFileURL(path.join(root, "web/node_modules/playwright/index.mjs")).href)).webkit.executablePath(), userAgent: probe.userAgent, platform: probe.platform, hardwareConcurrency: probe.hardwareConcurrency }, assets: { worker: { path: `web/dist/assets/${workerName}`, sha256: fileDigest(path.join(distRoot, "assets", workerName)) }, wasm: { path: `web/dist/assets/${wasmName}`, sha256: fileDigest(path.join(distRoot, "assets", wasmName)) } }, capabilities: Object.fromEntries(probe.capabilities.map((item) => [item.name, item])), supportRule: "PASS_ONLY_WHEN_PROBED; BLOCKED_OR_UNAVAILABLE_DOES_NOT_CLAIM_SUPPORT", execution: "DISABLED", nextTask: "M14-01D" }; + if (process.env.UPDATE_M14_01C_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); } + assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M14-01C", parentTask: "M14-01B", nextTask: "M14-01D" }); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileDigest(path.join(root, artifact.path)), artifact.sha256, artifact.path); + const summary = probe.capabilities.map((item) => `${item.name}=${item.status}`).join(","); + process.stdout.write(`webkit-capability-ok version=${report.browser.version} ${summary} execution=DISABLED next=${manifest.nextTask}\n`); +} finally { + await browser?.close(); + await new Promise((resolve) => server.close(resolve)); +} diff --git a/tools/web/fuzz-case-runner.mjs b/tools/web/fuzz-case-runner.mjs new file mode 100644 index 00000000..33f3df94 --- /dev/null +++ b/tools/web/fuzz-case-runner.mjs @@ -0,0 +1,108 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import ts from "../../web/node_modules/typescript/lib/typescript.js"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const domain = process.argv[2]; +const seed = Number.parseInt(process.argv[3] ?? "0", 10) >>> 0; +const iteration = Number.parseInt(process.argv[4] ?? "0", 10); +if (!["blend", "image", "font", "node", "manifest"].includes(domain) || !Number.isSafeInteger(iteration) || iteration < 0) process.exit(64); +let state = (seed ^ Math.imul(iteration + 1, 0x9e3779b1)) >>> 0; +const random = () => { state ^= state << 13; state ^= state >>> 17; state ^= state << 5; return state >>> 0; }; +const mutate = (source) => { + let bytes = Buffer.from(source); + if (iteration % 7 === 0) bytes = bytes.subarray(0, Math.max(0, Math.min(bytes.length, random() % Math.max(1, bytes.length)))); + else for (let index = 0; index < 1 + iteration % 8 && bytes.length > 0; index++) bytes[random() % bytes.length] ^= 1 << (random() % 8); + return bytes; +}; +const digest = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), `m13-fuzz-${domain}-`)); +const transpile = (name, replacements = []) => { + const sourcePath = path.join(root, "web/protocol", `${name}.ts`); + const result = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true }); + if (result.diagnostics?.length) throw new Error(`TRANSPILE_FAILED:${name}`); + const output = replacements.reduce((value, [from, to]) => value.replaceAll(from, to), result.outputText); + fs.writeFileSync(path.join(temporary, `${name}.mjs`), output); +}; +const result = (status, code) => process.stdout.write(`${JSON.stringify({ domain, seed, iteration, status, code })}\n`); +try { + if (domain === "blend") { + const [{ default: factory }, wasmBinary, source] = await Promise.all([ + import(pathToFileURL(path.join(root, "web/app/src/vendor/blender/web_engine.js")).href), + fs.promises.readFile(path.join(root, "web/app/src/vendor/blender/web_engine.wasm")), + fs.promises.readFile(path.join(root, "tests/files/web/basic_scene.blend")), + ]); + const bytes = mutate(source); + const engine = await factory({ wasmBinary }); + const handle = engine._web_engine_create(); + let pointer = 0; + try { + if (bytes.length) { pointer = engine._malloc(bytes.length); engine.HEAPU8.set(bytes, pointer); } + const code = engine._web_engine_open_blend(handle, pointer, bytes.length); + result(code === 0 ? "ACCEPTED" : "REJECTED", code === 0 ? "OK" : "BLEND_OPEN_INVALID"); + } finally { + if (pointer) engine._free(pointer); + engine._web_engine_destroy(handle); + } + } else if (domain === "image") { + transpile("asset-preview"); + transpile("asset-preview-decode", [['from "./asset-preview"', 'from "./asset-preview.mjs"']]); + const identityProtocol = await import(pathToFileURL(path.join(temporary, "asset-preview.mjs")).href); + const decode = await import(pathToFileURL(path.join(temporary, "asset-preview-decode.mjs")).href); + const source = fs.readFileSync(path.join(root, "tests/golden/M12-02B/preview.png")); + const bytes = mutate(source); + const original = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-02B/identity.json"), "utf8")); + const value = { ...original, content: { ...original.content, byteLength: bytes.length, sha256: digest(bytes) } }; + delete value.identitySha256; + const identity = await identityProtocol.createAssetPreviewIdentity(value); + await decode.planAssetPreviewDecode(identity, bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)); + result("ACCEPTED", "OK"); + } else if (domain === "font") { + transpile("asset-path"); + transpile("external-vfont", [['from "./asset-path"', 'from "./asset-path.mjs"']]); + const font = await import(pathToFileURL(path.join(temporary, "external-vfont.mjs")).href); + const bytes = mutate(fs.readFileSync(path.join(root, "blender-5.2.0/release/datafiles/bfont.pfb"))); + const data = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + await font.validateExternalVFontImport({ sourcePath: "//fonts/fuzz.pfb", mimeType: "application/x-font-type1", byteLength: bytes.length, sha256: digest(bytes), data }); + result("ACCEPTED", "OK"); + } else if (domain === "node") { + transpile("shader-compiler"); + const shader = await import(pathToFileURL(path.join(temporary, "shader-compiler.mjs")).href); + const material = { id: "material:fuzz", name: "fuzz", baseColor: [0.2, 0.3, 0.4, 1], roughness: 0.5, metallic: 0, emissionColor: [0, 0, 0, 1], alpha: 1, ior: 1.45, shaderGraphHash: "a".repeat(64), nodes: [{ id: "principled", type: "PRINCIPLED", name: "Principled" }, { id: "output", type: "OUTPUT", name: "Output" }], links: [{ fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" }] }; + switch (iteration % 6) { + case 0: material.nodes.push({ id: `unknown-${random()}`, type: "UNSUPPORTED", name: "Unknown" }); break; + case 1: material.nodes.push({ ...material.nodes[0] }); break; + case 2: material.links.push({ fromNodeId: "output", fromSocket: "Surface", toNodeId: "principled", toSocket: "Base Color" }); break; + case 3: material.shaderGraphHash = digest(Buffer.from(String(random()))).slice(1); break; + case 4: material.nodes[0].id = "x".repeat(4096); break; + default: material.roughness = Number.NaN; + } + const compiled = shader.compileMaterialGraph(material); + result(compiled.status === "BLOCKED" ? "REJECTED" : "ACCEPTED", compiled.issues?.[0]?.code ?? "OK"); + } else { + for (const name of ["asset-path", "capability-gates", "scripting-platform"]) transpile(name, [['from "./asset-path"', 'from "./asset-path.mjs"'], ['from "./capability-gates"', 'from "./capability-gates.mjs"']]); + const protocol = await import(pathToFileURL(path.join(temporary, "scripting-platform.mjs")).href); + const script = { id: "fuzz", name: "fuzz", entryPath: "scripts/fuzz.py", sourceByteLength: 128, sourceSha256: "a".repeat(64), publisher: "local", signature: "b".repeat(128), keyId: "key:local", permissions: ["READ_MAIN"], dependencies: [], module: false, cpuMs: 1000, memoryBytes: 1024 * 1024, wallMs: 5000, network: false, autorun: false, driverExpressions: false, addonInstall: false }; + const manifest = { schemaVersion: 1, scripts: [script] }; + switch (iteration % 6) { + case 0: script.entryPath = `../${random()}.py`; break; + case 1: script.sourceByteLength = Number.MAX_SAFE_INTEGER; break; + case 2: script.permissions = ["UNKNOWN"]; break; + case 3: script.dependencies = [{ id: "fuzz", sourceSha256: "x", sourcePath: "../dep.py" }]; break; + case 4: manifest.schemaVersion = 2; break; + default: script.module = true; + } + protocol.parseScriptingManifest(manifest); + result("ACCEPTED", "OK"); + } +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + const code = error?.code ?? message.match(/^([A-Z][A-Z0-9_]+):/u)?.[1] ?? "STRUCTURED_REJECTION"; + result("REJECTED", code); +} finally { + fs.rmSync(temporary, { recursive: true, force: true }); +} diff --git a/tools/web/generate-dependency-inventory.mjs b/tools/web/generate-dependency-inventory.mjs new file mode 100644 index 00000000..c414b34b --- /dev/null +++ b/tools/web/generate-dependency-inventory.mjs @@ -0,0 +1,79 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const packagePath = path.join(root, "web/package.json"); +const lockPath = path.join(root, "web/package-lock.json"); +const outputPath = path.resolve(process.argv[2] ?? path.join(root, "tests/golden/M13-05C/dependency-inventory.json")); +const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8")); +const lock = JSON.parse(fs.readFileSync(lockPath, "utf8")); +const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); +const packageBytes = fs.readFileSync(packagePath); +const lockBytes = fs.readFileSync(lockPath); +const entries = new Map(Object.entries(lock.packages)); +const packageName = (packageKey) => { + const marker = packageKey.lastIndexOf("node_modules/"); + return marker < 0 ? packageKey : packageKey.slice(marker + "node_modules/".length); +}; +function resolveDependency(fromKey, dependency) { + let base = fromKey; + while (true) { + const candidate = base ? `${base}/node_modules/${dependency}` : `node_modules/${dependency}`; + if (entries.has(candidate)) return candidate; + const marker = base.lastIndexOf("/node_modules/"); + if (marker < 0) return entries.has(`node_modules/${dependency}`) ? `node_modules/${dependency}` : null; + base = base.slice(0, marker); + } +} +function closure(roots) { + const visited = new Set(); + const queue = roots.map((name) => resolveDependency("", name)).filter(Boolean); + while (queue.length) { + const key = queue.shift(); + if (!key || visited.has(key)) continue; + visited.add(key); + const entry = entries.get(key); + for (const dependency of Object.keys({ ...(entry.dependencies ?? {}), ...(entry.optionalDependencies ?? {}) })) { + const next = resolveDependency(key, dependency); + if (next) queue.push(next); + } + } + return visited; +} +const roots = { + production: Object.keys(packageJson.dependencies ?? {}), + build: ["@eslint/js", "@types/react", "@types/react-dom", "@types/three", "@vitejs/plugin-react", "eslint", "typescript", "typescript-eslint", "vite"], + test: ["@axe-core/playwright", "@playwright/test"], +}; +const categories = Object.fromEntries(Object.entries(roots).map(([category, names]) => [category, closure(names)])); +const allKeys = new Set([...categories.production, ...categories.build, ...categories.test]); +const packages = [...allKeys].sort().map((key) => { + const entry = entries.get(key); + const category = Object.entries(categories).filter(([, keys]) => keys.has(key)).map(([name]) => name); + return { + path: key, + name: entry.name ?? packageName(key), + version: entry.version, + resolved: entry.resolved ?? null, + integrity: entry.integrity ?? null, + categories: category, + dependencies: Object.keys({ ...(entry.dependencies ?? {}), ...(entry.optionalDependencies ?? {}) }).sort(), + }; +}); +const inventory = { + schemaVersion: 1, + task: "M13-05C", + operation: "NPM_DEPENDENCY_INVENTORY", + package: { path: "web/package.json", sha256: sha256(packageBytes), name: lock.name, version: lock.version, lockfileVersion: lock.lockfileVersion }, + lockfile: { path: "web/package-lock.json", sha256: sha256(lockBytes), packageCount: entries.size - 1 }, + roots, + categoryCounts: Object.fromEntries(Object.entries(categories).map(([name, keys]) => [name, keys.size])), + sharedCount: packages.filter((item) => item.categories.length > 1).length, + packages, + nextTask: "M13-05D", +}; +fs.mkdirSync(path.dirname(outputPath), { recursive: true }); +fs.writeFileSync(outputPath, `${JSON.stringify(inventory, null, 2)}\n`); +process.stdout.write(`dependency-inventory-generated production=${inventory.categoryCounts.production} build=${inventory.categoryCounts.build} test=${inventory.categoryCounts.test} shared=${inventory.sharedCount} next=${inventory.nextTask}\n`); diff --git a/tools/web/generate-glb-desktop-fixtures.py b/tools/web/generate-glb-desktop-fixtures.py new file mode 100644 index 00000000..d16d34e2 --- /dev/null +++ b/tools/web/generate-glb-desktop-fixtures.py @@ -0,0 +1,455 @@ +"""Generate the bounded Blender 5.2 desktop GLB fixture group for M12-06A. + +The generator deliberately keeps each feature in its own file. Later import and +round-trip tasks can therefore fail on one capability without hiding it behind a +large all-in-one scene. +""" + +import hashlib +import json +import os +import struct +import sys +from pathlib import Path + +import bpy + + +FIXTURES = ( + ("mesh", "M12 Mesh Fixture", "mesh.glb"), + ("pbr", "M12 PBR Fixture", "pbr.glb"), + ("uv", "M12 UV Fixture", "uv.glb"), + ("skin", "M12 Skin Fixture", "skin.glb"), + ("animation", "M12 Animation Fixture", "animation.glb"), +) + + +def sha256_file(path): + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def reset(): + bpy.ops.wm.read_factory_settings(use_empty=True) + scene = bpy.context.scene + scene.frame_start = 1 + scene.frame_end = 25 + scene.render.fps = 24 + scene.unit_settings.system = "METRIC" + scene.unit_settings.scale_length = 1.0 + return scene + + +def mesh_object(name, vertices, faces): + mesh = bpy.data.meshes.new(name + " Mesh") + mesh.from_pydata(vertices, [], faces) + mesh.update() + obj = bpy.data.objects.new(name, mesh) + bpy.context.scene.collection.objects.link(obj) + return obj + + +def select_only(objects): + bpy.ops.object.select_all(action="DESELECT") + for obj in objects: + obj.select_set(True) + bpy.context.view_layer.objects.active = objects[0] + + +def export_selected(path): + result = bpy.ops.export_scene.gltf( + filepath=str(path), + export_format="GLB", + use_selection=True, + export_apply=False, + export_animations=True, + export_animation_mode="ACTIONS", + export_frame_range=True, + export_frame_step=1, + export_force_sampling=True, + export_skins=True, + export_all_influences=True, + export_morph=True, + export_morph_animation=True, + export_attributes=True, + export_texcoords=True, + export_normals=True, + export_tangents=False, + export_materials="EXPORT", + export_image_format="AUTO", + export_cameras=False, + export_lights=False, + export_draco_mesh_compression_enable=False, + export_meshopt_compression_enable=False, + export_try_sparse_sk=False, + export_try_omit_sparse_sk=False, + export_current_frame=False, + export_yup=True, + ) + if "FINISHED" not in result: + raise RuntimeError("Blender GLB export did not finish: %s" % (result,)) + + +def add_pbr_material(name, color, metallic, roughness): + material = bpy.data.materials.new(name) + material.use_nodes = True + material.diffuse_color = (*color, 1.0) + principled = material.node_tree.nodes.get("Principled BSDF") + principled.inputs["Base Color"].default_value = (*color, 1.0) + principled.inputs["Metallic"].default_value = metallic + principled.inputs["Roughness"].default_value = roughness + if principled.inputs.get("IOR"): + principled.inputs["IOR"].default_value = 1.45 + return material + + +def add_uv_layer(obj): + layer = obj.data.uv_layers.new(name="UVMap") + values = ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)) + for loop, value in zip(layer.data, values): + loop.uv = value + + +def add_corner_colors(obj): + colors = obj.data.color_attributes.new(name="M12Color", type="FLOAT_COLOR", domain="CORNER") + values = ( + (1.0, 0.0, 0.0, 1.0), + (0.0, 1.0, 0.0, 1.0), + (0.0, 0.0, 1.0, 1.0), + (1.0, 1.0, 0.0, 1.0), + ) + for item, value in zip(colors.data, values): + item.color = value + obj.data.color_attributes.active_color_index = 0 + + +def use_corner_colors(material): + vertex_color = material.node_tree.nodes.new("ShaderNodeVertexColor") + vertex_color.layer_name = "M12Color" + principled = material.node_tree.nodes.get("Principled BSDF") + material.node_tree.links.new(vertex_color.outputs["Color"], principled.inputs["Base Color"]) + + +def create_mesh_fixture(): + reset() + obj = mesh_object( + "M12 Mesh Fixture", + [(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (1.0, 1.0, 0.0), (-1.0, 1.0, 0.0)], + [(0, 1, 2, 3)], + ) + add_corner_colors(obj) + material = add_pbr_material("M12 Mesh Material", (0.22, 0.48, 0.83), 0.15, 0.55) + use_corner_colors(material) + obj.data.materials.append(material) + select_only([obj]) + + +def create_pbr_fixture(): + reset() + obj = mesh_object( + "M12 PBR Fixture", + [(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (1.0, 1.0, 0.0), (-1.0, 1.0, 0.0)], + [(0, 1, 2, 3)], + ) + material = add_pbr_material("M12 PBR Material", (0.31, 0.57, 0.91), 0.72, 0.28) + principled = material.node_tree.nodes.get("Principled BSDF") + principled.inputs["Emission Color"].default_value = (0.02, 0.04, 0.08, 1.0) + if principled.inputs.get("Emission Strength"): + principled.inputs["Emission Strength"].default_value = 1.5 + obj.data.materials.append(material) + select_only([obj]) + + +def create_uv_fixture(): + reset() + obj = mesh_object( + "M12 UV Fixture", + [(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (1.0, 1.0, 0.0), (-1.0, 1.0, 0.0)], + [(0, 1, 2, 3)], + ) + add_uv_layer(obj) + material = add_pbr_material("M12 UV Material", (1.0, 1.0, 1.0), 0.0, 0.45) + image = bpy.data.images.new("M12 UV Texture", width=2, height=2, alpha=True) + image.colorspace_settings.name = "sRGB" + image.pixels = ( + 1.0, 0.1, 0.1, 1.0, + 0.1, 1.0, 0.1, 1.0, + 0.1, 0.1, 1.0, 1.0, + 1.0, 1.0, 0.1, 1.0, + ) + image.pack() + texture = material.node_tree.nodes.new("ShaderNodeTexImage") + texture.name = "M12 UV Image Texture" + texture.image = image + texture.interpolation = "Closest" + texture.extension = "REPEAT" + principled = material.node_tree.nodes.get("Principled BSDF") + material.node_tree.links.new(texture.outputs["Color"], principled.inputs["Base Color"]) + obj.data.materials.append(material) + select_only([obj]) + + +def create_armature(name): + armature_data = bpy.data.armatures.new(name + " Data") + armature = bpy.data.objects.new(name, armature_data) + bpy.context.scene.collection.objects.link(armature) + bpy.context.view_layer.objects.active = armature + armature.select_set(True) + bpy.ops.object.mode_set(mode="EDIT") + root = armature_data.edit_bones.new("Root") + root.head = (0.0, 0.0, 0.0) + root.tail = (0.0, 0.0, 1.0) + tip = armature_data.edit_bones.new("Tip") + tip.head = (0.0, 0.0, 1.0) + tip.tail = (0.0, 0.0, 2.0) + tip.parent = root + bpy.ops.object.mode_set(mode="OBJECT") + return armature + + +def create_skin_fixture(): + reset() + armature = create_armature("M12 Skin Armature") + obj = mesh_object( + "M12 Skin Fixture", + [(-1.0, -0.5, 0.0), (1.0, -0.5, 0.0), (1.0, 0.5, 0.0), (-1.0, 0.5, 0.0)], + [(0, 1, 2, 3)], + ) + root = obj.vertex_groups.new(name="Root") + tip = obj.vertex_groups.new(name="Tip") + root.add([0, 3], 0.75, "REPLACE") + tip.add([0, 3], 0.25, "REPLACE") + root.add([1, 2], 0.2, "REPLACE") + tip.add([1, 2], 0.8, "REPLACE") + modifier = obj.modifiers.new(name="M12 Armature Deform", type="ARMATURE") + modifier.object = armature + obj.parent = armature + material = add_pbr_material("M12 Skin Material", (0.76, 0.24, 0.18), 0.05, 0.5) + obj.data.materials.append(material) + select_only([armature, obj]) + + +def create_animation_fixture(): + reset() + obj = mesh_object( + "M12 Animation Fixture", + [(-0.75, -0.75, 0.0), (0.75, -0.75, 0.0), (0.0, 0.75, 0.0)], + [(0, 1, 2)], + ) + material = add_pbr_material("M12 Animation Material", (0.16, 0.72, 0.38), 0.1, 0.4) + obj.data.materials.append(material) + obj.location = (-1.0, 0.0, 0.0) + obj.rotation_mode = "XYZ" + obj.keyframe_insert(data_path="location", frame=1) + obj.keyframe_insert(data_path="rotation_euler", frame=1) + obj.location = (0.0, 0.5, 0.25) + obj.rotation_euler[2] = 0.75 + obj.keyframe_insert(data_path="location", frame=13) + obj.keyframe_insert(data_path="rotation_euler", frame=13) + obj.location = (1.0, 0.0, 0.0) + obj.rotation_euler[2] = 1.5 + obj.keyframe_insert(data_path="location", frame=25) + obj.keyframe_insert(data_path="rotation_euler", frame=25) + if obj.animation_data and obj.animation_data.action: + obj.animation_data.action.name = "M12 Animation Action" + select_only([obj]) + + +def read_glb(path): + payload = path.read_bytes() + if len(payload) < 20 or payload[:4] != b"glTF": + raise RuntimeError("invalid GLB header: %s" % path) + version, total_length = struct.unpack_from(" crypto.createHash("sha256").update(bytes).digest("hex"); +const stableValue = (value) => Array.isArray(value) + ? value.map(stableValue) + : value && typeof value === "object" + ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])])) + : value; +const stableSha256 = (value) => sha256(JSON.stringify(stableValue(value))); +const arrayBuffer = (bytes) => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + +try { + const sourcePath = path.join(root, "web/protocol/glb-import.ts"); + const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, + }); + assert.deepEqual(transpiled.diagnostics, []); + const modulePath = path.join(temporary, "glb-import.mjs"); + fs.writeFileSync(modulePath, transpiled.outputText); + const protocol = await import(pathToFileURL(modulePath)); + const fixtures = JSON.parse(fs.readFileSync(fixtureReportPath, "utf8")).fixtures; + const comparisons = fixtures.map((fixture) => { + const bytes = fs.readFileSync(path.join(fixtureRoot, fixture.file)); + assert.equal(sha256(bytes), fixture.sha256, `${fixture.id} source SHA-256`); + const imported = protocol.importGLBDesktopFixtureSemantics(arrayBuffer(bytes)); + const comparison = protocol.compareGLBDesktopFixtureSemantics(fixture.semantic, imported); + const primitives = imported.meshes.flatMap((mesh) => mesh.primitives); + return { + id: fixture.id, + file: fixture.file, + sourceSha256: fixture.sha256, + byteLength: bytes.byteLength, + desktopSemanticSha256: stableSha256(fixture.semantic), + webSemanticSha256: stableSha256(imported), + compatible: comparison.compatible, + mismatchCount: comparison.mismatches.length, + topology: { + meshCount: imported.meshes.length, + primitiveCount: primitives.length, + indexCount: primitives.reduce((sum, primitive) => sum + (primitive.indices?.count ?? 0), 0), + modes: [...new Set(primitives.map((primitive) => primitive.mode))].sort((left, right) => left - right), + }, + attributes: [...new Set(primitives.flatMap((primitive) => Object.keys(primitive.attributes)))].sort(), + materials: { + count: imported.materials.length, + pbrCount: imported.materials.filter((material) => material.pbr.metallicFactor !== null && material.pbr.roughnessFactor !== null).length, + texturedCount: imported.materials.filter((material) => material.pbr.baseColorTexture !== null).length, + }, + nodes: { + count: imported.nodes.length, + namedCount: imported.nodes.filter((node) => node.name !== null).length, + hierarchyEdges: imported.nodes.reduce((sum, node) => sum + node.children.length, 0), + skinnedCount: imported.nodes.filter((node) => node.skin !== null).length, + }, + animations: { + count: imported.animations.length, + channelPaths: imported.animations.flatMap((animation) => animation.channels.map((channel) => channel.target.path)).sort(), + sampleCounts: imported.animations.flatMap((animation) => animation.samplers.map((sampler) => sampler.input?.count ?? 0)), + }, + }; + }); + const report = { + schemaVersion: 1, + task: "M12-06B", + operation: "WEB_GLB_IMPORT_SEMANTIC_COMPARISON", + parentManifestSha256: sha256(fs.readFileSync(parentManifestPath)), + fixtureReportSha256: sha256(fs.readFileSync(fixtureReportPath)), + fixtureCount: comparisons.length, + comparedDomains: ["topology", "attributes", "materials", "nodes", "animations"], + allCompatible: comparisons.every((comparison) => comparison.compatible && comparison.mismatchCount === 0), + comparisons, + routeState: "BLOCKED_UNTIL_MAIN_PERSISTENCE", + nextTask: "M12-06C", + }; + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, JSON.stringify(report, null, 2) + "\n"); + process.stdout.write(`glb-desktop-import-report-generated fixtures=${report.fixtureCount} domains=${report.comparedDomains.length} compatible=${report.allCompatible} next=${report.nextTask}\n`); +} +finally { + fs.rmSync(temporary, { recursive: true, force: true }); +} diff --git a/tools/web/generate-glb-main-persistence-fixtures.py b/tools/web/generate-glb-main-persistence-fixtures.py new file mode 100644 index 00000000..bedc7665 --- /dev/null +++ b/tools/web/generate-glb-main-persistence-fixtures.py @@ -0,0 +1,178 @@ +"""Import each M12 GLB with Blender 5.2 and freeze a Main persistence baseline.""" + +import hashlib +import json +import os +import sys +from pathlib import Path + +import bpy + + +FIXTURE_IDS = ("mesh", "pbr", "uv", "skin", "animation") + + +def sha256_file(path): + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def rounded(value): + return round(float(value), 6) + + +def graph_report(): + objects = [] + for obj in sorted(bpy.data.objects, key=lambda item: item.name): + objects.append( + { + "name": obj.name, + "type": obj.type, + "data": obj.data.name if obj.data is not None else None, + "parent": obj.parent.name if obj.parent is not None else None, + "children": sorted(child.name for child in obj.children), + "location": [rounded(value) for value in obj.location], + } + ) + meshes = [] + for mesh in sorted(bpy.data.meshes, key=lambda item: item.name): + mesh.calc_loop_triangles() + meshes.append( + { + "name": mesh.name, + "vertexCount": len(mesh.vertices), + "polygonCount": len(mesh.polygons), + "triangleCount": len(mesh.loop_triangles), + "uvLayers": sorted(layer.name for layer in mesh.uv_layers), + "materials": [material.name if material else None for material in mesh.materials], + } + ) + materials = [] + for material in sorted(bpy.data.materials, key=lambda item: item.name): + materials.append( + { + "name": material.name, + "nodeNames": sorted(node.name for node in material.node_tree.nodes) if material.node_tree else [], + } + ) + images = [] + for image in sorted(bpy.data.images, key=lambda item: item.name): + images.append( + { + "name": image.name, + "size": list(image.size), + "packed": image.packed_file is not None, + "mimeType": image.file_format, + } + ) + armatures = [] + for armature in sorted(bpy.data.armatures, key=lambda item: item.name): + armatures.append( + { + "name": armature.name, + "bones": [ + {"name": bone.name, "parent": bone.parent.name if bone.parent else None} + for bone in sorted(armature.bones, key=lambda item: item.name) + ], + } + ) + actions = [] + action_stable_ids = [] + for action in sorted(bpy.data.actions, key=lambda item: item.name): + fcurves = [] + for layer in action.layers: + for strip in layer.strips: + for channelbag in strip.channelbags: + fcurves.extend((curve.data_path, curve.array_index) for curve in channelbag.fcurves) + actions.append( + { + "name": action.name, + "frameRange": [rounded(action.frame_range[0]), rounded(action.frame_range[1])], + "fcurves": sorted(fcurves), + } + ) + owners = sorted( + object_.name + for object_ in bpy.data.objects + if object_.animation_data is not None and object_.animation_data.action == action + ) + action_stable_ids.extend( + "action:" + action.name + ":object:" + owner for owner in owners + ) + if not owners: + action_stable_ids.append("action:" + action.name) + return { + "objects": objects, + "meshes": meshes, + "materials": materials, + "images": images, + "armatures": armatures, + "actions": actions, + "stableIds": { + "objects": ["object:" + item["name"] for item in objects], + "meshes": ["mesh:" + item["name"] for item in meshes], + "materials": ["material:" + item["name"] for item in materials], + "images": ["image:" + item["name"] for item in images], + "armatures": ["armature:" + item["name"] for item in armatures], + "actions": sorted(action_stable_ids), + }, + } + + +def import_and_save(glb_path, blend_path): + bpy.ops.wm.read_factory_settings(use_empty=True) + result = bpy.ops.import_scene.gltf(filepath=str(glb_path)) + if "FINISHED" not in result: + raise RuntimeError("Blender GLB import failed: %s" % (result,)) + scene = bpy.context.scene + scene.frame_start = 1 + scene.frame_end = 25 + before = graph_report() + bpy.ops.wm.save_as_mainfile(filepath=str(blend_path), check_existing=False) + bpy.ops.wm.open_mainfile(filepath=str(blend_path), load_ui=False) + reopened = graph_report() + if before != reopened: + raise RuntimeError("desktop import save/reopen semantic drift: %s" % blend_path) + return reopened + + +def main(glb_root, output_root, report_path): + glb_root = Path(glb_root).resolve() + output_root = Path(output_root).resolve() + report_path = Path(report_path).resolve() + output_root.mkdir(parents=True, exist_ok=True) + fixtures = [] + for fixture_id in FIXTURE_IDS: + glb_path = glb_root / (fixture_id + ".glb") + blend_path = output_root / (fixture_id + ".blend") + graph = import_and_save(glb_path, blend_path) + fixtures.append( + { + "id": fixture_id, + "glb": {"file": glb_path.name, "sha256": sha256_file(glb_path)}, + "blend": {"file": blend_path.name, "byteLength": blend_path.stat().st_size, "sha256": sha256_file(blend_path)}, + "graph": graph, + } + ) + report = { + "schemaVersion": 1, + "task": "M12-06C", + "operation": "DESKTOP_GLB_IMPORT_MAIN_PERSISTENCE_BASELINE", + "blenderVersion": bpy.app.version_string, + "fixtureCount": len(fixtures), + "fixtures": fixtures, + "nextTask": "M12-06D", + } + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print("glb-main-persistence-fixtures-generated fixtures=%s next=%s" % (len(fixtures), report["nextTask"])) + + +if __name__ == "__main__": + args = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] + if len(args) != 3: + raise SystemExit("usage: blender --background --python generate-glb-main-persistence-fixtures.py -- GLB_ROOT OUTPUT_ROOT REPORT") + main(args[0], args[1], args[2]) diff --git a/tools/web/generate-glb-web-reimport-report.py b/tools/web/generate-glb-web-reimport-report.py new file mode 100644 index 00000000..b59d5d43 --- /dev/null +++ b/tools/web/generate-glb-web-reimport-report.py @@ -0,0 +1,149 @@ +"""Re-import Web-produced GLBs in Blender 5.2 and emit canonical graph reports.""" + +import hashlib +import json +import sys +from pathlib import Path + +import bpy + + +FIXTURE_IDS = ("pbr", "uv", "skin", "animation") + + +def sha256_file(path): + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def rounded(value): + return round(float(value), 6) + + +def graph_report(): + objects = [] + for obj in sorted(bpy.data.objects, key=lambda item: item.name): + objects.append({ + "name": obj.name, + "type": obj.type, + "data": obj.data.name if obj.data is not None else None, + "parent": obj.parent.name if obj.parent is not None else None, + "children": sorted(child.name for child in obj.children), + "location": [rounded(value) for value in obj.location], + }) + meshes = [] + for mesh in sorted(bpy.data.meshes, key=lambda item: item.name): + mesh.calc_loop_triangles() + meshes.append({ + "name": mesh.name, + "vertexCount": len(mesh.vertices), + "polygonCount": len(mesh.polygons), + "triangleCount": len(mesh.loop_triangles), + "uvLayers": sorted(layer.name for layer in mesh.uv_layers), + "materials": [material.name if material else None for material in mesh.materials], + }) + materials = [] + for material in sorted(bpy.data.materials, key=lambda item: item.name): + materials.append({ + "name": material.name, + "nodeNames": sorted(node.name for node in material.node_tree.nodes) if material.node_tree else [], + }) + images = [] + for image in sorted(bpy.data.images, key=lambda item: item.name): + images.append({ + "name": image.name, + "size": list(image.size), + "packed": image.packed_file is not None, + "mimeType": image.file_format, + }) + armatures = [] + for armature in sorted(bpy.data.armatures, key=lambda item: item.name): + armatures.append({ + "name": armature.name, + "bones": [{"name": bone.name, "parent": bone.parent.name if bone.parent else None} for bone in sorted(armature.bones, key=lambda item: item.name)], + }) + actions = [] + action_stable_ids = [] + for action in sorted(bpy.data.actions, key=lambda item: item.name): + fcurves = [] + for layer in action.layers: + for strip in layer.strips: + for channelbag in strip.channelbags: + fcurves.extend((curve.data_path, curve.array_index) for curve in channelbag.fcurves) + actions.append({ + "name": action.name, + "frameRange": [rounded(action.frame_range[0]), rounded(action.frame_range[1])], + "fcurves": sorted(fcurves), + }) + owners = sorted(object_.name for object_ in bpy.data.objects if object_.animation_data is not None and object_.animation_data.action == action) + action_stable_ids.extend("action:" + action.name + ":object:" + owner for owner in owners) + if not owners: + action_stable_ids.append("action:" + action.name) + return { + "objects": objects, + "meshes": meshes, + "materials": materials, + "images": images, + "armatures": armatures, + "actions": actions, + "stableIds": { + "objects": ["object:" + item["name"] for item in objects], + "meshes": ["mesh:" + item["name"] for item in meshes], + "materials": ["material:" + item["name"] for item in materials], + "images": ["image:" + item["name"] for item in images], + "armatures": ["armature:" + item["name"] for item in armatures], + "actions": sorted(action_stable_ids), + }, + } + + +def import_reopen(glb_path, blend_path): + bpy.ops.wm.read_factory_settings(use_empty=True) + result = bpy.ops.import_scene.gltf(filepath=str(glb_path)) + if "FINISHED" not in result: + raise RuntimeError("Blender Web GLB import failed: %s" % (result,)) + before = graph_report() + bpy.ops.wm.save_as_mainfile(filepath=str(blend_path), check_existing=False) + bpy.ops.wm.open_mainfile(filepath=str(blend_path), load_ui=False) + after = graph_report() + if before != after: + raise RuntimeError("desktop Web GLB save/reopen semantic drift: %s" % glb_path) + return after + + +def main(glb_root, output_root, report_path): + glb_root = Path(glb_root).resolve() + output_root = Path(output_root).resolve() + report_path = Path(report_path).resolve() + output_root.mkdir(parents=True, exist_ok=True) + fixtures = [] + for fixture_id in FIXTURE_IDS: + glb_path = glb_root / (fixture_id + ".glb") + blend_path = output_root / (fixture_id + ".blend") + fixtures.append({ + "id": fixture_id, + "glb": {"file": glb_path.name, "byteLength": glb_path.stat().st_size, "sha256": sha256_file(glb_path)}, + "graph": import_reopen(glb_path, blend_path), + }) + report = { + "schemaVersion": 1, + "task": "M12-06E", + "operation": "DESKTOP_REIMPORT_WEB_GLB_CANONICAL_REPORT", + "blenderVersion": bpy.app.version_string, + "fixtureCount": len(fixtures), + "fixtures": fixtures, + "nextTask": "M12-06F", + } + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print("glb-web-reimport-report-generated fixtures=%s next=%s" % (len(fixtures), report["nextTask"])) + + +if __name__ == "__main__": + args = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] + if len(args) != 3: + raise SystemExit("usage: blender --background --python generate-glb-web-reimport-report.py -- GLB_ROOT OUTPUT_ROOT REPORT") + main(args[0], args[1], args[2]) diff --git a/tools/web/generate-io-format-capability-matrix.mjs b/tools/web/generate-io-format-capability-matrix.mjs new file mode 100644 index 00000000..45b40a8a --- /dev/null +++ b/tools/web/generate-io-format-capability-matrix.mjs @@ -0,0 +1,50 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const inventoryPath = path.join(repoRoot, "tests/golden/M12-05A/format-inventory.json"); +const outputArgument = process.argv.indexOf("--output"); +const outputPath = outputArgument === -1 + ? path.join(repoRoot, "tests/golden/M12-05B/capability-matrix.json") + : path.resolve(process.argv[outputArgument + 1] ?? ""); +if (!outputPath) throw new Error("--output requires a file"); + +const inventoryBytes = fs.readFileSync(inventoryPath); +const inventory = JSON.parse(inventoryBytes); +const runtimeInventorySha256 = crypto.createHash("sha256").update(inventoryBytes).digest("hex"); +const formatOrder = ["GLTF", "GLB", "OBJ", "STL", "PLY", "USD", "ALEMBIC"]; + +const blocked = (code = "IO_FORMAT_UNSUPPORTED") => ({ status: "BLOCKED", execution: "NONE", code }); +const feature = (status, evidence) => ({ status, evidence }); +const operation = (format, name) => { + const glbExport = format === "GLB" && name === "EXPORT"; + const bounded = glbExport + ? feature("PARTIAL", "bounded GLB export gate exists; full format round-trip remains M12-06") + : feature("UNVERIFIED", "no verified Web executor for this format/operation"); + return { + local: glbExport ? { status: "READY", execution: "LOCAL", code: null } : blocked(), + server: blocked(), + geometry: bounded, + material: bounded, + animation: bounded, + }; +}; + +const byFormat = new Map(inventory.formats.map((entry) => [entry.format, entry])); +const formats = formatOrder.map((format) => { + const runtime = byFormat.get(format); + if (!runtime) throw new Error(`inventory is missing ${format}`); + return { + format, + runtimeImportStatus: runtime.import.runtimeStatus === "AVAILABLE" ? "AVAILABLE" : "OPERATOR_UNREGISTERED", + runtimeExportStatus: runtime.export.runtimeStatus === "AVAILABLE" ? "AVAILABLE" : "OPERATOR_UNREGISTERED", + operations: { IMPORT: operation(format, "IMPORT"), EXPORT: operation(format, "EXPORT") }, + }; +}); + +const matrix = { schemaVersion: 1, task: "M12-05B", runtimeInventorySha256, formats }; +fs.mkdirSync(path.dirname(outputPath), { recursive: true }); +fs.writeFileSync(outputPath, `${JSON.stringify(matrix, null, 2)}\n`); +process.stdout.write(`io-format-capability-matrix-generated formats=${formats.length} output=${outputPath}\n`); diff --git a/tools/web/generate-io-format-receipt-bindings.mjs b/tools/web/generate-io-format-receipt-bindings.mjs new file mode 100644 index 00000000..7dc4e6fe --- /dev/null +++ b/tools/web/generate-io-format-receipt-bindings.mjs @@ -0,0 +1,32 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const inventoryPath = path.join(repoRoot, "tests/golden/M12-05A/format-inventory.json"); +const parentPath = path.join(repoRoot, "tests/golden/M12-05D/runtime-receipts.json"); +const outputArgument = process.argv.indexOf("--output"); +const outputPath = outputArgument === -1 ? path.join(repoRoot, "tests/golden/M12-05E/bound-runtime-receipts.json") : path.resolve(process.argv[outputArgument + 1] ?? ""); +if (!outputPath) throw new Error("--output requires a file"); +const inventoryBytes = fs.readFileSync(inventoryPath); +const parentBytes = fs.readFileSync(parentPath); +const inventory = JSON.parse(inventoryBytes); +const parent = JSON.parse(parentBytes); +const canonical = (value) => value === null || typeof value !== "object" ? JSON.stringify(value) : Array.isArray(value) ? `[${value.map(canonical).join(",")}]` : `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`; +const hash = (value) => crypto.createHash("sha256").update(canonical(value)).digest("hex"); +const runtimeSha256 = hash(inventory.runtime); +const receipts = inventory.formats.flatMap((entry) => ["IMPORT", "EXPORT"].map((operation) => { + const source = entry[operation.toLowerCase()]; + const receipt = parent.receipts.find((item) => item.format === entry.format && item.operation === operation); + return { + ...receipt, + sourceSha256: hash({ format: receipt.format, family: receipt.family, operation: receipt.operation, operator: receipt.operator, registered: receipt.registered, rnaIdentifier: receipt.rnaIdentifier }), + settingsSha256: hash({ buildOption: receipt.buildOption, buildOptionEnabled: receipt.buildOptionEnabled, variants: receipt.variants, extensions: receipt.extensions, properties: source.properties }), + runtimeSha256, + }; +})); +const output = { schemaVersion: 1, task: "M12-05E", parentReceiptSetSha256: crypto.createHash("sha256").update(parentBytes).digest("hex"), inventorySha256: crypto.createHash("sha256").update(inventoryBytes).digest("hex"), runtime: inventory.runtime, receipts }; +fs.mkdirSync(path.dirname(outputPath), { recursive: true }); +fs.writeFileSync(outputPath, `${JSON.stringify(output, null, 2)}\n`); +process.stdout.write(`io-format-receipt-bindings-generated receipts=${receipts.length} output=${path.relative(repoRoot, outputPath)}\n`); diff --git a/tools/web/generate-io-format-receipt-freshness.mjs b/tools/web/generate-io-format-receipt-freshness.mjs new file mode 100644 index 00000000..eb875855 --- /dev/null +++ b/tools/web/generate-io-format-receipt-freshness.mjs @@ -0,0 +1,49 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const parentPath = path.join(repoRoot, "tests/golden/M12-05E/bound-runtime-receipts.json"); +const outputArgument = process.argv.indexOf("--output"); +const outputPath = outputArgument === -1 ? path.join(repoRoot, "tests/golden/M12-05F/fresh-runtime-receipts.json") : path.resolve(process.argv[outputArgument + 1] ?? ""); +const expectedArgument = process.argv.indexOf("--expected-output"); +const expectedOutputPath = expectedArgument === -1 ? null : path.resolve(process.argv[expectedArgument + 1] ?? ""); +if (!outputPath) throw new Error("--output requires a file"); + +function stableValue(value) { + if (Array.isArray(value)) return value.map(stableValue); + if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])])); + return value; +} + +function sha256(value) { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +const parentBytes = fs.readFileSync(parentPath); +const bound = JSON.parse(parentBytes); +if (bound.schemaVersion !== 1 || bound.task !== "M12-05E") throw new Error("M12-05E bound receipt header is invalid"); +const output = { + schemaVersion: 1, + task: "M12-05F", + parentBindingSha256: sha256(parentBytes), + boundReceiptSetSha256: sha256(JSON.stringify(stableValue(bound))), + runtimeSha256: sha256(JSON.stringify(stableValue(bound.runtime))), + bound, +}; +fs.mkdirSync(path.dirname(outputPath), { recursive: true }); +fs.writeFileSync(outputPath, `${JSON.stringify(output, null, 2)}\n`); +if (expectedOutputPath) { + fs.mkdirSync(path.dirname(expectedOutputPath), { recursive: true }); + fs.writeFileSync(expectedOutputPath, `${JSON.stringify({ + parentBindingSha256: output.parentBindingSha256, + parentReceiptSetSha256: bound.parentReceiptSetSha256, + inventorySha256: bound.inventorySha256, + boundReceiptSetSha256: output.boundReceiptSetSha256, + runtimeSha256: output.runtimeSha256, + runtime: bound.runtime, + receiptIdentities: bound.receipts, + }, null, 2)}\n`); +} +process.stdout.write(`io-format-receipt-freshness-generated receipts=${bound.receipts.length} output=${path.relative(repoRoot, outputPath)}\n`); diff --git a/tools/web/generate-io-format-runtime-inventory.py b/tools/web/generate-io-format-runtime-inventory.py new file mode 100644 index 00000000..fb9e1f15 --- /dev/null +++ b/tools/web/generate-io-format-runtime-inventory.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Generate the format capability inventory from one pinned Blender runtime.""" + +import hashlib +import json +import os +import pathlib +import sys + +import bpy + + +def decode(value): + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return str(value) + + +def binary_sha256(path): + digest = hashlib.sha256() + with open(path, "rb") as handle: + for block in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def property_info(prop): + result = {"identifier": prop.identifier, "type": prop.type} + if hasattr(prop, "array_length"): + result["arrayLength"] = prop.array_length + if prop.type == "ENUM": + try: + result["enumItems"] = [item.identifier for item in prop.enum_items] + except (AttributeError, RuntimeError): + result["enumItems"] = [] + return result + + +def operator_info(operator_path, build_option): + module_name, operator_name = operator_path.split(".", 1) + try: + operator = getattr(getattr(bpy.ops, module_name), operator_name) + rna = operator.get_rna_type() + properties = [property_info(prop) for prop in rna.properties if prop.identifier != "rna_type"] + properties.sort(key=lambda prop: prop["identifier"]) + enabled = True if build_option is None else bool(getattr(bpy.app.build_options, build_option)) + return { + "operator": operator_path, + "registered": True, + "rnaIdentifier": rna.identifier, + "buildOption": build_option, + "buildOptionEnabled": enabled, + "runtimeStatus": "AVAILABLE" if enabled else "BUILD_OPTION_DISABLED", + "properties": properties, + } + except (AttributeError, KeyError, RuntimeError) as error: + return { + "operator": operator_path, + "registered": False, + "rnaIdentifier": None, + "buildOption": build_option, + "buildOptionEnabled": None, + "runtimeStatus": "OPERATOR_UNREGISTERED", + "properties": [], + "error": type(error).__name__, + } + + +def format_entry(format_id, family, extensions, import_operator, export_operator, build_option, variants): + return { + "format": format_id, + "family": family, + "extensions": extensions, + "variants": variants, + "import": operator_info(import_operator, build_option), + "export": operator_info(export_operator, build_option), + } + + +def main(): + if "--" not in sys.argv or len(sys.argv[sys.argv.index("--") + 1:]) != 1: + raise SystemExit("usage: generate-io-format-runtime-inventory.py OUTPUT.json") + output = pathlib.Path(sys.argv[sys.argv.index("--") + 1]).resolve() + version = tuple(int(value) for value in bpy.app.version) + if version != (5, 2, 0): + raise RuntimeError(f"expected Blender 5.2.0, got {version}") + binary_path = pathlib.Path(bpy.app.binary_path).resolve() + options = { + "alembic": bool(bpy.app.build_options.alembic), + "usd": bool(bpy.app.build_options.usd), + "io_ply": bool(bpy.app.build_options.io_ply), + "io_stl": bool(bpy.app.build_options.io_stl), + "io_wavefront_obj": bool(bpy.app.build_options.io_wavefront_obj), + } + formats = [ + format_entry("GLTF", "GLTF", [".gltf"], "import_scene.gltf", "export_scene.gltf", None, ["GLTF_SEPARATE"]), + format_entry("GLB", "GLTF", [".glb"], "import_scene.gltf", "export_scene.gltf", None, ["GLB"]), + format_entry("OBJ", "OBJ", [".obj"], "wm.obj_import", "wm.obj_export", "io_wavefront_obj", ["OBJ"]), + format_entry("STL", "STL", [".stl"], "wm.stl_import", "wm.stl_export", "io_stl", ["STL_BINARY", "STL_ASCII"]), + format_entry("PLY", "PLY", [".ply"], "wm.ply_import", "wm.ply_export", "io_ply", ["PLY"]), + format_entry("USD", "USD", [".usd", ".usda", ".usdc", ".usdz"], "wm.usd_import", "wm.usd_export", "usd", ["USD", "USDA", "USDC", "USDZ"]), + format_entry("ALEMBIC", "ALEMBIC", [".abc"], "wm.alembic_import", "wm.alembic_export", "alembic", ["ALEMBIC"]), + ] + inventory = { + "schemaVersion": 1, + "task": "M12-05A", + "runtime": { + "blenderVersion": bpy.app.version_string, + "versionTuple": list(version), + "buildHash": decode(bpy.app.build_hash), + "buildBranch": decode(bpy.app.build_branch), + "buildPlatform": decode(bpy.app.build_platform), + "buildType": decode(bpy.app.build_type), + "buildDate": decode(bpy.app.build_date), + "buildTime": decode(bpy.app.build_time), + "buildCommitTimestamp": int(bpy.app.build_commit_timestamp), + "binarySha256": binary_sha256(binary_path), + "buildOptions": options, + }, + "formats": formats, + } + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(inventory, sort_keys=True, indent=2) + "\n", encoding="utf-8") + print(f"io-format-runtime-inventory-generated formats={len(formats)} blender={bpy.app.version_string} output={output}") + + +if __name__ == "__main__": + main() diff --git a/tools/web/generate-io-format-runtime-receipts.mjs b/tools/web/generate-io-format-runtime-receipts.mjs new file mode 100644 index 00000000..21f68f33 --- /dev/null +++ b/tools/web/generate-io-format-runtime-receipts.mjs @@ -0,0 +1,39 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const inventoryPath = path.join(repoRoot, "tests/golden/M12-05A/format-inventory.json"); +const outputArgument = process.argv.indexOf("--output"); +const outputPath = outputArgument === -1 ? path.join(repoRoot, "tests/golden/M12-05D/runtime-receipts.json") : path.resolve(process.argv[outputArgument + 1] ?? ""); +if (!outputPath) throw new Error("--output requires a file"); +const inventoryBytes = fs.readFileSync(inventoryPath); +const inventory = JSON.parse(inventoryBytes); +if (inventory.schemaVersion !== 1 || inventory.task !== "M12-05A") throw new Error("M12-05A inventory header is invalid"); +const receipts = inventory.formats.flatMap((entry) => ["IMPORT", "EXPORT"].map((operation) => { + const source = entry[operation.toLowerCase()]; + return { + format: entry.format, + family: entry.family, + operation, + operator: source.operator, + registered: source.registered, + rnaIdentifier: source.rnaIdentifier, + buildOption: source.buildOption, + buildOptionEnabled: source.buildOptionEnabled, + runtimeStatus: source.runtimeStatus, + variants: [...entry.variants], + extensions: [...entry.extensions], + }; +})); +const output = { + schemaVersion: 1, + task: "M12-05D", + inventorySha256: crypto.createHash("sha256").update(inventoryBytes).digest("hex"), + runtime: inventory.runtime, + receipts, +}; +fs.mkdirSync(path.dirname(outputPath), { recursive: true }); +fs.writeFileSync(outputPath, `${JSON.stringify(output, null, 2)}\n`); +process.stdout.write(`io-format-runtime-receipts-generated formats=${inventory.formats.length} receipts=${receipts.length} output=${path.relative(repoRoot, outputPath)}\n`); diff --git a/tools/web/generate-io-format-ui-gate.mjs b/tools/web/generate-io-format-ui-gate.mjs new file mode 100644 index 00000000..d796bd64 --- /dev/null +++ b/tools/web/generate-io-format-ui-gate.mjs @@ -0,0 +1,43 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import ts from "../../web/node_modules/typescript/lib/typescript.js"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const matrixPath = path.join(repoRoot, "tests/golden/M12-05B/capability-matrix.json"); +const protocolPath = path.join(repoRoot, "web/protocol/io-format-capability-matrix.ts"); +const gatePath = path.join(repoRoot, "web/protocol/io-format-ui-gate.ts"); +const output = process.argv[process.argv.indexOf("--output") + 1]; +if (!output || output.startsWith("--")) throw new Error("usage: node generate-io-format-ui-gate.mjs --output "); + +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "io-format-ui-gate-")); +try { + const transpile = (sourcePath, outputName) => { + const result = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, + }); + if (result.diagnostics?.length) throw new Error(ts.formatDiagnosticsWithColorAndContext(result.diagnostics, { getCanonicalFileName: (file) => file, getCurrentDirectory: () => repoRoot, getNewLine: () => "\n" })); + const target = path.join(temporary, outputName); + fs.writeFileSync(target, result.outputText); + return target; + }; + const matrixModulePath = transpile(protocolPath, "io-format-capability-matrix.mjs"); + const gateModulePath = transpile(gatePath, "io-format-ui-gate.mjs"); + const matrixModule = await import(pathToFileURL(matrixModulePath)); + const gateModule = await import(pathToFileURL(gateModulePath)); + const matrixBytes = fs.readFileSync(matrixPath); + const matrix = matrixModule.parseIOFormatCapabilityMatrix(JSON.parse(matrixBytes)); + const registry = gateModule.buildIOFormatUIRegistry(matrix, crypto.createHash("sha256").update(matrixBytes).digest("hex")); + const target = path.resolve(repoRoot, output); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, `${JSON.stringify(registry, null, 2)}\n`); + process.stdout.write(`io-format-ui-gate-generated output=${path.relative(repoRoot, target)} import=${registry.importRoutes.length} export=${registry.exportRoutes.length}\n`); +} +finally { + fs.rmSync(temporary, { recursive: true, force: true }); +} + diff --git a/tools/web/generate-library-link-fixture.py b/tools/web/generate-library-link-fixture.py new file mode 100644 index 00000000..eabd47b4 --- /dev/null +++ b/tools/web/generate-library-link-fixture.py @@ -0,0 +1,183 @@ +import hashlib +import json +import pathlib +import struct +import sys + +import bpy + + +ROOT_OBJECT = "M12 Link Object" +MESH_NAME = "M12 Link Mesh" +MATERIAL_NAME = "M12 Link Material" +IMAGE_NAME = "M12 Link Image" +IMAGE_NODE_NAME = "M12 Link Image Node" +SOURCE_MARKER = "M12-03F" + + +def sha256_file(path: pathlib.Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def pixel_sha256(image: bpy.types.Image) -> str: + values = list(image.pixels) + return hashlib.sha256(struct.pack(f"<{len(values)}f", *values)).hexdigest() + + +def reset() -> None: + bpy.ops.wm.read_factory_settings(use_empty=True) + + +def create_source(path: pathlib.Path) -> None: + reset() + image = bpy.data.images.new(IMAGE_NAME, width=2, height=2, alpha=True, float_buffer=False) + image.colorspace_settings.name = "sRGB" + image.pixels = [ + 1.0, 0.0, 0.0, 1.0, + 0.0, 1.0, 0.0, 1.0, + 0.0, 0.0, 1.0, 1.0, + 1.0, 1.0, 1.0, 0.5, + ] + image.pack() + + material = bpy.data.materials.new(MATERIAL_NAME) + material.use_nodes = True + node_tree = material.node_tree + principled = node_tree.nodes.get("Principled BSDF") + image_node = node_tree.nodes.new("ShaderNodeTexImage") + image_node.name = IMAGE_NODE_NAME + image_node.label = IMAGE_NODE_NAME + image_node.image = image + image_node.interpolation = "Closest" + image_node.extension = "REPEAT" + node_tree.links.new(image_node.outputs["Color"], principled.inputs["Base Color"]) + + mesh = bpy.data.meshes.new(MESH_NAME) + mesh.from_pydata( + [(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (1.0, 1.0, 0.0), (-1.0, 1.0, 0.0)], + [], + [(0, 1, 2, 3)], + ) + mesh.materials.append(material) + uv_layer = mesh.uv_layers.new(name="UVMap") + for loop, uv in zip(uv_layer.data, [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]): + loop.uv = uv + mesh.update() + + obj = bpy.data.objects.new(ROOT_OBJECT, mesh) + obj["m12_source_marker"] = SOURCE_MARKER + bpy.context.scene.collection.objects.link(obj) + bpy.ops.wm.save_as_mainfile(filepath=str(path), check_existing=False) + + +def data_block(id_type: str, value: object) -> dict: + library = value.library + return { + "idType": id_type, + "name": value.name, + "nameFull": value.name_full, + "library": None if library is None else pathlib.Path(library.filepath).name, + "isLibraryOverride": value.override_library is not None, + } + + +def inspect_graph() -> dict: + obj = bpy.data.objects[ROOT_OBJECT] + mesh = obj.data + material = mesh.materials[0] + image_node = material.node_tree.nodes[IMAGE_NODE_NAME] + image = image_node.image + ids = { + "OBJECT": data_block("OBJECT", obj), + "MESH": data_block("MESH", mesh), + "MATERIAL": data_block("MATERIAL", material), + "IMAGE": data_block("IMAGE", image), + } + return { + "root": ids["OBJECT"], + "ids": ids, + "edges": [ + {"from": f"Object/{ROOT_OBJECT}", "relation": "OBJECT_DATA", "to": f"Mesh/{MESH_NAME}"}, + {"from": f"Mesh/{MESH_NAME}", "relation": "MATERIAL_SLOT[0]", "to": f"Material/{MATERIAL_NAME}"}, + {"from": f"Material/{MATERIAL_NAME}", "relation": f"NODE_IMAGE[{IMAGE_NODE_NAME}]", "to": f"Image/{IMAGE_NAME}"}, + ], + "geometry": { + "vertices": len(mesh.vertices), + "edges": len(mesh.edges), + "polygons": len(mesh.polygons), + "loops": len(mesh.loops), + "uvLayers": [layer.name for layer in mesh.uv_layers], + "materialSlots": [item.name for item in mesh.materials], + }, + "image": { + "size": list(image.size), + "channels": image.channels, + "colorspace": image.colorspace_settings.name, + "packed": image.packed_file is not None, + "pixelFloat32Sha256": pixel_sha256(image), + }, + "sourceMarker": obj["m12_source_marker"], + } + + +def link_object(source: pathlib.Path, target: pathlib.Path) -> dict: + reset() + with bpy.data.libraries.load(str(source), link=True) as (data_from, data_to): + if ROOT_OBJECT not in data_from.objects: + raise RuntimeError("source root object is missing") + data_to.objects = [ROOT_OBJECT] + if len(data_to.objects) != 1 or data_to.objects[0] is None: + raise RuntimeError("desktop link did not return one object") + bpy.context.scene.collection.objects.link(data_to.objects[0]) + before_save = inspect_graph() + if any(item["library"] is None or item["isLibraryOverride"] for item in before_save["ids"].values()): + raise RuntimeError("linked dependency closure did not retain the source library") + bpy.ops.wm.save_as_mainfile(filepath=str(target), check_existing=False) + bpy.ops.wm.open_mainfile(filepath=str(target), load_ui=False) + reopened = inspect_graph() + if reopened != before_save: + raise RuntimeError("linked dependency mapping drifted after save/reopen") + return reopened + + +def main() -> None: + if "--" not in sys.argv or len(sys.argv[sys.argv.index("--") + 1 :]) != 2: + raise SystemExit("usage: blender --background --factory-startup --python generate-library-link-fixture.py -- OUTPUT_DIR REPORT") + output_arg, report_arg = sys.argv[sys.argv.index("--") + 1 :] + output_dir = pathlib.Path(output_arg).resolve() + report_path = pathlib.Path(report_arg).resolve() + output_dir.mkdir(parents=True, exist_ok=True) + report_path.parent.mkdir(parents=True, exist_ok=True) + source = output_dir / "m12_link_source.blend" + target = output_dir / "m12_link_target.blend" + + create_source(source) + source_graph = inspect_graph() + linked_graph = link_object(source, target) + report = { + "schemaVersion": 1, + "task": "M12-03F", + "operation": "LINK", + "blenderVersion": "5.2.0", + "source": {"file": source.name, "sha256": sha256_file(source)}, + "target": {"file": target.name, "sha256": sha256_file(target)}, + "selectedRoots": [f"Object/{ROOT_OBJECT}"], + "sourceGraph": source_graph, + "linkedGraph": linked_graph, + "stableMapping": [ + {"source": f"Object/{ROOT_OBJECT}", "local": f"Object/{ROOT_OBJECT}", "owner": "SOURCE_LIBRARY", "readOnly": True}, + {"source": f"Mesh/{MESH_NAME}", "local": f"Mesh/{MESH_NAME}", "owner": "SOURCE_LIBRARY", "readOnly": True}, + {"source": f"Material/{MATERIAL_NAME}", "local": f"Material/{MATERIAL_NAME}", "owner": "SOURCE_LIBRARY", "readOnly": True}, + {"source": f"Image/{IMAGE_NAME}", "local": f"Image/{IMAGE_NAME}", "owner": "SOURCE_LIBRARY", "readOnly": True}, + ], + "nextTask": "M12-03G", + } + report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print( + f"library-link-fixture-ok roots={len(report['selectedRoots'])} " + f"mapping={len(report['stableMapping'])} source={report['source']['sha256']} " + f"target={report['target']['sha256']} next={report['nextTask']}" + ) + + +main() diff --git a/tools/web/generate-library-override-fixture.py b/tools/web/generate-library-override-fixture.py new file mode 100644 index 00000000..b0ec946e --- /dev/null +++ b/tools/web/generate-library-override-fixture.py @@ -0,0 +1,164 @@ +import hashlib +import json +import pathlib +import sys + +import bpy + + +ROOT_OBJECT = "M12 Override Object" +MESH_NAME = "M12 Override Mesh" +MATERIAL_NAME = "M12 Override Material" +SOURCE_MARKER = "M12-03J" +PROJECT_ID = "m12-03j-project" +OVERRIDE_X = 2.5 +OVERRIDE_PROPERTY_PATH = '["m12_override_value"]' + + +def sha256_file(path: pathlib.Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def reset() -> None: + bpy.ops.wm.read_factory_settings(use_empty=True) + + +def create_source(path: pathlib.Path) -> None: + reset() + material = bpy.data.materials.new(MATERIAL_NAME) + material.diffuse_color = (0.15, 0.65, 0.35, 1.0) + mesh = bpy.data.meshes.new(MESH_NAME) + mesh.from_pydata([(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (1.0, 1.0, 0.0), (-1.0, 1.0, 0.0)], [], [(0, 1, 2, 3)]) + mesh.materials.append(material) + obj = bpy.data.objects.new(ROOT_OBJECT, mesh) + obj["m12_source_marker"] = SOURCE_MARKER + obj["m12_override_value"] = 1.0 + bpy.context.scene.collection.objects.link(obj) + bpy.ops.wm.save_as_mainfile(filepath=str(path), check_existing=False) + + +def data_block(id_type: str, value: object) -> dict: + library = value.library + return { + "idType": id_type, + "name": value.name, + "nameFull": value.name_full, + "library": None if library is None else pathlib.Path(library.filepath).name, + "isLibraryOverride": value.override_library is not None, + } + + +def override_property(value: object) -> dict: + override = value.override_library + if override is None: + raise RuntimeError("override library metadata is missing") + properties = list(override.properties) + matches = [item for item in properties if item.rna_path == OVERRIDE_PROPERTY_PATH] + if not matches: + print("DEBUG_OVERRIDE_PROPERTIES", [(item.rna_path, len(item.operations)) for item in properties], dir(override.properties)) + raise RuntimeError("custom property override path is missing") + return { + "rnaPath": OVERRIDE_PROPERTY_PATH, + "index": 0, + "value": float(value["m12_override_value"]), + "operationCount": sum(len(item.operations) for item in matches), + "propertyCount": len(properties), + } + + +def inspect_override() -> dict: + obj = bpy.data.objects[ROOT_OBJECT] + override = obj.override_library + if override is None or override.reference is None: + raise RuntimeError("desktop override reference is missing") + reference = override.reference + if reference.library is None: + raise RuntimeError("override reference did not retain source library") + if obj.library is not None: + raise RuntimeError("override object must be locally owned") + return { + "reference": { + "dataBlockId": f"Object/{reference.name}", + "idType": "OBJECT", + "library": pathlib.Path(reference.library.filepath).name, + "owner": "SOURCE_LIBRARY", + "readOnly": True, + "isLibraryOverride": False, + }, + "local": { + "dataBlockId": f"Object/{obj.name}", + "idType": "OBJECT", + "library": None, + "owner": "LOCAL_OVERRIDE", + "projectId": PROJECT_ID, + "readOnly": False, + "referenceSourceDataBlockId": f"Object/{reference.name}", + "hierarchyRootDataBlockId": f"Object/{obj.name}", + "isLibraryOverride": True, + }, + "propertyOverride": override_property(obj), + "sourceMarker": reference.get("m12_source_marker"), + } + + +def create_override(source: pathlib.Path, target: pathlib.Path) -> dict: + reset() + with bpy.data.libraries.load(str(source), link=True) as (data_from, data_to): + if ROOT_OBJECT not in data_from.objects: + raise RuntimeError("source root object is missing") + data_to.objects = [ROOT_OBJECT] + if len(data_to.objects) != 1 or data_to.objects[0] is None: + raise RuntimeError("desktop link did not return one object") + linked = data_to.objects[0] + bpy.context.scene.collection.objects.link(linked) + bpy.context.view_layer.objects.active = linked + linked.select_set(True) + if bpy.ops.object.make_override_library() != {"FINISHED"}: + raise RuntimeError("desktop override operator did not finish") + obj = bpy.data.objects[ROOT_OBJECT] + obj["m12_override_value"] = OVERRIDE_X + prop = obj.override_library.properties.add(OVERRIDE_PROPERTY_PATH) + prop.operations.add("REPLACE") + before_save = inspect_override() + if before_save["propertyOverride"]["value"] != OVERRIDE_X: + raise RuntimeError("override property did not apply") + bpy.ops.wm.save_as_mainfile(filepath=str(target), check_existing=False) + bpy.ops.wm.open_mainfile(filepath=str(target), load_ui=False) + reopened = inspect_override() + if reopened != before_save: + raise RuntimeError("desktop override metadata drifted after save/reopen") + return reopened + + +def main() -> None: + if "--" not in sys.argv or len(sys.argv[sys.argv.index("--") + 1 :]) != 2: + raise SystemExit("usage: blender --background --factory-startup --python generate-library-override-fixture.py -- OUTPUT_DIR REPORT") + output_arg, report_arg = sys.argv[sys.argv.index("--") + 1 :] + output_dir = pathlib.Path(output_arg).resolve() + report_path = pathlib.Path(report_arg).resolve() + output_dir.mkdir(parents=True, exist_ok=True) + report_path.parent.mkdir(parents=True, exist_ok=True) + source = output_dir / "m12_override_source.blend" + target = output_dir / "m12_override_target.blend" + create_source(source) + override_graph = create_override(source, target) + report = { + "schemaVersion": 1, + "task": "M12-03J", + "operation": "LIBRARY_OVERRIDE", + "blenderVersion": "5.2.0", + "source": {"file": source.name, "sha256": sha256_file(source)}, + "target": {"file": target.name, "sha256": sha256_file(target)}, + "selectedRoots": [f"Object/{ROOT_OBJECT}"], + "overrideGraph": override_graph, + "nextTask": "M12-03K", + } + report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print( + f"library-override-fixture-ok roots={len(report['selectedRoots'])} " + f"reference={override_graph['reference']['library']} owner={override_graph['local']['owner']} " + f"path={override_graph['propertyOverride']['rnaPath']} next={report['nextTask']}" + ) + + +main() diff --git a/tools/web/generate-malicious-archive-fixtures.mjs b/tools/web/generate-malicious-archive-fixtures.mjs new file mode 100644 index 00000000..6665a386 --- /dev/null +++ b/tools/web/generate-malicious-archive-fixtures.mjs @@ -0,0 +1,188 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const outputArgument = process.argv.indexOf("--output"); +const outputRoot = outputArgument === -1 + ? path.join(repoRoot, "tests/files/web/archive-security") + : path.resolve(process.argv[outputArgument + 1] ?? ""); +if (!outputRoot) throw new Error("--output requires a directory"); + +const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); +const text = (value) => Buffer.from(value, "utf8"); + +const crcTable = Array.from({ length: 256 }, (_, input) => { + let value = input; + for (let bit = 0; bit < 8; bit++) value = value & 1 ? 0xedb88320 ^ value >>> 1 : value >>> 1; + return value >>> 0; +}); + +function crc32(bytes) { + let value = 0xffffffff; + for (const byte of bytes) value = crcTable[(value ^ byte) & 0xff] ^ value >>> 8; + return (value ^ 0xffffffff) >>> 0; +} + +function zipArchive(entries) { + const localParts = []; + const centralParts = []; + let localOffset = 0; + for (const entry of entries) { + const name = text(entry.path); + const data = Buffer.from(entry.data); + const uncompressedBytes = entry.uncompressedBytes ?? data.length; + const crc = crc32(data); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0x0800, 6); + local.writeUInt16LE(0, 8); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(data.length, 18); + local.writeUInt32LE(uncompressedBytes, 22); + local.writeUInt16LE(name.length, 26); + localParts.push(local, name, data); + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(0x0314, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0x0800, 8); + central.writeUInt16LE(0, 10); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(data.length, 20); + central.writeUInt32LE(uncompressedBytes, 24); + central.writeUInt16LE(name.length, 28); + central.writeUInt32LE((0o100644 << 16) >>> 0, 38); + central.writeUInt32LE(localOffset, 42); + centralParts.push(central, name); + localOffset += local.length + name.length + data.length; + } + const centralDirectory = Buffer.concat(centralParts); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(entries.length, 8); + end.writeUInt16LE(entries.length, 10); + end.writeUInt32LE(centralDirectory.length, 12); + end.writeUInt32LE(localOffset, 16); + return Buffer.concat([...localParts, centralDirectory, end]); +} + +function writeTarText(header, value, offset, length) { + const bytes = text(value); + if (bytes.length > length) throw new Error(`tar field exceeds ${length} bytes`); + bytes.copy(header, offset); +} + +function writeTarOctal(header, value, offset, length) { + const encoded = value.toString(8).padStart(length - 2, "0"); + writeTarText(header, `${encoded}\0 `, offset, length); +} + +function tarHeader(entry) { + const header = Buffer.alloc(512); + writeTarText(header, entry.path, 0, 100); + writeTarOctal(header, entry.type === "DIRECTORY" ? 0o755 : 0o644, 100, 8); + writeTarOctal(header, 0, 108, 8); + writeTarOctal(header, 0, 116, 8); + const data = entry.type === "FILE" ? Buffer.from(entry.data) : Buffer.alloc(0); + writeTarOctal(header, data.length, 124, 12); + writeTarOctal(header, 0, 136, 12); + header.fill(0x20, 148, 156); + header[156] = { FILE: 0x30, SYMLINK: 0x32, HARDLINK: 0x31, DIRECTORY: 0x35 }[entry.type]; + if (entry.target) writeTarText(header, entry.target, 157, 100); + writeTarText(header, "ustar\0", 257, 6); + writeTarText(header, "00", 263, 2); + writeTarText(header, "root", 265, 32); + writeTarText(header, "root", 297, 32); + const checksum = header.reduce((sum, byte) => sum + byte, 0); + writeTarOctal(header, checksum, 148, 8); + return { header, data }; +} + +function tarArchive(entries) { + const parts = []; + for (const entry of entries) { + const { header, data } = tarHeader(entry); + parts.push(header, data); + if (data.length % 512 !== 0) parts.push(Buffer.alloc(512 - data.length % 512)); + } + parts.push(Buffer.alloc(1024)); + return Buffer.concat(parts); +} + +const definitions = [ + { + id: "ZIP_PATH_TRAVERSAL", + format: "ZIP", + file: "zip-path-traversal.zip", + threat: "ARCHIVE_ROOT_ESCAPE", + gate: "LINK_SAFETY", + bytes: zipArchive([{ path: "../outside.txt", data: text("escape") }]), + }, + { + id: "ZIP_COMPRESSION_BOMB", + format: "ZIP", + file: "zip-compression-bomb.zip", + threat: "COMPRESSION_RATIO", + gate: "CONFLICTS", + bytes: zipArchive([{ path: "bomb.bin", data: Buffer.from([0]), uncompressedBytes: 101 }]), + }, + { + id: "ZIP_DUPLICATE_PATH", + format: "ZIP", + file: "zip-duplicate-path.zip", + threat: "DUPLICATE_PATH", + gate: "LINK_SAFETY", + bytes: zipArchive([{ path: "same.bin", data: text("one") }, { path: "same.bin", data: text("two") }]), + }, + { + id: "TAR_PATH_TRAVERSAL", + format: "TAR", + file: "tar-path-traversal.tar", + threat: "ARCHIVE_ROOT_ESCAPE", + gate: "LINK_SAFETY", + bytes: tarArchive([{ path: "../../outside.txt", type: "FILE", data: text("escape") }]), + }, + { + id: "TAR_SYMLINK_ESCAPE", + format: "TAR", + file: "tar-symlink-escape.tar", + threat: "SYMLINK_ESCAPE", + gate: "LINK_SAFETY", + bytes: tarArchive([ + { path: "safe", type: "DIRECTORY" }, + { path: "safe/link", type: "SYMLINK", target: "../../outside" }, + ]), + }, + { + id: "TAR_PREFIX_CONFLICT", + format: "TAR", + file: "tar-prefix-conflict.tar", + threat: "FILE_DIRECTORY_PREFIX_CONFLICT", + gate: "CONFLICTS", + bytes: tarArchive([ + { path: "folder", type: "FILE", data: text("one") }, + { path: "folder/payload.bin", type: "FILE", data: text("two") }, + ]), + }, +]; + +fs.mkdirSync(outputRoot, { recursive: true }); +for (const definition of definitions) fs.writeFileSync(path.join(outputRoot, definition.file), definition.bytes); +const manifest = { + schemaVersion: 1, + task: "M12-04J", + generator: "tools/web/generate-malicious-archive-fixtures.mjs", + extractionAllowed: false, + cases: definitions.map(({ bytes, ...definition }) => ({ + ...definition, + byteLength: bytes.length, + sha256: sha256(bytes), + expectedCode: "IO_ARCHIVE_UNSAFE", + })), +}; +fs.writeFileSync(path.join(outputRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`); +process.stdout.write(`malicious-archive-fixtures-generated cases=${definitions.length} output=${outputRoot}\n`); diff --git a/tools/web/generate-malicious-script-fixture.py b/tools/web/generate-malicious-script-fixture.py new file mode 100644 index 00000000..c79d187a --- /dev/null +++ b/tools/web/generate-malicious-script-fixture.py @@ -0,0 +1,39 @@ +import hashlib +import json +import sys +from pathlib import Path + +import bpy + + +SOURCES = { + "MaliciousText.py": "import os\nos.system('touch /tmp/web-blender-forbidden')\n", + "DriverExploit.py": "__import__('os').system('touch /tmp/web-driver-forbidden')\n", + "HandlerExploit.py": "def handler(scene):\n __import__('subprocess').run(['touch','/tmp/web-handler-forbidden'])\n", + "EmbeddedModule.py": "def register():\n __import__('os').system('touch /tmp/web-module-forbidden')\n", +} + + +def sha256_file(path): + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def main(output_dir, report_path): + output_dir = Path(output_dir).resolve(); report_path = Path(report_path).resolve(); output_dir.mkdir(parents=True, exist_ok=True); report_path.parent.mkdir(parents=True, exist_ok=True) + bpy.ops.wm.read_factory_settings(use_empty=True) + for name, source in SOURCES.items(): + value = bpy.data.texts.new(name); value.write(source); value.use_module = name == "EmbeddedModule.py" + fixture = output_dir / "malicious-script.blend"; bpy.ops.wm.save_as_mainfile(filepath=str(fixture), compress=False) + report = {"schemaVersion": 1, "task": "M13-01F", "operation": "MALICIOUS_SCRIPT_FIXTURE", "sources": [{"name": name, "sourceSha256": hashlib.sha256(source.encode()).hexdigest(), "useModule": name == "EmbeddedModule.py", "expectedExecution": "BLOCKED"} for name, source in sorted(SOURCES.items())], "fixture": {"name": fixture.name, "byteLength": fixture.stat().st_size, "sha256": sha256_file(fixture)}, "nextTask": "M13-02A"} + report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print("malicious-script-fixture-generated sources=%s module=1 next=%s" % (len(SOURCES), report["nextTask"])) + + +if __name__ == "__main__": + args = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] + if len(args) != 2: raise SystemExit("usage: blender --background --python generate-malicious-script-fixture.py -- OUTPUT_DIR REPORT") + main(args[0], args[1]) diff --git a/tools/web/generate-obj-multi-negative-fixtures.py b/tools/web/generate-obj-multi-negative-fixtures.py new file mode 100644 index 00000000..0e15c012 --- /dev/null +++ b/tools/web/generate-obj-multi-negative-fixtures.py @@ -0,0 +1,285 @@ +"""Generate the pinned Blender 5.2 OBJ multi-object and negative fixtures for M12-07B.""" + +import hashlib +import json +import re +import struct +import sys +from pathlib import Path + +import bpy + + +OBJECTS = ("M12 OBJ Left", "M12 OBJ Right") +MATERIALS = ("M12 OBJ Left Material", "M12 OBJ Right Material") +TEXTURE_NAME = "m12_obj_texture.png" + + +def sha256_file(path): + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def text(value): + return value.decode("utf-8") if isinstance(value, bytes) else value + + +def runtime_identity(): + binary = Path(bpy.app.binary_path) + return { + "blenderVersion": text(bpy.app.version_string), + "versionTuple": list(bpy.app.version), + "buildDate": text(bpy.app.build_date), + "buildTime": text(bpy.app.build_time), + "buildHash": text(bpy.app.build_hash), + "buildBranch": text(bpy.app.build_branch), + "buildPlatform": text(bpy.app.build_platform), + "buildType": text(bpy.app.build_type), + "binarySha256": sha256_file(binary), + } + + +def create_texture(path): + image = bpy.data.images.new("M12 OBJ Texture", width=2, height=2, alpha=True, float_buffer=False) + image.pixels = [ + 1.0, 0.0, 0.0, 1.0, + 0.0, 1.0, 0.0, 1.0, + 0.0, 0.0, 1.0, 1.0, + 1.0, 1.0, 0.0, 1.0, + ] + image.filepath_raw = str(path) + image.file_format = "PNG" + image.save() + return image + + +def material(name, image): + value = bpy.data.materials.new(name) + value.use_nodes = True + nodes = value.node_tree.nodes + links = value.node_tree.links + principled = nodes.get("Principled BSDF") + texture = nodes.new("ShaderNodeTexImage") + texture.image = image + links.new(texture.outputs["Color"], principled.inputs["Base Color"]) + return value + + +def mesh_object(name, offset, material_value): + mesh = bpy.data.meshes.new(name + " Mesh") + mesh.from_pydata( + [(offset - 0.75, -0.75, 0.0), (offset + 0.75, -0.75, 0.0), (offset + 0.0, 0.75, 0.0)], + [], + [(0, 1, 2)], + ) + mesh.update() + uv = mesh.uv_layers.new(name="UVMap") + for loop, value in zip(mesh.loops, ((0.0, 0.0), (1.0, 0.0), (0.5, 1.0))): + uv.data[loop.index].uv = value + mesh.materials.append(material_value) + obj = bpy.data.objects.new(name, mesh) + bpy.context.scene.collection.objects.link(obj) + return obj + + +def create_scene(output_dir): + bpy.ops.wm.read_factory_settings(use_empty=True) + image = create_texture(output_dir / TEXTURE_NAME) + left_material = material(MATERIALS[0], image) + right_material = material(MATERIALS[1], image) + left = mesh_object(OBJECTS[0], -1.0, left_material) + right = mesh_object(OBJECTS[1], 1.0, right_material) + for obj in (left, right): + obj.select_set(True) + for polygon in obj.data.polygons: + polygon.use_smooth = False + bpy.context.view_layer.objects.active = left + return left, right + + +def export_obj(output_path): + result = bpy.ops.wm.obj_export( + filepath=str(output_path), + export_selected_objects=True, + apply_modifiers=False, + apply_transform=False, + export_eval_mode="DAG_EVAL_VIEWPORT", + export_uv=True, + export_normals=True, + export_colors=False, + export_materials=True, + export_pbr_extensions=False, + export_material_groups=True, + export_object_groups=True, + export_vertex_groups=False, + export_smooth_groups=False, + export_triangulated_mesh=False, + export_curves_as_nurbs=False, + global_scale=1.0, + forward_axis="NEGATIVE_Z", + up_axis="Y", + path_mode="RELATIVE", + ) + if "FINISHED" not in result: + raise RuntimeError("Blender OBJ export did not finish: %s" % (result,)) + + +def parse_obj(path): + positions = [] + texcoords = [] + normals = [] + faces = [] + material_libraries = [] + objects = [] + groups = [] + current_object = None + current_material = None + current_groups = [] + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + parts = line.split() + kind = parts[0] + if kind == "v": + positions.append([float(value) for value in parts[1:4]]) + elif kind == "vt": + texcoords.append([float(value) for value in parts[1:3]]) + elif kind == "vn": + normals.append([float(value) for value in parts[1:4]]) + elif kind == "mtllib": + material_libraries.extend(parts[1:]) + elif kind == "o": + current_object = " ".join(parts[1:]) + objects.append(current_object) + elif kind == "g": + current_groups = parts[1:] + for group in current_groups: + if group not in groups: + groups.append(group) + if group.endswith("_Mesh") and group not in objects: + current_object = group + objects.append(group) + elif kind == "usemtl": + current_material = " ".join(parts[1:]) + elif kind == "f": + vertices = [] + for token in parts[1:]: + indices = token.split("/") + vertices.append({ + "position": int(indices[0]), + "texcoord": int(indices[1]) if len(indices) > 1 and indices[1] else None, + "normal": int(indices[2]) if len(indices) > 2 and indices[2] else None, + }) + faces.append({"object": current_object, "groups": list(current_groups), "material": current_material, "vertices": vertices}) + return { + "materialLibraries": material_libraries, + "objects": objects, + "groups": groups, + "positions": positions, + "texcoords": texcoords, + "normals": normals, + "faces": faces, + } + + +def parse_mtl(path): + materials = [] + current = None + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + parts = line.split() + if parts[0] == "newmtl": + current = {"name": " ".join(parts[1:]), "mapKd": None} + materials.append(current) + elif current is not None and parts[0] == "map_Kd": + current["mapKd"] = " ".join(parts[1:]) + return materials + + +def negative_index_obj(source, target): + lines = [] + for raw_line in source.read_text(encoding="utf-8").splitlines(): + if not raw_line.startswith("f "): + lines.append(raw_line) + continue + converted = [] + for token in raw_line.split()[1:]: + position, texcoord, normal = token.split("/") + converted.append("%d/%d/%d" % (-int(position), -int(texcoord), -int(normal))) + lines.append("f " + " ".join(converted)) + target.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def malformed_obj(source, target): + lines = [] + replaced = False + for raw_line in source.read_text(encoding="utf-8").splitlines(): + if raw_line.startswith("f ") and not replaced: + lines.append("f 1/1/1 2/2/1") + replaced = True + else: + lines.append(raw_line) + target.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def main(output_dir, report_path): + output_dir = Path(output_dir).resolve() + report_path = Path(report_path).resolve() + output_dir.mkdir(parents=True, exist_ok=True) + report_path.parent.mkdir(parents=True, exist_ok=True) + obj_path = output_dir / "multi-object.obj" + create_scene(output_dir) + export_obj(obj_path) + mtl_path = obj_path.with_suffix(".mtl") + if not mtl_path.exists(): + raise RuntimeError("Blender OBJ export did not write the MTL sidecar") + negative_path = output_dir / "negative-index.obj" + malformed_path = output_dir / "malformed-face.obj" + negative_index_obj(obj_path, negative_path) + malformed_obj(obj_path, malformed_path) + semantic = parse_obj(obj_path) + semantic["materials"] = parse_mtl(mtl_path) + files = [] + for name in (obj_path.name, mtl_path.name, TEXTURE_NAME, negative_path.name, malformed_path.name): + item = output_dir / name + files.append({"name": name, "byteLength": item.stat().st_size, "sha256": sha256_file(item)}) + report = { + "schemaVersion": 1, + "task": "M12-07B", + "operation": "DESKTOP_OBJ_MULTI_OBJECT_AND_NEGATIVE_FIXTURES", + "runtime": runtime_identity(), + "sourceAnchor": "blender-5.2.0/source/blender/io/wavefront_obj", + "operator": "wm.obj_export", + "settings": { + "forwardAxis": "NEGATIVE_Z", + "upAxis": "Y", + "globalScale": 1.0, + "exportUV": True, + "exportNormals": True, + "exportMaterials": True, + "exportMaterialGroups": True, + "exportObjectGroups": True, + "pathMode": "RELATIVE", + }, + "files": files, + "semantic": semantic, + "negativeIndex": {"file": negative_path.name, "expectedStatus": "ACCEPT_WITH_NEGATIVE_INDICES", "faceCount": 2}, + "malformedFace": {"file": malformed_path.name, "expectedCode": "OBJ_FACE_ARITY_INVALID"}, + "textureOrigin": {"mtlMapKd": [material["mapKd"] for material in semantic["materials"]], "relative": True, "textureFile": TEXTURE_NAME}, + "nextTask": "M12-07C", + } + report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print("obj-multi-negative-fixtures-generated objects=%s faces=%s negative=true malformed=true next=%s" % (len(semantic["objects"]), len(semantic["faces"]), report["nextTask"])) + + +if __name__ == "__main__": + args = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] + if len(args) != 2: + raise SystemExit("usage: blender --background --python generate-obj-multi-negative-fixtures.py -- OUTPUT_DIR REPORT") + main(args[0], args[1]) diff --git a/tools/web/generate-obj-single-mesh-fixture.py b/tools/web/generate-obj-single-mesh-fixture.py new file mode 100644 index 00000000..825bfc74 --- /dev/null +++ b/tools/web/generate-obj-single-mesh-fixture.py @@ -0,0 +1,239 @@ +"""Generate the pinned Blender 5.2 single-Mesh OBJ fixture for M12-07A.""" + +import hashlib +import json +import sys +from pathlib import Path + +import bpy + + +OBJ_NAME = "M12 OBJ Single Mesh" +MATERIAL_NAMES = ("M12 OBJ Red", "M12 OBJ Blue") + + +def sha256_file(path): + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def text(value): + return value.decode("utf-8") if isinstance(value, bytes) else value + + +def runtime_identity(): + binary = Path(bpy.app.binary_path) + return { + "blenderVersion": text(bpy.app.version_string), + "versionTuple": list(bpy.app.version), + "buildDate": text(bpy.app.build_date), + "buildTime": text(bpy.app.build_time), + "buildHash": text(bpy.app.build_hash), + "buildBranch": text(bpy.app.build_branch), + "buildPlatform": text(bpy.app.build_platform), + "buildType": text(bpy.app.build_type), + "binarySha256": sha256_file(binary), + } + + +def create_fixture(): + bpy.ops.wm.read_factory_settings(use_empty=True) + mesh = bpy.data.meshes.new(OBJ_NAME + " Mesh") + mesh.from_pydata( + [(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (1.0, 1.0, 0.0), (-1.0, 1.0, 0.0)], + [], + [(0, 1, 2), (0, 2, 3)], + ) + mesh.update() + obj = bpy.data.objects.new(OBJ_NAME, mesh) + bpy.context.scene.collection.objects.link(obj) + + uv_layer = mesh.uv_layers.new(name="UVMap") + uv_by_vertex = ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)) + for loop in mesh.loops: + uv_layer.data[loop.index].uv = uv_by_vertex[loop.vertex_index] + + colors = ((0.8, 0.1, 0.05, 1.0), (0.05, 0.2, 0.85, 1.0)) + for name, color in zip(MATERIAL_NAMES, colors): + material = bpy.data.materials.new(name) + material.diffuse_color = color + material.metallic = 0.0 + material.roughness = 0.5 + mesh.materials.append(material) + mesh.polygons[0].material_index = 0 + mesh.polygons[1].material_index = 1 + for polygon in mesh.polygons: + polygon.use_smooth = False + + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + return obj + + +def export_obj(output_path): + result = bpy.ops.wm.obj_export( + filepath=str(output_path), + export_selected_objects=True, + apply_modifiers=False, + apply_transform=False, + export_eval_mode="DAG_EVAL_VIEWPORT", + export_uv=True, + export_normals=True, + export_colors=False, + export_materials=True, + export_pbr_extensions=False, + export_material_groups=True, + export_object_groups=False, + export_vertex_groups=False, + export_smooth_groups=False, + export_triangulated_mesh=False, + export_curves_as_nurbs=False, + global_scale=1.0, + forward_axis="NEGATIVE_Z", + up_axis="Y", + path_mode="RELATIVE", + ) + if "FINISHED" not in result: + raise RuntimeError("Blender OBJ export did not finish: %s" % (result,)) + + +def number(value): + parsed = float(value) + return 0.0 if parsed == 0.0 else parsed + + +def parse_obj(path): + positions = [] + texcoords = [] + normals = [] + faces = [] + material_libraries = [] + objects = [] + current_material = None + current_groups = [] + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + parts = line.split() + kind = parts[0] + if kind == "v": + positions.append([number(value) for value in parts[1:4]]) + elif kind == "vt": + texcoords.append([number(value) for value in parts[1:3]]) + elif kind == "vn": + normals.append([number(value) for value in parts[1:4]]) + elif kind == "mtllib": + material_libraries.extend(parts[1:]) + elif kind == "o": + objects.append(" ".join(parts[1:])) + elif kind == "g": + current_groups = parts[1:] + elif kind == "usemtl": + current_material = " ".join(parts[1:]) + elif kind == "f": + vertices = [] + for token in parts[1:]: + indices = token.split("/") + vertices.append({ + "position": int(indices[0]), + "texcoord": int(indices[1]) if len(indices) > 1 and indices[1] else None, + "normal": int(indices[2]) if len(indices) > 2 and indices[2] else None, + }) + faces.append({"vertices": vertices, "material": current_material, "groups": list(current_groups)}) + return { + "materialLibraries": material_libraries, + "objects": objects, + "positions": positions, + "texcoords": texcoords, + "normals": normals, + "faces": faces, + } + + +def parse_mtl(path): + materials = [] + current = None + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + parts = line.split() + if parts[0] == "newmtl": + current = {"name": " ".join(parts[1:]), "properties": {}} + materials.append(current) + elif current is not None: + values = parts[1:] + current["properties"][parts[0]] = [number(value) for value in values] if all_value_numbers(values) else " ".join(values) + return materials + + +def all_value_numbers(values): + if not values: + return False + try: + for value in values: + float(value) + return True + except ValueError: + return False + + +def main(output_dir, report_path): + output_dir = Path(output_dir).resolve() + report_path = Path(report_path).resolve() + output_dir.mkdir(parents=True, exist_ok=True) + report_path.parent.mkdir(parents=True, exist_ok=True) + obj_path = output_dir / "single-mesh.obj" + create_fixture() + export_obj(obj_path) + mtl_path = obj_path.with_suffix(".mtl") + if not mtl_path.exists(): + raise RuntimeError("Blender OBJ export did not write the MTL sidecar") + semantic = parse_obj(obj_path) + semantic["materials"] = parse_mtl(mtl_path) + report = { + "schemaVersion": 1, + "task": "M12-07A", + "operation": "DESKTOP_OBJ_SINGLE_MESH_FIXTURE", + "runtime": runtime_identity(), + "sourceAnchor": "blender-5.2.0/source/blender/io/wavefront_obj", + "operator": "wm.obj_export", + "settings": { + "forwardAxis": "NEGATIVE_Z", + "upAxis": "Y", + "globalScale": 1.0, + "exportUV": True, + "exportNormals": True, + "exportMaterials": True, + "exportMaterialGroups": True, + }, + "files": [ + {"name": obj_path.name, "byteLength": obj_path.stat().st_size, "sha256": sha256_file(obj_path)}, + {"name": mtl_path.name, "byteLength": mtl_path.stat().st_size, "sha256": sha256_file(mtl_path)}, + ], + "semantic": semantic, + "nextTask": "M12-07B", + } + report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print( + "obj-single-mesh-fixture-generated positions=%s texcoords=%s normals=%s faces=%s materials=%s next=%s" + % ( + len(semantic["positions"]), + len(semantic["texcoords"]), + len(semantic["normals"]), + len(semantic["faces"]), + len(semantic["materials"]), + report["nextTask"], + ) + ) + + +if __name__ == "__main__": + args = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] + if len(args) != 2: + raise SystemExit("usage: blender --background --python generate-obj-single-mesh-fixture.py -- OUTPUT_DIR REPORT") + main(args[0], args[1]) diff --git a/tools/web/generate-ply-capability-fixtures.py b/tools/web/generate-ply-capability-fixtures.py new file mode 100644 index 00000000..84c14509 --- /dev/null +++ b/tools/web/generate-ply-capability-fixtures.py @@ -0,0 +1,72 @@ +"""Generate pinned Blender 5.2 ASCII and binary little-endian PLY fixtures for M12-07G.""" + +import hashlib +import json +import struct +import sys +from pathlib import Path + +import bpy + + +def sha256_file(path): + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def text(value): + return value.decode("utf-8") if isinstance(value, bytes) else value + + +def runtime_identity(): + binary = Path(bpy.app.binary_path) + return {"blenderVersion": text(bpy.app.version_string), "versionTuple": list(bpy.app.version), "buildDate": text(bpy.app.build_date), "buildTime": text(bpy.app.build_time), "buildHash": text(bpy.app.build_hash), "buildBranch": text(bpy.app.build_branch), "buildPlatform": text(bpy.app.build_platform), "buildType": text(bpy.app.build_type), "binarySha256": sha256_file(binary)} + + +def create_scene(): + bpy.ops.wm.read_factory_settings(use_empty=True) + mesh = bpy.data.meshes.new("M12 PLY Capability Mesh") + mesh.from_pydata([(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (1.0, 1.0, 0.0), (-1.0, 1.0, 0.0)], [], [(0, 1, 2), (0, 2, 3)]) + mesh.update() + obj = bpy.data.objects.new("M12 PLY Capability", mesh) + bpy.context.scene.collection.objects.link(obj) + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + + +def export_ply(path, ascii_format): + result = bpy.ops.wm.ply_export(filepath=str(path), ascii_format=ascii_format, export_selected_objects=True, export_uv=False, export_normals=True, export_colors="NONE", export_triangulated_mesh=True, forward_axis="NEGATIVE_Z", up_axis="Y", global_scale=1.0) + if "FINISHED" not in result: + raise RuntimeError("Blender PLY export did not finish: %s" % (result,)) + + +def parse_header(path): + payload = path.read_bytes() + end = payload.find(b"end_header\n") + if end < 0: + raise RuntimeError("PLY header missing end_header") + header = payload[: end + len(b"end_header\n")].decode("ascii") + lines = header.splitlines() + format_line = next(line for line in lines if line.startswith("format ")) + elements = [{"name": parts[1], "count": int(parts[2])} for line in lines if (parts := line.split()) and parts[0] == "element"] + return {"format": format_line.split()[1], "elements": elements, "headerBytes": len(header.encode("ascii")), "byteLength": len(payload)} + + +def main(output_dir, report_path): + output_dir = Path(output_dir).resolve(); report_path = Path(report_path).resolve(); output_dir.mkdir(parents=True, exist_ok=True); report_path.parent.mkdir(parents=True, exist_ok=True) + create_scene() + ascii_path = output_dir / "capability-ascii.ply"; binary_path = output_dir / "capability-binary-le.ply" + export_ply(ascii_path, True); export_ply(binary_path, False) + files = [{"name": item.name, "byteLength": item.stat().st_size, "sha256": sha256_file(item)} for item in (ascii_path, binary_path)] + report = {"schemaVersion": 1, "task": "M12-07G", "operation": "DESKTOP_PLY_ASCII_BINARY_LE_CAPABILITY", "runtime": runtime_identity(), "sourceAnchor": "blender-5.2.0/source/blender/io/ply", "operator": "wm.ply_export", "settings": {"exportSelectedObjects": True, "exportNormals": True, "exportUV": False, "exportColors": "NONE", "exportTriangulatedMesh": True, "forwardAxis": "NEGATIVE_Z", "upAxis": "Y", "globalScale": 1.0}, "variants": [{"id": "PLY_ASCII", "asciiFormat": True, "file": ascii_path.name, "semantic": parse_header(ascii_path)}, {"id": "PLY_BINARY_LITTLE_ENDIAN", "asciiFormat": False, "file": binary_path.name, "semantic": parse_header(binary_path)}], "files": files, "nextTask": "M12-07H"} + report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print("ply-capability-fixtures-generated ascii=%s binary=%s next=%s" % (report["variants"][0]["semantic"]["format"], report["variants"][1]["semantic"]["format"], report["nextTask"])) + + +if __name__ == "__main__": + args = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] + if len(args) != 2: raise SystemExit("usage: blender --background --python generate-ply-capability-fixtures.py -- OUTPUT_DIR REPORT") + main(args[0], args[1]) diff --git a/tools/web/generate-ply-mapping-fixtures.py b/tools/web/generate-ply-mapping-fixtures.py new file mode 100644 index 00000000..53606f02 --- /dev/null +++ b/tools/web/generate-ply-mapping-fixtures.py @@ -0,0 +1,150 @@ +"""Generate pinned Blender 5.2 PLY mapping and unknown-property fixtures for M12-07H.""" + +import hashlib +import json +import struct +import sys +from pathlib import Path + +import bpy + + +SCALAR = {"char": "b", "uchar": "B", "short": "h", "ushort": "H", "int": "i", "uint": "I", "float": "f", "double": "d"} + + +def sha256_file(path): + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def text(value): + return value.decode("utf-8") if isinstance(value, bytes) else value + + +def runtime_identity(): + binary = Path(bpy.app.binary_path) + return {"blenderVersion": text(bpy.app.version_string), "versionTuple": list(bpy.app.version), "buildDate": text(bpy.app.build_date), "buildTime": text(bpy.app.build_time), "buildHash": text(bpy.app.build_hash), "buildBranch": text(bpy.app.build_branch), "buildPlatform": text(bpy.app.build_platform), "buildType": text(bpy.app.build_type), "binarySha256": sha256_file(binary)} + + +def create_scene(): + bpy.ops.wm.read_factory_settings(use_empty=True) + mesh = bpy.data.meshes.new("M12 PLY Mapping Mesh") + mesh.from_pydata([(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (1.0, 1.0, 0.0), (-1.0, 1.0, 0.0)], [], [(0, 1, 2), (0, 2, 3)]) + mesh.update() + colors = mesh.color_attributes.new(name="Col", type="FLOAT_COLOR", domain="POINT") + for value, color in zip(colors.data, ((1.0, 0.0, 0.0, 1.0), (0.0, 1.0, 0.0, 1.0), (0.0, 0.0, 1.0, 1.0), (1.0, 1.0, 0.0, 1.0))): + value.color = color + temperature = mesh.attributes.new(name="temperature", type="FLOAT", domain="POINT") + label = mesh.attributes.new(name="label", type="INT", domain="POINT") + for index, (temp, tag) in enumerate(zip((10.0, 20.0, 30.0, 40.0), (1, 2, 3, 4))): + temperature.data[index].value = temp + label.data[index].value = tag + obj = bpy.data.objects.new("M12 PLY Mapping", mesh) + bpy.context.scene.collection.objects.link(obj) + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + + +def export_ply(path, ascii_format): + result = bpy.ops.wm.ply_export(filepath=str(path), ascii_format=ascii_format, export_selected_objects=True, export_uv=False, export_normals=True, export_colors="SRGB", export_attributes=True, export_triangulated_mesh=True, forward_axis="NEGATIVE_Z", up_axis="Y", global_scale=1.0) + if "FINISHED" not in result: + raise RuntimeError("Blender PLY export did not finish: %s" % (result,)) + + +def parse_header(payload): + marker = b"end_header\n" + end = payload.find(marker) + if end < 0: + raise RuntimeError("PLY header missing end_header") + header = payload[: end + len(marker)].decode("ascii") + elements = [] + current = None + for line in header.splitlines(): + parts = line.split() + if not parts: + continue + if parts[0] == "format": + fmt = parts[1] + elif parts[0] == "element": + current = {"name": parts[1], "count": int(parts[2]), "properties": []} + elements.append(current) + elif parts[0] == "property" and current is not None: + if parts[1] == "list": current["properties"].append({"kind": "list", "countType": parts[2], "valueType": parts[3], "name": parts[4]}) + else: current["properties"].append({"kind": "scalar", "type": parts[1], "name": parts[2]}) + return fmt, elements, end + len(marker) + + +def read_value(data, cursor, value_type): + code = SCALAR[value_type] + size = struct.calcsize("<" + code) + value = struct.unpack_from("<" + code, data, cursor)[0] + return value, cursor + size + + +def parse_semantic(path): + payload = path.read_bytes() + fmt, elements, offset = parse_header(payload) + records = {} + if fmt == "ascii": + lines = payload[offset:].decode("utf-8").splitlines() + cursor = 0 + for element in elements: + rows = [] + for _ in range(element["count"]): + tokens = lines[cursor].split(); cursor += 1; token_index = 0; row = {} + for prop in element["properties"]: + if prop["kind"] == "scalar": row[prop["name"]] = float(tokens[token_index]) if prop["type"] in ("float", "double") else int(tokens[token_index]); token_index += 1 + else: + length = int(tokens[token_index]); token_index += 1; row[prop["name"]] = [int(tokens[token_index + i]) for i in range(length)]; token_index += length + rows.append(row) + records[element["name"]] = rows + else: + cursor = offset + for element in elements: + rows = [] + for _ in range(element["count"]): + row = {} + for prop in element["properties"]: + if prop["kind"] == "scalar": row[prop["name"]], cursor = read_value(payload, cursor, prop["type"]) + else: + length, cursor = read_value(payload, cursor, prop["countType"]); row[prop["name"]] = [] + for _ in range(length): value, cursor = read_value(payload, cursor, prop["valueType"]); row[prop["name"]].append(value) + rows.append(row) + records[element["name"]] = rows + vertices = [] + for row in records["vertex"]: + vertices.append({"position": [row["x"], row["y"], row["z"]], "normal": [row["nx"], row["ny"], row["nz"]], "color": [row["red"], row["green"], row["blue"], row["alpha"]], "customProperties": {name: row[name] for name in row if name not in {"x", "y", "z", "nx", "ny", "nz", "red", "green", "blue", "alpha"}}}) + faces = [{"indices": row["vertex_indices"], "customProperties": {name: row[name] for name in row if name != "vertex_indices"}} for row in records.get("face", [])] + return {"format": fmt, "vertexCount": len(vertices), "faceCount": len(faces), "vertices": vertices, "faces": faces} + + +def create_unknown_ascii(source, target): + lines = source.read_text(encoding="utf-8").splitlines() + header_end = lines.index("end_header") + property_index = next(index for index, line in enumerate(lines[:header_end]) if line == "property float label") + lines.insert(property_index + 1, "property list uchar float unknown_values") + header_end += 1 + vertex_count = next(int(line.split()[2]) for line in lines[:header_end + 1] if line.startswith("element vertex ")) + for index in range(header_end + 1, header_end + 1 + vertex_count): + lines[index] += " 1 0.5" + target.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def main(output_dir, report_path): + output_dir = Path(output_dir).resolve(); report_path = Path(report_path).resolve(); output_dir.mkdir(parents=True, exist_ok=True); report_path.parent.mkdir(parents=True, exist_ok=True) + create_scene() + ascii_path = output_dir / "mapping-ascii.ply"; binary_path = output_dir / "mapping-binary-le.ply"; unknown_path = output_dir / "unknown-property-ascii.ply" + export_ply(ascii_path, True); export_ply(binary_path, False); create_unknown_ascii(ascii_path, unknown_path) + files = [{"name": item.name, "byteLength": item.stat().st_size, "sha256": sha256_file(item)} for item in (ascii_path, binary_path, unknown_path)] + report = {"schemaVersion": 1, "task": "M12-07H", "operation": "PLY_VERTEX_FACE_COLOR_CUSTOM_MAPPING", "runtime": runtime_identity(), "sourceAnchor": "blender-5.2.0/source/blender/io/ply", "operator": "wm.ply_export", "settings": {"exportSelectedObjects": True, "exportNormals": True, "exportUV": False, "exportColors": "SRGB", "exportAttributes": True, "exportTriangulatedMesh": True, "forwardAxis": "NEGATIVE_Z", "upAxis": "Y", "globalScale": 1.0}, "variants": [{"id": "PLY_ASCII_MAPPING", "file": ascii_path.name, "semantic": parse_semantic(ascii_path)}, {"id": "PLY_BINARY_LITTLE_ENDIAN_MAPPING", "file": binary_path.name, "semantic": parse_semantic(binary_path)}], "unknownProperty": {"file": unknown_path.name, "property": "unknown_values", "expectedCode": "PLY_UNKNOWN_PROPERTY"}, "files": files, "nextTask": "M12-07I"} + report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print("ply-mapping-fixtures-generated vertices=%s faces=%s colors=rgba custom=2 unknown=PLY_UNKNOWN_PROPERTY next=%s" % (report["variants"][0]["semantic"]["vertexCount"], report["variants"][0]["semantic"]["faceCount"], report["nextTask"])) + + +if __name__ == "__main__": + args = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] + if len(args) != 2: raise SystemExit("usage: blender --background --python generate-ply-mapping-fixtures.py -- OUTPUT_DIR REPORT") + main(args[0], args[1]) diff --git a/tools/web/generate-ply-negative-fixtures.mjs b/tools/web/generate-ply-negative-fixtures.mjs new file mode 100644 index 00000000..ff77c4ee --- /dev/null +++ b/tools/web/generate-ply-negative-fixtures.mjs @@ -0,0 +1,51 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "../.."); +const fixtureRoot = path.resolve(process.env.M12_PLY_NEGATIVE_OUTPUT ?? path.join(root, "tests/files/web/m12_ply_negative_v1")); +const reportPath = path.resolve(process.env.M12_PLY_NEGATIVE_REPORT ?? path.join(root, "tests/golden/M12-07I/negative-report.json")); +const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); + +function write(name, content) { + const bytes = Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8"); + fs.writeFileSync(path.join(fixtureRoot, name), bytes); + return { name, byteLength: bytes.byteLength, sha256: sha256(bytes) }; +} + +function bigEndian() { + const header = "ply\nformat binary_big_endian 1.0\nelement vertex 1\nproperty float x\nproperty float y\nproperty float z\nelement face 0\nproperty list uchar uint vertex_indices\nend_header\n"; + const data = Buffer.alloc(12); + data.writeFloatBE(0, 0); data.writeFloatBE(0, 4); data.writeFloatBE(0, 8); + return Buffer.concat([Buffer.from(header, "ascii"), data]); +} + +function malformedList() { + return "ply\nformat ascii 1.0\nelement vertex 3\nproperty float x\nproperty float y\nproperty float z\nelement face 1\nproperty list uchar uint vertex_indices\nend_header\n0 0 0\n1 0 0\n0 0 1\n3 0 1\n"; +} + +function oversizedCount() { + return "ply\nformat ascii 1.0\nelement vertex 65537\nproperty float x\nproperty float y\nproperty float z\nelement face 0\nproperty list uchar uint vertex_indices\nend_header\n"; +} + +fs.mkdirSync(fixtureRoot, { recursive: true }); +const files = [ + write("big-endian.ply", bigEndian()), + write("malformed-list-ascii.ply", malformedList()), + write("oversized-count-ascii.ply", oversizedCount()), +]; +const report = { + schemaVersion: 1, + task: "M12-07I", + operation: "PLY_NEGATIVE_FORMAT_LIST_COUNT", + cases: [ + { id: "big-endian", file: "big-endian.ply", expectedCode: "PLY_FORMAT_UNSUPPORTED" }, + { id: "malformed-list", file: "malformed-list-ascii.ply", expectedCode: "PLY_DATA_TRUNCATED" }, + { id: "oversized-count", file: "oversized-count-ascii.ply", expectedCode: "PLY_IMPORT_BUDGET_EXCEEDED: vertex" }, + ], + files, + nextTask: "M12-07J", +}; +fs.mkdirSync(path.dirname(reportPath), { recursive: true }); +fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n"); +process.stdout.write(`ply-negative-fixtures-generated cases=${report.cases.length} next=${report.nextTask}\n`); diff --git a/tools/web/generate-script-entry-inventory.py b/tools/web/generate-script-entry-inventory.py new file mode 100644 index 00000000..d014f104 --- /dev/null +++ b/tools/web/generate-script-entry-inventory.py @@ -0,0 +1,66 @@ +"""Inventory Blender 5.2 scripting entry points without executing user script text.""" + +import hashlib +import json +import sys +from pathlib import Path + +import bpy + + +def sha256_file(path): + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def text(value): + return value.decode("utf-8") if isinstance(value, bytes) else value + + +def runtime_identity(): + binary = Path(bpy.app.binary_path) + return {"blenderVersion": text(bpy.app.version_string), "versionTuple": list(bpy.app.version), "buildDate": text(bpy.app.build_date), "buildTime": text(bpy.app.build_time), "buildHash": text(bpy.app.build_hash), "buildBranch": text(bpy.app.build_branch), "buildPlatform": text(bpy.app.build_platform), "buildType": text(bpy.app.build_type), "binarySha256": sha256_file(binary)} + + +def create_fixture(output_path): + bpy.ops.wm.read_factory_settings(use_empty=True) + texts = [("InternalSafe.py", "value = 7\nprint(value)\n", False, ""), ("ModuleAutorun.py", "def register():\n return 'blocked'\n", True, ""), ("ExternalProject.py", "message = 'project relative'\n", False, "//scripts/external_project.py")] + for name, source, use_module, filepath in texts: + value = bpy.data.texts.new(name); value.write(source); value.use_module = use_module; value.filepath = filepath + mesh = bpy.data.meshes.new("M13 Script Inventory Mesh"); mesh.from_pydata([(0, 0, 0), (1, 0, 0), (0, 1, 0)], [], [(0, 1, 2)]); mesh.update() + obj = bpy.data.objects.new("M13 Script Inventory Object", mesh); bpy.context.scene.collection.objects.link(obj) + obj["driver_source"] = 1.0 + driver = obj.driver_add('location', 0).driver; driver.expression = "var * 2"; variable = driver.variables.new(); variable.name = "var"; variable.type = "SINGLE_PROP"; variable.targets[0].id = obj; variable.targets[0].data_path = '[\"driver_source\"]' + output_path.parent.mkdir(parents=True, exist_ok=True); bpy.ops.wm.save_as_mainfile(filepath=str(output_path), compress=False) + + +def entry_inventory(): + text_entries = [] + for value in sorted(bpy.data.texts, key=lambda item: item.name): + source = value.as_string(); text_entries.append({"name": value.name, "byteLength": len(source.encode("utf-8")), "lineCount": len(source.split("\n")), "useModule": bool(value.use_module), "filepath": value.filepath, "internal": not bool(value.filepath)}) + console_ops = [name for name in ("execute", "history_append", "scrollback_append") if hasattr(bpy.ops.console, name)] + driver_entries = [] + for obj in sorted(bpy.data.objects, key=lambda item: item.name): + if not obj.animation_data: continue + for item in obj.animation_data.drivers: + driver_entries.append({"object": obj.name, "dataPath": item.data_path, "arrayIndex": item.array_index, "expression": item.driver.expression, "variableCount": len(item.driver.variables)}) + handler_groups = sorted(name for name in dir(bpy.app.handlers) if not name.startswith("_") and isinstance(getattr(bpy.app.handlers, name), list)) + addon_ops = [name for name in ("addon_install", "addon_enable", "addon_disable", "addon_remove") if hasattr(bpy.ops.preferences, name)] + return {"text": {"count": len(text_entries), "entries": text_entries}, "pythonConsole": {"operatorIds": [f"console.{name}" for name in console_ops], "available": len(console_ops) == 3}, "autorun": {"moduleTextNames": [item["name"] for item in text_entries if item["useModule"]], "defaultExecution": "DENY"}, "driverExpressions": {"count": len(driver_entries), "entries": driver_entries, "defaultExecution": "DENY"}, "handlers": {"groups": handler_groups, "defaultExecution": "DENY"}, "addons": {"operatorIds": [f"preferences.{name}" for name in addon_ops], "enabledAddons": sorted(addon.module for addon in bpy.context.preferences.addons), "defaultExecution": "DENY"}} + + +def main(output_dir, report_path): + output_dir = Path(output_dir).resolve(); report_path = Path(report_path).resolve(); output_dir.mkdir(parents=True, exist_ok=True); report_path.parent.mkdir(parents=True, exist_ok=True) + fixture_path = output_dir / "script-entry-inventory.blend"; create_fixture(fixture_path) + report = {"schemaVersion": 1, "task": "M13-01A", "operation": "BLENDER_SCRIPT_ENTRY_INVENTORY", "runtime": runtime_identity(), "sourceAnchor": "blender-5.2.0/source/blender/python", "executionPolicy": {"text": "READ_METADATA_ONLY", "pythonConsole": "DENY", "autorun": "DENY", "driverExpression": "DENY", "handler": "DENY", "addon": "DENY"}, "inventory": entry_inventory(), "fixture": {"name": fixture_path.name, "byteLength": fixture_path.stat().st_size, "sha256": sha256_file(fixture_path)}, "nextTask": "M13-01B"} + report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print("script-entry-inventory-generated texts=%s drivers=%s consoleOps=%s addonOps=%s next=%s" % (report["inventory"]["text"]["count"], report["inventory"]["driverExpressions"]["count"], len(report["inventory"]["pythonConsole"]["operatorIds"]), len(report["inventory"]["addons"]["operatorIds"]), report["nextTask"])) + + +if __name__ == "__main__": + args = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] + if len(args) != 2: raise SystemExit("usage: blender --background --python generate-script-entry-inventory.py -- OUTPUT_DIR REPORT") + main(args[0], args[1]) diff --git a/tools/web/generate-stl-capability-fixtures.py b/tools/web/generate-stl-capability-fixtures.py new file mode 100644 index 00000000..b2a2e16b --- /dev/null +++ b/tools/web/generate-stl-capability-fixtures.py @@ -0,0 +1,135 @@ +"""Generate pinned Blender 5.2 binary and ASCII STL capability fixtures for M12-07D.""" + +import hashlib +import json +import struct +import sys +from pathlib import Path + +import bpy + + +def sha256_file(path): + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def text(value): + return value.decode("utf-8") if isinstance(value, bytes) else value + + +def runtime_identity(): + binary = Path(bpy.app.binary_path) + return { + "blenderVersion": text(bpy.app.version_string), + "versionTuple": list(bpy.app.version), + "buildDate": text(bpy.app.build_date), + "buildTime": text(bpy.app.build_time), + "buildHash": text(bpy.app.build_hash), + "buildBranch": text(bpy.app.build_branch), + "buildPlatform": text(bpy.app.build_platform), + "buildType": text(bpy.app.build_type), + "binarySha256": sha256_file(binary), + } + + +def create_scene(): + bpy.ops.wm.read_factory_settings(use_empty=True) + mesh = bpy.data.meshes.new("M12 STL Capability Mesh") + mesh.from_pydata( + [(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (1.0, 1.0, 0.0), (-1.0, 1.0, 0.0)], + [], + [(0, 1, 2), (0, 2, 3)], + ) + mesh.update() + obj = bpy.data.objects.new("M12 STL Capability", mesh) + bpy.context.scene.collection.objects.link(obj) + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + return obj + + +def export_stl(path, ascii_format): + result = bpy.ops.wm.stl_export( + filepath=str(path), + ascii_format=ascii_format, + export_selected_objects=True, + apply_modifiers=False, + evaluation_mode="DAG_EVAL_VIEWPORT", + global_scale=1.0, + forward_axis="NEGATIVE_Z", + up_axis="Y", + use_scene_unit=False, + ) + if "FINISHED" not in result: + raise RuntimeError("Blender STL export did not finish: %s" % (result,)) + + +def binary_semantics(path): + payload = path.read_bytes() + if len(payload) < 84: + raise RuntimeError("STL binary is shorter than the header") + count = struct.unpack_from(" crypto.createHash("sha256").update(bytes).digest("hex"); + +fs.mkdirSync(outputRoot, { recursive: true }); +fs.mkdirSync(path.dirname(reportPath), { recursive: true }); +const source = fs.readFileSync(sourcePath); +if (source.length !== 184 || source.readUInt32LE(80) !== 2) throw new Error("M12-07D binary parent fixture drifted"); +fs.writeFileSync(path.join(outputRoot, "capability-binary.stl"), source); +const degenerate = Buffer.from(source); +const firstVertex = Buffer.from(degenerate.subarray(84 + 12, 84 + 24)); +firstVertex.copy(degenerate, 84 + 24); +firstVertex.copy(degenerate, 84 + 36); +const trailing = Buffer.concat([source, Buffer.from([0xde, 0xad, 0xbe, 0xef])]); +const outputs = [ + { id: "DEGENERATE_TRIANGLE", name: "degenerate-binary.stl", bytes: degenerate, expectedCode: "STL_DEGENERATE_TRIANGLE" }, + { id: "TRAILING_BYTES", name: "trailing-binary.stl", bytes: trailing, expectedCode: "STL_TRAILING_BYTES" }, +]; +for (const output of outputs) fs.writeFileSync(path.join(outputRoot, output.name), output.bytes); +const report = { + schemaVersion: 1, + task: "M12-07E", + operation: "STL_EDGE_FIXTURE_GENERATION", + parent: { path: "tests/files/web/m12_stl_capability_v1/capability-binary.stl", byteLength: source.length, sha256: sha256(source) }, + fixtures: outputs.map((output) => ({ id: output.id, file: output.name, byteLength: output.bytes.length, sha256: sha256(output.bytes), expectedCode: output.expectedCode })), + nextTask: "M12-07F", +}; +fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n"); +process.stdout.write(`stl-edge-fixtures-generated cases=${outputs.length} next=${report.nextTask}\n`); diff --git a/tools/web/probe-stl-edge-fixtures.py b/tools/web/probe-stl-edge-fixtures.py new file mode 100644 index 00000000..576837e8 --- /dev/null +++ b/tools/web/probe-stl-edge-fixtures.py @@ -0,0 +1,74 @@ +"""Probe STL normal/unit/edge behavior in pinned Blender 5.2 for M12-07E.""" + +import hashlib +import json +import sys +from pathlib import Path + +import bpy + + +def sha256_file(path): + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def import_case(path, global_scale): + bpy.ops.wm.read_factory_settings(use_empty=True) + result = bpy.ops.wm.stl_import( + filepath=str(path), + directory=str(path.parent), + forward_axis="NEGATIVE_Z", + up_axis="Y", + global_scale=global_scale, + use_scene_unit=False, + use_facet_normal=True, + use_mesh_validate=True, + ) + objects = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"] + if "FINISHED" not in result or not objects: + return {"status": "BLOCKED", "objectCount": len(objects)} + mesh = objects[0].data + mesh.calc_loop_triangles() + coordinates = [[float(value) for value in (objects[0].matrix_world @ vertex.co)] for vertex in mesh.vertices] + bounds = { + "min": [min(values) for values in zip(*coordinates)], + "max": [max(values) for values in zip(*coordinates)], + } if coordinates else {"min": [0.0, 0.0, 0.0], "max": [0.0, 0.0, 0.0]} + return { + "status": "ACCEPTED", + "objectCount": len(objects), + "vertexCount": len(mesh.vertices), + "polygonCount": len(mesh.polygons), + "triangleCount": len(mesh.loop_triangles), + "bounds": bounds, + "polygonNormals": [[float(value) for value in polygon.normal] for polygon in mesh.polygons], + } + + +def main(fixture_root, report_path): + fixture_root = Path(fixture_root).resolve() + report_path = Path(report_path).resolve() + cases = [] + for case_id, name, scale in ( + ("BINARY_UNIT_1", "capability-binary.stl", 1.0), + ("BINARY_UNIT_001", "capability-binary.stl", 0.001), + ("DEGENERATE_TRIANGLE", "degenerate-binary.stl", 1.0), + ("TRAILING_BYTES", "trailing-binary.stl", 1.0), + ): + path = fixture_root / name + cases.append({"id": case_id, "file": name, "globalScale": scale, "byteLength": path.stat().st_size, "sha256": sha256_file(path), "result": import_case(path, scale)}) + report = {"schemaVersion": 1, "task": "M12-07E", "operation": "BLENDER_STL_EDGE_PROBE", "cases": cases, "nextTask": "M12-07F"} + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print("stl-edge-probe-complete cases=%s next=%s" % (len(cases), report["nextTask"])) + + +if __name__ == "__main__": + args = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] + if len(args) != 2: + raise SystemExit("usage: blender --background --python probe-stl-edge-fixtures.py -- FIXTURE_ROOT REPORT") + main(args[0], args[1]) diff --git a/tools/web/server-job-fault.mjs b/tools/web/server-job-fault.mjs new file mode 100644 index 00000000..899c0411 --- /dev/null +++ b/tools/web/server-job-fault.mjs @@ -0,0 +1,70 @@ +export const SERVER_JOB_FAULT_SCHEMA = 1; +export const SERVER_JOB_FAULT_CODES = Object.freeze({ + SUCCEEDED: "SERVER_JOB_SUCCEEDED", + CANCELLED: "SERVER_JOB_CANCELLED", + TIMEOUT: "SERVER_JOB_TIMEOUT", + OOM: "SERVER_JOB_OOM", + SIGNAL: "SERVER_JOB_SIGNAL", + EXIT_FAILED: "SERVER_JOB_EXIT_FAILED", +}); + +function invalid(message) { + throw new Error(`SERVER_JOB_FAULT_INVALID: ${message}`); +} + +function revision(value, field) { + if (!Number.isSafeInteger(value) || value < 0) invalid(`${field} must be a non-negative revision`); + return value; +} + +function optionalBoolean(value, field) { + if (value !== undefined && typeof value !== "boolean") invalid(`${field} must be boolean`); + return value === true; +} + +export function classifyServerJobFault(value) { + if (!value || typeof value !== "object") invalid("input is invalid"); + const cancelRequested = optionalBoolean(value.cancelRequested, "cancelRequested"); + const timedOut = optionalBoolean(value.timedOut, "timedOut"); + const oom = optionalBoolean(value.oom, "oom"); + const memoryLimitBytes = value.memoryLimitBytes === undefined ? null : revision(value.memoryLimitBytes, "memoryLimitBytes"); + const memoryBytes = value.memoryBytes === undefined ? null : revision(value.memoryBytes, "memoryBytes"); + const memoryExceeded = oom || (memoryLimitBytes !== null && memoryBytes !== null && memoryBytes > memoryLimitBytes); + const signal = value.signal === null || value.signal === undefined ? null : String(value.signal); + const exitCode = value.code === null || value.code === undefined ? null : value.code; + if (exitCode !== null && (!Number.isInteger(exitCode) || exitCode < 0)) invalid("code must be a non-negative integer"); + let code = SERVER_JOB_FAULT_CODES.SUCCEEDED; + let state = "SUCCEEDED"; + if (cancelRequested || value.status === "CANCELLED") { code = SERVER_JOB_FAULT_CODES.CANCELLED; state = "CANCELLED"; } + else if (timedOut) { code = SERVER_JOB_FAULT_CODES.TIMEOUT; state = "FAILED"; } + else if (memoryExceeded) { code = SERVER_JOB_FAULT_CODES.OOM; state = "FAILED"; } + else if (signal) { code = SERVER_JOB_FAULT_CODES.SIGNAL; state = "FAILED"; } + else if (exitCode !== null && exitCode !== 0) { code = SERVER_JOB_FAULT_CODES.EXIT_FAILED; state = "FAILED"; } + return Object.freeze({ schemaVersion: SERVER_JOB_FAULT_SCHEMA, state, code, exitCode, signal, timedOut, memoryExceeded, publish: state === "SUCCEEDED", revisionPreserved: state !== "SUCCEEDED" }); +} + +export function createServerJobFaultReceipt(value) { + if (!value || typeof value !== "object") invalid("receipt input is invalid"); + const baseRevision = revision(value.baseRevision, "baseRevision"); + const currentRevision = revision(value.currentRevision, "currentRevision"); + const classification = classifyServerJobFault(value); + if (classification.state !== "SUCCEEDED" && currentRevision !== baseRevision) { + throw new Error("SERVER_JOB_REVISION_CONFLICT: failed job cannot replace a newer project revision"); + } + return Object.freeze({ + schemaVersion: SERVER_JOB_FAULT_SCHEMA, + state: classification.state, + code: classification.code, + stage: "PROCESS", + exitCode: classification.exitCode, + signal: classification.signal, + timedOut: classification.timedOut, + memoryExceeded: classification.memoryExceeded, + baseRevision, + currentRevision, + committedRevision: currentRevision, + publish: classification.publish, + revisionPreserved: classification.revisionPreserved, + execution: "DISABLED", + }); +} diff --git a/tools/web/server-job-idempotency.mjs b/tools/web/server-job-idempotency.mjs new file mode 100644 index 00000000..e88d9037 --- /dev/null +++ b/tools/web/server-job-idempotency.mjs @@ -0,0 +1,57 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { commitServerJobResult, createServerJobResultIdentity, verifyServerJobResultReceipt } from "./server-job-result-binding.mjs"; + +export const SERVER_JOB_IDEMPOTENCY_SCHEMA = 1; +const inFlight = new Map(); + +function invalid(message) { throw new Error(`SERVER_JOB_IDEMPOTENCY_INVALID: ${message}`); } +function outputHash(bytes) { return crypto.createHash("sha256").update(bytes).digest("hex"); } +function keyFor(identity) { return `${identity.projectId}:${identity.requestId}`; } +function receiptPath(directory, identity) { return path.join(directory, `${identity.requestId}.receipt.json`); } + +async function readReceipt(file) { + try { return JSON.parse(await fs.readFile(file, "utf8")); } + catch (error) { if (error?.code === "ENOENT") return null; throw error; } +} + +async function writeReceipt(file, receipt) { + const temporary = `${file}.${crypto.randomUUID()}.tmp`; + await fs.writeFile(temporary, `${JSON.stringify(receipt, null, 2)}\n`, { flag: "wx", mode: 0o600 }); + await fs.rename(temporary, file); +} + +async function submit(identityValue, outputBytes, options) { + const identity = createServerJobResultIdentity(identityValue); + if (!(outputBytes instanceof Uint8Array) || outputBytes.byteLength < 1) invalid("output bytes are empty"); + if (typeof options?.receiptDirectory !== "string" || !path.isAbsolute(options.receiptDirectory)) invalid("receipt directory is invalid"); + if (typeof options?.outputDirectory !== "string" || !path.isAbsolute(options.outputDirectory)) invalid("output directory is invalid"); + await fs.mkdir(options.receiptDirectory, { recursive: true, mode: 0o700 }); + await fs.mkdir(options.outputDirectory, { recursive: true, mode: 0o700 }); + const file = receiptPath(options.receiptDirectory, identity); + const expectedOutputSha256 = outputHash(outputBytes); + const existing = await readReceipt(file); + if (existing) { + if (existing.schemaVersion !== SERVER_JOB_IDEMPOTENCY_SCHEMA || existing.outputSha256 !== expectedOutputSha256 || existing.requestId !== identity.requestId || existing.projectId !== identity.projectId || existing.sourceSha256 !== identity.sourceSha256 || existing.settingsSha256 !== identity.settingsSha256 || existing.buildSha256 !== identity.buildSha256 || existing.baseRevision !== identity.baseRevision) { + throw new Error("SERVER_JOB_IDEMPOTENCY_CONFLICT: request is already bound to a different result"); + } + await verifyServerJobResultReceipt(existing.result, identity, options.outputDirectory); + return Object.freeze({ ...existing.result, reused: true, idempotencyKey: keyFor(identity), execution: "DISABLED" }); + } + const result = await commitServerJobResult(identity, outputBytes, { outputDirectory: options.outputDirectory, expectedIdentity: identity }); + const stored = Object.freeze({ schemaVersion: SERVER_JOB_IDEMPOTENCY_SCHEMA, requestId: identity.requestId, projectId: identity.projectId, baseRevision: identity.baseRevision, sourceSha256: identity.sourceSha256, settingsSha256: identity.settingsSha256, buildSha256: identity.buildSha256, outputSha256: result.outputSha256, result }); + await writeReceipt(file, stored); + return Object.freeze({ ...result, reused: false, idempotencyKey: keyFor(identity), execution: "DISABLED" }); +} + +export async function submitIdempotentServerJobResult(identityValue, outputBytes, options) { + const identity = createServerJobResultIdentity(identityValue); + const key = `${options?.receiptDirectory ?? ""}:${keyFor(identity)}`; + const running = inFlight.get(key); + if (running) return Object.freeze({ ...(await running), reused: true }); + const current = Promise.resolve().then(() => submit(identity, outputBytes, options)); + inFlight.set(key, current); + try { return await current; } + finally { if (inFlight.get(key) === current) inFlight.delete(key); } +} diff --git a/tools/web/server-job-isolation.mjs b/tools/web/server-job-isolation.mjs new file mode 100644 index 00000000..64f97777 --- /dev/null +++ b/tools/web/server-job-isolation.mjs @@ -0,0 +1,53 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +export const SERVER_JOB_DIRECTORY_SCHEMA = 1; +const JOB_ID = /^[-A-Za-z0-9:_./]{1,256}$/; + +function validateJobId(jobId) { + if (typeof jobId !== "string" || !JOB_ID.test(jobId) || jobId.includes("..")) throw new Error("SERVER_JOB_DIRECTORY_INVALID: jobId is invalid"); +} + +function validateRoot(root) { + if (typeof root !== "string" || !path.isAbsolute(root)) throw new Error("SERVER_JOB_DIRECTORY_INVALID: root must be absolute"); + return path.resolve(root); +} + +export async function createServerJobDirectory(root, jobId) { + const resolvedRoot = validateRoot(root); + validateJobId(jobId); + await fs.mkdir(resolvedRoot, { recursive: true, mode: 0o700 }); + const randomPrefix = `.blender-job-${crypto.randomUUID()}-`; + const directory = await fs.mkdtemp(path.join(resolvedRoot, randomPrefix), { encoding: "utf8" }); + await fs.chmod(directory, 0o700); + return Object.freeze({ schemaVersion: SERVER_JOB_DIRECTORY_SCHEMA, jobId, root: resolvedRoot, path: directory, directoryName: path.basename(directory), state: "ALLOCATED", cleanupCount: 0 }); +} + +export async function cleanupServerJobDirectory(job) { + if (!job || job.schemaVersion !== SERVER_JOB_DIRECTORY_SCHEMA || typeof job.path !== "string" || typeof job.root !== "string") throw new Error("SERVER_JOB_DIRECTORY_INVALID: receipt is invalid"); + if (job.state === "CLEANED") return job; + const root = validateRoot(job.root); + const directory = path.resolve(job.path); + if (path.dirname(directory) !== root || !path.basename(directory).startsWith(".blender-job-")) throw new Error("SERVER_JOB_DIRECTORY_INVALID: directory escaped the job root"); + await fs.chmod(directory, 0o700); + try { await fs.chmod(path.join(directory, "source"), 0o700); } catch (error) { if (error?.code !== "ENOENT") throw error; } + await fs.rm(directory, { recursive: true, force: false }); + return Object.freeze({ ...job, state: "CLEANED", cleanupCount: 1 }); +} + +export async function prepareServerJobWorkspace(job, sourceBytes, sourceName = "source.blend") { + if (!job || job.schemaVersion !== SERVER_JOB_DIRECTORY_SCHEMA || job.state !== "ALLOCATED") throw new Error("SERVER_JOB_WORKSPACE_INVALID: job receipt is not allocated"); + if (!(sourceBytes instanceof Uint8Array) || sourceBytes.byteLength < 1) throw new Error("SERVER_JOB_WORKSPACE_INVALID: source bytes are empty"); + if (typeof sourceName !== "string" || !/^[A-Za-z0-9_.-]{1,128}$/.test(sourceName) || sourceName.includes("..")) throw new Error("SERVER_JOB_WORKSPACE_INVALID: source name is invalid"); + const sourceDirectory = path.join(job.path, "source"); + const outputDirectory = path.join(job.path, "output"); + await fs.mkdir(sourceDirectory, { mode: 0o700 }); + await fs.mkdir(outputDirectory, { mode: 0o700 }); + const sourcePath = path.join(sourceDirectory, sourceName); + await fs.writeFile(sourcePath, sourceBytes, { mode: 0o444, flag: "wx" }); + await fs.chmod(sourceDirectory, 0o555); + await fs.chmod(sourcePath, 0o444); + await fs.chmod(outputDirectory, 0o700); + return Object.freeze({ schemaVersion: SERVER_JOB_DIRECTORY_SCHEMA, sourceDirectory, sourcePath, outputDirectory, sourceMode: "0444", sourceDirectoryMode: "0555", outputDirectoryMode: "0700", sourceReadOnly: true, outputWritable: true }); +} diff --git a/tools/web/server-job-network-policy.mjs b/tools/web/server-job-network-policy.mjs new file mode 100644 index 00000000..178fe452 --- /dev/null +++ b/tools/web/server-job-network-policy.mjs @@ -0,0 +1,25 @@ +export const SERVER_JOB_NETWORK_POLICY_SCHEMA = 1; + +function normalizeOrigin(value) { + if (typeof value !== "string" || value.length > 2048) throw new Error("SERVER_NETWORK_POLICY_INVALID: origin is invalid"); + let url; + try { url = new URL(value); } catch { throw new Error("SERVER_NETWORK_POLICY_INVALID: origin is invalid"); } + if (url.username || url.password || url.pathname !== "/" || url.search || url.hash) throw new Error("SERVER_NETWORK_POLICY_INVALID: origin is not canonical"); + if (url.protocol !== "https:" && !(url.protocol === "http:" && ["127.0.0.1", "localhost", "::1"].includes(url.hostname))) throw new Error("SERVER_NETWORK_POLICY_DENIED: origin must be HTTPS or loopback"); + return url.origin; +} + +export function parseServerJobNetworkPolicy(value) { + if (!value || typeof value !== "object" || value.schemaVersion !== SERVER_JOB_NETWORK_POLICY_SCHEMA || !Array.isArray(value.allowedOrigins)) throw new Error("SERVER_NETWORK_POLICY_INVALID: schema is unsupported"); + const origins = [...new Set(value.allowedOrigins.map(normalizeOrigin))].sort(); + if (origins.length > 64) throw new Error("SERVER_NETWORK_POLICY_INVALID: origin count exceeds budget"); + return Object.freeze({ schemaVersion: SERVER_JOB_NETWORK_POLICY_SCHEMA, defaultNetwork: "DENY", allowedOrigins: origins }); +} + +export function resolveServerJobNetwork(policyValue, requestedOrigin) { + const policy = parseServerJobNetworkPolicy(policyValue); + if (requestedOrigin === undefined || requestedOrigin === null) return Object.freeze({ status: "DENIED", code: "SERVER_NETWORK_DENIED", origin: null, network: "DISABLED" }); + const origin = normalizeOrigin(requestedOrigin); + if (!policy.allowedOrigins.includes(origin)) return Object.freeze({ status: "DENIED", code: "SERVER_NETWORK_DENIED", origin, network: "DISABLED" }); + return Object.freeze({ status: "ALLOWED", code: "SERVER_NETWORK_ALLOWED_ORIGIN", origin, network: "DECLARED_ORIGIN" }); +} diff --git a/tools/web/server-job-output.mjs b/tools/web/server-job-output.mjs new file mode 100644 index 00000000..b86edd08 --- /dev/null +++ b/tools/web/server-job-output.mjs @@ -0,0 +1,119 @@ +export const SERVER_JOB_OUTPUT_SCHEMA = 1; +export const SERVER_JOB_OUTPUT_LIMITS = Object.freeze({ + stdoutBytes: 64 * 1024, + stderrBytes: 64 * 1024, + totalBytes: 128 * 1024, +}); + +const REDACTION = ""; +const PATH_REDACTION = ""; +const TRUNCATION = "\n"; +const CREDENTIAL_KEY = "(?:token|api[_-]?key|secret|password|passwd|authorization|credential|private[_-]?key|access[_-]?key|client[_-]?secret)"; +const CREDENTIAL_ASSIGNMENT = /(\b(?:token|api[_-]?key|secret|password|passwd|authorization|credential|private[_-]?key|access[_-]?key|client[_-]?secret)\b\s*[:=]\s*)(["']?)([^\s,;"']+)\2/giu; +const BEARER = /\bBearer\s+[A-Za-z0-9._~+/=-]+/giu; +const BASIC = /\bBasic\s+[A-Za-z0-9+/=]+/giu; +const SECRET_HEADER = /(\b(?:authorization|proxy-authorization)\s*:\s*)([^\r\n]+)/giu; +const UNIX_PATH = /\/(?:[^\s/\\:*?"<>|]+\/)*[^\s/\\:*?"<>|]+/gu; +const WINDOWS_PATH = /\b[A-Za-z]:\\(?:[^\s\\/:*?"<>|]+\\)*[^\s\\/:*?"<>|]*/gu; +const FILE_URL = /\bfile:\/\/[^\s"']+/giu; + +function invalid(message) { + throw new Error(`SERVER_JOB_OUTPUT_INVALID: ${message}`); +} + +function assertText(value, field) { + if (typeof value !== "string") invalid(`${field} must be a string`); + return value; +} + +function assertLimit(value, field) { + if (!Number.isSafeInteger(value) || value < 1 || value > SERVER_JOB_OUTPUT_LIMITS[field]) { + invalid(`${field} is outside the fixed output budget`); + } + return value; +} + +function redactCredentials(value) { + let text = value; + let count = 0; + const replace = (pattern, replacement) => { + text = text.replace(pattern, (...args) => { + count += 1; + return typeof replacement === "function" ? replacement(...args) : replacement; + }); + }; + replace(CREDENTIAL_ASSIGNMENT, (_match, prefix, quote) => `${prefix}${quote}${REDACTION}${quote}`); + replace(BEARER, `Bearer ${REDACTION}`); + replace(BASIC, `Basic ${REDACTION}`); + replace(SECRET_HEADER, (_match, prefix) => `${prefix}${REDACTION}`); + return { text, count }; +} + +function redactPaths(value) { + let count = 0; + let text = value.replace(FILE_URL, () => { count += 1; return PATH_REDACTION; }); + text = text.replace(WINDOWS_PATH, () => { count += 1; return PATH_REDACTION; }); + text = text.replace(UNIX_PATH, (match, offset, source) => { + // Keep URL paths and protocol markers readable; only redact filesystem-looking paths. + const before = source.slice(Math.max(0, offset - 8), offset); + if (/https?:$|https?:\/\/$/iu.test(before) || match === "/") return match; + count += 1; + return PATH_REDACTION; + }); + return { text, count }; +} + +function truncateUtf8(value, maxBytes) { + const source = Buffer.from(value, "utf8"); + if (source.byteLength <= maxBytes) return { text: value, truncated: false }; + const marker = Buffer.from(TRUNCATION, "utf8"); + const available = Math.max(0, maxBytes - marker.byteLength); + let end = Math.min(available, source.byteLength); + while (end > 0 && (source[end] & 0xc0) === 0x80) end -= 1; + return { text: `${source.subarray(0, end).toString("utf8")}${TRUNCATION}`, truncated: true }; +} + +export function sanitizeServerJobOutput(value, maxBytes) { + assertText(value, "output"); + assertLimit(maxBytes, "stdoutBytes"); + const originalBytes = Buffer.byteLength(value, "utf8"); + const credentials = redactCredentials(value); + const paths = redactPaths(credentials.text); + const truncated = truncateUtf8(paths.text, maxBytes); + return Object.freeze({ + text: truncated.text, + originalBytes, + emittedBytes: Buffer.byteLength(truncated.text, "utf8"), + redactionCount: credentials.count + paths.count, + truncated: truncated.truncated, + }); +} + +export function createServerJobOutputReceipt(value, limits = SERVER_JOB_OUTPUT_LIMITS) { + if (!value || typeof value !== "object") invalid("receipt input is invalid"); + if (!limits || typeof limits !== "object") invalid("limits are invalid"); + const stdoutBytes = assertLimit(limits.stdoutBytes, "stdoutBytes"); + const stderrBytes = assertLimit(limits.stderrBytes, "stderrBytes"); + const totalBytes = assertLimit(limits.totalBytes, "totalBytes"); + if (stdoutBytes + stderrBytes > totalBytes) invalid("stream budgets exceed total budget"); + const stdout = sanitizeServerJobOutput(value.stdout ?? "", "stdoutBytes" in limits ? stdoutBytes : stdoutBytes); + const stderr = sanitizeServerJobOutput(value.stderr ?? "", "stderrBytes" in limits ? stderrBytes : stderrBytes); + const totalOriginalBytes = stdout.originalBytes + stderr.originalBytes; + const totalEmittedBytes = stdout.emittedBytes + stderr.emittedBytes; + const totalTruncated = stdout.truncated || stderr.truncated || totalOriginalBytes > totalBytes; + return Object.freeze({ + schemaVersion: SERVER_JOB_OUTPUT_SCHEMA, + stdout, + stderr, + limits: Object.freeze({ stdoutBytes, stderrBytes, totalBytes }), + totalOriginalBytes, + totalEmittedBytes, + totalRedactions: stdout.redactionCount + stderr.redactionCount, + totalTruncated, + execution: "DISABLED", + }); +} + +export function redactServerJobOutput(value, maxBytes) { + return sanitizeServerJobOutput(value, maxBytes).text; +} diff --git a/tools/web/server-job-process.mjs b/tools/web/server-job-process.mjs new file mode 100644 index 00000000..80aa3081 --- /dev/null +++ b/tools/web/server-job-process.mjs @@ -0,0 +1,117 @@ +import { spawn } from "node:child_process"; + +export const SERVER_JOB_PROCESS_SCHEMA = 1; + +function invalid(message) { + throw new Error(`SERVER_JOB_PROCESS_INVALID: ${message}`); +} + +function signalTree(pid, signal) { + if (!Number.isInteger(pid) || pid <= 0) return false; + if (process.platform === "win32") return false; + try { + process.kill(-pid, signal); + return true; + } catch (error) { + if (error?.code === "ESRCH") return false; + throw error; + } +} + +function processTreeAlive(pid) { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(process.platform === "win32" ? pid : -pid, 0); + return true; + } catch (error) { + if (error?.code === "ESRCH") return false; + throw error; + } +} + +async function waitForTreeExit(pid, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (processTreeAlive(pid) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + return !processTreeAlive(pid); +} + +function waitForClose(child) { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); + return new Promise((resolve) => child.once("close", (code, signal) => resolve({ code, signal }))); +} + +export function startServerJobProcess(command, args = [], options = {}) { + if (typeof command !== "string" || command.length === 0) invalid("command is required"); + if (!Array.isArray(args) || args.some((arg) => typeof arg !== "string")) invalid("args must be strings"); + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env, + detached: process.platform !== "win32", + stdio: options.stdio ?? ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + // Drain output even when the caller is testing cancellation before M13-04F receipt handling. + child.stdout?.resume(); + child.stderr?.resume(); + const receipt = { + schemaVersion: SERVER_JOB_PROCESS_SCHEMA, + pid: child.pid, + detached: process.platform !== "win32", + state: "RUNNING", + cancelRequested: false, + treeSignal: null, + result: null, + cleanupCount: 0, + orphanCount: 0, + }; + const completion = waitForClose(child).then((result) => { + receipt.state = receipt.cancelRequested ? "CANCELLED" : "EXITED"; + receipt.result = result; + return Object.freeze({ ...receipt }); + }); + return Object.freeze({ child, receipt, completion }); +} + +export async function cancelServerJobProcess(handle, cleanup, options = {}) { + if (!handle || !handle.child || !handle.receipt || handle.receipt.schemaVersion !== SERVER_JOB_PROCESS_SCHEMA) invalid("process handle is invalid"); + if (typeof cleanup !== "function") invalid("cleanup callback is required"); + const graceMs = Number.isSafeInteger(options.graceMs) && options.graceMs >= 0 ? options.graceMs : 500; + if (handle.receipt.state === "CANCELLED" || handle.receipt.state === "EXITED") { + if (handle.receipt.cleanupCount === 0) { + await cleanup(); + handle.receipt.cleanupCount = 1; + } + return Object.freeze({ ...handle.receipt }); + } + handle.receipt.cancelRequested = true; + if (process.platform === "win32") { + const treeSignal = spawn("taskkill", ["/PID", String(handle.receipt.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }); + handle.receipt.treeSignal = "TASKKILL_TREE"; + await waitForClose(treeSignal); + } else { + handle.receipt.treeSignal = signalTree(handle.receipt.pid, "SIGTERM") ? "SIGTERM_GROUP" : "ALREADY_EXITED"; + } + const settled = await Promise.race([ + handle.completion, + new Promise((resolve) => setTimeout(() => resolve(null), graceMs)), + ]); + if (!settled && process.platform !== "win32") { + signalTree(handle.receipt.pid, "SIGKILL"); + handle.receipt.treeSignal = "SIGKILL_GROUP"; + } + const result = settled ?? await handle.completion; + let treeExited = process.platform === "win32" || await waitForTreeExit(handle.receipt.pid, graceMs); + if (!treeExited && process.platform !== "win32") { + signalTree(handle.receipt.pid, "SIGKILL"); + handle.receipt.treeSignal = "SIGKILL_GROUP"; + treeExited = await waitForTreeExit(handle.receipt.pid, graceMs); + } + if (handle.receipt.cleanupCount === 0) { + await cleanup(); + handle.receipt.cleanupCount = 1; + } + handle.receipt.orphanCount = treeExited ? 0 : 1; + return Object.freeze({ ...result, treeSignal: handle.receipt.treeSignal, cleanupCount: handle.receipt.cleanupCount, orphanCount: handle.receipt.orphanCount }); +} diff --git a/tools/web/server-job-resource-budget.mjs b/tools/web/server-job-resource-budget.mjs new file mode 100644 index 00000000..f9ffd9df --- /dev/null +++ b/tools/web/server-job-resource-budget.mjs @@ -0,0 +1,32 @@ +export const SERVER_JOB_RESOURCE_BUDGET_SCHEMA = 1; +export const SERVER_JOB_RESOURCE_LIMITS = Object.freeze({ + cpuMs: 60_000, + memoryBytes: 512 * 1024 * 1024, + processCount: 1, + fileCount: 1_024, + wallMs: 300_000, + outputBytes: 512 * 1024 * 1024, +}); + +const fields = Object.keys(SERVER_JOB_RESOURCE_LIMITS); + +function integer(value, field, minimum = 0) { + if (!Number.isSafeInteger(value) || value < minimum || value > SERVER_JOB_RESOURCE_LIMITS[field]) throw new Error(`SERVER_JOB_BUDGET_EXCEEDED: ${field} is outside the enforced budget`); + return value; +} + +export function parseServerJobResourceBudget(value) { + if (!value || value.schemaVersion !== SERVER_JOB_RESOURCE_BUDGET_SCHEMA || typeof value !== "object") throw new Error("SERVER_JOB_BUDGET_INVALID: schema is unsupported"); + if (Object.keys(value).some((key) => key !== "schemaVersion" && !fields.includes(key))) throw new Error("SERVER_JOB_BUDGET_INVALID: undeclared budget field"); + return Object.freeze({ schemaVersion: SERVER_JOB_RESOURCE_BUDGET_SCHEMA, ...Object.fromEntries(fields.map((field) => [field, integer(value[field], field)])) }); +} + +export function createServerJobResourceReceipt(budgetValue, usageValue, status = "SUCCEEDED") { + const budget = parseServerJobResourceBudget(budgetValue); + if (!usageValue || typeof usageValue !== "object") throw new Error("SERVER_JOB_BUDGET_INVALID: usage is invalid"); + const usage = Object.freeze(Object.fromEntries(fields.map((field) => [field, integer(usageValue[field], field)]))); + const exceeded = fields.filter((field) => usage[field] > budget[field]); + if (exceeded.length > 0) throw new Error(`SERVER_JOB_BUDGET_EXCEEDED: ${exceeded.join(",")}`); + if (!["SUCCEEDED", "FAILED", "CANCELLED"].includes(status)) throw new Error("SERVER_JOB_BUDGET_INVALID: status is unsupported"); + return Object.freeze({ schemaVersion: SERVER_JOB_RESOURCE_BUDGET_SCHEMA, status, budget, usage, enforced: true, exceeded: [] }); +} diff --git a/tools/web/server-job-result-binding.mjs b/tools/web/server-job-result-binding.mjs new file mode 100644 index 00000000..bdd7edeb --- /dev/null +++ b/tools/web/server-job-result-binding.mjs @@ -0,0 +1,77 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +export const SERVER_JOB_RESULT_SCHEMA = 1; +const DIGEST = /^[a-f0-9]{64}$/; + +function invalid(message) { throw new Error(`SERVER_JOB_RESULT_INVALID: ${message}`); } +function digest(value, field) { + if (typeof value !== "string" || !DIGEST.test(value)) invalid(`${field} hash is invalid`); + return value; +} +function revision(value, field) { + if (!Number.isSafeInteger(value) || value < 0) invalid(`${field} is invalid`); + return value; +} +function hashBytes(bytes) { return crypto.createHash("sha256").update(bytes).digest("hex"); } +async function hashFile(file) { return hashBytes(await fs.readFile(file)); } + +export function createServerJobResultIdentity(value) { + if (!value || typeof value !== "object") invalid("identity is invalid"); + const requestId = typeof value.requestId === "string" && /^[A-Za-z0-9:_-]{1,128}$/.test(value.requestId) ? value.requestId : invalid("requestId is invalid"); + const projectId = typeof value.projectId === "string" && /^[A-Za-z0-9:_-]{1,128}$/.test(value.projectId) ? value.projectId : invalid("projectId is invalid"); + return Object.freeze({ + schemaVersion: SERVER_JOB_RESULT_SCHEMA, + requestId, + projectId, + baseRevision: revision(value.baseRevision, "baseRevision"), + sourceSha256: digest(value.sourceSha256, "source"), + settingsSha256: digest(value.settingsSha256, "settings"), + buildSha256: digest(value.buildSha256, "build"), + }); +} + +export async function commitServerJobResult(identityValue, outputBytes, options = {}) { + const identity = createServerJobResultIdentity(identityValue); + if (options.expectedIdentity !== undefined) { + const expected = createServerJobResultIdentity(options.expectedIdentity); + for (const field of ["requestId", "projectId", "baseRevision", "sourceSha256", "settingsSha256", "buildSha256"]) { + if (identity[field] !== expected[field]) throw new Error(`SERVER_JOB_RESULT_IDENTITY_MISMATCH: ${field} does not match the request`); + } + } + if (!(outputBytes instanceof Uint8Array) || outputBytes.byteLength < 1) invalid("output bytes are empty"); + const outputSha256 = hashBytes(outputBytes); + const outputByteLength = outputBytes.byteLength; + const outputDirectory = options.outputDirectory; + if (typeof outputDirectory !== "string" || !path.isAbsolute(outputDirectory)) invalid("output directory is invalid"); + await fs.mkdir(outputDirectory, { recursive: true, mode: 0o700 }); + const fileName = `${identity.requestId}.result`; + const target = path.join(outputDirectory, fileName); + const temporary = path.join(outputDirectory, `.${fileName}.${crypto.randomUUID()}.tmp`); + try { + await fs.writeFile(temporary, outputBytes, { flag: "wx", mode: 0o600 }); + const staged = await fs.readFile(temporary); + if (staged.byteLength !== outputByteLength || hashBytes(staged) !== outputSha256) invalid("staged output readback mismatch"); + if (options.faultAt === "AFTER_STAGE") throw new Error("SERVER_JOB_RESULT_STORAGE: injected failure after stage"); + if (options.faultAt === "QUOTA") throw new Error("SERVER_JOB_RESULT_STORAGE_QUOTA: output quota exceeded"); + await fs.rename(temporary, target); + const persisted = await fs.readFile(target); + if (persisted.byteLength !== outputByteLength || hashBytes(persisted) !== outputSha256) invalid("committed output readback mismatch"); + return Object.freeze({ schemaVersion: SERVER_JOB_RESULT_SCHEMA, status: "COMMITTED", requestId: identity.requestId, projectId: identity.projectId, baseRevision: identity.baseRevision, sourceSha256: identity.sourceSha256, settingsSha256: identity.settingsSha256, buildSha256: identity.buildSha256, outputSha256, outputByteLength, outputPath: target, publish: true, execution: "DISABLED" }); + } catch (error) { + await fs.rm(temporary, { force: true }); + if (error instanceof Error && (error.message.startsWith("SERVER_JOB_RESULT_") || error.message.startsWith("SERVER_JOB_RESULT_STORAGE"))) throw error; + throw new Error(`SERVER_JOB_RESULT_STORAGE: ${error instanceof Error ? error.message : String(error)}`); + } +} + +export async function verifyServerJobResultReceipt(receipt, expected, outputDirectory) { + if (!receipt || receipt.schemaVersion !== SERVER_JOB_RESULT_SCHEMA || receipt.status !== "COMMITTED" || receipt.publish !== true) invalid("receipt is not committed"); + const identity = createServerJobResultIdentity(expected); + for (const field of ["requestId", "projectId", "baseRevision", "sourceSha256", "settingsSha256", "buildSha256"]) if (receipt[field] !== identity[field]) invalid(`${field} identity mismatch`); + const target = path.join(outputDirectory, `${identity.requestId}.result`); + const bytes = await fs.readFile(target); + if (bytes.byteLength !== receipt.outputByteLength || hashBytes(bytes) !== receipt.outputSha256) throw new Error("SERVER_JOB_RESULT_HASH_MISMATCH: committed output changed"); + return Object.freeze({ ...receipt, verified: true }); +} diff --git a/tools/web/server-job-startup.py b/tools/web/server-job-startup.py new file mode 100644 index 00000000..87e0a89b --- /dev/null +++ b/tools/web/server-job-startup.py @@ -0,0 +1,11 @@ +import bpy +import json +import sys + +print(json.dumps({ + "schemaVersion": 1, + "runtime": "BLENDER_BACKGROUND_FACTORY_STARTUP", + "background": bool(bpy.app.background), + "version": bpy.app.version_string, + "argv": sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [], +}, sort_keys=True)) diff --git a/web/app/src/app/App.tsx b/web/app/src/app/App.tsx index de0f671c..9c78336d 100644 --- a/web/app/src/app/App.tsx +++ b/web/app/src/app/App.tsx @@ -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(null); const [snapshot, setSnapshot] = useState(null); const [selectedObjectIds, setSelectedObjectIds] = useState>(() => new Set()); const [meshSelection, setMeshSelection] = useState({ meshId: null, mode: "FACE", indices: new Set() }); @@ -1070,6 +1084,7 @@ export function App() { const [recentProjectIssues, setRecentProjectIssues] = useState([]); const [storageBudget, setStorageBudget] = useState(null); const [diagnostics, setDiagnostics] = useState([]); + const imeCompositionRef = useRef(imeComposition); const webClientRef = useRef(null); const storageClientRef = useRef(null); const autosaveRef = useRef(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: 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 => { 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([ ...(["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 ( -
)}
{workerFaultTestMode ? : null}
- { const file = event.target.files?.[0]; if (file) void openBlendFile(file); event.target.value = ""; }} /> + 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 = ""; }} /> {workerFault ?
{workerFault.source === "engine" ? "Engine Worker" : "Storage Worker"} stopped; current project list and scene are retained.{workerRecoveryStatus === "FAILED" ? Recovery failed; retry is safe. : null}
: null} {recentProjectIssues.length > 0 ?
最近项目中有 {recentProjectIssues.length} 个条目无法验证;当前场景不会被删除。
{recentProjectIssues.map((issue) =>
{issue.project.displayName}: {recentProjectIssueMessage(issue.code)}
)}
: null} diff --git a/web/app/src/capabilities/io-format-runtime-receipts-freshness-expected.json b/web/app/src/capabilities/io-format-runtime-receipts-freshness-expected.json new file mode 100644 index 00000000..c9f8cf9e --- /dev/null +++ b/web/app/src/capabilities/io-format-runtime-receipts-freshness-expected.json @@ -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" + } + ] +} diff --git a/web/app/src/capabilities/io-format-runtime-receipts-freshness.json b/web/app/src/capabilities/io-format-runtime-receipts-freshness.json new file mode 100644 index 00000000..ef996cbd --- /dev/null +++ b/web/app/src/capabilities/io-format-runtime-receipts-freshness.json @@ -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" + } + ] + } +} diff --git a/web/app/src/capabilities/io-format-runtime-receipts.json b/web/app/src/capabilities/io-format-runtime-receipts.json new file mode 100644 index 00000000..77eb5fcc --- /dev/null +++ b/web/app/src/capabilities/io-format-runtime-receipts.json @@ -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" + ] + } + ] +} diff --git a/web/app/src/capabilities/io-format-ui-registry.json b/web/app/src/capabilities/io-format-ui-registry.json new file mode 100644 index 00000000..dd5873ae --- /dev/null +++ b/web/app/src/capabilities/io-format-ui-registry.json @@ -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" + ] + } + ] +} diff --git a/web/app/src/library-link-chromium.ts b/web/app/src/library-link-chromium.ts new file mode 100644 index 00000000..c3ff8f6a --- /dev/null +++ b/web/app/src/library-link-chromium.ts @@ -0,0 +1 @@ +export { gateLinkedDataMutation } from "../../protocol/library-linked-mutation"; diff --git a/web/app/src/library-override-chromium.ts b/web/app/src/library-override-chromium.ts new file mode 100644 index 00000000..a112a980 --- /dev/null +++ b/web/app/src/library-override-chromium.ts @@ -0,0 +1 @@ +export { applyOverrideWriter } from "../../protocol/library-override-writer"; diff --git a/web/app/src/storage/StorageClient.ts b/web/app/src/storage/StorageClient.ts index 66eefc0c..7417cb68 100644 --- a/web/app/src/storage/StorageClient.ts +++ b/web/app/src/storage/StorageClient.ts @@ -135,8 +135,8 @@ export class StorageClient { return this.request({ type: "readSnapshot", projectId, revision }) as Promise; } - putAsset(projectId: string, data: ArrayBuffer, mimeType: string, sourcePath?: string): Promise { - return this.request({ type: "putAsset", projectId, data, mimeType, sourcePath }, [data]) as Promise; + putAsset(projectId: string, data: ArrayBuffer, mimeType: string, sourcePath?: string, faultAt?: "quota"): Promise { + return this.request({ type: "putAsset", projectId, data, mimeType, sourcePath, faultAt }, [data]) as Promise; } readAsset(projectId: string, sha256: string): Promise { diff --git a/web/app/src/testing/glb-recovery.ts b/web/app/src/testing/glb-recovery.ts new file mode 100644 index 00000000..2a27646f --- /dev/null +++ b/web/app/src/testing/glb-recovery.ts @@ -0,0 +1,7 @@ +export { + GLB_RECOVERY_SCHEMA_VERSION, + beginGLBRecoveryOperation, + blockGLBRecoveryForQuota, + parseGLBRecoveryReceipt, + recoverGLBRecoveryOperation, +} from "../../../protocol/glb-recovery"; diff --git a/web/app/src/testing/io-format-recovery.ts b/web/app/src/testing/io-format-recovery.ts new file mode 100644 index 00000000..1b98510b --- /dev/null +++ b/web/app/src/testing/io-format-recovery.ts @@ -0,0 +1,9 @@ +export { + IO_FORMAT_RECOVERY_SCHEMA_VERSION, + beginIOFormatRecoveryOperation, + blockIOFormatRecoveryOperation, + cancelIOFormatRecoveryOperation, + commitIOFormatRecoveryOperation, + parseIOFormatRecoveryReceipt, + recoverIOFormatRecoveryOperation, +} from "../../../protocol/io-format-recovery"; diff --git a/web/app/src/testing/script-sandbox-dispose.ts b/web/app/src/testing/script-sandbox-dispose.ts new file mode 100644 index 00000000..6aad57aa --- /dev/null +++ b/web/app/src/testing/script-sandbox-dispose.ts @@ -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, + }; +} diff --git a/web/app/src/testing/script-sandbox-recovery.ts b/web/app/src/testing/script-sandbox-recovery.ts new file mode 100644 index 00000000..09216994 --- /dev/null +++ b/web/app/src/testing/script-sandbox-recovery.ts @@ -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 { + 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" }; +} diff --git a/web/app/src/three-adapter/offscreen-viewport.ts b/web/app/src/three-adapter/offscreen-viewport.ts index 1150fa89..5b10d969 100644 --- a/web/app/src/three-adapter/offscreen-viewport.ts +++ b/web/app/src/three-adapter/offscreen-viewport.ts @@ -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; }; diff --git a/web/app/src/three-adapter/viewport.ts b/web/app/src/three-adapter/viewport.ts index 90f5cc29..7258abbd 100644 --- a/web/app/src/three-adapter/viewport.ts +++ b/web/app/src/three-adapter/viewport.ts @@ -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 { const groups = new Map(); @@ -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(); diff --git a/web/app/src/workers/glb-desktop-import-test.worker.ts b/web/app/src/workers/glb-desktop-import-test.worker.ts new file mode 100644 index 00000000..34705be5 --- /dev/null +++ b/web/app/src/workers/glb-desktop-import-test.worker.ts @@ -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 { + 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 {}; diff --git a/web/app/src/workers/glb-loss-report-test.worker.ts b/web/app/src/workers/glb-loss-report-test.worker.ts new file mode 100644 index 00000000..8aae6a35 --- /dev/null +++ b/web/app/src/workers/glb-loss-report-test.worker.ts @@ -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) => 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 {}; diff --git a/web/app/src/workers/glb-negative-cases-test.worker.ts b/web/app/src/workers/glb-negative-cases-test.worker.ts new file mode 100644 index 00000000..fc3655a8 --- /dev/null +++ b/web/app/src/workers/glb-negative-cases-test.worker.ts @@ -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 {}; diff --git a/web/app/src/workers/glb-recovery-test.worker.ts b/web/app/src/workers/glb-recovery-test.worker.ts new file mode 100644 index 00000000..f7267371 --- /dev/null +++ b/web/app/src/workers/glb-recovery-test.worker.ts @@ -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(); +const running = new Map(); +const scope = self as unknown as { + onmessage: ((event: MessageEvent) => void) | null; + postMessage(message: unknown, transfer?: Transferable[]): void; +}; + +async function sha256Hex(value: ArrayBuffer | string): Promise { + 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 { + return new Promise((resolve) => setTimeout(resolve, 2)); +} + +async function checkCancelled(request: RunCommand, receipt: GLBRecoveryReceipt): Promise { + 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 { + 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) => { + if (event.data.type === "cancel") { + cancelled.add(event.data.targetRequestId); + return; + } + void run(event.data); +}; diff --git a/web/app/src/workers/io-format-recovery-test.worker.ts b/web/app/src/workers/io-format-recovery-test.worker.ts new file mode 100644 index 00000000..b803987a --- /dev/null +++ b/web/app/src/workers/io-format-recovery-test.worker.ts @@ -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(); +const running = new Map(); +const scope = self as unknown as { onmessage: ((event: MessageEvent) => void) | null; postMessage(message: unknown, transfer?: Transferable[]): void }; + +async function sha256Hex(bytes: ArrayBuffer): Promise { + 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((resolve) => setTimeout(resolve, 2)); + +async function checkCancel(request: RunCommand, receipt: IOFormatRecoveryReceipt): Promise { + 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 { + 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 {}; diff --git a/web/app/src/workers/obj-roundtrip-test.worker.ts b/web/app/src/workers/obj-roundtrip-test.worker.ts new file mode 100644 index 00000000..19b74eb4 --- /dev/null +++ b/web/app/src/workers/obj-roundtrip-test.worker.ts @@ -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) => 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 {}; diff --git a/web/app/src/workers/ply-roundtrip-test.worker.ts b/web/app/src/workers/ply-roundtrip-test.worker.ts new file mode 100644 index 00000000..7b7e16b4 --- /dev/null +++ b/web/app/src/workers/ply-roundtrip-test.worker.ts @@ -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) => 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 {}; diff --git a/web/app/src/workers/script-host-call-test.worker.ts b/web/app/src/workers/script-host-call-test.worker.ts new file mode 100644 index 00000000..9d56e425 --- /dev/null +++ b/web/app/src/workers/script-host-call-test.worker.ts @@ -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) => ({ 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 = {}; + 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 }); +}; diff --git a/web/app/src/workers/script-permission-policy-test.worker.ts b/web/app/src/workers/script-permission-policy-test.worker.ts new file mode 100644 index 00000000..10daea7d --- /dev/null +++ b/web/app/src/workers/script-permission-policy-test.worker.ts @@ -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, + }); +}; diff --git a/web/app/src/workers/script-sandbox-budget-test.worker.ts b/web/app/src/workers/script-sandbox-budget-test.worker.ts new file mode 100644 index 00000000..14374fb7 --- /dev/null +++ b/web/app/src/workers/script-sandbox-budget-test.worker.ts @@ -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 = {}; + 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" }); +}; diff --git a/web/app/src/workers/script-sandbox-cancellation-test.worker.ts b/web/app/src/workers/script-sandbox-cancellation-test.worker.ts new file mode 100644 index 00000000..48ab2f46 --- /dev/null +++ b/web/app/src/workers/script-sandbox-cancellation-test.worker.ts @@ -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) => { + 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); + } +}; diff --git a/web/app/src/workers/script-sandbox-dispose-test.worker.ts b/web/app/src/workers/script-sandbox-dispose-test.worker.ts new file mode 100644 index 00000000..fcc1aba9 --- /dev/null +++ b/web/app/src/workers/script-sandbox-dispose-test.worker.ts @@ -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() }); + } +}; diff --git a/web/app/src/workers/script-sandbox-isolation-test.worker.ts b/web/app/src/workers/script-sandbox-isolation-test.worker.ts new file mode 100644 index 00000000..a33c2fa3 --- /dev/null +++ b/web/app/src/workers/script-sandbox-isolation-test.worker.ts @@ -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) => { + 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); + } +}; diff --git a/web/app/src/workers/script-sandbox-recovery-test.worker.ts b/web/app/src/workers/script-sandbox-recovery-test.worker.ts new file mode 100644 index 00000000..807f023e --- /dev/null +++ b/web/app/src/workers/script-sandbox-recovery-test.worker.ts @@ -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 }); +}; diff --git a/web/app/src/workers/script-sandbox-scope-test.worker.ts b/web/app/src/workers/script-sandbox-scope-test.worker.ts new file mode 100644 index 00000000..2dc62e41 --- /dev/null +++ b/web/app/src/workers/script-sandbox-scope-test.worker.ts @@ -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 = {}; + 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" }); +}; diff --git a/web/app/src/workers/script-signature-negative-test.worker.ts b/web/app/src/workers/script-signature-negative-test.worker.ts new file mode 100644 index 00000000..e8474f93 --- /dev/null +++ b/web/app/src/workers/script-signature-negative-test.worker.ts @@ -0,0 +1,17 @@ +import { verifyScriptManifestSignature } from "../../../protocol/scripting-platform"; + +const publicKey = "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8"; +const signature = "fc396c6c68e6f6eb38a18c147becfaec1621a167f6db0a0d76874209accf3cb80dfa1fac1528ebc1bc6b090801a3ad397cae18e6ddb41740766678711c0a8804"; +const script = (id = "clean", overrides: Record = {}) => ({ 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 = {}) => ({ 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 = {}) => ({ 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 }); +}; diff --git a/web/app/src/workers/script-signature-test.worker.ts b/web/app/src/workers/script-signature-test.worker.ts new file mode 100644 index 00000000..967e8eaf --- /dev/null +++ b/web/app/src/workers/script-signature-test.worker.ts @@ -0,0 +1,15 @@ +import { verifyScriptManifestSignature } from "../../../protocol/scripting-platform"; + +const publicKey = "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8"; +const signature = "fc396c6c68e6f6eb38a18c147becfaec1621a167f6db0a0d76874209accf3cb80dfa1fac1528ebc1bc6b090801a3ad397cae18e6ddb41740766678711c0a8804"; +const script = (overrides: Record = {}) => ({ 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 = {}) => ({ 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 }); +}; diff --git a/web/app/src/workers/script-trust-policy-test.worker.ts b/web/app/src/workers/script-trust-policy-test.worker.ts new file mode 100644 index 00000000..c237ab0c --- /dev/null +++ b/web/app/src/workers/script-trust-policy-test.worker.ts @@ -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 = {}) => ({ 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 = {}) => ({ 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 = {}) => ({ 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 }); +}; diff --git a/web/app/src/workers/scripting-manifest-budget-test.worker.ts b/web/app/src/workers/scripting-manifest-budget-test.worker.ts new file mode 100644 index 00000000..b9438c8f --- /dev/null +++ b/web/app/src/workers/scripting-manifest-budget-test.worker.ts @@ -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 = {}) => ({ + 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"] })])), + }); +}; diff --git a/web/app/src/workers/scripting-manifest-canonical-test.worker.ts b/web/app/src/workers/scripting-manifest-canonical-test.worker.ts new file mode 100644 index 00000000..0302cc46 --- /dev/null +++ b/web/app/src/workers/scripting-manifest-canonical-test.worker.ts @@ -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 = {}) => ({ + 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]) }); +}; diff --git a/web/app/src/workers/scripting-platform-test.worker.ts b/web/app/src/workers/scripting-platform-test.worker.ts index 827bf68a..c19db05a 100644 --- a/web/app/src/workers/scripting-platform-test.worker.ts +++ b/web/app/src/workers/scripting-platform-test.worker.ts @@ -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 () => { diff --git a/web/app/src/workers/stl-edge-test.worker.ts b/web/app/src/workers/stl-edge-test.worker.ts new file mode 100644 index 00000000..ef00c16c --- /dev/null +++ b/web/app/src/workers/stl-edge-test.worker.ts @@ -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) => 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 {}; diff --git a/web/app/src/workers/stl-roundtrip-test.worker.ts b/web/app/src/workers/stl-roundtrip-test.worker.ts new file mode 100644 index 00000000..4c9eb326 --- /dev/null +++ b/web/app/src/workers/stl-roundtrip-test.worker.ts @@ -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) => 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 {}; diff --git a/web/app/src/workers/storage.worker.ts b/web/app/src/workers/storage.worker.ts index d29c0708..8b908c59 100644 --- a/web/app/src/workers/storage.worker.ts +++ b/web/app/src/workers/storage.worker.ts @@ -751,7 +751,8 @@ async function readAssetRow(projectId: string, sha256: string): Promise { +async function putAsset(projectId: string, data: ArrayBuffer, mimeType: string, sourcePath?: string, faultAt?: "quota"): Promise { + 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): Promise 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)); diff --git a/web/package.json b/web/package.json index aaee8939..c2a1f4da 100644 --- a/web/package.json +++ b/web/package.json @@ -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": { diff --git a/web/protocol/archive-conflicts.ts b/web/protocol/archive-conflicts.ts new file mode 100644 index 00000000..2b3f64c3 --- /dev/null +++ b/web/protocol/archive-conflicts.ts @@ -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 { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new ArchiveConflictError(`${path} must be an object`, path); + return value as Record; +} +function exactKeys(value: Record, expected: readonly string[], path: string): void { + const actual = Object.keys(value).sort(); const allowed = [...expected].sort(); + if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) throw new 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(); 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 }; +} diff --git a/web/protocol/archive-extraction-recovery.ts b/web/protocol/archive-extraction-recovery.ts new file mode 100644 index 00000000..f497852d --- /dev/null +++ b/web/protocol/archive-extraction-recovery.ts @@ -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; + +/** 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 { + 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"; diff --git a/web/protocol/archive-extraction-transaction.ts b/web/protocol/archive-extraction-transaction.ts new file mode 100644 index 00000000..9aa98980 --- /dev/null +++ b/web/protocol/archive-extraction-transaction.ts @@ -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; + createStaging(transactionId: string, projectId: string): Promise; + writeStaging(transactionId: string, path: string, bytes: Uint8Array): Promise; + countStagingEntries(transactionId: string): Promise; + discardStaging(transactionId: string): Promise; + commitStaging( + transactionId: string, + expected: ArchiveProjectIdentityIR, + candidate: ArchiveProjectIdentityIR, + ): Promise; +} + +export type ArchiveExtractionEntryReader = ( + entry: ArchiveExtractionEntryIR, + signal: AbortSignal, +) => Promise; + +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 { + 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; +} + +function exactKeys(value: Record, expected: readonly string[], path: string): void { + const actual = Object.keys(value).sort(); + const allowed = [...expected].sort(); + if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) { + throw new 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(); + 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 { + 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 { + 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; + } +} diff --git a/web/protocol/archive-link-safety.ts b/web/protocol/archive-link-safety.ts new file mode 100644 index 00000000..6d1362f5 --- /dev/null +++ b/web/protocol/archive-link-safety.ts @@ -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 { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new ArchiveLinkSafetyError(`${path} must be an object`, path); + return value as Record; +} + +function exactKeys(value: Record, expected: readonly string[], path: string): void { + const actual = Object.keys(value).sort(); + const allowed = [...expected].sort(); + if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) throw new 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(); + 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(); + const resolved = new Map(); + + 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 }; +} diff --git a/web/protocol/archive-metadata-first.ts b/web/protocol/archive-metadata-first.ts new file mode 100644 index 00000000..da292925 --- /dev/null +++ b/web/protocol/archive-metadata-first.ts @@ -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 { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new ArchiveMetadataFirstError(`${path} must be an object`, path); + return value as Record; +} + +function exactKeys(value: Record, expected: readonly string[], path: string): void { + const actual = Object.keys(value).sort(); + const allowed = [...expected].sort(); + if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) throw new 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 }; +} diff --git a/web/protocol/asset-library-io.ts b/web/protocol/asset-library-io.ts index 6dab8801..1190bf42 100644 --- a/web/protocol/asset-library-io.ts +++ b/web/protocol/asset-library-io.ts @@ -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(["GLB", "GLTF", "OBJ", "PLY", "STL", "USD", "A function record(value: unknown): value is Record { 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(); 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; diff --git a/web/protocol/asset-path.ts b/web/protocol/asset-path.ts index 052e16c9..166bfe0f 100644 --- a/web/protocol/asset-path.ts +++ b/web/protocol/asset-path.ts @@ -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; } diff --git a/web/protocol/device-budget.ts b/web/protocol/device-budget.ts new file mode 100644 index 00000000..659538f8 --- /dev/null +++ b/web/protocol/device-budget.ts @@ -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> = 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; +} diff --git a/web/protocol/diagnostic-report.ts b/web/protocol/diagnostic-report.ts index 446da22b..54e04a2b 100644 --- a/web/protocol/diagnostic-report.ts +++ b/web/protocol/diagnostic-report.ts @@ -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", diff --git a/web/protocol/error.ts b/web/protocol/error.ts index f0c04814..8ca1d77a 100644 --- a/web/protocol/error.ts +++ b/web/protocol/error.ts @@ -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" diff --git a/web/protocol/glb-import.ts b/web/protocol/glb-import.ts index 0f840fed..cb4a9f39 100644 --- a/web/protocol/glb-import.ts +++ b/web/protocol/glb-import.ts @@ -11,6 +11,10 @@ interface GLBAccessor { componentType: number; count: number; type: string; + normalized?: boolean; + min?: number[]; + max?: number[]; + sparse?: Record; } interface GLBBufferView { @@ -22,20 +26,62 @@ interface GLBBufferView { interface GLBPrimitive { attributes?: Record; indices?: number; + material?: number; + mode?: number; targets?: Array>; } 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 }>; - images?: Array<{ name?: string; mimeType?: string; bufferView?: number; extras?: Record }>; - skins?: Array<{ joints?: number[]; inverseBindMatrices?: number; extras?: Record }>; - animations?: Array<{ name?: string; channels?: Array<{ target?: { node?: number; path?: string } }>; extras?: Record }>; + images?: Array<{ name?: string; mimeType?: string; bufferView?: number; uri?: string; extras?: Record }>; + samplers?: Array>; + textures?: Array<{ sampler?: number; source?: number }>; + materials?: Array<{ + name?: string; + alphaMode?: string; + doubleSided?: boolean; + pbrMetallicRoughness?: { + baseColorFactor?: number[]; + baseColorTexture?: Record; + metallicFactor?: number; + roughnessFactor?: number; + }; + normalTexture?: Record; + 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 }>; + animations?: Array<{ + name?: string; + samplers?: Array<{ input?: number; output?: number; interpolation?: string }>; + channels?: Array<{ sampler?: number; target?: { node?: number; path?: string } }>; + extras?: Record; + }>; } +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; + 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; + indices: GLBDesktopAccessorSemantics | null; + material: number | null; + mode: number; + targets: Array>; + }>; + }>; + materials: Array<{ + name: string | null; + alphaMode: string; + doubleSided: boolean; + pbr: { + baseColorFactor: number[] | null; + baseColorTexture: Record | null; + metallicFactor: number | null; + roughnessFactor: number | null; + }; + normalTexture: Record | null; + emissiveFactor: number[] | null; + }>; + textures: Array>; + images: Array>; + samplers: Array>; + 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 | 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 = {}; + 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 = {}; + 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; + const actualRecord = actual as Record; + 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)); diff --git a/web/protocol/glb-loss-report.ts b/web/protocol/glb-loss-report.ts new file mode 100644 index 00000000..2744602c --- /dev/null +++ b/web/protocol/glb-loss-report.ts @@ -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, + }, + }; +} diff --git a/web/protocol/glb-recovery.ts b/web/protocol/glb-recovery.ts new file mode 100644 index 00000000..1cf51c3b --- /dev/null +++ b/web/protocol/glb-recovery.ts @@ -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 }; +} diff --git a/web/protocol/ime-composition.ts b/web/protocol/ime-composition.ts new file mode 100644 index 00000000..75054d15 --- /dev/null +++ b/web/protocol/ime-composition.ts @@ -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; +} diff --git a/web/protocol/input-modal.ts b/web/protocol/input-modal.ts new file mode 100644 index 00000000..cd94fbc9 --- /dev/null +++ b/web/protocol/input-modal.ts @@ -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 }; +} diff --git a/web/protocol/io-format-capability-matrix.ts b/web/protocol/io-format-capability-matrix.ts new file mode 100644 index 00000000..dd7d45c5 --- /dev/null +++ b/web/protocol/io-format-capability-matrix.ts @@ -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 | 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; +} + +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 { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new IOFormatCapabilityMatrixError("ASSET_MANIFEST_INVALID", `${path} must be an object`); + return value as Record; +} + +function exactKeys(value: Record, expected: readonly string[], path: string): void { + const actual = Object.keys(value).sort(); + const allowed = [...expected].sort(); + if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) throw new 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 }; +} diff --git a/web/protocol/io-format-receipt-binding.ts b/web/protocol/io-format-receipt-binding.ts new file mode 100644 index 00000000..d6f646f9 --- /dev/null +++ b/web/protocol/io-format-receipt-binding.ts @@ -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 { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new IOFormatReceiptBindingError(`${path} must be an object`); + return value as Record; +} + +function exactKeys(value: Record, expected: readonly string[], path: string): void { + const actual = Object.keys(value).sort(); + const allowed = [...expected].sort(); + if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) throw new 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 { + 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 { + 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; +} diff --git a/web/protocol/io-format-receipt-freshness.ts b/web/protocol/io-format-receipt-freshness.ts new file mode 100644 index 00000000..7f33027d --- /dev/null +++ b/web/protocol/io-format-receipt-freshness.ts @@ -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>; +} + +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 { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new IOFormatReceiptFreshnessError("RECEIPT_INVALID", `${path} must be an object`); + } + return value as Record; +} + +function exactKeys(value: Record, expected: readonly string[], path: string): void { + const actual = Object.keys(value).sort(); + const allowed = [...expected].sort(); + if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) { + throw new 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).sort().map((key) => [key, stableValue((value as Record)[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 { + 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 { + 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 }; + } +} diff --git a/web/protocol/io-format-recovery.ts b/web/protocol/io-format-recovery.ts new file mode 100644 index 00000000..0df6cd6a --- /dev/null +++ b/web/protocol/io-format-recovery.ts @@ -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 }; +} diff --git a/web/protocol/io-format-runtime-receipt.ts b/web/protocol/io-format-runtime-receipt.ts new file mode 100644 index 00000000..4da0824b --- /dev/null +++ b/web/protocol/io-format-runtime-receipt.ts @@ -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; +} + +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 { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new IOFormatRuntimeReceiptError(`${path} must be an object`); + return value as Record; +} + +function exactKeys(value: Record, expected: readonly string[], path: string): void { + const actual = Object.keys(value).sort(); + const allowed = [...expected].sort(); + if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) throw new 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 = {}; + 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 }; +} diff --git a/web/protocol/io-format-ui-gate.ts b/web/protocol/io-format-ui-gate.ts new file mode 100644 index 00000000..2d134a56 --- /dev/null +++ b/web/protocol/io-format-ui-gate.ts @@ -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 = { + 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, 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 { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new IOFormatUIGateError(`${path} must be an object`); + return value as Record; +} + +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(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(","); +} diff --git a/web/protocol/keyboard-contract.ts b/web/protocol/keyboard-contract.ts new file mode 100644 index 00000000..2573e8f9 --- /dev/null +++ b/web/protocol/keyboard-contract.ts @@ -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", + }; +} diff --git a/web/protocol/library-linked-missing.ts b/web/protocol/library-linked-missing.ts new file mode 100644 index 00000000..51aa87d7 --- /dev/null +++ b/web/protocol/library-linked-missing.ts @@ -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 { + 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; +} + +function exactKeys(value: Record, expected: readonly string[], path: string): void { + const actual = Object.keys(value).sort(); + const allowed = [...expected].sort(); + if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) { + throw new 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] } })) } }; +} diff --git a/web/protocol/library-linked-mutation.ts b/web/protocol/library-linked-mutation.ts new file mode 100644 index 00000000..44141dd0 --- /dev/null +++ b/web/protocol/library-linked-mutation.ts @@ -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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function exactKeys(value: Record): 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)]); +} diff --git a/web/protocol/library-linked-reload.ts b/web/protocol/library-linked-reload.ts new file mode 100644 index 00000000..606b46f8 --- /dev/null +++ b/web/protocol/library-linked-reload.ts @@ -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 { + 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; +} + +function exactKeys(value: Record, 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 })), + })) }, + }; +} diff --git a/web/protocol/library-negative-cases.ts b/web/protocol/library-negative-cases.ts new file mode 100644 index 00000000..625bb82a --- /dev/null +++ b/web/protocol/library-negative-cases.ts @@ -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 { + 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; +} + +function exactKeys(value: Record, expected: readonly string[], path: string): void { + const actual = Object.keys(value).sort(); + const allowed = [...expected].sort(); + if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) throw new 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, edges: Map, label: string): void { + const active = new Set(); + const complete = new Set(); + 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(); + 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(); + 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(); + 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" }; +} diff --git a/web/protocol/library-override-freshness.ts b/web/protocol/library-override-freshness.ts new file mode 100644 index 00000000..e7b4c170 --- /dev/null +++ b/web/protocol/library-override-freshness.ts @@ -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 { + 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; +} + +function exactKeys(value: Record, expected: readonly string[], path: string): void { + const actual = Object.keys(value).sort(); + const allowed = [...expected].sort(); + if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) throw new 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, 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 }; +} diff --git a/web/protocol/library-override-writer.ts b/web/protocol/library-override-writer.ts new file mode 100644 index 00000000..ad448b57 --- /dev/null +++ b/web/protocol/library-override-writer.ts @@ -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 { + 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; +} + +function exactKeys(value: Record, expected: readonly string[], path: string): void { + const actual = Object.keys(value).sort(); + const allowed = [...expected].sort(); + if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) { + throw new 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 }, + }; +} diff --git a/web/protocol/library-source-origin.ts b/web/protocol/library-source-origin.ts new file mode 100644 index 00000000..c8136240 --- /dev/null +++ b/web/protocol/library-source-origin.ts @@ -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 { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new LibrarySourceOriginValidationError("ASSET_MANIFEST_INVALID", `${path} must be an object`, path); + return value as Record; +} + +function exactKeys(value: Record, expected: readonly string[], path: string): void { + const actual = Object.keys(value).sort(); + const allowed = [...expected].sort(); + if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) throw new LibrarySourceOriginValidationError("ASSET_MANIFEST_INVALID", `${path} contains undeclared fields`, path); +} + +function text(value: unknown, path: string, maximum: number): string { + if (typeof value !== "string" || value.length === 0 || value.length > maximum) throw new LibrarySourceOriginValidationError("ASSET_MANIFEST_INVALID", `${path} is invalid`, path); + return value; +} + +function digest(value: unknown, path: string): string { + if (typeof value !== "string" || !SHA256.test(value)) throw new LibrarySourceOriginValidationError("ASSET_MANIFEST_INVALID", `${path} must be a lowercase SHA-256 digest`, path); + return value; +} + +function validateUriText(value: string, path: string): void { + if (value.includes("\\") || CONTROL_CHARACTER.test(value) || /%(?![0-9a-fA-F]{2})/.test(value)) { + throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", `${path} contains unsafe URI characters`, path); + } +} + +function validateUriPath(pathname: string, path: string): void { + let decoded: string; + try { decoded = decodeURIComponent(pathname); } + catch { throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", `${path} contains malformed percent encoding`, path); } + if (decoded.includes("%") || decoded.includes("\\") || CONTROL_CHARACTER.test(decoded)) { + throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", `${path} contains an unsafe path`, path); + } +} + +function canonicalOrigin(value: unknown, path: string): string { + const textValue = text(value, path, 2_048); + validateUriText(textValue, path); + let parsed: URL; + try { parsed = new URL(textValue); } catch { throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", `${path} is not an absolute URL`, path); } + if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.port) throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", `${path} must be a credential-free HTTPS origin`, path); + // A policy entry is an origin, not a URL whose path/query/fragment is discarded by URL.origin. + // Check the raw suffix too: URL parsing normalizes encoded and literal dot segments before exposing + // pathname, which must not turn an origin-smuggling declaration into an apparently trusted origin. + const schemeSeparator = textValue.indexOf("://"); + const authorityAndSuffix = schemeSeparator < 0 ? textValue : textValue.slice(schemeSeparator + 3); + const suffixStart = authorityAndSuffix.search(/[/?#]/); + const rawSuffix = suffixStart < 0 ? "" : authorityAndSuffix.slice(suffixStart); + validateUriPath(parsed.pathname, path); + if (schemeSeparator < 0 || parsed.pathname !== "/" || parsed.search || parsed.hash || rawSuffix !== "" && rawSuffix !== "/") throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", `${path} must not include a path, query, or fragment`, path); + return parsed.origin; +} + +function httpsUrl(value: unknown, path: string): string { + const textValue = text(value, path, 8_192); + validateUriText(textValue, path); + let parsed: URL; + try { parsed = new URL(textValue); } catch { throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", `${path} is not an absolute URL`, path); } + if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.port) throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", `${path} must be a credential-free HTTPS URL`, path); + validateUriPath(parsed.pathname, path); + return parsed.href; +} + +export function parseLibrarySourcePolicy(value: unknown): LibrarySourcePolicyIR { + const policy = record(value, "policy"); + exactKeys(policy, ["schemaVersion", "declaredHttpsOrigins"], "policy"); + if (policy.schemaVersion !== LIBRARY_SOURCE_ORIGIN_SCHEMA || !Array.isArray(policy.declaredHttpsOrigins) || policy.declaredHttpsOrigins.length === 0 || policy.declaredHttpsOrigins.length > 1_024) throw new LibrarySourceOriginValidationError("PROTOCOL_MISMATCH", "Unsupported or empty library source policy", "policy"); + const declaredHttpsOrigins = policy.declaredHttpsOrigins.map((origin, index) => canonicalOrigin(origin, `declaredHttpsOrigins[${index}]`)); + if (new Set(declaredHttpsOrigins).size !== declaredHttpsOrigins.length) throw new LibrarySourceOriginValidationError("ASSET_MANIFEST_INVALID", "declared HTTPS origins must be unique", "declaredHttpsOrigins"); + return { schemaVersion: LIBRARY_SOURCE_ORIGIN_SCHEMA, declaredHttpsOrigins }; +} + +export function parseLibrarySourceRequest(value: unknown): LibrarySourceRequestIR { + const request = record(value, "request"); + if (request.schemaVersion !== LIBRARY_SOURCE_ORIGIN_SCHEMA) throw new LibrarySourceOriginValidationError("PROTOCOL_MISMATCH", "Unsupported library source request schema", "schemaVersion"); + if (request.kind === "HTTPS_ORIGIN") { + exactKeys(request, ["schemaVersion", "kind", "url"], "request"); + return { schemaVersion: LIBRARY_SOURCE_ORIGIN_SCHEMA, kind: "HTTPS_ORIGIN", url: httpsUrl(request.url, "url") }; + } + if (request.kind === "PROJECT_ASSET") { + exactKeys(request, ["schemaVersion", "kind", "path"], "request"); + let path: string; + try { path = normalizeProjectAssetPath(text(request.path, "path", 2_048)); } + catch (error) { throw new LibrarySourceOriginValidationError(error instanceof Error && error.message === "ASSET_PATH_INVALID" ? "ASSET_MANIFEST_INVALID" : "IO_EXTERNAL_URI_BLOCKED", "project asset path is outside the project", "path"); } + return { schemaVersion: LIBRARY_SOURCE_ORIGIN_SCHEMA, kind: "PROJECT_ASSET", path }; + } + if (request.kind === "USER_SELECTED_FILE") { + exactKeys(request, ["schemaVersion", "kind", "selectionId", "fileName", "byteLength", "sourceSha256"], "request"); + if (typeof request.selectionId !== "string" || !SELECTION_ID.test(request.selectionId)) throw new LibrarySourceOriginValidationError("ASSET_MANIFEST_INVALID", "selectionId is invalid", "selectionId"); + if (typeof request.fileName !== "string" || !FILE_NAME.test(request.fileName) || request.fileName === "." || request.fileName === "..") throw new LibrarySourceOriginValidationError("ASSET_MANIFEST_INVALID", "fileName is invalid", "fileName"); + if (typeof request.byteLength !== "number" || !Number.isSafeInteger(request.byteLength) || request.byteLength <= 0 || request.byteLength > MAX_FILE_BYTES) throw new LibrarySourceOriginValidationError("ASSET_BUDGET_EXCEEDED", "selected file length is outside the budget", "byteLength"); + return { schemaVersion: LIBRARY_SOURCE_ORIGIN_SCHEMA, kind: "USER_SELECTED_FILE", selectionId: request.selectionId, fileName: request.fileName, byteLength: request.byteLength, sourceSha256: digest(request.sourceSha256, "sourceSha256") }; + } + throw new LibrarySourceOriginValidationError("ASSET_MANIFEST_INVALID", "source kind is unsupported", "kind"); +} + +export function acceptLibrarySource(policyValue: unknown, requestValue: unknown): AcceptedLibrarySourceIR { + const policy = parseLibrarySourcePolicy(policyValue); + const request = parseLibrarySourceRequest(requestValue); + if (request.kind === "HTTPS_ORIGIN") { + const origin = new URL(request.url).origin; + if (!policy.declaredHttpsOrigins.includes(origin)) throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", "HTTPS origin is not declared by the policy", "url"); + return { status: "READY", kind: request.kind, canonicalLocator: request.url }; + } + if (request.kind === "PROJECT_ASSET") return { status: "READY", kind: request.kind, canonicalLocator: `project-assets/${request.path}` }; + return { status: "READY", kind: request.kind, canonicalLocator: `user-file/${request.selectionId}/${request.fileName}` }; +} diff --git a/web/protocol/obj-import.ts b/web/protocol/obj-import.ts new file mode 100644 index 00000000..626d7f3d --- /dev/null +++ b/web/protocol/obj-import.ts @@ -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 }; +} diff --git a/web/protocol/ply-import.ts b/web/protocol/ply-import.ts new file mode 100644 index 00000000..0d83c378 --- /dev/null +++ b/web/protocol/ply-import.ts @@ -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; +} + +export interface PLYFace { + indices: number[]; + customProperties: Record; +} + +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 = { + 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>> { + 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>>(); + for (const element of elements) { + const values: Array> = []; + 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 = {}; + 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>> { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let cursor = offset; + const records = new Map>>(); + for (const element of elements) { + const values: Array> = []; + for (let row = 0; row < element.count; row++) { + const record: Record = {}; + 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>>): 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; +} diff --git a/web/protocol/pointer-contract.ts b/web/protocol/pointer-contract.ts new file mode 100644 index 00000000..1c30cb85 --- /dev/null +++ b/web/protocol/pointer-contract.ts @@ -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", + }; +} diff --git a/web/protocol/scripting-platform.ts b/web/protocol/scripting-platform.ts index def04005..696f4687 100644 --- a/web/protocol/scripting-platform.ts +++ b/web/protocol/scripting-platform.ts @@ -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 } + | { 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(); + 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(); const complete = new Set(); + 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 { + 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 SCRIPTING_BUDGET.maxScripts) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", "Script count exceeds the budget"); - const ids = new Set(); + const ids = new Set(); 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(); + 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(); const complete = new Set(); 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): 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")]); diff --git a/web/protocol/stl-export.ts b/web/protocol/stl-export.ts new file mode 100644 index 00000000..7e345cd2 --- /dev/null +++ b/web/protocol/stl-export.ts @@ -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 }; +} diff --git a/web/protocol/stl-import.ts b/web/protocol/stl-import.ts new file mode 100644 index 00000000..9a900f98 --- /dev/null +++ b/web/protocol/stl-import.ts @@ -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"); +} diff --git a/web/protocol/storage.ts b/web/protocol/storage.ts index 772d94fa..13cfc28c 100644 --- a/web/protocol/storage.ts +++ b/web/protocol/storage.ts @@ -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 } diff --git a/web/protocol/viewport-dpr.ts b/web/protocol/viewport-dpr.ts new file mode 100644 index 00000000..54c6f341 --- /dev/null +++ b/web/protocol/viewport-dpr.ts @@ -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, + }; +} diff --git a/web/tests/e2e/archive-security-fixtures.spec.ts b/web/tests/e2e/archive-security-fixtures.spec.ts new file mode 100644 index 00000000..7075c9ab --- /dev/null +++ b/web/tests/e2e/archive-security-fixtures.spec.ts @@ -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"), + ); +}); diff --git a/web/tests/e2e/glb-desktop-import.spec.ts b/web/tests/e2e/glb-desktop-import.spec.ts new file mode 100644 index 00000000..20460303 --- /dev/null +++ b/web/tests/e2e/glb-desktop-import.spec.ts @@ -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>((resolve, reject) => { + const worker = new Worker("/src/workers/glb-desktop-import-test.worker.ts", { type: "module" }); + worker.onmessage = (event: MessageEvent>) => { + 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 }, + ]); +}); diff --git a/web/tests/e2e/glb-export-loss-report.spec.ts b/web/tests/e2e/glb-export-loss-report.spec.ts new file mode 100644 index 00000000..a1262f00 --- /dev/null +++ b/web/tests/e2e/glb-export-loss-report.spec.ts @@ -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 => { + 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))); + } +}); diff --git a/web/tests/e2e/glb-main-persistence.spec.ts b/web/tests/e2e/glb-main-persistence.spec.ts new file mode 100644 index 00000000..d5437dc5 --- /dev/null +++ b/web/tests/e2e/glb-main-persistence.spec.ts @@ -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) { + 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 { + 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 } }) => ({ + 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) => ({ + 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 => { + 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); +}); diff --git a/web/tests/e2e/glb-negative-cases.spec.ts b/web/tests/e2e/glb-negative-cases.spec.ts new file mode 100644 index 00000000..0fdf0155 --- /dev/null +++ b/web/tests/e2e/glb-negative-cases.spec.ts @@ -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" }, + ] }); +}); diff --git a/web/tests/e2e/glb-recovery.spec.ts b/web/tests/e2e/glb-recovery.spec.ts new file mode 100644 index 00000000..a7f138cb --- /dev/null +++ b/web/tests/e2e/glb-recovery.spec.ts @@ -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>((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>) => { + 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 }); +}); diff --git a/web/tests/e2e/io-format-recovery.spec.ts b/web/tests/e2e/io-format-recovery.spec.ts new file mode 100644 index 00000000..8130bf24 --- /dev/null +++ b/web/tests/e2e/io-format-recovery.spec.ts @@ -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((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) => { 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((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) => { 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 = {}; + 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"))); +}); diff --git a/web/tests/e2e/io-format-ui-gate.spec.ts b/web/tests/e2e/io-format-ui-gate.spec.ts new file mode 100644 index 00000000..e3410495 --- /dev/null +++ b/web/tests/e2e/io-format-ui-gate.spec.ts @@ -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"); +}); diff --git a/web/tests/e2e/library-append-main.spec.ts b/web/tests/e2e/library-append-main.spec.ts index fe106874..2adac6d2 100644 --- a/web/tests/e2e/library-append-main.spec.ts +++ b/web/tests/e2e/library-append-main.spec.ts @@ -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); diff --git a/web/tests/e2e/library-link-chromium.spec.ts b/web/tests/e2e/library-link-chromium.spec.ts new file mode 100644 index 00000000..247c645f --- /dev/null +++ b/web/tests/e2e/library-link-chromium.spec.ts @@ -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 }); +}); diff --git a/web/tests/e2e/library-override-chromium.spec.ts b/web/tests/e2e/library-override-chromium.spec.ts new file mode 100644 index 00000000..34a35416 --- /dev/null +++ b/web/tests/e2e/library-override-chromium.spec.ts @@ -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 }); +}); diff --git a/web/tests/e2e/malicious-script.spec.ts b/web/tests/e2e/malicious-script.spec.ts new file mode 100644 index 00000000..405309b1 --- /dev/null +++ b/web/tests/e2e/malicious-script.spec.ts @@ -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"]); +}); diff --git a/web/tests/e2e/obj-web-roundtrip.spec.ts b/web/tests/e2e/obj-web-roundtrip.spec.ts new file mode 100644 index 00000000..1684dee0 --- /dev/null +++ b/web/tests/e2e/obj-web-roundtrip.spec.ts @@ -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((resolve, reject) => { + const worker = new Worker("/src/workers/obj-roundtrip-test.worker.ts", { type: "module" }); + worker.onmessage = (event: MessageEvent) => { 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 }); + } +}); diff --git a/web/tests/e2e/ply-negative.spec.ts b/web/tests/e2e/ply-negative.spec.ts new file mode 100644 index 00000000..f3232945 --- /dev/null +++ b/web/tests/e2e/ply-negative.spec.ts @@ -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((resolve, reject) => { + const worker = new Worker("/src/workers/ply-roundtrip-test.worker.ts", { type: "module" }); + worker.onmessage = (event: MessageEvent) => { 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 }))); +}); diff --git a/web/tests/e2e/ply-web-roundtrip.spec.ts b/web/tests/e2e/ply-web-roundtrip.spec.ts new file mode 100644 index 00000000..7da2cca8 --- /dev/null +++ b/web/tests/e2e/ply-web-roundtrip.spec.ts @@ -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((resolve, reject) => { + const worker = new Worker("/src/workers/ply-roundtrip-test.worker.ts", { type: "module" }); + worker.onmessage = (event: MessageEvent) => { 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 }); + } +}); diff --git a/web/tests/e2e/script-host-call.spec.ts b/web/tests/e2e/script-host-call.spec.ts new file mode 100644 index 00000000..e97eb8d9 --- /dev/null +++ b/web/tests/e2e/script-host-call.spec.ts @@ -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>((resolve, reject) => { + const worker = new Worker("/src/workers/script-host-call-test.worker.ts", { type: "module" }); + worker.onmessage = (event: MessageEvent>) => { 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" }); +}); diff --git a/web/tests/e2e/script-manifest-budgets.spec.ts b/web/tests/e2e/script-manifest-budgets.spec.ts new file mode 100644 index 00000000..1202134d --- /dev/null +++ b/web/tests/e2e/script-manifest-budgets.spec.ts @@ -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>((resolve, reject) => { + const worker = new Worker("/src/workers/scripting-manifest-budget-test.worker.ts", { type: "module" }); + worker.onmessage = (event: MessageEvent>) => { 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"); +}); diff --git a/web/tests/e2e/script-manifest-canonical.spec.ts b/web/tests/e2e/script-manifest-canonical.spec.ts new file mode 100644 index 00000000..c0f1ad72 --- /dev/null +++ b/web/tests/e2e/script-manifest-canonical.spec.ts @@ -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>((resolve, reject) => { + const worker = new Worker("/src/workers/scripting-manifest-canonical-test.worker.ts", { type: "module" }); + worker.onmessage = (event: MessageEvent>) => { 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 }); +}); diff --git a/web/tests/e2e/script-open-metadata.spec.ts b/web/tests/e2e/script-open-metadata.spec.ts new file mode 100644 index 00000000..ce3d7f2c --- /dev/null +++ b/web/tests/e2e/script-open-metadata.spec.ts @@ -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" }); +}); diff --git a/web/tests/e2e/script-permission-policy.spec.ts b/web/tests/e2e/script-permission-policy.spec.ts new file mode 100644 index 00000000..d3b7f182 --- /dev/null +++ b/web/tests/e2e/script-permission-policy.spec.ts @@ -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>((resolve, reject) => { + const worker = new Worker("/src/workers/script-permission-policy-test.worker.ts", { type: "module" }); + worker.onmessage = (event: MessageEvent>) => { 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"); +}); diff --git a/web/tests/e2e/script-sandbox-budget.spec.ts b/web/tests/e2e/script-sandbox-budget.spec.ts new file mode 100644 index 00000000..7262e31d --- /dev/null +++ b/web/tests/e2e/script-sandbox-budget.spec.ts @@ -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>((resolve, reject) => { + const worker = new Worker("/src/workers/script-sandbox-budget-test.worker.ts", { type: "module" }); + worker.onmessage = (event: MessageEvent>) => { 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"); +}); diff --git a/web/tests/e2e/script-sandbox-cancellation.spec.ts b/web/tests/e2e/script-sandbox-cancellation.spec.ts new file mode 100644 index 00000000..ed73ce43 --- /dev/null +++ b/web/tests/e2e/script-sandbox-cancellation.spec.ts @@ -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>((resolve, reject) => { + const worker = new Worker("/src/workers/script-sandbox-cancellation-test.worker.ts", { type: "module" }); + worker.onmessage = (event: MessageEvent>) => { 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 }); +}); diff --git a/web/tests/e2e/script-sandbox-dispose.spec.ts b/web/tests/e2e/script-sandbox-dispose.spec.ts new file mode 100644 index 00000000..ec00d4fd --- /dev/null +++ b/web/tests/e2e/script-sandbox-dispose.spec.ts @@ -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>((resolve, reject) => { + const worker = new Worker("/src/workers/script-sandbox-dispose-test.worker.ts", { type: "module" }); + let ready: Record | undefined; + let first: Record | undefined; + let lateTimerMessages = 0; + worker.onmessage = (event: MessageEvent>) => { + 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); +}); diff --git a/web/tests/e2e/script-sandbox-isolation.spec.ts b/web/tests/e2e/script-sandbox-isolation.spec.ts new file mode 100644 index 00000000..bf2a2b13 --- /dev/null +++ b/web/tests/e2e/script-sandbox-isolation.spec.ts @@ -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>((resolve, reject) => { + const worker = new Worker("/src/workers/script-sandbox-isolation-test.worker.ts", { type: "module" }); + worker.onmessage = (event: MessageEvent>) => { worker.terminate(); resolve(event.data); }; + worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; + worker.postMessage({ mode: "RECEIPTS" }); + }); + const runCrash = await new Promise((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"); +}); diff --git a/web/tests/e2e/script-sandbox-recovery.spec.ts b/web/tests/e2e/script-sandbox-recovery.spec.ts new file mode 100644 index 00000000..9915d5cd --- /dev/null +++ b/web/tests/e2e/script-sandbox-recovery.spec.ts @@ -0,0 +1,21 @@ +import { expect, test } from "@playwright/test"; + +test("M13-03G recovers a denied script in a new generation with a continuous audit chain", async ({ page }) => { + await page.goto("/"); + const result = await page.evaluate(() => new Promise>((resolve, reject) => { + const worker = new Worker("/src/workers/script-sandbox-recovery-test.worker.ts", { type: "module" }); + worker.onmessage = (event: MessageEvent>) => { worker.terminate(); resolve(event.data); }; + worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; + worker.postMessage({ type: "recover" }); + })); + expect(result.receipt).toMatchObject({ schemaVersion: 1, operation: "SCRIPT_SANDBOX_RECOVERY", previousGeneration: 4, nextGeneration: 5, mainRevisionBefore: 11, mainRevisionAfter: 11, recovered: true, execution: "DISABLED" }); + expect(result.receipt.audit.entries).toBe(2); + expect(result.receipt.audit.first.sequence).toBe(1); + expect(result.receipt.audit.second.sequence).toBe(2); + expect(result.receipt.audit.second.previousEntrySha256).toBe(result.receipt.audit.first.entrySha256); + expect(result.receipt.audit.first.requestId).not.toBe(result.receipt.audit.second.requestId); + expect(result.receipt.audit.first.sourceSha256).toBe(result.receipt.audit.second.sourceSha256); + expect(result.receipt.audit.first.manifestSha256).toBe(result.receipt.audit.second.manifestSha256); + expect(result.replayError).toBe("SCRIPT_MANIFEST_INVALID"); + expect(result.tamperError).toBe("SCRIPT_MANIFEST_INVALID"); +}); diff --git a/web/tests/e2e/script-sandbox-scope.spec.ts b/web/tests/e2e/script-sandbox-scope.spec.ts new file mode 100644 index 00000000..df1ea81e --- /dev/null +++ b/web/tests/e2e/script-sandbox-scope.spec.ts @@ -0,0 +1,14 @@ +import { expect, test } from "@playwright/test"; + +test("M13-03A exposes an all-deny sandbox scope contract", async ({ page }) => { + await page.goto("/"); + const result = await page.evaluate(() => new Promise>((resolve, reject) => { + const worker = new Worker("/src/workers/script-sandbox-scope-test.worker.ts", { type: "module" }); + worker.onmessage = (event: MessageEvent>) => { worker.terminate(); resolve(event.data); }; + worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; + worker.postMessage({}); + })); + expect(result.accepted).toEqual({ schemaVersion: 1, dom: false, hostWorker: false, opfs: false, indexedDB: false, network: false }); + expect(result.blocked).toEqual({ dom: "SCRIPT_POLICY_DENIED", hostWorker: "SCRIPT_POLICY_DENIED", opfs: "SCRIPT_POLICY_DENIED", indexedDB: "SCRIPT_POLICY_DENIED", network: "SCRIPT_POLICY_DENIED" }); + expect(result.execution).toBe("DISABLED"); +}); diff --git a/web/tests/e2e/script-save-reopen.spec.ts b/web/tests/e2e/script-save-reopen.spec.ts new file mode 100644 index 00000000..84d470d6 --- /dev/null +++ b/web/tests/e2e/script-save-reopen.spec.ts @@ -0,0 +1,33 @@ +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 fixture = fs.readFileSync(path.join(root, "tests/files/web/script_scene.blend")); +const reportPath = path.join(root, "tests/golden/M13-01E/script-save-reopen-report.json"); +const sha256 = (bytes: Uint8Array | Buffer) => crypto.createHash("sha256").update(bytes).digest("hex"); + +test("M13-01E preserves Text sources through save and reopen", async ({ page }) => { + await page.goto("/"); + const result = await page.evaluate(async (bytes) => { + const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); + const first = new WebEngineClient({ timeoutMs: 30_000 }); + const opened = await first.openBlend(Uint8Array.from(bytes).buffer); + const before = opened.snapshot.scriptSources; + const saved = await first.saveBlend(); + const savedBytes = saved.byteLength; + first.terminate(); + const second = new WebEngineClient({ timeoutMs: 30_000 }); + const reopened = await second.openBlend(saved); + const after = reopened.snapshot.scriptSources; + second.terminate(); + return { before, after, savedBytes }; + }, Array.from(fixture)); + expect(result.before?.sources).toHaveLength(3); + expect(result.after?.sources).toEqual(result.before?.sources); + expect(result.after?.sources.every((source: any) => source.readOnly && source.executionStatus === "BLOCKED")).toBe(true); + const report = { schemaVersion: 1, task: "M13-01E", operation: "SCRIPT_TEXT_SAVE_REOPEN", source: { fixtureSha256: sha256(fixture), fixtureBytes: fixture.byteLength }, before: result.before, after: result.after, savedBytes: result.savedBytes, exact: JSON.stringify(result.before) === JSON.stringify(result.after), nextTask: "M13-01F" }; + if (process.env.UPDATE_SCRIPT_SAVE_REOPEN_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"))); +}); diff --git a/web/tests/e2e/script-signature-negative.spec.ts b/web/tests/e2e/script-signature-negative.spec.ts new file mode 100644 index 00000000..0ac28d9a --- /dev/null +++ b/web/tests/e2e/script-signature-negative.spec.ts @@ -0,0 +1,13 @@ +import { expect, test } from "@playwright/test"; + +test("M13-02F blocks signer replay, expiry and key confusion cases", async ({ page }) => { + await page.goto("/"); + const result = await page.evaluate(() => new Promise>((resolve, reject) => { + const worker = new Worker("/src/workers/script-signature-negative-test.worker.ts", { type: "module" }); + worker.onmessage = (event: MessageEvent>) => { worker.terminate(); resolve(event.data); }; + worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; + worker.postMessage({}); + })); + for (const key of ["missing", "expired", "notYetValid", "publisherMismatch"]) expect(result[key]).toMatchObject({ status: "BLOCKED", code: "SCRIPT_POLICY_DENIED" }); + expect(result.swapped).toMatchObject({ status: "BLOCKED", code: "SCRIPT_SIGNATURE_INVALID" }); +}); diff --git a/web/tests/e2e/script-signature.spec.ts b/web/tests/e2e/script-signature.spec.ts new file mode 100644 index 00000000..22ad9926 --- /dev/null +++ b/web/tests/e2e/script-signature.spec.ts @@ -0,0 +1,15 @@ +import { expect, test } from "@playwright/test"; + +test("M13-02D verifies declared script content and invalidates source hash changes", async ({ page }) => { + await page.goto("/"); + const result = await page.evaluate(() => new Promise>((resolve, reject) => { + const worker = new Worker("/src/workers/script-signature-test.worker.ts", { type: "module" }); + worker.onmessage = (event: MessageEvent>) => { worker.terminate(); resolve(event.data); }; + worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; + worker.postMessage({}); + })); + expect(result.valid).toMatchObject({ status: "VERIFIED", code: "SCRIPT_SIGNATURE_VERIFIED", keyId: "key:new", sourceSha256: "a".repeat(64) }); + expect(result.sourceChanged).toMatchObject({ status: "BLOCKED", code: "SCRIPT_SIGNATURE_INVALID", sourceSha256: "d".repeat(64) }); + expect(result.signatureChanged).toMatchObject({ status: "BLOCKED", code: "SCRIPT_SIGNATURE_INVALID" }); + expect(result.revoked).toMatchObject({ status: "BLOCKED", code: "SCRIPT_POLICY_DENIED" }); +}); diff --git a/web/tests/e2e/script-trust-policy.spec.ts b/web/tests/e2e/script-trust-policy.spec.ts new file mode 100644 index 00000000..db0ee55f --- /dev/null +++ b/web/tests/e2e/script-trust-policy.spec.ts @@ -0,0 +1,16 @@ +import { expect, test } from "@playwright/test"; + +test("M13-02C resolves signer identity and rotation policy without enabling execution", async ({ page }) => { + await page.goto("/"); + const result = await page.evaluate(() => new Promise>((resolve, reject) => { + const worker = new Worker("/src/workers/script-trust-policy-test.worker.ts", { type: "module" }); + worker.onmessage = (event: MessageEvent>) => { worker.terminate(); resolve(event.data); }; + worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; + worker.postMessage({}); + })); + expect(result.eligible).toEqual({ status: "ELIGIBLE", keyId: "key:new", publisher: "Team", trust: "ACTIVE", cryptographicVerification: "REQUIRED" }); + expect(result.revoked).toBe("REVOKED"); + expect(result.crossPublisher).toContain("SCRIPT_POLICY_DENIED"); + expect(result.policyExpired).toBe("POLICY_EXPIRED"); + expect(result.rotationSerialized).toBeGreaterThan(0); +}); diff --git a/web/tests/e2e/stl-edge-parity.spec.ts b/web/tests/e2e/stl-edge-parity.spec.ts new file mode 100644 index 00000000..2832c060 --- /dev/null +++ b/web/tests/e2e/stl-edge-parity.spec.ts @@ -0,0 +1,62 @@ +import fs from "node:fs"; +import path from "node:path"; +import { expect, test } from "@playwright/test"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const edgeRoot = path.join(root, "tests/files/web/m12_stl_edges_v1"); +const capabilityRoot = path.join(root, "tests/files/web/m12_stl_capability_v1"); +const desktop = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-07E/desktop-edge-report.json"), "utf8")); +const desktopCanonical = JSON.parse(JSON.stringify(desktop)); +const reportPath = path.join(root, "tests/golden/M12-07E/web-edge-report.json"); + +test("M12-07E compares STL normal/unit/degenerate/trailing behavior in Chromium and Blender", async ({ page }) => { + await page.goto("/"); + const fixtures = [ + { id: "BINARY_UNIT_1", bytes: Array.from(fs.readFileSync(path.join(edgeRoot, "capability-binary.stl"))), variant: "STL_BINARY", unitScale: 1 }, + { id: "BINARY_UNIT_001", bytes: Array.from(fs.readFileSync(path.join(edgeRoot, "capability-binary.stl"))), variant: "STL_BINARY", unitScale: 0.001 }, + { id: "ASCII_UNIT_1", bytes: Array.from(fs.readFileSync(path.join(capabilityRoot, "capability-ascii.stl"))), variant: "STL_ASCII", unitScale: 1 }, + { id: "DEGENERATE_TRIANGLE", bytes: Array.from(fs.readFileSync(path.join(edgeRoot, "degenerate-binary.stl"))), variant: "STL_BINARY", unitScale: 1 }, + { id: "TRAILING_BYTES", bytes: Array.from(fs.readFileSync(path.join(edgeRoot, "trailing-binary.stl"))), variant: "STL_BINARY", unitScale: 1 }, + ]; + const browser = await page.evaluate(async (input) => new Promise((resolve, reject) => { + const worker = new Worker("/src/workers/stl-edge-test.worker.ts", { type: "module" }); + worker.onmessage = (event: MessageEvent) => { worker.terminate(); resolve(event.data); }; + worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; + const cases = input.map((candidate) => ({ ...candidate, bytes: Uint8Array.from(candidate.bytes).buffer })); + worker.postMessage({ cases }, cases.map((candidate) => candidate.bytes)); + }), fixtures); + expect(browser.ok).toBe(true); + const byId = Object.fromEntries(browser.results.map((result: any) => [result.id, result])); + const desktopById = Object.fromEntries(desktop.cases.map((result: any) => [result.id, result])); + expect(byId.TRAILING_BYTES).toEqual({ id: "TRAILING_BYTES", status: "BLOCKED", code: "STL_TRAILING_BYTES" }); + const unitOne = byId.BINARY_UNIT_1.result.bounds.max[0]; + const unitSmall = byId.BINARY_UNIT_001.result.bounds.max[0]; + const desktopUnitOne = desktopById.BINARY_UNIT_1.result.bounds.max[0]; + const desktopUnitSmall = desktopById.BINARY_UNIT_001.result.bounds.max[0]; + const report = { + schemaVersion: 1, + task: "M12-07E", + operation: "STL_NORMAL_UNIT_EDGE_PARITY", + browser: browser.results, + desktop: desktopCanonical, + comparisons: { + binaryAsciiNormalExact: JSON.stringify(byId.BINARY_UNIT_1.result.normals) === JSON.stringify(byId.ASCII_UNIT_1.result.normals), + desktopNormalExact: JSON.stringify(byId.BINARY_UNIT_1.result.normals) === JSON.stringify(desktopById.BINARY_UNIT_1.result.polygonNormals), + webUnitRatio: unitOne / unitSmall, + desktopUnitRatio: desktopUnitOne / desktopUnitSmall, + unitRatioExactWithinFloat32: Math.abs(unitOne / unitSmall - desktopUnitOne / desktopUnitSmall) < 0.001, + degenerateTriangleExact: byId.DEGENERATE_TRIANGLE.result.triangleCount === desktopById.DEGENERATE_TRIANGLE.result.triangleCount && byId.DEGENERATE_TRIANGLE.result.removedDegenerateTriangles === 1, + trailingBytes: { web: "BLOCKED/STL_TRAILING_BYTES", desktop: "ACCEPTED_EMPTY", parity: "STRICTER_WEB_BLOCK" }, + }, + nextTask: "M12-07F", + }; + if (process.env.UPDATE_STL_EDGE_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.binaryAsciiNormalExact).toBe(true); + expect(report.comparisons.desktopNormalExact).toBe(true); + expect(report.comparisons.unitRatioExactWithinFloat32).toBe(true); + expect(report.comparisons.degenerateTriangleExact).toBe(true); +}); diff --git a/web/tests/e2e/stl-web-roundtrip.spec.ts b/web/tests/e2e/stl-web-roundtrip.spec.ts new file mode 100644 index 00000000..22c958e6 --- /dev/null +++ b/web/tests/e2e/stl-web-roundtrip.spec.ts @@ -0,0 +1,63 @@ +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 } from "@playwright/test"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const source = fs.readFileSync(path.join(root, "tests/files/web/m12_stl_capability_v1/capability-binary.stl")); +const reportPath = path.join(root, "tests/golden/M12-07F/web-roundtrip-report.json"); +const sha256 = (bytes: Uint8Array | Buffer) => crypto.createHash("sha256").update(bytes).digest("hex"); + +test("M12-07F round-trips Web STL through desktop Blender with material loss report", async ({ page }) => { + await page.goto("/"); + const result = await page.evaluate(async (bytes) => new Promise((resolve, reject) => { + const worker = new Worker("/src/workers/stl-roundtrip-test.worker.ts", { type: "module" }); + worker.onmessage = (event: MessageEvent) => { 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 input = Uint8Array.from(bytes).buffer; + worker.postMessage({ bytes: input, unitScale: 1, sourceMaterialCount: 2 }, [input]); + }), Array.from(source)); + expect(result.ok).toBe(true); + expect(result.lossReport).toEqual({ + schemaVersion: 1, + operation: "STL_EXPORT_LOSS_REPORT", + canRoundTrip: true, + warningCount: 1, + warnings: [{ code: "STL_MATERIAL_UNSUPPORTED", severity: "warning", message: "STL has no material slots; 2 source material assignments are omitted" }], + }); + const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-07f-stl-")); + try { + const outputPath = path.join(temporary, "web-output.stl"); + fs.writeFileSync(outputPath, Buffer.from(result.output)); + const desktopPath = 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-stl-web-roundtrip.py"), "--", outputPath, desktopPath], { cwd: root, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 }); + expect(command.status, `${command.stdout}\n${command.stderr}`).toBe(0); + const desktop = JSON.parse(JSON.stringify(JSON.parse(fs.readFileSync(desktopPath, "utf8")))); + const report = { + schemaVersion: 1, + task: "M12-07F", + operation: "WEB_STL_TO_DESKTOP_ROUNDTRIP", + source: { sha256: sha256(source), bytes: source.byteLength }, + browser: { imported: result.imported, outputSha256: sha256(Buffer.from(result.output)), outputBytes: result.output.length, lossReport: result.lossReport }, + desktop, + comparison: { + triangleCountExact: result.imported.triangleCount === desktop.triangleCount, + normalExact: JSON.stringify(result.imported.normals) === JSON.stringify(desktop.polygonNormals), + materialLossExplicit: result.lossReport.warnings[0]?.code === "STL_MATERIAL_UNSUPPORTED", + }, + nextTask: "M12-07G", + }; + if (process.env.UPDATE_STL_ROUNDTRIP_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.comparison).toEqual({ triangleCountExact: true, normalExact: true, materialLossExplicit: true }); + } + finally { + fs.rmSync(temporary, { recursive: true, force: true }); + } +}); diff --git a/web/tests/unit/device-budget.test.mjs b/web/tests/unit/device-budget.test.mjs new file mode 100644 index 00000000..d6d78019 --- /dev/null +++ b/web/tests/unit/device-budget.test.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import ts from "../../node_modules/typescript/lib/typescript.js"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const sourcePath = path.join(root, "web/protocol/device-budget.ts"); +const source = fs.readFileSync(sourcePath, "utf8"); +const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true }); +assert.deepEqual(output.diagnostics, []); +const budget = await import(`data:text/javascript;base64,${Buffer.from(output.outputText).toString("base64")}`); +const base = { schemaVersion: 1, identitySha256: "a".repeat(64), webgl2: { status: "PASS", renderer: "ANGLE NVIDIA", vendor: "NVIDIA" }, webgpu: { status: "PASS", description: "NVIDIA RTX", isFallbackAdapter: false }, hardwareConcurrency: 16, deviceMemory: 16 }; + +test("M14-04A selects HIGH only from trusted observed capability", () => { + const result = budget.selectDeviceBudget(base); + assert.equal(result.tier, "HIGH"); + assert.equal(result.reason, "HIGH_CAPABILITY"); + assert.equal(result.limits.maxTextureGPUBytes, 1024 * 1024 * 1024); +}); + +test("M14-04A fails closed for missing WebGPU, SwiftShader, fallback and invalid identity", () => { + for (const observation of [ + { ...base, webgpu: { status: "BLOCKED" } }, + { ...base, webgl2: { ...base.webgl2, renderer: "ANGLE SwiftShader" } }, + { ...base, webgpu: { ...base.webgpu, isFallbackAdapter: true } }, + { ...base, deviceMemory: null }, + ]) assert.equal(budget.selectDeviceBudget(observation).tier, "CONSERVATIVE"); + assert.throws(() => budget.selectDeviceBudget({ ...base, identitySha256: "bad" }), /DEVICE_BUDGET_IDENTITY_INVALID/); +}); + +test("M14-04A exposes fixed limits and never expands an unknown tier", () => { + assert.equal(budget.deviceBudgetLimits("CONSERVATIVE").maxLights, 8); + assert.throws(() => budget.deviceBudgetLimits("UNKNOWN"), /DEVICE_BUDGET_TIER_INVALID/); +}); diff --git a/web/tests/unit/glb-desktop-import.test.mjs b/web/tests/unit/glb-desktop-import.test.mjs new file mode 100644 index 00000000..cd606e12 --- /dev/null +++ b/web/tests/unit/glb-desktop-import.test.mjs @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "glb-desktop-import-unit-")); +const sourcePath = path.join(root, "web/protocol/glb-import.ts"); +const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, +}); +assert.deepEqual(transpiled.diagnostics, []); +const modulePath = path.join(temporary, "glb-import.mjs"); +fs.writeFileSync(modulePath, transpiled.outputText); +const protocol = await import(pathToFileURL(modulePath)); +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"); +const arrayBuffer = (bytes) => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + +test("M12-06B imports every desktop fixture with exact Web semantics", () => { + assert.deepEqual(report.fixtures.map((fixture) => fixture.id), ["mesh", "pbr", "uv", "skin", "animation"]); + for (const fixture of report.fixtures) { + const bytes = fs.readFileSync(path.join(fixtureRoot, fixture.file)); + assert.equal(crypto.createHash("sha256").update(bytes).digest("hex"), fixture.sha256); + const imported = protocol.importGLBDesktopFixtureSemantics(arrayBuffer(bytes)); + assert.deepEqual(protocol.compareGLBDesktopFixtureSemantics(fixture.semantic, imported), { compatible: true, mismatches: [] }, fixture.id); + } +}); + +test("M12-06B exposes topology, attributes, materials, nodes and animations separately", () => { + const imported = Object.fromEntries(report.fixtures.map((fixture) => { + const bytes = fs.readFileSync(path.join(fixtureRoot, fixture.file)); + return [fixture.id, protocol.importGLBDesktopFixtureSemantics(arrayBuffer(bytes))]; + })); + assert.deepEqual(Object.keys(imported.mesh.meshes[0].primitives[0].attributes), ["COLOR_0", "NORMAL", "POSITION"]); + assert.equal(imported.mesh.meshes[0].primitives[0].indices.count, 6); + assert.equal(Number(imported.pbr.materials[0].pbr.metallicFactor.toFixed(3)), 0.72); + assert.ok(imported.uv.meshes[0].primitives[0].attributes.TEXCOORD_0); + assert.equal(imported.uv.materials[0].pbr.baseColorTexture.index, 0); + assert.deepEqual(imported.skin.nodeNames, ["Tip", "Root", "M12 Skin Fixture", "M12 Skin Armature"]); + assert.equal(imported.skin.skins[0].inverseBindMatrices.type, "MAT4"); + assert.deepEqual(imported.animation.animations[0].channels.map((channel) => channel.target.path), ["translation", "rotation"]); + assert.equal(imported.animation.animations[0].samplers[0].input.count, 25); +}); + +test("M12-06B reports a field-level semantic mismatch", () => { + const fixture = report.fixtures.find((candidate) => candidate.id === "pbr"); + const bytes = fs.readFileSync(path.join(fixtureRoot, fixture.file)); + const imported = protocol.importGLBDesktopFixtureSemantics(arrayBuffer(bytes)); + const expected = structuredClone(fixture.semantic); + expected.materials[0].pbr.metallicFactor = 0.5; + const comparison = protocol.compareGLBDesktopFixtureSemantics(expected, imported); + assert.equal(comparison.compatible, false); + assert.deepEqual(comparison.mismatches, ["$.materials[0].pbr.metallicFactor: expected 0.5 got 0.7200000286102295"]); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/glb-loss-report.test.mjs b/web/tests/unit/glb-loss-report.test.mjs new file mode 100644 index 00000000..e5de49ac --- /dev/null +++ b/web/tests/unit/glb-loss-report.test.mjs @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "glb-loss-report-unit-")); +const sourcePath = path.join(root, "web/protocol/glb-loss-report.ts"); +const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, +}); +assert.deepEqual(transpiled.diagnostics, []); +const modulePath = path.join(temporary, "glb-loss-report.mjs"); +fs.writeFileSync(modulePath, transpiled.outputText); +const protocol = await import(pathToFileURL(modulePath)); + +test("M12-06D produces deterministic sorted machine loss entries", () => { + const snapshot = { + sceneId: "scene:test", + revision: 7, + nodes: [{}, {}], + meshes: [{}], + materials: [{}, {}], + images: [{}], + animations: [{}], + nonMeshData: [{}], + }; + const report = protocol.createGLBLossReport(snapshot, { + canExport: false, + warnings: [ + { code: "Z_LOSS", severity: "warning", message: "z", id: "id:z" }, + { code: "A_BLOCK", severity: "error", message: "a", id: "id:a" }, + { code: "A_BLOCK", severity: "warning", message: "b", id: undefined }, + ], + }); + assert.deepEqual(report, { + schemaVersion: 1, + operation: "GLB_EXPORT_LOSS_REPORT", + sceneId: "scene:test", + sourceRevision: 7, + canExport: false, + errorCount: 1, + warningCount: 2, + losses: [ + { code: "A_BLOCK", severity: "error", message: "a", id: "id:a" }, + { code: "A_BLOCK", severity: "warning", message: "b", id: null }, + { code: "Z_LOSS", severity: "warning", message: "z", id: "id:z" }, + ], + surface: { nodeCount: 2, meshCount: 1, materialCount: 2, imageCount: 1, animationCount: 1, nonMeshCount: 1 }, + }); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/glb-negative-cases.test.mjs b/web/tests/unit/glb-negative-cases.test.mjs new file mode 100644 index 00000000..49d35d82 --- /dev/null +++ b/web/tests/unit/glb-negative-cases.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "glb-negative-unit-")); +const sourcePath = path.join(root, "web/protocol/glb-import.ts"); +const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, +}); +assert.deepEqual(transpiled.diagnostics, []); +const modulePath = path.join(temporary, "glb-import.mjs"); +fs.writeFileSync(modulePath, transpiled.outputText); +const protocol = await import(pathToFileURL(modulePath)); +const source = fs.readFileSync(path.join(root, "tests/files/web/m12_glb_desktop_v1/mesh.glb")); + +function arrayBuffer(bytes) { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); +} + +function rewriteJson(mutator) { + 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("M12-06F rejects sparse accessors, extensions and external URIs", () => { + assert.throws(() => protocol.importGLBSemantics(arrayBuffer(rewriteJson((document) => { document.accessors[0].sparse = { count: 1 }; }))), /GLB_SPARSE_ACCESSOR_UNSUPPORTED/); + assert.throws(() => protocol.importGLBSemantics(arrayBuffer(rewriteJson((document) => { document.extensionsUsed = ["KHR_draco_mesh_compression"]; }))), /GLB_EXTENSION_UNSUPPORTED/); + assert.throws(() => protocol.importGLBSemantics(arrayBuffer(rewriteJson((document) => { document.buffers[0].uri = "external.bin"; }))), /GLB_EXTERNAL_URI_BLOCKED/); +}); + +test("M12-06F rejects over-budget GLB bytes and table counts", () => { + const oversized = Buffer.alloc(protocol.GLB_IMPORT_BUDGET.maxBytes + 1); + source.copy(oversized); + assert.throws(() => protocol.importGLBSemantics(arrayBuffer(oversized)), /GLB_IMPORT_BUDGET_EXCEEDED/); + assert.throws(() => protocol.importGLBSemantics(arrayBuffer(rewriteJson((document) => { document.bufferViews = Array.from({ length: protocol.GLB_IMPORT_BUDGET.maxBufferViews + 1 }, () => ({ buffer: 0, byteLength: 0 })); }))), /GLB_IMPORT_BUDGET_EXCEEDED/); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/glb-recovery.test.mjs b/web/tests/unit/glb-recovery.test.mjs new file mode 100644 index 00000000..7cdec7ed --- /dev/null +++ b/web/tests/unit/glb-recovery.test.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "glb-recovery-unit-")); +const sourcePath = path.join(root, "web/protocol/glb-recovery.ts"); +const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, +}); +assert.deepEqual(transpiled.diagnostics, []); +const modulePath = path.join(temporary, "glb-recovery.mjs"); +fs.writeFileSync(modulePath, transpiled.outputText); +const protocol = await import(pathToFileURL(modulePath)); + +const hash = "a".repeat(64); +const outputHash = "b".repeat(64); + +test("M12-06G keeps cancellation and quota failure fail-closed", () => { + const running = protocol.beginGLBRecoveryOperation({ + operationId: "import-1", + operation: "IMPORT", + workerGeneration: 1, + baseRevision: 7, + inputBytes: 128, + inputSha256: hash, + }); + assert.equal(running.status, "RUNNING"); + assert.equal(running.candidateRevision, 8); + const cancelled = protocol.cancelGLBRecoveryOperation(running); + assert.deepEqual(protocol.parseGLBRecoveryReceipt(cancelled), cancelled); + assert.equal(cancelled.errorCode, "GLB_OPERATION_CANCELLED"); + assert.equal(cancelled.temporaryBytes, 0); + assert.equal(cancelled.committed, false); + assert.throws(() => protocol.commitGLBRecoveryOperation(cancelled, { bytes: 1, sha256: outputHash }), /GLB_RECOVERY_INVALID/); + assert.equal(protocol.blockGLBRecoveryForQuota(running).errorCode, "GLB_OPFS_QUOTA"); +}); + +test("M12-06G binds committed output and worker-generation recovery", () => { + const running = protocol.beginGLBRecoveryOperation({ + operationId: "export-1", + operation: "EXPORT", + workerGeneration: 1, + baseRevision: 11, + inputBytes: 256, + inputSha256: hash, + }); + const committed = protocol.commitGLBRecoveryOperation(running, { bytes: 512, sha256: outputHash }); + assert.equal(committed.status, "COMMITTED"); + assert.equal(committed.committed, true); + assert.equal(committed.liveRequests, 0); + const recovered = protocol.recoverGLBRecoveryOperation(committed, 2); + assert.equal(recovered.status, "RECOVERED"); + assert.equal(recovered.workerGeneration, 2); + assert.equal(recovered.errorCode, "GLB_WORKER_RESTARTED"); + assert.throws(() => protocol.recoverGLBRecoveryOperation(committed, 1), /GLB_RECOVERY_INVALID/); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/ime-composition.test.mjs b/web/tests/unit/ime-composition.test.mjs new file mode 100644 index 00000000..819061c4 --- /dev/null +++ b/web/tests/unit/ime-composition.test.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import ts from "../../node_modules/typescript/lib/typescript.js"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const sourcePath = path.join(root, "web/protocol/ime-composition.ts"); +const output = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true }); +assert.deepEqual(output.diagnostics, []); +const ime = await import(`data:text/javascript;base64,${Buffer.from(output.outputText).toString("base64")}`); + +test("M14-04D blocks operators through composition start/update and reopens after end", () => { + let state = ime.createIMECompositionState(); + assert.equal(ime.shouldBlockOperatorShortcuts(state), false); + state = ime.reduceIMEComposition(state, { type: "compositionstart", data: "n" }); + assert.equal(ime.shouldBlockOperatorShortcuts(state), true); + state = ime.reduceIMEComposition(state, { type: "compositionupdate", data: "ni" }); + assert.deepEqual([state.composing, state.pendingText, state.lastEvent], [true, "ni", "UPDATE"]); + state = ime.reduceIMEComposition(state, { type: "compositionend", data: "你" }); + assert.equal(ime.shouldBlockOperatorShortcuts(state), false); + assert.equal(ime.shouldBlockOperatorShortcuts(state, true), true); +}); + +test("M14-04D ignores stray updates and rejects malformed state", () => { + let state = ime.createIMECompositionState(); + assert.equal(ime.reduceIMEComposition(state, { type: "compositionupdate", data: "x" }), state); + assert.throws(() => ime.shouldBlockOperatorShortcuts({ schemaVersion: 2 }), /IME_STATE_INVALID/); +}); diff --git a/web/tests/unit/input-modal.test.mjs b/web/tests/unit/input-modal.test.mjs new file mode 100644 index 00000000..4d0db9e0 --- /dev/null +++ b/web/tests/unit/input-modal.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import ts from "../../node_modules/typescript/lib/typescript.js"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const sourcePath = path.join(root, "web/protocol/input-modal.ts"); +const output = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true }); +assert.deepEqual(output.diagnostics, []); +const modal = await import(`data:text/javascript;base64,${Buffer.from(output.outputText).toString("base64")}`); + +test("M14-04F cancels touch modal without commit and starts one two-finger navigation revision", () => { + let state = modal.createInputModalState(); + state = modal.beginTouch(state, 4); + state = modal.beginTouch(state, 2); + assert.deepEqual([state.kind, state.activePointerIds, state.navigationRevision, state.mainCommitCount], ["TOUCH_NAVIGATION", [2, 4], 1, 0]); + state = modal.cancelInputModal(state); + assert.deepEqual([state.kind, state.activePointerIds, state.cancelled, state.mainCommitCount], ["NONE", [], true, 0]); +}); + +test("M14-04F commits pen stroke once and ignores late up/cancel", () => { + let state = modal.beginPenStroke(modal.createInputModalState(), 9); + state = modal.commitPenStroke(state, 9); + state = modal.commitPenStroke(state, 9); + assert.deepEqual([state.kind, state.mainCommitCount, state.activePointerIds], ["NONE", 1, []]); +}); diff --git a/web/tests/unit/io-format-capability-matrix.test.mjs b/web/tests/unit/io-format-capability-matrix.test.mjs new file mode 100644 index 00000000..00e77a87 --- /dev/null +++ b/web/tests/unit/io-format-capability-matrix.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "io-format-capability-matrix-unit-")); +const sourcePath = path.join(root, "web/protocol/io-format-capability-matrix.ts"); +const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, +}); +assert.deepEqual(transpiled.diagnostics, []); +fs.writeFileSync(path.join(temporary, "io-format-capability-matrix.mjs"), transpiled.outputText); +const matrix = await import(pathToFileURL(path.join(temporary, "io-format-capability-matrix.mjs"))); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-05B/manifest.json"), "utf8")); +const source = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-05B/capability-matrix.json"), "utf8")); +const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex"); + +test("M12-05B binds the capability matrix and runtime inventory artifacts", () => { + assert.equal(manifest.task, "M12-05B"); + assert.equal(manifest.parentTask, "M12-05A"); + assert.equal(manifest.nextTask, "M12-05C"); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(artifact.path), artifact.sha256, artifact.path); +}); + +test("M12-05B accepts the seven-format matrix and keeps the bounded GLB export route explicit", () => { + const parsed = matrix.parseIOFormatCapabilityMatrix(source); + assert.deepEqual(parsed.formats.map((entry) => entry.format), ["GLTF", "GLB", "OBJ", "STL", "PLY", "USD", "ALEMBIC"]); + assert.equal(parsed.formats.find((entry) => entry.format === "GLB").operations.EXPORT.local.status, "READY"); + assert.equal(parsed.formats.find((entry) => entry.format === "GLB").operations.EXPORT.local.execution, "LOCAL"); + assert.equal(parsed.formats.find((entry) => entry.format === "GLB").operations.IMPORT.local.status, "BLOCKED"); + assert.ok(parsed.formats.filter((entry) => entry.operations.IMPORT.local.status === "BLOCKED").length >= 6); +}); + +test("M12-05B rejects duplicate formats, ready routes without a real executor, and unverified ready features", () => { + assert.throws(() => matrix.parseIOFormatCapabilityMatrix({ ...source, formats: [...source.formats.slice(0, 6), source.formats[0]] }), { code: "ASSET_MANIFEST_INVALID" }); + const badRoute = structuredClone(source); + badRoute.formats.find((entry) => entry.format === "OBJ").operations.IMPORT.local = { status: "READY", execution: "LOCAL", code: null }; + assert.throws(() => matrix.parseIOFormatCapabilityMatrix(badRoute), { code: "ASSET_MANIFEST_INVALID" }); + const badFeature = structuredClone(source); + badFeature.formats.find((entry) => entry.format === "GLB").operations.EXPORT.geometry.status = "UNVERIFIED"; + assert.throws(() => matrix.parseIOFormatCapabilityMatrix(badFeature), { code: "ASSET_MANIFEST_INVALID" }); + const badCode = structuredClone(source); + badCode.formats.find((entry) => entry.format === "PLY").operations.EXPORT.local.code = null; + assert.throws(() => matrix.parseIOFormatCapabilityMatrix(badCode), { code: "IO_FORMAT_UNSUPPORTED" }); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/io-format-receipt-binding.test.mjs b/web/tests/unit/io-format-receipt-binding.test.mjs new file mode 100644 index 00000000..d33477f4 --- /dev/null +++ b/web/tests/unit/io-format-receipt-binding.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "io-format-receipt-binding-unit-")); +const sourcePath = path.join(root, "web/protocol/io-format-receipt-binding.ts"); +const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true }); +assert.deepEqual(transpiled.diagnostics, []); fs.writeFileSync(path.join(temporary, "protocol.mjs"), transpiled.outputText); +const protocol = await import(pathToFileURL(path.join(temporary, "protocol.mjs"))); +const bound = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-05E/bound-runtime-receipts.json"), "utf8")); + +test("M12-05E accepts receipts with all three identity hashes", () => { + const parsed = protocol.validateIOFormatBoundReceiptSet(bound, bound.parentReceiptSetSha256, bound.inventorySha256); + assert.match(protocol.resolveBoundReceipt(parsed, "GLB", "EXPORT").sourceSha256, /^[a-f0-9]{64}$/); + assert.match(protocol.resolveBoundReceipt(parsed, "GLB", "EXPORT").settingsSha256, /^[a-f0-9]{64}$/); + assert.match(protocol.resolveBoundReceipt(parsed, "GLB", "EXPORT").runtimeSha256, /^[a-f0-9]{64}$/); +}); + +test("M12-05E rejects source, settings, runtime and parent hash drift", () => { + for (const field of ["sourceSha256", "settingsSha256", "runtimeSha256"]) { + const mutated = structuredClone(bound); mutated.receipts[0][field] = "invalid-hash"; + assert.throws(() => protocol.validateIOFormatBoundReceiptSet(mutated, mutated.parentReceiptSetSha256, mutated.inventorySha256), { code: "IO_FORMAT_UNSUPPORTED" }); + } + const stale = structuredClone(bound); stale.parentReceiptSetSha256 = "0".repeat(64); + assert.throws(() => protocol.validateIOFormatBoundReceiptSet(stale, bound.parentReceiptSetSha256, bound.inventorySha256), { code: "IO_FORMAT_UNSUPPORTED" }); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/io-format-receipt-freshness.test.mjs b/web/tests/unit/io-format-receipt-freshness.test.mjs new file mode 100644 index 00000000..670bcdb5 --- /dev/null +++ b/web/tests/unit/io-format-receipt-freshness.test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "io-format-receipt-freshness-unit-")); +const stableValue = (value) => Array.isArray(value) ? value.map(stableValue) : value && typeof value === "object" ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])])) : value; +const stableSha256 = (value) => crypto.createHash("sha256").update(JSON.stringify(stableValue(value))).digest("hex"); + +function transpile(sourcePath, outputName, replacements = []) { + const result = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, + }); + assert.deepEqual(result.diagnostics, []); + let output = result.outputText; + for (const [from, to] of replacements) output = output.replaceAll(from, to); + const outputPath = path.join(temporary, outputName); + fs.writeFileSync(outputPath, output); + return outputPath; +} + +const parentPath = path.join(root, "tests/golden/M12-05E/bound-runtime-receipts.json"); +const freshnessPath = path.join(root, "tests/golden/M12-05F/fresh-runtime-receipts.json"); +const parentBytes = fs.readFileSync(parentPath); +const bound = JSON.parse(parentBytes); +const freshness = JSON.parse(fs.readFileSync(freshnessPath, "utf8")); +const protocolPath = path.join(root, "web/protocol/io-format-receipt-freshness.ts"); +transpile(path.join(root, "web/protocol/io-format-runtime-receipt.ts"), "io-format-runtime-receipt.mjs"); +transpile(path.join(root, "web/protocol/io-format-receipt-binding.ts"), "io-format-receipt-binding.mjs"); +const protocol = await import(pathToFileURL(transpile(protocolPath, "io-format-receipt-freshness.mjs", [["./io-format-receipt-binding\"", "./io-format-receipt-binding.mjs\""], ["./io-format-runtime-receipt\"", "./io-format-runtime-receipt.mjs\""]]))); +const expected = { + parentBindingSha256: crypto.createHash("sha256").update(parentBytes).digest("hex"), + parentReceiptSetSha256: bound.parentReceiptSetSha256, + inventorySha256: bound.inventorySha256, + boundReceiptSetSha256: stableSha256(bound), + runtimeSha256: stableSha256(bound.runtime), + runtime: bound.runtime, + receiptIdentities: bound.receipts, +}; + +test("M12-05F artifact is deterministic and bound to M12-05E", () => { + const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-05F/manifest.json"), "utf8")); + assert.equal(manifest.task, "M12-05F"); + assert.equal(manifest.parentTask, "M12-05E"); + assert.equal(manifest.nextTask, "M12-06A"); + for (const artifact of Object.values(manifest.artifacts)) { + assert.equal(crypto.createHash("sha256").update(fs.readFileSync(path.join(root, artifact.path))).digest("hex"), artifact.sha256, artifact.path); + } + assert.equal(freshness.parentBindingSha256, expected.parentBindingSha256); + assert.equal(freshness.boundReceiptSetSha256, expected.boundReceiptSetSha256); + assert.equal(freshness.runtimeSha256, expected.runtimeSha256); +}); + +test("M12-05F accepts only the exact trusted runtime receipt", async () => { + const parsed = await protocol.verifyIOFormatReceiptFreshness(freshness, expected); + assert.equal(parsed.bound.receipts.length, 14); + assert.equal(protocol.resolveFreshIOFormatRuntimeRoute(freshness, expected, { format: "GLB", operation: "EXPORT" }).status, "READY"); + assert.equal(protocol.resolveFreshIOFormatRuntimeRoute(freshness, expected, { format: "USD", operation: "EXPORT" }).status, "BLOCKED"); +}); + +test("M12-05F rejects forged content before route execution", async () => { + const forged = structuredClone(freshness); + forged.bound.receipts[0].operator = "forged.operator"; + await assert.rejects(() => protocol.verifyIOFormatReceiptFreshness(forged, expected), (error) => error.reason === "RECEIPT_FORGED"); + const malformed = structuredClone(freshness); + delete malformed.boundReceiptSetSha256; + assert.equal(protocol.resolveFreshIOFormatRuntimeRoute(malformed, expected, { format: "GLB", operation: "EXPORT" }).reason, "RECEIPT_FORGED"); +}); + +test("M12-05F rejects stale parent identity and cross-version runtime", () => { + const stale = structuredClone(freshness); + stale.bound.inventorySha256 = "a".repeat(64); + assert.equal(protocol.resolveFreshIOFormatRuntimeRoute(stale, expected, { format: "GLB", operation: "EXPORT" }).reason, "RECEIPT_STALE"); + const crossVersion = structuredClone(freshness); + crossVersion.bound.runtime.versionTuple = [5, 3, 0]; + assert.equal(protocol.resolveFreshIOFormatRuntimeRoute(crossVersion, expected, { format: "GLB", operation: "EXPORT" }).reason, "RECEIPT_CROSS_VERSION"); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/io-format-recovery.test.mjs b/web/tests/unit/io-format-recovery.test.mjs new file mode 100644 index 00000000..5c7455ad --- /dev/null +++ b/web/tests/unit/io-format-recovery.test.mjs @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "io-format-recovery-unit-")); +const sourcePath = path.join(root, "web/protocol/io-format-recovery.ts"); +const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true }); +assert.deepEqual(transpiled.diagnostics, []); +const modulePath = path.join(temporary, "io-format-recovery.mjs"); +fs.writeFileSync(modulePath, transpiled.outputText); +const protocol = await import(pathToFileURL(modulePath)); +const hash = "a".repeat(64); +const outputHash = "b".repeat(64); + +test("M12-07J keeps cancellation and OOM receipts unpublished", () => { + const running = protocol.beginIOFormatRecoveryOperation({ operationId: "ply-cancel-1", format: "PLY", operation: "IMPORT", workerGeneration: 1, baseRevision: 3, inputBytes: 32, inputSha256: hash }); + const cancelled = protocol.cancelIOFormatRecoveryOperation(running); + assert.deepEqual(cancelled, { ...running, status: "CANCELLED", errorCode: "IO_FORMAT_OPERATION_CANCELLED", temporaryBytes: 0, liveRequests: 0, publishedResults: 0, committed: false }); + const oomRunning = protocol.beginIOFormatRecoveryOperation({ operationId: "obj-oom-1", format: "OBJ", operation: "IMPORT", workerGeneration: 1, baseRevision: 3, inputBytes: 512 * 1024 + 1, inputSha256: hash }); + const blocked = protocol.blockIOFormatRecoveryOperation(oomRunning); + assert.equal(blocked.errorCode, "IO_FORMAT_OOM"); + assert.equal(blocked.publishedResults, 0); + assert.throws(() => protocol.commitIOFormatRecoveryOperation(cancelled, { bytes: 1, sha256: outputHash }), /IO_FORMAT_RECOVERY_INVALID/); +}); + +test("M12-07J binds output identity across restart and rejects stale generation", () => { + const running = protocol.beginIOFormatRecoveryOperation({ operationId: "stl-restart-1", format: "STL", operation: "EXPORT", workerGeneration: 1, baseRevision: 7, inputBytes: 64, inputSha256: hash }); + const committed = protocol.commitIOFormatRecoveryOperation(running, { bytes: 128, sha256: outputHash }); + const recovered = protocol.recoverIOFormatRecoveryOperation(committed, 2); + assert.deepEqual(recovered, { ...committed, status: "RECOVERED", workerGeneration: 2, errorCode: "IO_FORMAT_WORKER_RESTARTED" }); + assert.equal(protocol.parseIOFormatRecoveryReceipt(recovered).outputSha256, outputHash); + assert.throws(() => protocol.recoverIOFormatRecoveryOperation(committed, 1), /IO_FORMAT_RECOVERY_INVALID/); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/io-format-runtime-receipt.test.mjs b/web/tests/unit/io-format-runtime-receipt.test.mjs new file mode 100644 index 00000000..2d868fcf --- /dev/null +++ b/web/tests/unit/io-format-runtime-receipt.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "io-format-runtime-receipt-unit-")); +const sourcePath = path.join(root, "web/protocol/io-format-runtime-receipt.ts"); +const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true }); +assert.deepEqual(transpiled.diagnostics, []); +fs.writeFileSync(path.join(temporary, "protocol.mjs"), transpiled.outputText); +const protocol = await import(pathToFileURL(path.join(temporary, "protocol.mjs"))); +const receiptSet = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-05D/runtime-receipts.json"), "utf8")); +const inventorySha256 = receiptSet.inventorySha256; + +test("M12-05D resolves capability from the runtime operator receipt", () => { + const parsed = protocol.validateIOFormatRuntimeReceiptSet(receiptSet, inventorySha256); + assert.equal(protocol.resolveIOFormatRuntimeRoute(parsed, { format: "GLB", operation: "EXPORT" }).status, "READY"); + assert.equal(protocol.resolveIOFormatRuntimeRoute(parsed, { format: "USD", operation: "EXPORT" }).status, "BLOCKED"); +}); + +test("M12-05D does not infer capability from a filename extension", () => { + const mutated = structuredClone(receiptSet); + const receipt = mutated.receipts.find((candidate) => candidate.format === "GLB" && candidate.operation === "EXPORT"); + receipt.extensions = [".usd"]; + const parsed = protocol.validateIOFormatRuntimeReceiptSet(mutated, inventorySha256); + assert.deepEqual(protocol.resolveIOFormatRuntimeRoute(parsed, { format: "GLB", operation: "EXPORT" }).status, "READY"); + assert.deepEqual(protocol.resolveIOFormatRuntimeRoute(parsed, { format: "USD", operation: "EXPORT" }).status, "BLOCKED"); +}); + +test("M12-05D rejects receipt identity drift and consumes explicit receipt status", () => { + const stale = structuredClone(receiptSet); + stale.inventorySha256 = "0".repeat(64); + assert.throws(() => protocol.validateIOFormatRuntimeReceiptSet(stale, inventorySha256), { code: "IO_FORMAT_UNSUPPORTED" }); + const forged = structuredClone(receiptSet); + const usd = forged.receipts.find((candidate) => candidate.format === "USD" && candidate.operation === "EXPORT"); + usd.runtimeStatus = "AVAILABLE"; + usd.registered = true; + usd.rnaIdentifier = "WM_OT_usd_export"; + usd.buildOptionEnabled = true; + assert.doesNotThrow(() => protocol.parseIOFormatRuntimeReceiptSet(forged)); + assert.equal(protocol.resolveIOFormatRuntimeRoute(protocol.parseIOFormatRuntimeReceiptSet(forged), { format: "USD", operation: "EXPORT" }).status, "READY"); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/io-format-ui-gate.test.mjs b/web/tests/unit/io-format-ui-gate.test.mjs new file mode 100644 index 00000000..915ffa5c --- /dev/null +++ b/web/tests/unit/io-format-ui-gate.test.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "io-format-ui-gate-unit-")); +const sourcePath = path.join(root, "web/protocol/io-format-ui-gate.ts"); +const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, +}); +assert.deepEqual(transpiled.diagnostics, []); +fs.writeFileSync(path.join(temporary, "io-format-ui-gate.mjs"), transpiled.outputText); +const gate = await import(pathToFileURL(path.join(temporary, "io-format-ui-gate.mjs"))); +const registry = JSON.parse(fs.readFileSync(path.join(root, "web/app/src/capabilities/io-format-ui-registry.json"), "utf8")); + +test("M12-05C keeps only matrix-declared executable routes in UI", () => { + const parsed = gate.parseIOFormatUIRegistry(registry); + assert.deepEqual(parsed.importRoutes, []); + assert.deepEqual(parsed.exportRoutes.map((route) => route.format), ["GLB"]); + const commands = [ + { id: "file.export-glb", ioFormat: { format: "GLB", operation: "EXPORT", execution: "LOCAL" } }, + { id: "file.import-obj", ioFormat: { format: "OBJ", operation: "IMPORT", execution: "LOCAL" } }, + { id: "file.export-usd", ioFormat: { format: "USD", operation: "EXPORT", execution: "SERVER" } }, + { id: "edit.undo" }, + ]; + assert.deepEqual(gate.filterIOFormatOperatorCommands(commands, parsed).map((command) => command.id), ["file.export-glb", "edit.undo"]); +}); + +test("M12-05C file selection is project-only while import routes are blocked", () => { + const parsed = gate.parseIOFormatUIRegistry(registry); + assert.equal(gate.ioFormatUIAccept(parsed), ".blend,application/octet-stream"); + assert.deepEqual(gate.gateIOFormatFileSelection("scene.blend", parsed), { status: "READY", kind: "BLEND" }); + assert.deepEqual(gate.gateIOFormatFileSelection("mesh.obj", parsed), { status: "BLOCKED", code: "IO_FORMAT_UNSUPPORTED", extension: ".obj" }); + assert.deepEqual(gate.gateIOFormatFileSelection("scene.glb", parsed), { status: "BLOCKED", code: "IO_FORMAT_UNSUPPORTED", extension: ".glb" }); +}); + +test("M12-05C rejects malformed route metadata", () => { + const bad = structuredClone(registry); + bad.importRoutes = [{ format: "OBJ", operation: "IMPORT", execution: "LOCAL", extensions: [".obj"] }]; + assert.doesNotThrow(() => gate.parseIOFormatUIRegistry(bad)); + const parsed = gate.parseIOFormatUIRegistry(bad); + assert.equal(gate.filterIOFormatOperatorCommands([{ id: "import-obj", ioFormat: { format: "OBJ", operation: "IMPORT", execution: "LOCAL" } }], parsed).length, 1); + bad.importRoutes[0].extensions = [".not-obj"]; + assert.throws(() => gate.parseIOFormatUIRegistry(bad), { code: "IO_FORMAT_UNSUPPORTED" }); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/keyboard-contract.test.mjs b/web/tests/unit/keyboard-contract.test.mjs new file mode 100644 index 00000000..605077c5 --- /dev/null +++ b/web/tests/unit/keyboard-contract.test.mjs @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import ts from "../../node_modules/typescript/lib/typescript.js"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const sourcePath = path.join(root, "web/protocol/keyboard-contract.ts"); +const output = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true }); +assert.deepEqual(output.diagnostics, []); +const keyboard = await import(`data:text/javascript;base64,${Buffer.from(output.outputText).toString("base64")}`); + +test("M14-04E preserves layout character, physical code, location and modifiers", () => { + assert.deepEqual(keyboard.observeKeyboardEvent({ key: "ä", code: "Quote", location: 0, shiftKey: true, ctrlKey: false, altKey: true, metaKey: false, repeat: true, isComposing: false }), { schemaVersion: 1, key: "ä", code: "Quote", location: 0, shiftKey: true, ctrlKey: false, altKey: true, metaKey: false, repeat: true, isComposing: false, deadKey: false }); + assert.equal(keyboard.observeKeyboardEvent({ key: "Dead", code: "Quote", location: 0 }).deadKey, true); +}); + +test("M14-04E rejects malformed key identity", () => { + assert.throws(() => keyboard.observeKeyboardEvent({ key: "", code: "KeyA", location: 0 }), /KEY_IDENTITY_INVALID/); + assert.throws(() => keyboard.observeKeyboardEvent({ key: "a", code: "KeyA", location: 4 }), /KEY_LOCATION_INVALID/); +}); diff --git a/web/tests/unit/library-append-wasm.test.mjs b/web/tests/unit/library-append-wasm.test.mjs new file mode 100644 index 00000000..4555ec70 --- /dev/null +++ b/web/tests/unit/library-append-wasm.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-append-wasm-unit-")); +const sourcePath = path.join(root, "web/protocol/library-main-append.ts"); +const identityPath = path.join(root, "web/protocol/library-operation-identity.ts"); +const identityTranspiled = ts.transpileModule(fs.readFileSync(identityPath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: identityPath, + reportDiagnostics: true, +}); +assert.deepEqual(identityTranspiled.diagnostics, []); +fs.writeFileSync(path.join(temporary, "library-operation-identity.mjs"), identityTranspiled.outputText); +const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, +}); +assert.deepEqual(transpiled.diagnostics, []); +fs.writeFileSync(path.join(temporary, "library-main-append.mjs"), transpiled.outputText.replaceAll("./library-operation-identity\"", "./library-operation-identity.mjs\"")); +const append = await import(pathToFileURL(path.join(temporary, "library-main-append.mjs"))); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-03N/manifest.json"), "utf8")); +const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex"); +const closure = { + object: "Object/M12 Append Object", + mesh: "Mesh/M12 Append Mesh", + material: "Material/M12 Append Material", + image: "Image/M12 Append Image", +}; + +test("M12-03N binds the independent append WASM command", () => { + assert.equal(manifest.task, "M12-03N"); + assert.equal(manifest.nextTask, "M12-04A"); + assert.equal(sha256("web/protocol/library-main-append.ts"), manifest.artifacts.appendWasmProtocol.sha256); +}); + +test("M12-03N validates the append closure and one-transaction receipt in the WASM lane", async () => { + assert.deepEqual(append.parseLibraryAppendClosure(closure), closure); + const request = { + baseRevision: 11, + binding: { + source: { sourceLibraryId: "library:" + "a".repeat(64) }, + sourceDataBlockId: closure.object, + dependencyClosureSha256: await append.computeLibraryAppendClosureSha256(closure), + }, + expectedClosure: closure, + }; + const receipt = append.createLibraryMainAppendReceipt(request, 12); + assert.equal(receipt.operation, "APPEND"); + assert.equal(receipt.transactionCount, 1); + assert.equal(receipt.baseRevision, 11); + assert.equal(receipt.nextRevision, 12); + assert.equal(receipt.mapping.length, 4); + assert(receipt.mapping.every((item) => item.owner === "LOCAL_MAIN" && item.readOnly === false && item.source === item.local)); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/library-archive-budget.test.mjs b/web/tests/unit/library-archive-budget.test.mjs new file mode 100644 index 00000000..bffb9ea0 --- /dev/null +++ b/web/tests/unit/library-archive-budget.test.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-archive-budget-unit-")); +for (const name of ["asset-path.ts", "capability-gates.ts", "error.ts", "asset-library-io.ts"]) { + const sourcePath = path.join(root, "web/protocol", name); + const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, + }); + assert.deepEqual(transpiled.diagnostics, []); + let output = transpiled.outputText; + output = output.replaceAll('from "./asset-path"', 'from "./asset-path.mjs"').replaceAll('from "./capability-gates"', 'from "./capability-gates.mjs"').replaceAll('from "./error"', 'from "./error.mjs"'); + fs.writeFileSync(path.join(temporary, name.replace(".ts", ".mjs")), output); +} +const io = await import(pathToFileURL(path.join(temporary, "asset-library-io.mjs"))); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-04F/manifest.json"), "utf8")); +const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex"); +const base = { format: "GLB", operation: "IMPORT", externalUris: [], archiveEntries: [] }; +const entry = (pathName, compressedBytes = 2, uncompressedBytes = 2) => ({ path: pathName, compressedBytes, uncompressedBytes }); + +test("M12-04F binds archive budget constants and evidence artifacts", () => { + assert.equal(manifest.task, "M12-04F"); + assert.equal(manifest.parentTask, "M12-04E"); + assert.equal(manifest.nextTask, "M12-04G"); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(artifact.path), artifact.sha256, artifact.path); + assert.equal(io.ASSET_LIBRARY_BUDGET.maxArchivePathDepth, 64); + assert.equal(io.ASSET_LIBRARY_BUDGET.maxArchiveFileNameBytes, 255); +}); + +test("M12-04F accepts entries at the per-entry, total, depth and filename limits", () => { + const fileName = "é".repeat(125) + ".bin"; + const deepPath = `${Array.from({ length: io.ASSET_LIBRARY_BUDGET.maxArchivePathDepth }, (_, index) => `d${index}`).join("/")}/file.bin`; + const result = io.parseIORequest({ ...base, byteLength: 10, archiveEntries: [entry("a.bin"), entry(deepPath), entry(fileName)] }); + assert.equal(result.archiveEntries.length, 3); +}); + +test("M12-04F rejects per-entry, total, count, depth and UTF-8 filename overflow", () => { + assert.throws(() => io.parseIORequest({ ...base, archiveEntries: [entry("huge.bin", 1, io.ASSET_LIBRARY_BUDGET.maxEntryBytes + 1)] }), { code: "ASSET_BUDGET_EXCEEDED" }); + assert.throws(() => io.parseIORequest({ ...base, archiveEntries: [entry("a.bin", io.ASSET_LIBRARY_BUDGET.maxEntryBytes, io.ASSET_LIBRARY_BUDGET.maxEntryBytes), entry("b.bin", io.ASSET_LIBRARY_BUDGET.maxEntryBytes, io.ASSET_LIBRARY_BUDGET.maxEntryBytes), entry("c.bin", 1, 1)] }), { code: "IO_ARCHIVE_UNSAFE" }); + assert.throws(() => io.parseIORequest({ ...base, archiveEntries: [entry(`${Array.from({ length: io.ASSET_LIBRARY_BUDGET.maxArchivePathDepth + 1 }, () => "d").join("/")}/file.bin`)] }), { code: "IO_ARCHIVE_UNSAFE" }); + assert.throws(() => io.parseIORequest({ ...base, archiveEntries: [entry("é".repeat(128))] }), { code: "IO_ARCHIVE_UNSAFE" }); + assert.throws(() => io.parseIORequest({ ...base, archiveEntries: Array.from({ length: io.ASSET_LIBRARY_BUDGET.maxArchiveEntries + 1 }, (_, index) => entry(`f${index}.bin`)) }), { code: "ASSET_BUDGET_EXCEEDED" }); +}); + +test("M12-04F keeps compression and declared source-byte budgets fail-closed", () => { + assert.throws(() => io.parseIORequest({ ...base, archiveEntries: [entry("bomb.bin", 1, io.ASSET_LIBRARY_BUDGET.maxCompressionRatio + 1)] }), { code: "IO_ARCHIVE_UNSAFE" }); + assert.throws(() => io.parseIORequest({ ...base, byteLength: 1, archiveEntries: [entry("source.bin", 2, 2)] }), { code: "IO_ARCHIVE_UNSAFE" }); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/library-archive-cancellation.test.mjs b/web/tests/unit/library-archive-cancellation.test.mjs new file mode 100644 index 00000000..82844885 --- /dev/null +++ b/web/tests/unit/library-archive-cancellation.test.mjs @@ -0,0 +1,209 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-archive-cancellation-unit-")); +const sourcePath = path.join(root, "web/protocol/archive-extraction-transaction.ts"); +const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, +}); +assert.deepEqual(transpiled.diagnostics, []); +fs.writeFileSync(path.join(temporary, "archive-extraction-transaction.mjs"), transpiled.outputText); +const extraction = await import(pathToFileURL(path.join(temporary, "archive-extraction-transaction.mjs"))); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-04H/manifest.json"), "utf8")); +const fileSha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex"); +const bytesSha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); + +class DirectoryExtractionStorage { + constructor(directory, committed) { + this.directory = directory; + this.committed = committed; + fs.mkdirSync(path.join(directory, "committed"), { recursive: true }); + fs.writeFileSync(path.join(directory, "committed", "project.blend"), Buffer.from("old-project")); + } + + async readCommitted() { return { ...this.committed }; } + + async createStaging(transactionId) { + fs.mkdirSync(path.join(this.directory, "staging", transactionId), { recursive: true }); + } + + async writeStaging(transactionId, entryPath, bytes) { + const target = path.join(this.directory, "staging", transactionId, entryPath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, bytes); + } + + async countStagingEntries(transactionId) { + const staging = path.join(this.directory, "staging", transactionId); + if (!fs.existsSync(staging)) return 0; + const visit = (directory) => fs.readdirSync(directory, { withFileTypes: true }).reduce( + (count, entry) => count + (entry.isDirectory() ? visit(path.join(directory, entry.name)) : 1), 0, + ); + return visit(staging); + } + + async discardStaging(transactionId) { + const count = await this.countStagingEntries(transactionId); + fs.rmSync(path.join(this.directory, "staging", transactionId), { recursive: true, force: true }); + return count; + } + + async commitStaging(transactionId, expected, candidate) { + assert.deepEqual(this.committed, expected); + const source = path.join(this.directory, "staging", transactionId, "project.blend"); + const target = path.join(this.directory, "committed", "project.blend"); + fs.renameSync(source, target); + await this.discardStaging(transactionId); + this.committed = { ...candidate }; + return { ...this.committed }; + } +} + +const oldBytes = Buffer.from("old-project"); +const newBytes = Buffer.from("new-project"); +const metadataBytes = Buffer.from("metadata"); +const committed = { projectId: "project:m12-04h", revision: 8, sha256: bytesSha256(oldBytes) }; +const candidate = { projectId: committed.projectId, revision: 9, sha256: bytesSha256(newBytes) }; +const request = { + schemaVersion: 1, + transactionId: "transaction:m12-04h", + archiveId: "archive:m12-04h", + committed, + candidate, + entries: [ + { path: "metadata/index.json", uncompressedBytes: metadataBytes.byteLength, sha256: bytesSha256(metadataBytes) }, + { path: "project.blend", uncompressedBytes: newBytes.byteLength, sha256: bytesSha256(newBytes) }, + ], +}; + +function createStorage(name) { + const directory = path.join(temporary, name); + fs.mkdirSync(directory, { recursive: true }); + return new DirectoryExtractionStorage(directory, committed); +} + +test("M12-04H binds cancellation rollback and evidence artifacts", () => { + assert.equal(manifest.task, "M12-04H"); + assert.equal(manifest.parentTask, "M12-04G"); + assert.equal(manifest.nextTask, "M12-04I"); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileSha256(artifact.path), artifact.sha256, artifact.path); +}); + +test("M12-04H removes partially written staging and preserves the committed project on cancellation", async () => { + const storage = createStorage("cancel-after-write"); + const controller = new AbortController(); + let reads = 0; + const receipt = await extraction.runArchiveExtractionTransaction(request, storage, async (entry) => { + reads++; + if (reads === 2) controller.abort(); + return entry.path === "project.blend" ? newBytes : metadataBytes; + }, controller.signal); + assert.deepEqual(receipt, { + status: "CANCELLED", + code: "IO_ARCHIVE_CANCELLED", + transactionId: request.transactionId, + committedBefore: committed, + committedAfter: committed, + removedStagingEntries: 1, + stagingEntriesAfter: 0, + publishedProjects: 0, + }); + assert.equal(fs.readFileSync(path.join(storage.directory, "committed", "project.blend"), "utf8"), "old-project"); + assert.equal(await storage.countStagingEntries(request.transactionId), 0); +}); + +test("M12-04H cancels before staging and after the last staged write without publishing", async () => { + const beforeStorage = createStorage("cancel-before-stage"); + const beforeController = new AbortController(); + beforeController.abort(); + const before = await extraction.runArchiveExtractionTransaction(request, beforeStorage, async () => newBytes, beforeController.signal); + assert.equal(before.status, "CANCELLED"); + assert.equal(before.removedStagingEntries, 0); + + const finalStorage = createStorage("cancel-after-last-write"); + const finalController = new AbortController(); + const originalWrite = finalStorage.writeStaging.bind(finalStorage); + finalStorage.writeStaging = async (transactionId, entryPath, bytes) => { + await originalWrite(transactionId, entryPath, bytes); + if (entryPath === "project.blend") finalController.abort(); + }; + const after = await extraction.runArchiveExtractionTransaction(request, finalStorage, async (entry) => + entry.path === "project.blend" ? newBytes : metadataBytes, finalController.signal); + assert.equal(after.status, "CANCELLED"); + assert.equal(after.removedStagingEntries, 2); + assert.deepEqual(await finalStorage.readCommitted(), committed); + assert.equal(fs.readFileSync(path.join(finalStorage.directory, "committed", "project.blend"), "utf8"), "old-project"); +}); + +test("M12-04H commits only after all staged payloads pass identity checks", async () => { + const storage = createStorage("commit"); + const receipt = await extraction.runArchiveExtractionTransaction(request, storage, async (entry) => + entry.path === "project.blend" ? newBytes : metadataBytes, new AbortController().signal); + assert.deepEqual(receipt, { + status: "COMMITTED", + transactionId: request.transactionId, + committed: candidate, + stagingEntriesAfter: 0, + }); + assert.equal(fs.readFileSync(path.join(storage.directory, "committed", "project.blend"), "utf8"), "new-project"); +}); + +test("M12-04H cleans staging on payload failure and detects rollback identity drift", async () => { + const failedStorage = createStorage("payload-failure"); + await assert.rejects( + extraction.runArchiveExtractionTransaction(request, failedStorage, async () => Buffer.from("wrong"), new AbortController().signal), + { code: "ASSET_SOURCE_HASH_MISMATCH" }, + ); + assert.equal(await failedStorage.countStagingEntries(request.transactionId), 0); + assert.deepEqual(await failedStorage.readCommitted(), committed); + + const driftStorage = createStorage("rollback-drift"); + const originalDiscard = driftStorage.discardStaging.bind(driftStorage); + driftStorage.discardStaging = async (transactionId) => { + const removed = await originalDiscard(transactionId); + driftStorage.committed = { ...committed, revision: committed.revision + 1 }; + return removed; + }; + const controller = new AbortController(); + await assert.rejects( + extraction.runArchiveExtractionTransaction(request, driftStorage, async (entry) => { + const bytes = entry.path === "project.blend" ? newBytes : metadataBytes; + queueMicrotask(() => controller.abort()); + return bytes; + }, controller.signal), + { code: "STORAGE_TRANSACTION" }, + ); +}); + +test("M12-04H rejects stale commits, non-canonical paths, prefix conflicts, and undeclared fields", async () => { + const storage = createStorage("invalid-requests"); + await assert.rejects( + extraction.runArchiveExtractionTransaction({ ...request, committed: { ...committed, revision: 7 } }, storage, async () => newBytes, new AbortController().signal), + { code: "REVISION_CONFLICT" }, + ); + for (const unsafePath of ["../project.blend", "C:/project.blend", "https:project.blend", "dir\\project.blend"]) { + await assert.rejects( + extraction.runArchiveExtractionTransaction({ ...request, entries: [{ ...request.entries[0], path: unsafePath }] }, storage, async () => metadataBytes, new AbortController().signal), + { code: "IO_ARCHIVE_UNSAFE" }, + ); + } + await assert.rejects( + extraction.runArchiveExtractionTransaction({ ...request, entries: [request.entries[0], { ...request.entries[1], path: "metadata" }] }, storage, async () => metadataBytes, new AbortController().signal), + { code: "IO_ARCHIVE_UNSAFE" }, + ); + await assert.rejects( + extraction.runArchiveExtractionTransaction({ ...request, future: true }, storage, async () => newBytes, new AbortController().signal), + { code: "IO_ARCHIVE_UNSAFE" }, + ); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/library-archive-conflicts.test.mjs b/web/tests/unit/library-archive-conflicts.test.mjs new file mode 100644 index 00000000..d8050113 --- /dev/null +++ b/web/tests/unit/library-archive-conflicts.test.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-archive-conflicts-unit-")); +const sourcePath = path.join(root, "web/protocol/archive-conflicts.ts"); +const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true }); +assert.deepEqual(transpiled.diagnostics, []); +fs.writeFileSync(path.join(temporary, "archive-conflicts.mjs"), transpiled.outputText); +const conflicts = await import(pathToFileURL(path.join(temporary, "archive-conflicts.mjs"))); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-04G/manifest.json"), "utf8")); +const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex"); +const range = (pathName, compressedOffset, compressedBytes = 2, uncompressedBytes = 2) => ({ path: pathName, compressedOffset, compressedBytes, uncompressedBytes }); +const valid = { schemaVersion: 1, byteLength: 10, ranges: [range("a.bin", 0), range("dir/b.bin", 2, 3, 3)] }; + +test("M12-04G binds range/conflict validation and evidence artifacts", () => { + assert.equal(manifest.task, "M12-04G"); + assert.equal(manifest.parentTask, "M12-04F"); + assert.equal(manifest.nextTask, "M12-04H"); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(artifact.path), artifact.sha256, artifact.path); +}); + +test("M12-04G accepts deterministic non-overlapping ranges and reports totals", () => { + assert.deepEqual(conflicts.validateArchiveConflicts(valid), { status: "VALID", totalCompressedBytes: 5, totalUncompressedBytes: 5, nonOverlapping: true, uniquePaths: true, noPrefixConflicts: true }); +}); + +test("M12-04G rejects compression bombs, overlaps, duplicate paths, and prefix conflicts", () => { + assert.throws(() => conflicts.validateArchiveConflicts({ ...valid, ranges: [range("bomb.bin", 0, 1, conflicts.ARCHIVE_CONFLICT_BUDGET.maxCompressionRatio + 1)] }), { code: "IO_ARCHIVE_UNSAFE" }); + assert.throws(() => conflicts.validateArchiveConflicts({ ...valid, ranges: [range("a.bin", 0, 4), range("b.bin", 3, 2)] }), { code: "IO_ARCHIVE_UNSAFE" }); + assert.throws(() => conflicts.validateArchiveConflicts({ ...valid, ranges: [range("same.bin", 0), range("same.bin", 2)] }), { code: "IO_ARCHIVE_UNSAFE" }); + assert.throws(() => conflicts.validateArchiveConflicts({ ...valid, ranges: [range("folder", 0), range("folder/file.bin", 2)] }), { code: "IO_ARCHIVE_UNSAFE" }); +}); + +test("M12-04G rejects range/source bounds and undeclared fields", () => { + assert.throws(() => conflicts.validateArchiveConflicts({ ...valid, byteLength: 4 }), { code: "IO_ARCHIVE_UNSAFE" }); + assert.throws(() => conflicts.validateArchiveConflicts({ ...valid, ranges: [{ ...valid.ranges[0], future: true }] }), { code: "IO_ARCHIVE_UNSAFE" }); + assert.throws(() => conflicts.validateArchiveConflicts({ ...valid, ranges: [{ ...valid.ranges[0], compressedOffset: 9, compressedBytes: 2 }] }), { code: "IO_ARCHIVE_UNSAFE" }); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/library-archive-recovery.test.mjs b/web/tests/unit/library-archive-recovery.test.mjs new file mode 100644 index 00000000..90e0416c --- /dev/null +++ b/web/tests/unit/library-archive-recovery.test.mjs @@ -0,0 +1,163 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-archive-recovery-unit-")); +for (const sourceName of ["archive-extraction-transaction.ts", "archive-extraction-recovery.ts"]) { + const sourcePath = path.join(root, "web/protocol", sourceName); + const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, + }); + assert.deepEqual(transpiled.diagnostics, []); + fs.writeFileSync( + path.join(temporary, sourceName.replace(".ts", ".mjs")), + transpiled.outputText.replaceAll('from "./archive-extraction-transaction"', 'from "./archive-extraction-transaction.mjs"'), + ); +} +const extraction = await import(pathToFileURL(path.join(temporary, "archive-extraction-recovery.mjs"))); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-04I/manifest.json"), "utf8")); +const fileSha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex"); +const bytesSha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); + +class FaultingDirectoryStorage { + constructor(directory, committed) { + this.directory = directory; + this.committed = { ...committed }; + this.fault = null; + this.partialBytes = 0; + fs.mkdirSync(path.join(directory, "committed"), { recursive: true }); + fs.writeFileSync(path.join(directory, "committed", "project.blend"), Buffer.from("old-project")); + } + + async readCommitted() { return { ...this.committed }; } + + async createStaging(transactionId) { + fs.mkdirSync(path.join(this.directory, "staging", transactionId), { recursive: true }); + } + + async writeStaging(transactionId, entryPath, bytes) { + const target = path.join(this.directory, "staging", transactionId, entryPath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + if (this.fault) { + const partial = bytes.subarray(0, Math.max(1, Math.floor(bytes.byteLength / 2))); + fs.writeFileSync(target, partial); + this.partialBytes += partial.byteLength; + const fault = this.fault; + this.fault = null; + throw fault; + } + fs.writeFileSync(target, bytes); + } + + async countStagingEntries(transactionId) { + const staging = path.join(this.directory, "staging", transactionId); + if (!fs.existsSync(staging)) return 0; + const visit = (directory) => fs.readdirSync(directory, { withFileTypes: true }).reduce( + (count, entry) => count + (entry.isDirectory() ? visit(path.join(directory, entry.name)) : 1), 0, + ); + return visit(staging); + } + + async discardStaging(transactionId) { + const count = await this.countStagingEntries(transactionId); + fs.rmSync(path.join(this.directory, "staging", transactionId), { recursive: true, force: true }); + return count; + } + + async commitStaging(transactionId, expected, candidate) { + assert.deepEqual(this.committed, expected); + const source = path.join(this.directory, "staging", transactionId, "project.blend"); + const target = path.join(this.directory, "committed", "project.blend"); + fs.renameSync(source, target); + await this.discardStaging(transactionId); + this.committed = { ...candidate }; + return { ...this.committed }; + } +} + +const oldBytes = Buffer.from("old-project"); +const largeBytes = Buffer.alloc(4096, 0x51); +const smallBytes = Buffer.from("small-project"); +const committed = { projectId: "project:m12-04i", revision: 9, sha256: bytesSha256(oldBytes) }; + +function request(transactionId, base, bytes) { + return { + schemaVersion: 1, + transactionId, + archiveId: `archive:${transactionId}`, + committed: base, + candidate: { projectId: base.projectId, revision: base.revision + 1, sha256: bytesSha256(bytes) }, + entries: [{ path: "project.blend", uncompressedBytes: bytes.byteLength, sha256: bytesSha256(bytes) }], + }; +} + +function createStorage(name) { + const directory = path.join(temporary, name); + fs.mkdirSync(directory, { recursive: true }); + return new FaultingDirectoryStorage(directory, committed); +} + +async function faultThenRecover(name, fault, expectedCode) { + const storage = createStorage(name); + storage.fault = fault; + const failedRequest = request(`transaction:${name}:large`, committed, largeBytes); + await assert.rejects( + extraction.runArchiveExtractionTransaction(failedRequest, storage, async () => largeBytes, new AbortController().signal), + { code: expectedCode }, + ); + assert.ok(storage.partialBytes > 0); + assert.equal(await storage.countStagingEntries(failedRequest.transactionId), 0); + assert.deepEqual(await storage.readCommitted(), committed); + assert.equal(fs.readFileSync(path.join(storage.directory, "committed", "project.blend"), "utf8"), "old-project"); + + const recoveryRequest = request(`transaction:${name}:small`, committed, smallBytes); + const receipt = await extraction.runArchiveExtractionTransaction( + recoveryRequest, + storage, + async () => smallBytes, + new AbortController().signal, + ); + assert.deepEqual(receipt, { + status: "COMMITTED", + transactionId: recoveryRequest.transactionId, + committed: recoveryRequest.candidate, + stagingEntriesAfter: 0, + }); + assert.equal(await storage.countStagingEntries(recoveryRequest.transactionId), 0); + assert.equal(fs.readFileSync(path.join(storage.directory, "committed", "project.blend"), "utf8"), "small-project"); +} + +test("M12-04I binds quota/OOM cleanup and recovery evidence", () => { + assert.equal(manifest.task, "M12-04I"); + assert.equal(manifest.parentTask, "M12-04H"); + assert.equal(manifest.nextTask, "M12-04J"); + assert.deepEqual(manifest.assertions.faultCodes, ["STORAGE_QUOTA", "WASM_OUT_OF_MEMORY"]); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileSha256(artifact.path), artifact.sha256, artifact.path); +}); + +test("M12-04I releases partial staging after quota and accepts a small archive in the same storage", async () => { + await faultThenRecover("quota", new DOMException("quota exhausted", "QuotaExceededError"), "STORAGE_QUOTA"); +}); + +test("M12-04I releases partial staging after OOM and accepts a small archive in the same storage", async () => { + await faultThenRecover("oom", Object.assign(new Error("deterministic allocation failure"), { code: "WASM_OUT_OF_MEMORY" }), "WASM_OUT_OF_MEMORY"); +}); + +test("M12-04I maps only declared resource faults", () => { + assert.equal(extraction.archiveExtractionResourceFaultCode(new DOMException("full", "QuotaExceededError")), "STORAGE_QUOTA"); + assert.equal(extraction.archiveExtractionResourceFaultCode(Object.assign(new Error("fault"), { code: "STORAGE_QUOTA" })), "STORAGE_QUOTA"); + assert.equal(extraction.archiveExtractionResourceFaultCode(Object.assign(new Error("fault"), { code: "WASM_OUT_OF_MEMORY" })), "WASM_OUT_OF_MEMORY"); + assert.equal(extraction.archiveExtractionResourceFaultCode(Object.assign(new Error("fault"), { name: "OutOfMemoryError" })), "WASM_OUT_OF_MEMORY"); + assert.equal(extraction.archiveExtractionResourceFaultCode(new RangeError("array length is invalid")), undefined); + assert.equal(extraction.archiveExtractionResourceFaultCode(new Error("ordinary IO failure")), undefined); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/library-link-safety.test.mjs b/web/tests/unit/library-link-safety.test.mjs new file mode 100644 index 00000000..6cf5829a --- /dev/null +++ b/web/tests/unit/library-link-safety.test.mjs @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-link-safety-unit-")); +const sourcePath = path.join(root, "web/protocol/archive-link-safety.ts"); +const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, +}); +assert.deepEqual(transpiled.diagnostics, []); +fs.writeFileSync(path.join(temporary, "archive-link-safety.mjs"), transpiled.outputText); +const safety = await import(pathToFileURL(path.join(temporary, "archive-link-safety.mjs"))); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-04D/manifest.json"), "utf8")); +const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex"); + +const valid = { + schemaVersion: 1, + temporaryRootId: "staging:archive-1", + entries: [ + { path: "payload/data.bin", type: "FILE", target: null }, + { path: "payload/data-alias.bin", type: "SYMLINK", target: "./data.bin" }, + { path: "payload/data-hard.bin", type: "HARDLINK", target: "payload/data.bin" }, + { path: "payload", type: "DIRECTORY", target: null }, + { path: "alias-dir", type: "SYMLINK", target: "payload" }, + ], +}; + +test("M12-04D binds the archive link gate and evidence artifacts", () => { + assert.equal(manifest.task, "M12-04D"); + assert.equal(manifest.parentTask, "M12-04C"); + assert.equal(manifest.nextTask, "M12-04E"); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(artifact.path), artifact.sha256, artifact.path); +}); + +test("M12-04D resolves symlinks relative to their parent and hardlinks from the archive root", () => { + const result = safety.resolveArchiveLinkEntries(valid); + assert.equal(result.status, "READY"); + assert.equal(result.entries.find((entry) => entry.path === "payload/data-alias.bin").resolvedPath, "payload/data.bin"); + assert.equal(result.entries.find((entry) => entry.path === "payload/data-hard.bin").resolvedPath, "payload/data.bin"); + assert.equal(result.entries.find((entry) => entry.path === "alias-dir").resolvedPath, "payload"); + assert.ok(result.entries.every((entry) => entry.withinTemporaryRoot === true)); +}); + +test("M12-04D rejects absolute, drive, URI and traversal targets before writing", () => { + for (const target of ["/outside", "\\\\server\\share", "C:/outside", "https://evil.example/a", "../../outside", "payload/../../outside"]) { + assert.throws(() => safety.resolveArchiveLinkEntries({ + ...valid, + entries: [{ path: "payload/link", type: "SYMLINK", target }, { path: "payload", type: "DIRECTORY", target: null }], + }), { code: "IO_ARCHIVE_UNSAFE" }); + } +}); + +test("M12-04D rejects missing targets, cycles and hardlinks to directories", () => { + assert.throws(() => safety.resolveArchiveLinkEntries({ + ...valid, + entries: [{ path: "link", type: "SYMLINK", target: "missing.bin" }], + }), { code: "IO_ARCHIVE_UNSAFE" }); + assert.throws(() => safety.resolveArchiveLinkEntries({ + ...valid, + entries: [ + { path: "a", type: "SYMLINK", target: "b" }, + { path: "b", type: "HARDLINK", target: "a" }, + ], + }), { code: "IO_ARCHIVE_UNSAFE" }); + assert.throws(() => safety.resolveArchiveLinkEntries({ + ...valid, + entries: [ + { path: "dir", type: "DIRECTORY", target: null }, + { path: "dir-hard", type: "HARDLINK", target: "dir" }, + ], + }), { code: "IO_ARCHIVE_UNSAFE" }); +}); + +test("M12-04D rejects duplicate members and undeclared fields", () => { + assert.throws(() => safety.parseArchiveLinkRequest({ ...valid, entries: [{ ...valid.entries[0], target: null }, { ...valid.entries[0], target: null }] }), { code: "IO_ARCHIVE_UNSAFE" }); + assert.throws(() => safety.parseArchiveLinkRequest({ ...valid, future: true }), { code: "IO_ARCHIVE_UNSAFE" }); + assert.throws(() => safety.parseArchiveLinkRequest({ ...valid, entries: [{ ...valid.entries[0], mode: 0o644 }] }), { code: "IO_ARCHIVE_UNSAFE" }); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/library-linked-missing.test.mjs b/web/tests/unit/library-linked-missing.test.mjs new file mode 100644 index 00000000..cdde7ad9 --- /dev/null +++ b/web/tests/unit/library-linked-missing.test.mjs @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-linked-missing-unit-")); +const sourcePath = path.join(root, "web/protocol/library-linked-missing.ts"); +const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, +}); +assert.deepEqual(transpiled.diagnostics, []); +fs.writeFileSync(path.join(temporary, "library-linked-missing.mjs"), transpiled.outputText); +const missing = await import(pathToFileURL(path.join(temporary, "library-linked-missing.mjs"))); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-03I/manifest.json"), "utf8")); +const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex"); +const library = (value) => `library:${String(value).repeat(64).slice(0, 64)}`; +const digest = (value) => crypto.createHash("sha256").update(String(value)).digest("hex"); +const reference = (sourceLibraryId, generation, revision, marker, status = "AVAILABLE") => ({ + sourceLibraryId, + sourceLocator: `project://libraries/${marker}.blend`, + sourceSha256: digest(`${marker}-source`), + sourceGeneration: generation, + sourceRevision: revision, + dataBlockIds: [`Object/${marker}`, `Mesh/${marker}`], + status, + placeholder: status === "MISSING" ? { + kind: "MISSING_LIBRARY", + sourceLibraryId, + dataBlockIds: [`Object/${marker}`, `Mesh/${marker}`], + } : null, +}); + +test("M12-03I binds the missing-library protocol and evidence artifacts", () => { + assert.equal(manifest.task, "M12-03I"); + assert.equal(manifest.parentTask, "M12-03H"); + assert.equal(manifest.nextTask, "M12-03J"); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(artifact.path), artifact.sha256, artifact.path); +}); + +test("M12-03I marks only the matching reference missing and preserves its original source", () => { + const sourceLibraryId = library("a"); + const otherLibraryId = library("b"); + const current = reference(sourceLibraryId, 4, 9, "primary"); + const other = reference(otherLibraryId, 1, 2, "other"); + const state = { schemaVersion: 1, references: [current, other] }; + const decision = missing.markLinkedLibraryMissing(state, { + schemaVersion: 1, + operation: "MARK_MISSING", + sourceLibraryId, + sourceLocator: current.sourceLocator, + sourceSha256: current.sourceSha256, + expectedGeneration: 4, + expectedRevision: 9, + }); + assert.equal(decision.status, "MARKED"); + assert.equal(decision.code, null); + assert.deepEqual(decision.state.references[0], { ...current, status: "MISSING", placeholder: { + kind: "MISSING_LIBRARY", sourceLibraryId, dataBlockIds: current.dataBlockIds, + } }); + assert.deepEqual(decision.state.references[1], other); + assert.deepEqual(state.references, [current, other]); +}); + +test("M12-03I preserves the reference on stale generation and source hash drift", () => { + const sourceLibraryId = library("c"); + const current = reference(sourceLibraryId, 2, 5, "stable"); + const state = { schemaVersion: 1, references: [current] }; + const base = { + schemaVersion: 1, + operation: "MARK_MISSING", + sourceLibraryId, + sourceLocator: current.sourceLocator, + sourceSha256: current.sourceSha256, + expectedGeneration: 2, + expectedRevision: 5, + }; + assert.equal(missing.markLinkedLibraryMissing(state, { ...base, expectedRevision: 6 }).code, "REVISION_CONFLICT"); + assert.equal(missing.markLinkedLibraryMissing(state, { ...base, sourceSha256: digest("different") }).code, "ASSET_SOURCE_HASH_MISMATCH"); + assert.deepEqual(state.references, [current]); +}); + +test("M12-03I rejects undeclared fields, duplicate identities, and invalid placeholders", () => { + const sourceLibraryId = library("d"); + const current = reference(sourceLibraryId, 1, 1, "invalid"); + assert.throws(() => missing.parseLinkedMissingRequest({ + schemaVersion: 1, + operation: "MARK_MISSING", + sourceLibraryId, + sourceLocator: current.sourceLocator, + sourceSha256: current.sourceSha256, + expectedGeneration: 1, + expectedRevision: 1, + future: true, + }), { code: "TASK_VALIDATION_FAILED" }); + assert.throws(() => missing.parseLinkedMissingState({ schemaVersion: 1, references: [current, current] }), { code: "TASK_VALIDATION_FAILED" }); + assert.throws(() => missing.parseLinkedLibraryReference({ ...current, status: "MISSING", placeholder: null }), { code: "TASK_VALIDATION_FAILED" }); + assert.throws(() => missing.parseLinkedLibraryReference({ ...current, status: "AVAILABLE", placeholder: { kind: "MISSING_LIBRARY", sourceLibraryId, dataBlockIds: current.dataBlockIds } }), { code: "TASK_VALIDATION_FAILED" }); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/library-linked-mutation.test.mjs b/web/tests/unit/library-linked-mutation.test.mjs new file mode 100644 index 00000000..0eaf11dc --- /dev/null +++ b/web/tests/unit/library-linked-mutation.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-linked-mutation-unit-")); +const transpile = (sourceName, outputName, replacements = []) => { + const sourcePath = path.join(root, "web/protocol", sourceName); + const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, + }); + assert.deepEqual(transpiled.diagnostics, []); + fs.writeFileSync(path.join(temporary, outputName), replacements.reduce((source, [from, to]) => source.replaceAll(from, to), transpiled.outputText)); +}; +transpile("capability-gates.ts", "capability-gates.mjs"); +transpile("library-linked-mutation.ts", "library-linked-mutation.mjs", [["from \"./capability-gates\"", "from \"./capability-gates.mjs\""]]); +const linked = await import(pathToFileURL(path.join(temporary, "library-linked-mutation.mjs"))); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-03G/manifest.json"), "utf8")); +const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex"); +const base = (operation = "MESH_GEOMETRY") => ({ + schemaVersion: 1, + operation, + dataBlockId: "mesh:M12 Link Mesh", + baseRevision: 7, + owner: "SOURCE_LIBRARY", + linkedLibrary: true, + readOnly: true, +}); + +test("M12-03G binds the linked writer protocol and evidence artifacts", () => { + assert.equal(manifest.task, "M12-03G"); + assert.equal(manifest.parentTask, "M12-03F"); + assert.equal(manifest.nextTask, "M12-03H"); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(artifact.path), artifact.sha256, artifact.path); +}); + +test("M12-03G blocks every linked data writer with the stable mutation code", () => { + assert.equal(linked.LINKED_DATA_WRITER_OPERATIONS.length, 6); + for (const operation of linked.LINKED_DATA_WRITER_OPERATIONS) { + const gate = linked.gateLinkedDataMutation(base(operation), 7); + assert.equal(gate.status, "BLOCKED"); + assert.deepEqual(gate.issues.map((issue) => issue.code), ["LINKED_DATA_MUTATION_BLOCKED"]); + assert.equal(gate.issues[0].recoverable, false); + assert.deepEqual(linked.parseLinkedDataMutation(base(operation)), { ...base(operation) }); + } +}); + +test("M12-03G rejects stale, malformed, and ownership-substituted writes before Main", () => { + assert.equal(linked.gateLinkedDataMutation(base(), 8).issues[0].code, "REVISION_CONFLICT"); + assert.equal(linked.gateLinkedDataMutation({ ...base(), owner: "LOCAL_MAIN", linkedLibrary: false, readOnly: false }, 7).issues[0].code, "LINKED_DATA_MUTATION_BLOCKED"); + assert.equal(linked.gateLinkedDataMutation({ ...base(), future: true }, 7).issues[0].code, "TASK_VALIDATION_FAILED"); + assert.equal(linked.gateLinkedDataMutation({ ...base(), operation: "UNKNOWN" }, 7).issues[0].code, "TASK_VALIDATION_FAILED"); +}); diff --git a/web/tests/unit/library-linked-reload.test.mjs b/web/tests/unit/library-linked-reload.test.mjs new file mode 100644 index 00000000..de063165 --- /dev/null +++ b/web/tests/unit/library-linked-reload.test.mjs @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-linked-reload-unit-")); +const sourcePath = path.join(root, "web/protocol/library-linked-reload.ts"); +const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, +}); +assert.deepEqual(transpiled.diagnostics, []); +fs.writeFileSync(path.join(temporary, "library-linked-reload.mjs"), transpiled.outputText); +const reload = await import(pathToFileURL(path.join(temporary, "library-linked-reload.mjs"))); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-03H/manifest.json"), "utf8")); +const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex"); +const library = (value) => `library:${String(value).repeat(64).slice(0, 64)}`; +const digest = (value) => crypto.createHash("sha256").update(String(value)).digest("hex"); +const snapshot = (sourceLibraryId, generation, revision, marker) => ({ + sourceLibraryId, + sourceGeneration: generation, + sourceRevision: revision, + dependencyClosureSha256: digest(marker), + graphSha256: digest(`${marker}-graph`), + dataBlocks: [ + { dataBlockId: `Object/${marker}`, owner: "SOURCE_LIBRARY", readOnly: true }, + { dataBlockId: `Mesh/${marker}`, owner: "SOURCE_LIBRARY", readOnly: true }, + ], +}); + +test("M12-03H binds the reload protocol and evidence artifacts", () => { + assert.equal(manifest.task, "M12-03H"); + assert.equal(manifest.parentTask, "M12-03G"); + assert.equal(manifest.nextTask, "M12-03I"); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(artifact.path), artifact.sha256, artifact.path); +}); + +test("M12-03H replaces only the matching library generation and preserves every other snapshot", () => { + const sourceLibraryId = library("a"); + const otherLibraryId = library("b"); + const current = snapshot(sourceLibraryId, 3, 7, "old"); + const otherGeneration = snapshot(sourceLibraryId, 9, 2, "future"); + const otherLibrary = snapshot(otherLibraryId, 1, 4, "other"); + const state = { schemaVersion: 1, snapshots: [current, otherGeneration, otherLibrary] }; + const replacement = snapshot(sourceLibraryId, 4, 8, "new"); + const decision = reload.reloadMatchingLinkedSnapshot(state, { + schemaVersion: 1, + operation: "RELOAD", + sourceLibraryId, + expectedGeneration: 3, + expectedRevision: 7, + replacement, + }); + assert.equal(decision.status, "REPLACED"); + assert.equal(decision.code, null); + assert.deepEqual(decision.state.snapshots, [replacement, otherGeneration, otherLibrary]); + assert.deepEqual(state.snapshots, [current, otherGeneration, otherLibrary]); + assert(decision.state.snapshots.every((item) => item.dataBlocks.every((block) => block.owner === "SOURCE_LIBRARY" && block.readOnly))); +}); + +test("M12-03H rejects stale generations and keeps state byte-for-byte equivalent", () => { + const sourceLibraryId = library("c"); + const state = { schemaVersion: 1, snapshots: [snapshot(sourceLibraryId, 5, 11, "stable")] }; + const request = { + schemaVersion: 1, + operation: "RELOAD", + sourceLibraryId, + expectedGeneration: 4, + expectedRevision: 10, + replacement: snapshot(sourceLibraryId, 5, 12, "late"), + }; + const decision = reload.reloadMatchingLinkedSnapshot(state, request); + assert.deepEqual(decision, { + status: "STALE", + code: "REVISION_CONFLICT", + sourceLibraryId, + replacedGeneration: 4, + replacementGeneration: 5, + state, + }); +}); + +test("M12-03H fails closed for malformed, substituted, duplicate, and non-adjacent reloads", () => { + const sourceLibraryId = library("d"); + const current = snapshot(sourceLibraryId, 1, 1, "current"); + const baseRequest = { + schemaVersion: 1, + operation: "RELOAD", + sourceLibraryId, + expectedGeneration: 1, + expectedRevision: 1, + replacement: snapshot(sourceLibraryId, 2, 2, "replacement"), + }; + assert.throws(() => reload.parseLinkedReloadRequest({ ...baseRequest, future: true }), { code: "TASK_VALIDATION_FAILED" }); + assert.throws(() => reload.parseLinkedReloadRequest({ ...baseRequest, replacement: { ...baseRequest.replacement, sourceLibraryId: library("e") } }), { code: "TASK_VALIDATION_FAILED" }); + assert.throws(() => reload.parseLinkedReloadState({ schemaVersion: 1, snapshots: [current, current] }), { code: "TASK_VALIDATION_FAILED" }); + const decision = reload.reloadMatchingLinkedSnapshot({ schemaVersion: 1, snapshots: [current] }, { + ...baseRequest, + replacement: snapshot(sourceLibraryId, 3, 3, "skip"), + }); + assert.equal(decision.status, "STALE"); + assert.equal(decision.code, "REVISION_CONFLICT"); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/library-metadata-first.test.mjs b/web/tests/unit/library-metadata-first.test.mjs new file mode 100644 index 00000000..72a06d9a --- /dev/null +++ b/web/tests/unit/library-metadata-first.test.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-metadata-first-unit-")); +const sourcePath = path.join(root, "web/protocol/archive-metadata-first.ts"); +const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, +}); +assert.deepEqual(transpiled.diagnostics, []); +fs.writeFileSync(path.join(temporary, "archive-metadata-first.mjs"), transpiled.outputText); +const metadata = await import(pathToFileURL(path.join(temporary, "archive-metadata-first.mjs"))); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-04E/manifest.json"), "utf8")); +const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex"); +const zip = { schemaVersion: 1, archiveId: "archive:zip-1", format: "ZIP", archiveByteLength: 4096, metadataOffset: 3072, metadataByteLength: 512 }; +const tar = { schemaVersion: 1, archiveId: "archive:tar-1", format: "TAR", archiveByteLength: 4096, metadataOffset: 0, metadataByteLength: 1024 }; + +test("M12-04E binds metadata-first planning and evidence artifacts", () => { + assert.equal(manifest.task, "M12-04E"); + assert.equal(manifest.parentTask, "M12-04D"); + assert.equal(manifest.nextTask, "M12-04F"); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(artifact.path), artifact.sha256, artifact.path); +}); + +test("M12-04E plans ZIP central-directory and TAR manifest reads without payload ranges", () => { + assert.deepEqual(metadata.planArchiveMetadataRead(zip), { + status: "METADATA_ONLY", schemaVersion: 1, archiveId: "archive:zip-1", format: "ZIP", + firstRead: { kind: "CENTRAL_DIRECTORY", byteOffset: 3072, byteLength: 512 }, payloadReads: [], + }); + assert.deepEqual(metadata.planArchiveMetadataRead(tar).firstRead, { kind: "MANIFEST", byteOffset: 0, byteLength: 1024 }); +}); + +test("M12-04E accepts a trace only when metadata is the first exact read", () => { + assert.deepEqual(metadata.validateArchiveReadTrace(zip, { + schemaVersion: 1, archiveId: "archive:zip-1", reads: [ + { sequence: 0, kind: "CENTRAL_DIRECTORY", byteOffset: 3072, byteLength: 512 }, + { sequence: 1, kind: "PAYLOAD", byteOffset: 32, byteLength: 128 }, + ], + }), { status: "VALID", metadataFirst: true, payloadReadsAfterMetadata: true }); + assert.deepEqual(metadata.validateArchiveReadTrace(tar, { + schemaVersion: 1, archiveId: "archive:tar-1", reads: [{ sequence: 0, kind: "MANIFEST", byteOffset: 0, byteLength: 1024 }], + }).metadataFirst, true); +}); + +test("M12-04E rejects payload-first, wrong-range, duplicate-metadata and invalid ranges", () => { + assert.throws(() => metadata.validateArchiveReadTrace(zip, { schemaVersion: 1, archiveId: "archive:zip-1", reads: [ + { sequence: 0, kind: "PAYLOAD", byteOffset: 0, byteLength: 16 }, + { sequence: 1, kind: "CENTRAL_DIRECTORY", byteOffset: 3072, byteLength: 512 }, + ] }), { code: "IO_ARCHIVE_UNSAFE" }); + assert.throws(() => metadata.validateArchiveReadTrace(zip, { schemaVersion: 1, archiveId: "archive:zip-1", reads: [{ sequence: 0, kind: "CENTRAL_DIRECTORY", byteOffset: 3000, byteLength: 512 }] }), { code: "IO_ARCHIVE_UNSAFE" }); + assert.throws(() => metadata.validateArchiveReadTrace(zip, { schemaVersion: 1, archiveId: "archive:zip-1", reads: [ + { sequence: 0, kind: "CENTRAL_DIRECTORY", byteOffset: 3072, byteLength: 512 }, + { sequence: 1, kind: "CENTRAL_DIRECTORY", byteOffset: 3072, byteLength: 512 }, + ] }), { code: "IO_ARCHIVE_UNSAFE" }); + assert.throws(() => metadata.planArchiveMetadataRead({ ...zip, metadataOffset: 4000, metadataByteLength: 200 }), { code: "IO_ARCHIVE_UNSAFE" }); + assert.throws(() => metadata.planArchiveMetadataRead({ ...zip, metadataByteLength: 64 * 1024 * 1024 + 1 }), { code: "IO_ARCHIVE_UNSAFE" }); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/library-negative-cases.test.mjs b/web/tests/unit/library-negative-cases.test.mjs new file mode 100644 index 00000000..3958d667 --- /dev/null +++ b/web/tests/unit/library-negative-cases.test.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-negative-cases-unit-")); +const sourcePath = path.join(root, "web/protocol/library-negative-cases.ts"); +const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, +}); +assert.deepEqual(transpiled.diagnostics, []); +fs.writeFileSync(path.join(temporary, "library-negative-cases.mjs"), transpiled.outputText); +const negatives = await import(pathToFileURL(path.join(temporary, "library-negative-cases.mjs"))); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-03M/manifest.json"), "utf8")); +const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex"); +const a = "library:" + "a".repeat(64); +const b = "library:" + "b".repeat(64); +const c = "library:" + "c".repeat(64); +const valid = { + schemaVersion: 1, + libraries: [{ libraryId: a, dependencyIds: [b] }, { libraryId: b, dependencyIds: [] }, { libraryId: c, dependencyIds: [] }], + dataBlocks: [{ dataBlockId: "Object/A", sourceLibraryId: a }, { dataBlockId: "Object/B", sourceLibraryId: b }], + crossReferences: [], + reloads: [{ sourceLibraryId: a, generation: 1 }, { sourceLibraryId: a, generation: 2 }], +}; + +test("M12-03M binds the library negative-case protocol and evidence artifacts", () => { + assert.equal(manifest.task, "M12-03M"); + assert.equal(manifest.parentTask, "M12-03L"); + assert.equal(manifest.nextTask, "M12-03N"); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(artifact.path), artifact.sha256, artifact.path); +}); + +test("M12-03M accepts an acyclic graph with unique data-block and reload identities", () => { + assert.deepEqual(negatives.validateLibraryNegativeInput(valid), { status: "VALID" }); +}); + +test("M12-03M rejects dependency and cross-library cycles", () => { + assert.throws(() => negatives.validateLibraryNegativeInput({ ...valid, libraries: [{ libraryId: a, dependencyIds: [b] }, { libraryId: b, dependencyIds: [a] }, { libraryId: c, dependencyIds: [] }] }), { code: "LIBRARY_DEPENDENCY_CYCLE" }); + assert.throws(() => negatives.validateLibraryNegativeInput({ ...valid, crossReferences: [{ fromLibraryId: a, toLibraryId: b }, { fromLibraryId: b, toLibraryId: a }] }), { code: "LIBRARY_DEPENDENCY_CYCLE" }); +}); + +test("M12-03M rejects ID collision, duplicate reload and missing library references", () => { + assert.throws(() => negatives.validateLibraryNegativeInput({ ...valid, dataBlocks: [{ dataBlockId: "Object/A", sourceLibraryId: a }, { dataBlockId: "Object/A", sourceLibraryId: b }] }), { code: "TASK_VALIDATION_FAILED" }); + assert.throws(() => negatives.validateLibraryNegativeInput({ ...valid, reloads: [{ sourceLibraryId: a, generation: 1 }, { sourceLibraryId: a, generation: 1 }] }), { code: "REVISION_CONFLICT" }); + assert.throws(() => negatives.validateLibraryNegativeInput({ ...valid, dataBlocks: [{ dataBlockId: "Object/A", sourceLibraryId: "library:" + "d".repeat(64) }] }), { code: "ASSET_SOURCE_HASH_MISMATCH" }); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/library-override-freshness.test.mjs b/web/tests/unit/library-override-freshness.test.mjs new file mode 100644 index 00000000..2bde1c2e --- /dev/null +++ b/web/tests/unit/library-override-freshness.test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-override-freshness-unit-")); +const sourcePath = path.join(root, "web/protocol/library-override-freshness.ts"); +const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, +}); +assert.deepEqual(transpiled.diagnostics, []); +fs.writeFileSync(path.join(temporary, "library-override-freshness.mjs"), transpiled.outputText); +const freshness = await import(pathToFileURL(path.join(temporary, "library-override-freshness.mjs"))); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-03L/manifest.json"), "utf8")); +const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex"); +const library = "library:" + "a".repeat(64); +const digest = "b".repeat(64); +const token = "override-token:" + "c".repeat(64); +const state = (revision = 7) => ({ + schemaVersion: 1, + sourceLibraryId: library, + sourceGeneration: 4, + sourceRevision: revision, + dependencyClosureSha256: digest, + invalidationToken: token, + localDataBlockId: "Object/M12 Override Object", + referenceSourceDataBlockId: "Object/M12 Override Object", + hierarchyRootDataBlockId: "Object/M12 Override Object", + owner: "LOCAL_OVERRIDE", + readOnly: false, + referenceReadOnly: true, +}); +const request = (overrides = {}) => ({ + schemaVersion: 1, + operation: "COMMIT_OVERRIDE", + sourceLibraryId: library, + sourceGeneration: 4, + sourceRevision: 7, + dependencyClosureSha256: digest, + invalidationToken: token, + baseRevision: 7, + localDataBlockId: "Object/M12 Override Object", + referenceSourceDataBlockId: "Object/M12 Override Object", + hierarchyRootDataBlockId: "Object/M12 Override Object", + owner: "LOCAL_OVERRIDE", + readOnly: false, + referenceReadOnly: true, + ...overrides, +}); + +test("M12-03L binds the override freshness gate and evidence artifacts", () => { + assert.equal(manifest.task, "M12-03L"); + assert.equal(manifest.parentTask, "M12-03K"); + assert.equal(manifest.nextTask, "M12-03M"); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(artifact.path), artifact.sha256, artifact.path); +}); + +test("M12-03L allows only a fully matching source generation/revision commit", () => { + assert.deepEqual(freshness.gateOverrideFreshness(state(), request()), { status: "READY", code: null }); +}); + +test("M12-03L blocks stale generation, revision, closure, token, and identity before Main", () => { + for (const overrides of [ + { sourceGeneration: 3 }, + { sourceRevision: 6, baseRevision: 6 }, + { dependencyClosureSha256: "d".repeat(64) }, + { invalidationToken: "override-token:" + "e".repeat(64) }, + ]) assert.deepEqual(freshness.gateOverrideFreshness(state(), request(overrides)), { status: "BLOCKED", code: "REVISION_CONFLICT" }); + assert.deepEqual(freshness.gateOverrideFreshness(state(), request({ localDataBlockId: "Object/Other" })), { status: "BLOCKED", code: "ASSET_SOURCE_HASH_MISMATCH" }); +}); + +test("M12-03L rejects linked ownership, alternate operations, malformed tokens, and extra fields", () => { + assert.equal(freshness.gateOverrideFreshness(state(), request({ owner: "SOURCE_LIBRARY", readOnly: true })).code, "LINKED_DATA_MUTATION_BLOCKED"); + assert.equal(freshness.gateOverrideFreshness(state(), request({ operation: "SET_LOCATION" })).code, "TASK_VALIDATION_FAILED"); + assert.equal(freshness.gateOverrideFreshness(state(), request({ invalidationToken: "bad" })).code, "TASK_VALIDATION_FAILED"); + assert.equal(freshness.gateOverrideFreshness(state(), { ...request(), future: true }).code, "TASK_VALIDATION_FAILED"); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/library-override-writer.test.mjs b/web/tests/unit/library-override-writer.test.mjs new file mode 100644 index 00000000..fd4cc942 --- /dev/null +++ b/web/tests/unit/library-override-writer.test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-override-writer-unit-")); +const sourcePath = path.join(root, "web/protocol/library-override-writer.ts"); +const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, +}); +assert.deepEqual(transpiled.diagnostics, []); +fs.writeFileSync(path.join(temporary, "library-override-writer.mjs"), transpiled.outputText); +const writer = await import(pathToFileURL(path.join(temporary, "library-override-writer.mjs"))); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-03K/manifest.json"), "utf8")); +const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex"); +const state = (revision = 3, value = 2.5) => ({ + schemaVersion: 1, + revision, + 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, +}); +const request = (baseRevision = 3, value = 4.5) => ({ + schemaVersion: 1, + operation: "SET_M12_OVERRIDE_VALUE", + baseRevision, + 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, +}); + +test("M12-03K binds the single-property writer and evidence artifacts", () => { + assert.equal(manifest.task, "M12-03K"); + assert.equal(manifest.parentTask, "M12-03J"); + assert.equal(manifest.nextTask, "M12-03L"); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(artifact.path), artifact.sha256, artifact.path); +}); + +test("M12-03K applies exactly the verified override property and advances one revision", () => { + const result = writer.applyOverrideWriter(state(), request()); + assert.equal(result.status, "APPLIED"); + assert.equal(result.code, null); + assert.equal(result.state.revision, 4); + assert.equal(result.state.value, 4.5); + assert.equal(result.state.propertyPath, writer.LIBRARY_OVERRIDE_PROPERTY_PATH); + assert.equal(result.state.owner, "LOCAL_OVERRIDE"); + assert.equal(result.state.referenceReadOnly, true); +}); + +test("M12-03K blocks stale, linked-owner, identity, and second-property writes", () => { + assert.equal(writer.applyOverrideWriter(state(3), request(2)).code, "REVISION_CONFLICT"); + assert.equal(writer.applyOverrideWriter(state(), { ...request(), owner: "SOURCE_LIBRARY", readOnly: true, referenceReadOnly: true }).code, "LINKED_DATA_MUTATION_BLOCKED"); + assert.equal(writer.applyOverrideWriter(state(), { ...request(), localDataBlockId: "Object/Other" }).code, "ASSET_SOURCE_HASH_MISMATCH"); + assert.equal(writer.applyOverrideWriter(state(), { ...request(), propertyPath: "location" }).code, "EDITOR_WRITER_UNAVAILABLE"); + assert.equal(writer.applyOverrideWriter(state(), { ...request(), operation: "SET_LOCATION" }).code, "TASK_VALIDATION_FAILED"); +}); + +test("M12-03K rejects malformed values and undeclared fields before the writer", () => { + assert.equal(writer.applyOverrideWriter(state(), { ...request(), future: true }).code, "TASK_VALIDATION_FAILED"); + assert.equal(writer.applyOverrideWriter(state(), { ...request(), value: Number.NaN }).code, "TASK_VALIDATION_FAILED"); + assert.throws(() => writer.parseOverrideWriterState({ ...state(), propertyPath: "location" }), { code: "EDITOR_WRITER_UNAVAILABLE" }); + assert.throws(() => writer.parseOverrideWriterRequest({ ...request(), value: 1e9 }), { code: "TASK_VALIDATION_FAILED" }); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/library-path-normalization.test.mjs b/web/tests/unit/library-path-normalization.test.mjs new file mode 100644 index 00000000..e72b3380 --- /dev/null +++ b/web/tests/unit/library-path-normalization.test.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-path-normalization-unit-")); +for (const name of ["asset-path.ts", "library-source-origin.ts"]) { + const sourcePath = path.join(root, "web/protocol", name); + const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, + }); + assert.deepEqual(transpiled.diagnostics, []); + fs.writeFileSync(path.join(temporary, name.replace(".ts", ".mjs")), transpiled.outputText.replaceAll('from "./asset-path"', 'from "./asset-path.mjs"')); +} +const paths = await import(pathToFileURL(path.join(temporary, "asset-path.mjs"))); +const origin = await import(pathToFileURL(path.join(temporary, "library-source-origin.mjs"))); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-04B/manifest.json"), "utf8")); +const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex"); +const policy = { schemaVersion: 1, declaredHttpsOrigins: ["https://assets.example.test"] }; + +test("M12-04B binds the shared path normalizer and evidence artifacts", () => { + assert.equal(manifest.task, "M12-04B"); + assert.equal(manifest.nextTask, "M12-04C"); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(artifact.path), artifact.sha256, artifact.path); +}); + +test("M12-04B canonicalizes POSIX and Windows separators with dot segments", () => { + const aliases = [ + "libraries/characters/main.blend", + "libraries\\characters\\.\\hero\\..\\main.blend", + "libraries//characters/models/../main.blend", + "//libraries/characters/./main.blend", + ]; + for (const alias of aliases) assert.equal(paths.normalizeProjectAssetPath(alias), "libraries/characters/main.blend"); + assert.equal(paths.normalizeProjectAssetPath(paths.normalizeProjectAssetPath(aliases[1])), "libraries/characters/main.blend"); +}); + +test("M12-04B decodes percent octets and NFC-normalizes Unicode names", () => { + const canonical = "libraries/角色/caf\u00e9.blend"; + const aliases = [ + "libraries/%E8%A7%92%E8%89%B2/caf%65%CC%81.blend", + "libraries%2F%E8%A7%92%E8%89%B2%5Ccafe%CC%81.blend", + "libraries/角色/cafe\u0301.blend", + ]; + for (const alias of aliases) assert.equal(paths.normalizeProjectAssetPath(alias), canonical); + assert.deepEqual(origin.acceptLibrarySource(policy, { schemaVersion: 1, kind: "PROJECT_ASSET", path: aliases[1] }), { + status: "READY", + kind: "PROJECT_ASSET", + canonicalLocator: `project-assets/${canonical}`, + }); +}); + +test("M12-04B resolves encoded dot segments once and fails closed on escape or re-decoding", () => { + assert.equal(paths.normalizeProjectAssetPath("libraries/temp/%2E%2E/main.blend"), "libraries/main.blend"); + assert.throws(() => paths.normalizeProjectAssetPath("libraries/%2E%2E/%2E%2E/outside.blend"), /ASSET_PATH_OUTSIDE_PROJECT/); + assert.throws(() => paths.normalizeProjectAssetPath("libraries/%252E%252E/outside.blend"), /ASSET_PATH_OUTSIDE_PROJECT/); + assert.throws(() => paths.normalizeProjectAssetPath("libraries/%GG/outside.blend"), /ASSET_PATH_INVALID/); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/library-path-security.test.mjs b/web/tests/unit/library-path-security.test.mjs new file mode 100644 index 00000000..cb4d2f4b --- /dev/null +++ b/web/tests/unit/library-path-security.test.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-path-security-unit-")); +for (const name of ["asset-path.ts", "library-source-origin.ts"]) { + const sourcePath = path.join(root, "web/protocol", name); + const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, + }); + assert.deepEqual(transpiled.diagnostics, []); + fs.writeFileSync(path.join(temporary, name.replace(".ts", ".mjs")), transpiled.outputText.replaceAll('from "./asset-path"', 'from "./asset-path.mjs"')); +} +const paths = await import(pathToFileURL(path.join(temporary, "asset-path.mjs"))); +const origin = await import(pathToFileURL(path.join(temporary, "library-source-origin.mjs"))); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-04C/manifest.json"), "utf8")); +const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex"); +const policy = { schemaVersion: 1, declaredHttpsOrigins: ["https://assets.example.test"] }; +const source = (pathValue) => ({ schemaVersion: 1, kind: "PROJECT_ASSET", path: pathValue }); +const remote = (url) => ({ schemaVersion: 1, kind: "HTTPS_ORIGIN", url }); + +test("M12-04C binds the path security gate and evidence artifacts", () => { + assert.equal(manifest.task, "M12-04C"); + assert.equal(manifest.parentTask, "M12-04B"); + assert.equal(manifest.nextTask, "M12-04D"); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(artifact.path), artifact.sha256, artifact.path); +}); + +test("M12-04C rejects absolute, UNC, drive, NUL, control, and origin-style project paths", () => { + for (const value of [ + "/tmp/library.blend", + "\\\\server\\share\\library.blend", + "\\server\\library.blend", + "C:\\libraries\\main.blend", + "C:/libraries/main.blend", + "libraries/\u0000main.blend", + "libraries/\u0001main.blend", + "libraries/%00main.blend", + "//https://evil.example.test/library.blend", + ]) assert.throws(() => paths.normalizeProjectAssetPath(value), /ASSET_PATH_OUTSIDE_PROJECT|ASSET_PATH_INVALID/); + for (const value of ["/tmp/library.blend", "\\\\server\\share\\library.blend", "C:/libraries/main.blend", "libraries/\u0000main.blend", "libraries/%00main.blend"]) { + assert.throws(() => origin.acceptLibrarySource(policy, source(value)), { code: "IO_EXTERNAL_URI_BLOCKED" }); + } +}); + +test("M12-04C rejects raw and encoded unsafe HTTPS URI characters before admission", () => { + for (const value of [ + "https://assets.example.test\\evil.example.test/main.blend", + "https://assets.example.test/\nmain.blend", + "https://assets.example.test/%00main.blend", + "https://assets.example.test/%01main.blend", + "https://assets.example.test/%GGmain.blend", + "https://user:pass@evil.example.test/main.blend", + "https://evil.example.test/main.blend", + ]) assert.throws(() => origin.acceptLibrarySource(policy, remote(value)), { code: "IO_EXTERNAL_URI_BLOCKED" }); + assert.deepEqual(origin.acceptLibrarySource(policy, remote("https://assets.example.test/library/main.blend")), { + status: "READY", + kind: "HTTPS_ORIGIN", + canonicalLocator: "https://assets.example.test/library/main.blend", + }); +}); + +test("M12-04C fails closed on policy origin smuggling and duplicate declarations", () => { + for (const value of [ + "https://assets.example.test\\evil.example.test", + "https://assets.example.test/%00", + "https://assets.example.test/%2e", + "https://assets.example.test/..", + "https://assets.example.test/library", + "https://assets.example.test/?scope=library", + "https://assets.example.test/#library", + ]) assert.throws(() => origin.parseLibrarySourcePolicy({ schemaVersion: 1, declaredHttpsOrigins: [value] }), { code: "IO_EXTERNAL_URI_BLOCKED" }); + assert.throws(() => origin.parseLibrarySourcePolicy({ schemaVersion: 1, declaredHttpsOrigins: ["https://assets.example.test", "https://assets.example.test/"] }), { code: "ASSET_MANIFEST_INVALID" }); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/library-source-origin.test.mjs b/web/tests/unit/library-source-origin.test.mjs new file mode 100644 index 00000000..092640fd --- /dev/null +++ b/web/tests/unit/library-source-origin.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-source-origin-unit-")); +const paths = ["asset-path.ts", "library-source-origin.ts"]; +for (const name of paths) { + const sourcePath = path.join(root, "web/protocol", name); + const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, + }); + assert.deepEqual(transpiled.diagnostics, []); + fs.writeFileSync(path.join(temporary, name.replace(".ts", ".mjs")), transpiled.outputText.replaceAll('from "./asset-path"', 'from "./asset-path.mjs"')); +} +const origin = await import(pathToFileURL(path.join(temporary, "library-source-origin.mjs"))); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-04A/manifest.json"), "utf8")); +const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex"); +const policy = { schemaVersion: 1, declaredHttpsOrigins: ["https://assets.example.test"] }; +const digest = "a".repeat(64); + +test("M12-04A binds the declared source-origin protocol and evidence artifacts", () => { + assert.equal(manifest.task, "M12-04A"); + assert.equal(manifest.nextTask, "M12-04B"); + for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(artifact.path), artifact.sha256, artifact.path); +}); + +test("M12-04A accepts declared HTTPS, project asset, and user-selected file sources", () => { + assert.deepEqual(origin.acceptLibrarySource(policy, { schemaVersion: 1, kind: "HTTPS_ORIGIN", url: "https://assets.example.test/library/main.blend" }), { status: "READY", kind: "HTTPS_ORIGIN", canonicalLocator: "https://assets.example.test/library/main.blend" }); + assert.deepEqual(origin.acceptLibrarySource(policy, { schemaVersion: 1, kind: "PROJECT_ASSET", path: "libraries/main.blend" }), { status: "READY", kind: "PROJECT_ASSET", canonicalLocator: "project-assets/libraries/main.blend" }); + assert.deepEqual(origin.acceptLibrarySource(policy, { schemaVersion: 1, kind: "USER_SELECTED_FILE", selectionId: "file-selection:pick-1", fileName: "main.blend", byteLength: 128, sourceSha256: digest }), { status: "READY", kind: "USER_SELECTED_FILE", canonicalLocator: "user-file/file-selection:pick-1/main.blend" }); +}); + +test("M12-04A rejects undeclared origins, credentials, unsafe paths, and missing policy declarations", () => { + assert.throws(() => origin.acceptLibrarySource(policy, { schemaVersion: 1, kind: "HTTPS_ORIGIN", url: "https://other.example.test/main.blend" }), { code: "IO_EXTERNAL_URI_BLOCKED" }); + assert.throws(() => origin.acceptLibrarySource(policy, { schemaVersion: 1, kind: "HTTPS_ORIGIN", url: "https://user:pass@assets.example.test/main.blend" }), { code: "IO_EXTERNAL_URI_BLOCKED" }); + assert.throws(() => origin.acceptLibrarySource(policy, { schemaVersion: 1, kind: "PROJECT_ASSET", path: "../outside.blend" }), { code: "IO_EXTERNAL_URI_BLOCKED" }); + assert.throws(() => origin.parseLibrarySourcePolicy({ schemaVersion: 1, declaredHttpsOrigins: [] }), { code: "PROTOCOL_MISMATCH" }); +}); + +test("M12-04A rejects malformed user-file identity and undeclared fields", () => { + assert.throws(() => origin.parseLibrarySourceRequest({ schemaVersion: 1, kind: "USER_SELECTED_FILE", selectionId: "bad", fileName: "main.blend", byteLength: 1, sourceSha256: digest }), { code: "ASSET_MANIFEST_INVALID" }); + assert.throws(() => origin.parseLibrarySourceRequest({ schemaVersion: 1, kind: "USER_SELECTED_FILE", selectionId: "file-selection:pick-1", fileName: "../main.blend", byteLength: 1, sourceSha256: digest }), { code: "ASSET_MANIFEST_INVALID" }); + assert.throws(() => origin.parseLibrarySourceRequest({ schemaVersion: 1, kind: "PROJECT_ASSET", path: "main.blend", future: true }), { code: "ASSET_MANIFEST_INVALID" }); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/obj-import.test.mjs b/web/tests/unit/obj-import.test.mjs new file mode 100644 index 00000000..62220b29 --- /dev/null +++ b/web/tests/unit/obj-import.test.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "obj-import-unit-")); +const sourcePath = path.join(root, "web/protocol/obj-import.ts"); +const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, +}); +assert.deepEqual(transpiled.diagnostics, []); +const modulePath = path.join(temporary, "obj-import.mjs"); +fs.writeFileSync(modulePath, transpiled.outputText); +const protocol = await import(pathToFileURL(modulePath)); + +const obj = new TextEncoder().encode(`# test\nmtllib materials.mtl\no Test\nv 0 0 0\nv 1 0 0\nv 0 1 0\nvt 0 0\nvt 1 0\nvt 0 1\nvn 0 0 1\ng TestGroup\nusemtl TestMaterial\nf -3/-3/-1 -2/-2/-1 -1/-1/-1\n`).buffer; +const mtl = new TextEncoder().encode("newmtl TestMaterial\nmap_Kd texture.png\n").buffer; + +test("M12-07C resolves negative indices and serializes deterministic OBJ", () => { + const imported = protocol.importOBJ(obj, mtl); + assert.deepEqual(imported.faces[0].vertices.map((vertex) => vertex.position), [1, 2, 3]); + assert.deepEqual(imported.faces[0].vertices.map((vertex) => vertex.texcoord), [1, 2, 3]); + assert.deepEqual(imported.materials, [{ name: "TestMaterial", mapKd: "texture.png" }]); + const serialized = protocol.serializeOBJ(imported); + assert.match(serialized.obj, /f 1\/1\/1 2\/2\/1 3\/3\/1/); + assert.equal(serialized.mtl, "# Web Blender MTL export\n# schema 1\nnewmtl TestMaterial\nmap_Kd texture.png\n"); +}); + +test("M12-07C reports unresolved texture origin without blocking geometry", () => { + const imported = protocol.importOBJ(obj, mtl); + const missing = protocol.createOBJLossReport(imported); + assert.equal(missing.canRoundTrip, true); + assert.deepEqual(missing.warnings.map((warning) => warning.code), ["OBJ_TEXTURE_ORIGIN_UNRESOLVED"]); + const bound = protocol.createOBJLossReport(imported, ["texture.png"]); + assert.deepEqual(bound.warnings, []); +}); + +test("M12-07C rejects malformed face arity and out-of-range indices", () => { + const malformed = new TextEncoder().encode("v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2\n").buffer; + assert.throws(() => protocol.importOBJ(malformed), /OBJ_FACE_ARITY_INVALID/); + const outOfRange = new TextEncoder().encode("v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 4\n").buffer; + assert.throws(() => protocol.importOBJ(outOfRange), /OBJ_INDEX_OUT_OF_RANGE/); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/ply-import.test.mjs b/web/tests/unit/ply-import.test.mjs new file mode 100644 index 00000000..2b7d3d99 --- /dev/null +++ b/web/tests/unit/ply-import.test.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "ply-import-unit-")); +const sourcePath = path.join(root, "web/protocol/ply-import.ts"); +const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true }); +assert.deepEqual(transpiled.diagnostics, []); +const modulePath = path.join(temporary, "ply-import.mjs"); +fs.writeFileSync(modulePath, transpiled.outputText); +const protocol = await import(pathToFileURL(modulePath)); +const fixtureRoot = path.join(root, "tests/files/web/m12_ply_mapping_v1"); +const bytes = (name) => { const value = fs.readFileSync(path.join(fixtureRoot, name)); return value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength); }; + +test("M12-07H maps ASCII and binary little-endian PLY fields", () => { + const ascii = protocol.importPLY(bytes("mapping-ascii.ply"), { format: "ascii" }); + const binary = protocol.importPLY(bytes("mapping-binary-le.ply"), { format: "binary_little_endian" }); + assert.equal(ascii.vertices.length, 4); + assert.equal(ascii.faces.length, 2); + assert.deepEqual(ascii.vertices[0].position, [-1, 0, 1]); + assert.deepEqual(ascii.vertices[0].normal, [0, 1, 0]); + assert.deepEqual(ascii.vertices[0].color, [254 / 255, 0, 0, 1]); + assert.deepEqual(ascii.vertices[3].customProperties, { label: 4, temperature: 40 }); + assert.deepEqual(binary.vertices, ascii.vertices); + assert.deepEqual(binary.faces, ascii.faces); + assert.deepEqual(protocol.createPLYLossReport(ascii), { schemaVersion: 1, operation: "PLY_IMPORT_LOSS_REPORT", canImport: true, warningCount: 0, warnings: [] }); +}); + +test("M12-07H reports unknown list properties without dropping mapped fields", () => { + const imported = protocol.importPLY(bytes("unknown-property-ascii.ply"), { format: "ascii" }); + assert.equal(imported.vertices.length, 4); + assert.equal(imported.vertices[0].customProperties.temperature, 10); + assert.deepEqual(protocol.createPLYLossReport(imported).warnings.map((warning) => warning.code), ["PLY_UNKNOWN_PROPERTY"]); + assert.equal(protocol.createPLYLossReport(imported).warnings[0].property, "unknown_values"); +}); + +test("M12-07H serializes mapped data and rejects an explicit format mismatch", () => { + const document = protocol.importPLY(bytes("mapping-ascii.ply")); + const output = protocol.serializePLYAscii(document); + const reopened = protocol.importPLY(output, { format: "ascii" }); + assert.deepEqual(reopened.faces, document.faces); + assert.deepEqual(reopened.vertices.map((vertex) => vertex.customProperties), document.vertices.map((vertex) => vertex.customProperties)); + assert.throws(() => protocol.importPLY(bytes("mapping-ascii.ply"), { format: "binary_little_endian" }), /PLY_FORMAT_MISMATCH/); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/ply-negative.test.mjs b/web/tests/unit/ply-negative.test.mjs new file mode 100644 index 00000000..f45415e5 --- /dev/null +++ b/web/tests/unit/ply-negative.test.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "ply-negative-unit-")); +const sourcePath = path.join(root, "web/protocol/ply-import.ts"); +const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true }); +assert.deepEqual(transpiled.diagnostics, []); +const modulePath = path.join(temporary, "ply-import.mjs"); +fs.writeFileSync(modulePath, transpiled.outputText); +const protocol = await import(pathToFileURL(modulePath)); +const fixtureRoot = path.join(root, "tests/files/web/m12_ply_negative_v1"); +const bytes = (name) => { const value = fs.readFileSync(path.join(fixtureRoot, name)); return value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength); }; + +test("M12-07I blocks big-endian PLY with a stable format code", () => { + assert.throws(() => protocol.importPLY(bytes("big-endian.ply")), /PLY_FORMAT_UNSUPPORTED/); +}); + +test("M12-07I blocks malformed lists and oversized element counts", () => { + assert.throws(() => protocol.importPLY(bytes("malformed-list-ascii.ply")), /PLY_DATA_TRUNCATED/); + assert.throws(() => protocol.importPLY(bytes("oversized-count-ascii.ply")), /PLY_IMPORT_BUDGET_EXCEEDED: vertex/); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/pointer-contract.test.mjs b/web/tests/unit/pointer-contract.test.mjs new file mode 100644 index 00000000..ad26a594 --- /dev/null +++ b/web/tests/unit/pointer-contract.test.mjs @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import ts from "../../node_modules/typescript/lib/typescript.js"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const sourcePath = path.join(root, "web/protocol/pointer-contract.ts"); +const output = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true }); +assert.deepEqual(output.diagnostics, []); +const pointer = await import(`data:text/javascript;base64,${Buffer.from(output.outputText).toString("base64")}`); + +test("M14-04C preserves pointer identity and bounded pen fields", () => { + assert.deepEqual(pointer.observePointerEvent({ type: "pointerdown", pointerType: "pen", pointerId: 7, pressure: 1.4, tiltX: 120, tiltY: -100, button: 0, buttons: 1 }), { schemaVersion: 1, pointerType: "pen", pointerId: 7, pressure: 1, tiltX: 90, tiltY: -90, button: 0, buttons: 1, cancelled: false }); + assert.equal(pointer.observePointerEvent({ type: "pointercancel", pointerType: "touch", pointerId: 2, pressure: 0.4, button: 0, buttons: 0 }).cancelled, true); +}); + +test("M14-04C rejects unknown pointer and invalid id", () => { + assert.throws(() => pointer.observePointerEvent({ pointerType: "trackpad", pointerId: 1 }), /POINTER_TYPE_UNSUPPORTED/); + assert.throws(() => pointer.observePointerEvent({ pointerType: "mouse", pointerId: -1 }), /POINTER_ID_INVALID/); +}); diff --git a/web/tests/unit/script-audit-integrity.test.mjs b/web/tests/unit/script-audit-integrity.test.mjs new file mode 100644 index 00000000..c818a26b --- /dev/null +++ b/web/tests/unit/script-audit-integrity.test.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "script-audit-integrity-")); +const require = createRequire(import.meta.url); +for (const name of ["asset-path", "capability-gates", "scripting-platform"]) { + const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8"); + const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText + .replace('require("./asset-path")', 'require("./asset-path.cjs")') + .replace('require("./capability-gates")', 'require("./capability-gates.cjs")'); + fs.writeFileSync(path.join(temporary, `${name}.cjs`), output); +} +const protocol = require(path.join(temporary, "scripting-platform.cjs")); +const digest = "a".repeat(64); +const manifest = { schemaVersion: 1, scripts: [{ id: "script:audit", name: "Audit", entryPath: "scripts/audit.py", sourceByteLength: 32, sourceSha256: digest, publisher: "local", signature: "b".repeat(128), keyId: "key:local", permissions: ["READ_MAIN"], dependencies: [], module: false, cpuMs: 1000, memoryBytes: 1024, wallMs: 5000, network: false, autorun: false, driverExpressions: false, addonInstall: false }] }; + +test("M13-05H accepts a strictly ordered, chained audit log", async () => { + const first = await protocol.createScriptExecutionAudit(manifest, "script:audit", new Set(), { requestId: "audit:first", requestedAt: "2026-08-19T00:00:00.000Z" }); + const second = await protocol.createScriptExecutionAudit(manifest, "script:audit", new Set(), { requestId: "audit:second", requestedAt: "2026-08-19T00:00:01.000Z" }); + const log = await protocol.appendScriptExecutionAudit(await protocol.appendScriptExecutionAudit({ schemaVersion: 1, entries: [] }, first), second); + assert.deepEqual(log.entries.map((entry) => entry.sequence), [1, 2]); + assert.deepEqual(log.entries.map((entry) => entry.audit.requestId), ["audit:first", "audit:second"]); + assert.equal(log.entries[0].previousEntrySha256, null); + assert.equal(log.entries[1].previousEntrySha256, log.entries[0].entrySha256); + assert.deepEqual((await protocol.parseScriptExecutionAuditLog(log)).entries, log.entries); +}); + +test("M13-05H rejects replay, time, sequence and hash-chain drift", async () => { + const first = await protocol.createScriptExecutionAudit(manifest, "script:audit", new Set(), { requestId: "audit:one", requestedAt: "2026-08-19T00:00:00.000Z" }); + const second = await protocol.createScriptExecutionAudit(manifest, "script:audit", new Set(), { requestId: "audit:two", requestedAt: "2026-08-19T00:00:01.000Z" }); + const log = await protocol.appendScriptExecutionAudit(await protocol.appendScriptExecutionAudit({ schemaVersion: 1, entries: [] }, first), second); + await assert.rejects(protocol.appendScriptExecutionAudit(log, first), /SCRIPT_MANIFEST_INVALID/); + const earlier = await protocol.createScriptExecutionAudit(manifest, "script:audit", new Set(), { requestId: "audit:earlier", requestedAt: "2026-08-18T23:59:59.000Z" }); + await assert.rejects(protocol.appendScriptExecutionAudit(log, earlier), /SCRIPT_MANIFEST_INVALID/); + await assert.rejects(protocol.parseScriptExecutionAuditLog({ ...log, entries: [{ ...log.entries[0], sequence: 2 }, log.entries[1]] }), /SCRIPT_MANIFEST_INVALID/); + await assert.rejects(protocol.parseScriptExecutionAuditLog({ ...log, entries: [{ ...log.entries[0], entrySha256: "c".repeat(64) }, log.entries[1]] }), /SCRIPT_MANIFEST_INVALID/); + await assert.rejects(protocol.parseScriptExecutionAuditLog({ ...log, entries: [log.entries[0], { ...log.entries[1], previousEntrySha256: "d".repeat(64) }] }), /SCRIPT_MANIFEST_INVALID/); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/script-host-call.test.mjs b/web/tests/unit/script-host-call.test.mjs new file mode 100644 index 00000000..e4fe8d74 --- /dev/null +++ b/web/tests/unit/script-host-call.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "script-host-call-")); +const require = createRequire(import.meta.url); +for (const name of ["asset-path", "capability-gates", "scripting-platform"]) { + const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8"); + const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText + .replace('require("./asset-path")', 'require("./asset-path.cjs")').replace('require("./capability-gates")', 'require("./capability-gates.cjs")'); + fs.writeFileSync(path.join(temporary, `${name}.cjs`), output); +} +const protocol = require(path.join(temporary, "scripting-platform.cjs")); +const digest = "a".repeat(64); +const permissions = new Set(protocol.SCRIPT_PERMISSIONS); +const call = (name, parameters) => ({ schemaVersion: 1, requestId: `host:${name.toLowerCase()}`, scriptId: "clean", call: name, permission: name, parameters }); + +test("M13-03C parses all allowlisted host calls with structured parameters", () => { + const inputs = [ + call("READ_MAIN", { revision: 3 }), + call("READ_ASSET", { path: "//assets/model.bin", expectedSha256: digest }), + call("WRITE_MAIN", { revision: 3, operation: "object.transform", payload: { objectId: "obj:1", x: 1 } }), + call("WRITE_ASSET", { path: "assets/out.bin", byteLength: 4, sha256: digest }), + call("SUBMIT_SERVER_JOB", { inputBlendSha256: digest, settingsSha256: digest }), + ]; + const parsed = inputs.map((input) => protocol.parseScriptHostCall(input, permissions)); + assert.deepEqual(parsed.map((item) => item.call), ["READ_MAIN", "READ_ASSET", "WRITE_MAIN", "WRITE_ASSET", "SUBMIT_SERVER_JOB"]); + assert.equal(parsed[1].parameters.path, "assets/model.bin"); + assert.equal(parsed[2].execution, "DISABLED"); +}); + +test("M13-03C rejects non-allowlisted calls, permission confusion and unknown fields", () => { + assert.throws(() => protocol.parseScriptHostCall(call("EXECUTE", {}), permissions), /SCRIPT_POLICY_DENIED/); + assert.throws(() => protocol.parseScriptHostCall({ ...call("READ_MAIN", { revision: 3 }), permission: "WRITE_MAIN" }, permissions), /SCRIPT_POLICY_DENIED/); + assert.throws(() => protocol.parseScriptHostCall(call("READ_MAIN", { revision: 3, extra: true }), permissions), /SCRIPT_MANIFEST_INVALID/); + assert.throws(() => protocol.parseScriptHostCall(call("READ_ASSET", { path: "../escape", expectedSha256: digest }), permissions), /SCRIPT_MANIFEST_INVALID/); + assert.throws(() => protocol.parseScriptHostCall(call("WRITE_MAIN", { revision: 3, operation: "x", payload: [] }), permissions), /SCRIPT_MANIFEST_INVALID/); +}); + +test("M13-03C requires the declared permission set", () => { + assert.throws(() => protocol.parseScriptHostCall(call("WRITE_MAIN", { revision: 0, operation: "x", payload: {} }), new Set(["READ_MAIN"])), /SCRIPT_POLICY_DENIED/); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/script-manifest-budgets.test.mjs b/web/tests/unit/script-manifest-budgets.test.mjs new file mode 100644 index 00000000..5bb4249a --- /dev/null +++ b/web/tests/unit/script-manifest-budgets.test.mjs @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "script-manifest-budgets-")); +const require = createRequire(import.meta.url); +for (const name of ["asset-path", "capability-gates", "scripting-platform"]) { + const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8"); + const output = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + fileName: `${name}.ts`, + }).outputText + .replace('require("./asset-path")', 'require("./asset-path.cjs")') + .replace('require("./capability-gates")', 'require("./capability-gates.cjs")'); + fs.writeFileSync(path.join(temporary, `${name}.cjs`), output); +} +const protocol = require(path.join(temporary, "scripting-platform.cjs")); +const digest = "a".repeat(64); +const signature = "b".repeat(128); +const script = (id, overrides = {}) => ({ + 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 }); + +test("M13-02A accepts bounded manifest fields and normalizes project paths", () => { + const parsed = protocol.parseScriptingManifest(manifest([ + script("base", { entryPath: "//scripts/../scripts/base.py" }), + script("clean", { dependencies: [{ id: "base", sourceSha256: digest, sourcePath: "//deps/base.py" }] }), + ])); + assert.equal(parsed.scripts.length, 2); + assert.equal(parsed.scripts[0].entryPath, "scripts/base.py"); + assert.equal(parsed.scripts[1].dependencies[0].sourcePath, "deps/base.py"); + assert.equal(parsed.scripts.reduce((total, item) => total + item.sourceByteLength, 0), 256); + assert.equal(parsed.scripts.every((item) => item.module === false), true); +}); + +test("M13-02A rejects text count and aggregate source byte budget overflow", () => { + assert.throws( + () => protocol.parseScriptingManifest(manifest(Array.from({ length: protocol.SCRIPTING_BUDGET.maxScripts + 1 }, (_, index) => script(`script-${index}`)))), + /SCRIPT_BUDGET_EXCEEDED/, + ); + assert.throws( + () => protocol.parseScriptingManifest(manifest([script("large", { sourceByteLength: protocol.SCRIPTING_BUDGET.maxSourceBytes }), script("overflow", { sourceByteLength: 1 })])), + /SCRIPT_BUDGET_EXCEEDED/, + ); + assert.throws(() => protocol.parseScriptingManifest(manifest([script("missing-bytes", { sourceByteLength: undefined })])), /SCRIPT_BUDGET_EXCEEDED/); +}); + +test("M13-02A rejects module execution, unsafe paths, and malformed dependencies", () => { + assert.throws(() => protocol.parseScriptingManifest(manifest([script("module", { module: true })])), /SCRIPT_POLICY_DENIED/); + assert.throws(() => protocol.parseScriptingManifest(manifest([script("escape", { entryPath: "../escape.py" })])), /SCRIPT_MANIFEST_INVALID/); + assert.throws(() => protocol.parseScriptingManifest(manifest([script("dependency-escape", { dependencies: [{ id: "base", sourceSha256: digest, sourcePath: "//../escape.py" }] }), script("base")])), /SCRIPT_MANIFEST_INVALID/); + assert.throws(() => protocol.parseScriptingManifest(manifest([script("duplicate-dependency", { dependencies: [{ id: "base", sourceSha256: digest, sourcePath: "deps/a.py" }, { id: "base", sourceSha256: digest, sourcePath: "deps/b.py" }] }), script("base")])), /SCRIPT_MANIFEST_INVALID/); + assert.throws(() => protocol.parseScriptingManifest(manifest([script("too-many-dependencies", { dependencies: Array.from({ length: protocol.SCRIPTING_BUDGET.maxDependencies + 1 }, (_, index) => ({ id: `dep-${index}`, sourceSha256: digest, sourcePath: `deps/${index}.py` })) })])), /SCRIPT_BUDGET_EXCEEDED/); +}); + +test("M13-02A rejects unknown or over-budget permissions", () => { + assert.throws(() => protocol.parseScriptingManifest(manifest([script("unknown-permission", { permissions: ["EXECUTE"] })])), /SCRIPT_POLICY_DENIED/); + assert.throws(() => protocol.parseScriptingManifest(manifest([script("permission-budget", { permissions: new Array(protocol.SCRIPTING_BUDGET.maxPermissions + 1).fill("READ_MAIN") })])), /SCRIPT_POLICY_DENIED/); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/script-manifest-canonical.test.mjs b/web/tests/unit/script-manifest-canonical.test.mjs new file mode 100644 index 00000000..9c5dcf85 --- /dev/null +++ b/web/tests/unit/script-manifest-canonical.test.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "script-manifest-canonical-")); +const require = createRequire(import.meta.url); +for (const name of ["asset-path", "capability-gates", "scripting-platform"]) { + const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8"); + const output = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + fileName: `${name}.ts`, + }).outputText + .replace('require("./asset-path")', 'require("./asset-path.cjs")') + .replace('require("./capability-gates")', 'require("./capability-gates.cjs")'); + fs.writeFileSync(path.join(temporary, `${name}.cjs`), output); +} +const protocol = require(path.join(temporary, "scripting-platform.cjs")); +const digest = "a".repeat(64); +const signature = "b".repeat(128); +const script = (id, overrides = {}) => ({ + 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, +}); + +test("M13-02B canonical serialization is invariant to manifest/script array order", () => { + const base = { + 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 reordered = { + schemaVersion: 1, + scripts: [ + { ...base.scripts[1], permissions: [...base.scripts[1].permissions].reverse(), ignored: "removed" }, + { ...base.scripts[0], permissions: [...base.scripts[0].permissions].reverse(), dependencies: [...base.scripts[0].dependencies].reverse() }, + base.scripts[2], + ], + }; + const first = protocol.serializeScriptingManifest(base); + const second = protocol.serializeScriptingManifest(reordered); + assert.equal(first, second); + assert.match(first, /^\{"schemaVersion":1,"scripts":\[/); + assert.equal(Object.keys(protocol.canonicalizeScriptingManifest(reordered).scripts[0]).includes("ignored"), false); +}); + +test("M13-02B canonical serialization includes security-relevant declaration fields", () => { + const base = { schemaVersion: 1, scripts: [script("clean")] }; + const changedBytes = { schemaVersion: 1, scripts: [script("clean", { sourceByteLength: 129 })] }; + const changedPermission = { schemaVersion: 1, scripts: [script("clean", { permissions: ["READ_ASSET"] })] }; + assert.notEqual(protocol.serializeScriptingManifest(base), protocol.serializeScriptingManifest(changedBytes)); + assert.notEqual(protocol.serializeScriptingManifest(base), protocol.serializeScriptingManifest(changedPermission)); + assert.throws(() => protocol.serializeScriptingManifest({ ...base, schemaVersion: 2 }), /PROTOCOL_MISMATCH/); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/script-permission-policy.test.mjs b/web/tests/unit/script-permission-policy.test.mjs new file mode 100644 index 00000000..b4aba07f --- /dev/null +++ b/web/tests/unit/script-permission-policy.test.mjs @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "script-permission-policy-")); +const require = createRequire(import.meta.url); +for (const name of ["asset-path", "capability-gates", "scripting-platform"]) { + const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8"); + const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText + .replace('require("./asset-path")', 'require("./asset-path.cjs")') + .replace('require("./capability-gates")', 'require("./capability-gates.cjs")'); + fs.writeFileSync(path.join(temporary, `${name}.cjs`), output); +} +const protocol = require(path.join(temporary, "scripting-platform.cjs")); +const script = (permissions = ["READ_MAIN"]) => ({ 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) => ({ schemaVersion: 1, scripts: [script(permissions)] }); + +test("M13-02E grants no undeclared permission by default", () => { + assert.deepEqual(protocol.resolveScriptPermissions(manifest(["READ_MAIN"]), "clean"), { status: "ALLOWED", code: "SCRIPT_PERMISSIONS_ALLOWED", scriptId: "clean", declared: ["READ_MAIN"], requested: [], granted: [] }); + assert.deepEqual(protocol.resolveScriptPermissions(manifest(["WRITE_ASSET", "READ_MAIN"]), "clean", ["READ_MAIN"]), { status: "ALLOWED", code: "SCRIPT_PERMISSIONS_ALLOWED", scriptId: "clean", declared: ["READ_MAIN", "WRITE_ASSET"], requested: ["READ_MAIN"], granted: ["READ_MAIN"] }); +}); + +test("M13-02E blocks escalation, unknown and duplicate permission requests", () => { + assert.equal(protocol.resolveScriptPermissions(manifest(["READ_MAIN"]), "clean", ["WRITE_MAIN"]).code, "SCRIPT_POLICY_DENIED"); + assert.equal(protocol.resolveScriptPermissions(manifest(["READ_MAIN"]), "clean", ["EXECUTE"]).code, "SCRIPT_POLICY_DENIED"); + assert.equal(protocol.resolveScriptPermissions(manifest(["READ_MAIN"]), "clean", ["READ_MAIN", "READ_MAIN"]).code, "SCRIPT_POLICY_DENIED"); + assert.throws(() => protocol.parseScriptingManifest(manifest(["EXECUTE"])), /SCRIPT_POLICY_DENIED/); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/script-policy-codes.test.mjs b/web/tests/unit/script-policy-codes.test.mjs new file mode 100644 index 00000000..c1d66367 --- /dev/null +++ b/web/tests/unit/script-policy-codes.test.mjs @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "script-policy-codes-")); +const require = createRequire(import.meta.url); +for (const name of ["asset-path", "capability-gates", "scripting-platform"]) { + const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8"); + const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText.replace('require("./asset-path")', 'require("./asset-path.cjs")').replace('require("./capability-gates")', 'require("./capability-gates.cjs")'); + fs.writeFileSync(path.join(temporary, `${name}.cjs`), output); +} +const protocol = require(path.join(temporary, "scripting-platform.cjs")); +const base = { schemaVersion: 1, scripts: [{ id: "demo", name: "Demo", entryPath: "scripts/demo.py", sourceByteLength: 128, sourceSha256: "a".repeat(64), publisher: "local", signature: "b".repeat(128), keyId: "key", permissions: ["READ_MAIN"], dependencies: [], module: false, cpuMs: 1000, memoryBytes: 64 * 1024 * 1024, wallMs: 2000, network: false, autorun: false, driverExpressions: false, addonInstall: false }] }; + +test("M13-01C returns stable default-deny codes for autorun, driver and add-on execution", () => { + assert.throws(() => protocol.parseScriptingManifest({ ...base, scripts: [{ ...base.scripts[0], autorun: true }] }), /SCRIPT_POLICY_DENIED/); + assert.throws(() => protocol.parseScriptingManifest({ ...base, scripts: [{ ...base.scripts[0], driverExpressions: true }] }), /DRIVER_EXECUTION_BLOCKED/); + assert.throws(() => protocol.parseScriptingManifest({ ...base, scripts: [{ ...base.scripts[0], addonInstall: true }] }), /ADDON_INSTALL_BLOCKED/); + const gate = protocol.gateScriptExecution(base, "demo", new Set(["key"])); + assert.equal(gate.status, "BLOCKED"); + assert.equal(gate.issues[0].code, "SCRIPT_SANDBOX_UNAVAILABLE"); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/script-sandbox-budget.test.mjs b/web/tests/unit/script-sandbox-budget.test.mjs new file mode 100644 index 00000000..4455534d --- /dev/null +++ b/web/tests/unit/script-sandbox-budget.test.mjs @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "script-sandbox-budget-")); +const require = createRequire(import.meta.url); +for (const name of ["asset-path", "capability-gates", "scripting-platform"]) { + const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8"); + const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText + .replace('require("./asset-path")', 'require("./asset-path.cjs")').replace('require("./capability-gates")', 'require("./capability-gates.cjs")'); + fs.writeFileSync(path.join(temporary, `${name}.cjs`), output); +} +const protocol = require(path.join(temporary, "scripting-platform.cjs")); +const budget = { schemaVersion: 1, cpuMs: 1000, wallMs: 5000, memoryBytes: 1024 * 1024, maxMessageBytes: 4096, maxOutputBytes: 8192 }; + +test("M13-03B accepts bounded CPU, wall, memory, message and output budgets", () => { + assert.deepEqual(protocol.parseScriptSandboxBudget(budget), budget); +}); + +test("M13-03B rejects every budget overflow and schema drift", () => { + for (const [field, limit] of Object.entries({ cpuMs: protocol.SCRIPT_SANDBOX_BUDGET.maxCpuMs, wallMs: protocol.SCRIPT_SANDBOX_BUDGET.maxWallMs, memoryBytes: protocol.SCRIPT_SANDBOX_BUDGET.maxMemoryBytes, maxMessageBytes: protocol.SCRIPT_SANDBOX_BUDGET.maxMessageBytes, maxOutputBytes: protocol.SCRIPT_SANDBOX_BUDGET.maxOutputBytes })) assert.throws(() => protocol.parseScriptSandboxBudget({ ...budget, [field]: limit + 1 }), /SCRIPT_BUDGET_EXCEEDED/); + assert.throws(() => protocol.parseScriptSandboxBudget({ ...budget, schemaVersion: 2 }), /PROTOCOL_MISMATCH/); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/script-sandbox-cancellation.test.mjs b/web/tests/unit/script-sandbox-cancellation.test.mjs new file mode 100644 index 00000000..e951fb78 --- /dev/null +++ b/web/tests/unit/script-sandbox-cancellation.test.mjs @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "script-sandbox-cancellation-")); +const require = createRequire(import.meta.url); +for (const name of ["asset-path", "capability-gates", "scripting-platform"]) { + const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8"); + const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText + .replace('require("./asset-path")', 'require("./asset-path.cjs")').replace('require("./capability-gates")', 'require("./capability-gates.cjs")'); + fs.writeFileSync(path.join(temporary, `${name}.cjs`), output); +} +const protocol = require(path.join(temporary, "scripting-platform.cjs")); +const running = { schemaVersion: 1, jobId: "sandbox:cancel", workerGeneration: 5, baseRevision: 11, mainRevisionBefore: 11, status: "RUNNING" }; + +test("M13-03E cancellation receipt blocks late message and cache publication", () => { + const cancelled = protocol.terminateScriptSandboxJob(running, "CANCEL"); + assert.deepEqual({ status: cancelled.status, errorCode: cancelled.errorCode, mainRevisionAfter: cancelled.mainRevisionAfter, temporaryBytes: cancelled.temporaryBytes, publishedResults: cancelled.publishedResults, lateResults: cancelled.lateResults, committed: cancelled.committed }, { status: "CANCELLED", errorCode: "SCRIPT_SANDBOX_CANCELLED", mainRevisionAfter: 11, temporaryBytes: 0, publishedResults: 0, lateResults: 0, committed: false }); + assert.throws(() => protocol.rejectLateScriptSandboxResult(cancelled), /SCRIPT_SANDBOX_LATE_RESULT/); +}); + +test("M13-03E rejects a late result that is not tied to a terminated receipt", () => { + assert.throws(() => protocol.rejectLateScriptSandboxResult({ ...running, status: "RUNNING" }), /SCRIPT_MANIFEST_INVALID/); + assert.throws(() => protocol.terminateScriptSandboxJob({ ...running, baseRevision: 10 }, "CANCEL"), /SCRIPT_MANIFEST_INVALID/); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/script-sandbox-dispose.test.mjs b/web/tests/unit/script-sandbox-dispose.test.mjs new file mode 100644 index 00000000..8c2d4222 --- /dev/null +++ b/web/tests/unit/script-sandbox-dispose.test.mjs @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "script-sandbox-dispose-")); +const source = fs.readFileSync(path.join(root, "web/app/src/testing/script-sandbox-dispose.ts"), "utf8"); +fs.writeFileSync(path.join(temporary, "script-sandbox-dispose.cjs"), ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: "script-sandbox-dispose.ts" }).outputText); +const protocol = createRequire(import.meta.url)(path.join(temporary, "script-sandbox-dispose.cjs")); + +test("M13-03F emits zero-resource disposal receipts", () => { + assert.deepEqual(protocol.createScriptSandboxDisposeReceipt(1), { schemaVersion: 1, disposeCount: 1, idempotent: false, resources: { messagePorts: 0, timers: 0, abortControllers: 0, transferableBuffers: 0, pendingRequests: 0, cacheReferences: 0 }, lateTimerMessages: 0 }); + assert.deepEqual(protocol.createScriptSandboxDisposeReceipt(2), { schemaVersion: 1, disposeCount: 2, idempotent: true, resources: { messagePorts: 0, timers: 0, abortControllers: 0, transferableBuffers: 0, pendingRequests: 0, cacheReferences: 0 }, lateTimerMessages: 0 }); +}); + +test("M13-03F rejects an invalid disposal count", () => { + assert.throws(() => protocol.createScriptSandboxDisposeReceipt(0), /SCRIPT_SANDBOX_DISPOSE_INVALID/); + assert.throws(() => protocol.createScriptSandboxDisposeReceipt(1.5), /SCRIPT_SANDBOX_DISPOSE_INVALID/); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/script-sandbox-isolation.test.mjs b/web/tests/unit/script-sandbox-isolation.test.mjs new file mode 100644 index 00000000..1bc96b7d --- /dev/null +++ b/web/tests/unit/script-sandbox-isolation.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "script-sandbox-isolation-")); +const require = createRequire(import.meta.url); +for (const name of ["asset-path", "capability-gates", "scripting-platform"]) { + const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8"); + const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText + .replace('require("./asset-path")', 'require("./asset-path.cjs")').replace('require("./capability-gates")', 'require("./capability-gates.cjs")'); + fs.writeFileSync(path.join(temporary, `${name}.cjs`), output); +} +const protocol = require(path.join(temporary, "scripting-platform.cjs")); +const running = { schemaVersion: 1, jobId: "sandbox:1", workerGeneration: 4, baseRevision: 9, mainRevisionBefore: 9, status: "RUNNING" }; + +test("M13-03D crash and timeout terminate jobs without changing Main revision", () => { + const crashed = protocol.terminateScriptSandboxJob(running, "CRASH"); + const timedOut = protocol.terminateScriptSandboxJob(running, "TIMEOUT"); + assert.deepEqual({ status: crashed.status, errorCode: crashed.errorCode, mainRevisionAfter: crashed.mainRevisionAfter, temporaryBytes: crashed.temporaryBytes, publishedResults: crashed.publishedResults, committed: crashed.committed }, { status: "CRASHED", errorCode: "SCRIPT_SANDBOX_CRASHED", mainRevisionAfter: 9, temporaryBytes: 0, publishedResults: 0, committed: false }); + assert.deepEqual({ status: timedOut.status, errorCode: timedOut.errorCode, mainRevisionAfter: timedOut.mainRevisionAfter, temporaryBytes: timedOut.temporaryBytes, publishedResults: timedOut.publishedResults, committed: timedOut.committed }, { status: "TIMED_OUT", errorCode: "SCRIPT_SANDBOX_TIMEOUT", mainRevisionAfter: 9, temporaryBytes: 0, publishedResults: 0, committed: false }); +}); + +test("M13-03D cancellation and late results are fail-closed", () => { + const cancelled = protocol.terminateScriptSandboxJob(running, "CANCEL"); + assert.equal(cancelled.errorCode, "SCRIPT_SANDBOX_CANCELLED"); + assert.throws(() => protocol.rejectLateScriptSandboxResult(cancelled), /SCRIPT_SANDBOX_LATE_RESULT/); + assert.throws(() => protocol.terminateScriptSandboxJob({ ...running, baseRevision: 8 }, "CRASH"), /SCRIPT_MANIFEST_INVALID/); + assert.throws(() => protocol.terminateScriptSandboxJob({ ...running, status: "CRASHED" }, "TIMEOUT"), /SCRIPT_MANIFEST_INVALID/); + assert.throws(() => protocol.rejectLateScriptSandboxResult(running), /SCRIPT_MANIFEST_INVALID/); +}); + +test("M13-03D rejects malformed termination receipts", () => { + assert.throws(() => protocol.terminateScriptSandboxJob({ ...running, workerGeneration: 0 }, "CRASH"), /SCRIPT_BUDGET_EXCEEDED/); + assert.throws(() => protocol.terminateScriptSandboxJob({ ...running, mainRevisionBefore: 10 }, "TIMEOUT"), /SCRIPT_MANIFEST_INVALID/); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/script-sandbox-recovery.test.mjs b/web/tests/unit/script-sandbox-recovery.test.mjs new file mode 100644 index 00000000..488fa424 --- /dev/null +++ b/web/tests/unit/script-sandbox-recovery.test.mjs @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "script-sandbox-recovery-")); +const source = fs.readFileSync(path.join(root, "web/app/src/testing/script-sandbox-recovery.ts"), "utf8"); +fs.writeFileSync(path.join(temporary, "script-sandbox-recovery.cjs"), ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: "script-sandbox-recovery.ts" }).outputText); +const protocol = createRequire(import.meta.url)(path.join(temporary, "script-sandbox-recovery.cjs")); +const digest = "a".repeat(64); +const entry = (sequence, requestId, previousEntrySha256, entrySha256) => ({ sequence, requestId, previousEntrySha256, entrySha256, sourceSha256: digest, manifestSha256: "b".repeat(64) }); + +test("M13-03G accepts one-generation recovery with a continuous audit chain", () => { + const receipt = protocol.createScriptSandboxRecoveryReceipt({ previousGeneration: 4, nextGeneration: 5, mainRevisionBefore: 11, mainRevisionAfter: 11, sourceSha256: digest, manifestSha256: "b".repeat(64), audit: { entries: 2, first: entry(1, "sandbox-recovery:g4", null, digest), second: entry(2, "sandbox-recovery:g5", digest, "c".repeat(64)) } }); + assert.equal(receipt.recovered, true); + assert.equal(receipt.execution, "DISABLED"); + assert.equal(receipt.audit.second.previousEntrySha256, receipt.audit.first.entrySha256); +}); + +test("M13-03G rejects generation, revision, request and hash-chain drift", () => { + const base = { previousGeneration: 4, nextGeneration: 5, mainRevisionBefore: 11, mainRevisionAfter: 11, sourceSha256: digest, manifestSha256: "b".repeat(64), audit: { entries: 2, first: entry(1, "sandbox-recovery:g4", null, digest), second: entry(2, "sandbox-recovery:g5", digest, "c".repeat(64)) } }; + assert.throws(() => protocol.createScriptSandboxRecoveryReceipt({ ...base, nextGeneration: 7 }), /generation/); + assert.throws(() => protocol.createScriptSandboxRecoveryReceipt({ ...base, mainRevisionAfter: 12 }), /revision/); + assert.throws(() => protocol.createScriptSandboxRecoveryReceipt({ ...base, audit: { ...base.audit, second: entry(2, "sandbox-recovery:g5", "d".repeat(64), "c".repeat(64)) } }), /hash chain/); + assert.throws(() => protocol.createScriptSandboxRecoveryReceipt({ ...base, audit: { ...base.audit, second: entry(2, "sandbox-recovery:g4", digest, "c".repeat(64)) } }), /replayed/); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/script-sandbox-scope.test.mjs b/web/tests/unit/script-sandbox-scope.test.mjs new file mode 100644 index 00000000..b87140f4 --- /dev/null +++ b/web/tests/unit/script-sandbox-scope.test.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "script-sandbox-scope-")); +const require = createRequire(import.meta.url); +for (const name of ["asset-path", "capability-gates", "scripting-platform"]) { + const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8"); + const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText + .replace('require("./asset-path")', 'require("./asset-path.cjs")') + .replace('require("./capability-gates")', 'require("./capability-gates.cjs")'); + fs.writeFileSync(path.join(temporary, `${name}.cjs`), output); +} +const protocol = require(path.join(temporary, "scripting-platform.cjs")); +const deniedScope = { schemaVersion: 1, dom: false, hostWorker: false, opfs: false, indexedDB: false, network: false }; + +test("M13-03A accepts only the all-deny sandbox scope", () => { + assert.deepEqual(protocol.parseScriptSandboxScope(deniedScope), deniedScope); + for (const capability of ["dom", "hostWorker", "opfs", "indexedDB", "network"]) { + assert.throws(() => protocol.parseScriptSandboxScope({ ...deniedScope, [capability]: true }), /SCRIPT_POLICY_DENIED/); + } +}); + +test("M13-03A rejects unknown scope versions and missing declarations", () => { + assert.throws(() => protocol.parseScriptSandboxScope({ ...deniedScope, schemaVersion: 2 }), /PROTOCOL_MISMATCH/); + assert.throws(() => protocol.parseScriptSandboxScope({ schemaVersion: 1 }), /SCRIPT_POLICY_DENIED/); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/script-signature-negative.test.mjs b/web/tests/unit/script-signature-negative.test.mjs new file mode 100644 index 00000000..283b7f2c --- /dev/null +++ b/web/tests/unit/script-signature-negative.test.mjs @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "script-signature-negative-")); +const require = createRequire(import.meta.url); +for (const name of ["asset-path", "capability-gates", "scripting-platform"]) { + const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8"); + const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText + .replace('require("./asset-path")', 'require("./asset-path.cjs")') + .replace('require("./capability-gates")', 'require("./capability-gates.cjs")'); + fs.writeFileSync(path.join(temporary, `${name}.cjs`), output); +} +const protocol = require(path.join(temporary, "scripting-platform.cjs")); +const publicKey = "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8"; +const signature = "fc396c6c68e6f6eb38a18c147becfaec1621a167f6db0a0d76874209accf3cb80dfa1fac1528ebc1bc6b090801a3ad397cae18e6ddb41740766678711c0a8804"; +const script = (id = "clean", overrides = {}) => ({ 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 = {}) => ({ 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 = {}) => ({ schemaVersion: 1, issuer: "web-trust", issuedAt: "2026-01-01T00:00:00.000Z", expiresAt: "2027-01-01T00:00:00.000Z", maxClockSkewMs: 300000, keys: [key()], ...overrides }); + +test("M13-02F blocks missing, expired, not-yet-valid and publisher-confused signers", async () => { + const at = "2026-08-18T12:00:00.000Z"; + assert.equal((await protocol.verifyScriptManifestSignature(manifest(), "clean", policy({ keys: [] }), at)).code, "SCRIPT_POLICY_DENIED"); + assert.equal((await protocol.verifyScriptManifestSignature(manifest(), "clean", policy({ expiresAt: "2026-06-01T00:00:00.000Z" }), at)).code, "SCRIPT_POLICY_DENIED"); + assert.equal((await protocol.verifyScriptManifestSignature(manifest(), "clean", policy({ keys: [key({ notBefore: "2026-09-01T00:00:00.000Z" })] }), at)).code, "SCRIPT_POLICY_DENIED"); + assert.equal((await protocol.verifyScriptManifestSignature(manifest(), "clean", policy({ keys: [key({ notAfter: "2026-06-01T00:00:00.000Z" })] }), at)).code, "SCRIPT_POLICY_DENIED"); + assert.equal((await protocol.verifyScriptManifestSignature(manifest(), "clean", policy({ keys: [key({ publisher: "Other" })] }), at)).code, "SCRIPT_POLICY_DENIED"); +}); + +test("M13-02F binds a signature to one script and does not accept reordered/swapped content", async () => { + const at = "2026-08-18T12:00:00.000Z"; + assert.equal((await protocol.verifyScriptManifestSignature(manifest(), "clean", policy(), at)).status, "VERIFIED"); + const swapped = manifest([script("other")]); + assert.equal((await protocol.verifyScriptManifestSignature(swapped, "other", policy(), at)).code, "SCRIPT_SIGNATURE_INVALID"); + assert.equal((await protocol.verifyScriptManifestSignature(manifest([script("other"), script()]), "clean", policy(), at)).status, "VERIFIED"); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/script-trust-policy.test.mjs b/web/tests/unit/script-trust-policy.test.mjs new file mode 100644 index 00000000..4a187fe0 --- /dev/null +++ b/web/tests/unit/script-trust-policy.test.mjs @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { createPrivateKey, createPublicKey, sign } from "node:crypto"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "script-trust-policy-")); +const require = createRequire(import.meta.url); +for (const name of ["asset-path", "capability-gates", "scripting-platform"]) { + const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8"); + const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText + .replace('require("./asset-path")', 'require("./asset-path.cjs")') + .replace('require("./capability-gates")', 'require("./capability-gates.cjs")'); + fs.writeFileSync(path.join(temporary, `${name}.cjs`), output); +} +const protocol = require(path.join(temporary, "scripting-platform.cjs")); +const digest = "a".repeat(64); +const signature = "b".repeat(128); +const privateKey = createPrivateKey({ key: Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), Buffer.from("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", "hex")]), format: "der", type: "pkcs8" }); +const publicKey = createPublicKey(privateKey).export({ format: "der", type: "spki" }).subarray(-32).toString("hex"); +const script = (id = "clean", overrides = {}) => ({ 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, overrides = {}) => ({ 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 = {}) => ({ schemaVersion: 1, issuer: "web-trust", issuedAt: "2026-01-01T00:00:00.000Z", expiresAt: "2027-01-01T00:00:00.000Z", maxClockSkewMs: 300000, keys, ...overrides }); + +test("M13-02C parses signer identity, active rotation and timestamp windows", () => { + const parsed = protocol.parseScriptTrustPolicy(policy([key("key:new", { replaces: "key:old" }), key("key:old", { status: "REVOKED", revokedAt: "2026-06-01T00:00:00.000Z" })])); + assert.equal(parsed.keys.length, 2); + assert.equal(protocol.canonicalizeScriptTrustPolicy(parsed).keys[0].keyId, "key:new"); + assert.match(protocol.serializeScriptTrustPolicy(parsed), /"algorithm":"ED25519"/); + assert.deepEqual(protocol.resolveScriptSigner({ schemaVersion: 1, scripts: [script()] }, "clean", parsed, "2026-08-18T12:00:00.000Z"), { status: "ELIGIBLE", keyId: "key:new", publisher: "Team", trust: "ACTIVE", cryptographicVerification: "REQUIRED" }); +}); + +test("M13-02C rejects invalid rotation, revocation and timestamp policy declarations", () => { + assert.throws(() => protocol.parseScriptTrustPolicy(policy([key("key:new", { replaces: "missing" })])), /SCRIPT_MANIFEST_INVALID/); + assert.throws(() => protocol.parseScriptTrustPolicy(policy([key("key:new", { replaces: "key:old" }), key("key:old", { publisher: "Other" })])), /SCRIPT_POLICY_DENIED/); + assert.throws(() => protocol.parseScriptTrustPolicy(policy([key("key:a", { replaces: "key:b" }), key("key:b", { replaces: "key:a" })])), /SCRIPT_MANIFEST_INVALID/); + assert.throws(() => protocol.parseScriptTrustPolicy(policy([key("key:new", { status: "REVOKED" })])), /SCRIPT_POLICY_DENIED/); + assert.throws(() => protocol.parseScriptTrustPolicy(policy([key("key:new", { revokedAt: "2026-06-01T00:00:00.000Z" })])), /SCRIPT_POLICY_DENIED/); + assert.throws(() => protocol.parseScriptTrustPolicy(policy([key("key:new", { notAfter: "2025-01-01T00:00:00.000Z" })])), /SCRIPT_MANIFEST_INVALID/); + assert.throws(() => protocol.parseScriptTrustPolicy(policy([key("key:new", { publicKey: "not-a-key" })])), /SCRIPT_SIGNATURE_INVALID/); +}); + +test("M13-02C resolves revoked, publisher-mismatched and expired signers fail-closed", () => { + const baseManifest = { schemaVersion: 1, scripts: [script()] }; + const revoked = policy([key("key:new", { status: "REVOKED", revokedAt: "2026-06-01T00:00:00.000Z" })]); + assert.equal(protocol.resolveScriptSigner(baseManifest, "clean", revoked, "2026-08-18T12:00:00.000Z").trust, "REVOKED"); + const mismatch = policy([key("key:new", { publisher: "Other" })]); + assert.equal(protocol.resolveScriptSigner(baseManifest, "clean", mismatch, "2026-08-18T12:00:00.000Z").trust, "PUBLISHER_MISMATCH"); + const expired = policy([key("key:new", { notAfter: "2026-06-01T00:00:00.000Z" })]); + assert.equal(protocol.resolveScriptSigner(baseManifest, "clean", expired, "2026-08-18T12:00:00.000Z").trust, "KEY_EXPIRED"); + const policyExpired = policy([key("key:new")], { expiresAt: "2026-06-01T00:00:00.000Z" }); + assert.equal(protocol.resolveScriptSigner(baseManifest, "clean", policyExpired, "2026-08-18T12:00:00.000Z").trust, "POLICY_EXPIRED"); +}); + +test("M13-02D verifies only the canonical declared content and source hash", async () => { + const baseScript = script("clean", { signature: "0".repeat(128) }); + const unsigned = { schemaVersion: 1, scripts: [baseScript] }; + const signedScript = { ...baseScript, signature: sign(null, Buffer.from(protocol.serializeScriptSignatureInput(unsigned, "clean")), privateKey).toString("hex") }; + const signed = { schemaVersion: 1, scripts: [signedScript] }; + const trust = policy([key("key:new", { publicKey })]); + const verified = await protocol.verifyScriptManifestSignature(signed, "clean", trust, "2026-08-18T12:00:00.000Z"); + assert.equal(verified.status, "VERIFIED"); + assert.equal(verified.code, "SCRIPT_SIGNATURE_VERIFIED"); + const changedSource = { schemaVersion: 1, scripts: [{ ...signedScript, sourceSha256: "d".repeat(64) }] }; + assert.deepEqual((await protocol.verifyScriptManifestSignature(changedSource, "clean", trust, "2026-08-18T12:00:00.000Z")).code, "SCRIPT_SIGNATURE_INVALID"); + const changedSignature = { schemaVersion: 1, scripts: [{ ...signedScript, signature: `${signedScript.signature.slice(0, -1)}${signedScript.signature.endsWith("0") ? "1" : "0"}` }] }; + assert.deepEqual((await protocol.verifyScriptManifestSignature(changedSignature, "clean", trust, "2026-08-18T12:00:00.000Z")).code, "SCRIPT_SIGNATURE_INVALID"); + const revoked = policy([key("key:new", { publicKey, status: "REVOKED", revokedAt: "2026-06-01T00:00:00.000Z" })]); + assert.deepEqual((await protocol.verifyScriptManifestSignature(signed, "clean", revoked, "2026-08-18T12:00:00.000Z")).code, "SCRIPT_POLICY_DENIED"); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/server-job-fault.test.mjs b/web/tests/unit/server-job-fault.test.mjs new file mode 100644 index 00000000..4623c71b --- /dev/null +++ b/web/tests/unit/server-job-fault.test.mjs @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { classifyServerJobFault, createServerJobFaultReceipt, SERVER_JOB_FAULT_CODES } from "../../../tools/web/server-job-fault.mjs"; + +test("M13-04H classifies faults with stable precedence", () => { + assert.equal(classifyServerJobFault({ timedOut: true, oom: true, signal: "SIGKILL" }).code, SERVER_JOB_FAULT_CODES.TIMEOUT); + assert.equal(classifyServerJobFault({ oom: true, signal: "SIGKILL" }).code, SERVER_JOB_FAULT_CODES.OOM); + assert.equal(classifyServerJobFault({ signal: "SIGTERM" }).code, SERVER_JOB_FAULT_CODES.SIGNAL); + assert.equal(classifyServerJobFault({ code: 7 }).code, SERVER_JOB_FAULT_CODES.EXIT_FAILED); + assert.equal(classifyServerJobFault({ cancelRequested: true, timedOut: true }).code, SERVER_JOB_FAULT_CODES.CANCELLED); +}); + +test("M13-04H preserves the old revision and blocks failure publication", () => { + const receipt = createServerJobFaultReceipt({ baseRevision: 11, currentRevision: 11, timedOut: true }); + assert.deepEqual(receipt, { schemaVersion: 1, state: "FAILED", code: "SERVER_JOB_TIMEOUT", stage: "PROCESS", exitCode: null, signal: null, timedOut: true, memoryExceeded: false, baseRevision: 11, currentRevision: 11, committedRevision: 11, publish: false, revisionPreserved: true, execution: "DISABLED" }); + assert.throws(() => createServerJobFaultReceipt({ baseRevision: 11, currentRevision: 12, signal: "SIGTERM" }), /SERVER_JOB_REVISION_CONFLICT/); + assert.equal(createServerJobFaultReceipt({ baseRevision: 11, currentRevision: 11, code: 0 }).publish, true); +}); + +test("M13-04H derives OOM from bounded memory usage", () => { + const receipt = createServerJobFaultReceipt({ baseRevision: 3, currentRevision: 3, memoryBytes: 513, memoryLimitBytes: 512 }); + assert.equal(receipt.code, SERVER_JOB_FAULT_CODES.OOM); + assert.equal(receipt.memoryExceeded, true); +}); diff --git a/web/tests/unit/server-job-idempotency.test.mjs b/web/tests/unit/server-job-idempotency.test.mjs new file mode 100644 index 00000000..09a4db7d --- /dev/null +++ b/web/tests/unit/server-job-idempotency.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { submitIdempotentServerJobResult } from "../../../tools/web/server-job-idempotency.mjs"; + +const h = (value) => crypto.createHash("sha256").update(value).digest("hex"); +const identity = { requestId: "retry-1", projectId: "project-1", baseRevision: 4, sourceSha256: h("source"), settingsSha256: h("settings"), buildSha256: h("build") }; + +test("M13-04J reuses the exact verified result for the same request", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "m13-04j-reuse-")); + const options = { receiptDirectory: path.join(root, "receipts"), outputDirectory: path.join(root, "outputs") }; + try { + const first = await submitIdempotentServerJobResult(identity, new Uint8Array([1, 2]), options); + const second = await submitIdempotentServerJobResult(identity, new Uint8Array([1, 2]), options); + assert.equal(first.reused, false); + assert.equal(second.reused, true); + assert.equal(first.outputSha256, second.outputSha256); + assert.equal((await fs.readdir(options.receiptDirectory)).length, 1); + } finally { await fs.rm(root, { recursive: true, force: true }); } +}); + +test("M13-04J rejects conflicting output/identity and isolates different requests", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "m13-04j-conflict-")); + const options = { receiptDirectory: path.join(root, "receipts"), outputDirectory: path.join(root, "outputs") }; + try { + await submitIdempotentServerJobResult(identity, new Uint8Array([3]), options); + await assert.rejects(submitIdempotentServerJobResult(identity, new Uint8Array([4]), options), /SERVER_JOB_IDEMPOTENCY_CONFLICT/); + await assert.rejects(submitIdempotentServerJobResult({ ...identity, settingsSha256: h("changed") }, new Uint8Array([3]), options), /SERVER_JOB_IDEMPOTENCY_CONFLICT/); + const other = await submitIdempotentServerJobResult({ ...identity, requestId: "retry-2" }, new Uint8Array([4]), options); + assert.equal(other.reused, false); + assert.equal((await fs.readdir(options.receiptDirectory)).length, 2); + } finally { await fs.rm(root, { recursive: true, force: true }); } +}); + +test("M13-04J serializes concurrent duplicate requests", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "m13-04j-concurrent-")); + const options = { receiptDirectory: path.join(root, "receipts"), outputDirectory: path.join(root, "outputs") }; + try { + const results = await Promise.all([ + submitIdempotentServerJobResult({ ...identity, requestId: "retry-3" }, new Uint8Array([8]), options), + submitIdempotentServerJobResult({ ...identity, requestId: "retry-3" }, new Uint8Array([8]), options), + ]); + assert.deepEqual(results.map((result) => result.reused).sort(), [false, true]); + } finally { await fs.rm(root, { recursive: true, force: true }); } +}); diff --git a/web/tests/unit/server-job-isolation.test.mjs b/web/tests/unit/server-job-isolation.test.mjs new file mode 100644 index 00000000..0e57783d --- /dev/null +++ b/web/tests/unit/server-job-isolation.test.mjs @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { cleanupServerJobDirectory, createServerJobDirectory, prepareServerJobWorkspace } from "../../../tools/web/server-job-isolation.mjs"; + +test("M13-04A creates unpredictable one-shot directories and cleans them once", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "m13-04a-job-root-")); + try { + const first = await createServerJobDirectory(root, "server:job-one"); + const second = await createServerJobDirectory(root, "server:job-two"); + assert.notEqual(first.directoryName, second.directoryName); + assert.notEqual(first.directoryName, first.jobId); + assert.match(first.directoryName, /^\.blender-job-[0-9a-f-]+-[A-Za-z0-9]+$/); + assert.equal((await fs.stat(first.path)).mode & 0o777, 0o700); + assert.equal((await fs.stat(second.path)).mode & 0o777, 0o700); + const cleanedFirst = await cleanupServerJobDirectory(first); + const cleanedSecond = await cleanupServerJobDirectory(second); + assert.equal(cleanedFirst.state, "CLEANED"); + assert.equal(cleanedFirst.cleanupCount, 1); + assert.equal(cleanedSecond.cleanupCount, 1); + await assert.rejects(fs.stat(first.path), { code: "ENOENT" }); + await assert.rejects(fs.stat(second.path), { code: "ENOENT" }); + assert.equal((await cleanupServerJobDirectory(cleanedFirst)).cleanupCount, 1); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("M13-04A rejects unsafe roots, IDs and cleanup escapes", async () => { + await assert.rejects(createServerJobDirectory("relative-root", "server:job"), /SERVER_JOB_DIRECTORY_INVALID/); + const root = await fs.mkdtemp(path.join(os.tmpdir(), "m13-04a-invalid-root-")); + try { + await assert.rejects(createServerJobDirectory(root, "../escape"), /SERVER_JOB_DIRECTORY_INVALID/); + await assert.rejects(cleanupServerJobDirectory({ schemaVersion: 1, root, path: path.join(root, "other"), state: "ALLOCATED" }), /SERVER_JOB_DIRECTORY_INVALID/); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("M13-04B isolates read-only source from writable output", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "m13-04b-workspace-")); + try { + const job = await createServerJobDirectory(root, "server:job-mount"); + const workspace = await prepareServerJobWorkspace(job, new Uint8Array([1, 2, 3])); + assert.notEqual(path.dirname(workspace.sourcePath), workspace.outputDirectory); + assert.equal((await fs.stat(workspace.sourceDirectory)).mode & 0o777, 0o555); + assert.equal((await fs.stat(workspace.sourcePath)).mode & 0o777, 0o444); + assert.equal((await fs.stat(workspace.outputDirectory)).mode & 0o777, 0o700); + await assert.rejects(fs.writeFile(workspace.sourcePath, new Uint8Array([9])), { code: "EACCES" }); + await fs.writeFile(path.join(workspace.outputDirectory, "result.bin"), new Uint8Array([4, 5])); + assert.deepEqual([...await fs.readFile(path.join(workspace.outputDirectory, "result.bin"))], [4, 5]); + await cleanupServerJobDirectory(job); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); diff --git a/web/tests/unit/server-job-network-policy.test.mjs b/web/tests/unit/server-job-network-policy.test.mjs new file mode 100644 index 00000000..e37397fe --- /dev/null +++ b/web/tests/unit/server-job-network-policy.test.mjs @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseServerJobNetworkPolicy, resolveServerJobNetwork } from "../../../tools/web/server-job-network-policy.mjs"; + +test("M13-04D defaults to deny and admits only declared safe origins", () => { + const policy = parseServerJobNetworkPolicy({ schemaVersion: 1, allowedOrigins: ["https://example.com", "http://127.0.0.1:8787", "https://example.com"] }); + assert.deepEqual(policy, { schemaVersion: 1, defaultNetwork: "DENY", allowedOrigins: ["http://127.0.0.1:8787", "https://example.com"] }); + assert.deepEqual(resolveServerJobNetwork(policy), { status: "DENIED", code: "SERVER_NETWORK_DENIED", origin: null, network: "DISABLED" }); + assert.equal(resolveServerJobNetwork(policy, "https://example.com").status, "ALLOWED"); + assert.equal(resolveServerJobNetwork(policy, "https://other.example").code, "SERVER_NETWORK_DENIED"); +}); + +test("M13-04D rejects unsafe or non-canonical origins", () => { + for (const origin of ["http://example.com", "file:///tmp/x", "https://example.com/path", "https://user:pass@example.com"]) { + assert.throws(() => parseServerJobNetworkPolicy({ schemaVersion: 1, allowedOrigins: [origin] }), /SERVER_NETWORK_/); + } +}); diff --git a/web/tests/unit/server-job-output.test.mjs b/web/tests/unit/server-job-output.test.mjs new file mode 100644 index 00000000..84d30496 --- /dev/null +++ b/web/tests/unit/server-job-output.test.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createServerJobOutputReceipt, redactServerJobOutput, SERVER_JOB_OUTPUT_LIMITS } from "../../../tools/web/server-job-output.mjs"; + +test("M13-04F redacts credentials and filesystem paths before publishing output", () => { + const receipt = createServerJobOutputReceipt({ + stdout: 'authorization: Bearer abc123 token="secret-value" source=/home/user/private.blend', + stderr: "failed at C:\\Users\\alice\\job\\source.blend file:///tmp/internal.log", + }); + assert.equal(receipt.execution, "DISABLED"); + assert.ok(receipt.totalRedactions >= 5); + assert.doesNotMatch(receipt.stdout.text, /abc123|secret-value|\/home\/user|C:\\Users|file:\/\//u); + assert.doesNotMatch(receipt.stderr.text, /alice|internal\.log/u); + assert.match(receipt.stdout.text, //u); + assert.match(receipt.stderr.text, //u); +}); + +test("M13-04F truncates at UTF-8 byte boundaries and reports the source size", () => { + const value = "模型".repeat(100); + const receipt = createServerJobOutputReceipt({ stdout: value, stderr: "" }, { ...SERVER_JOB_OUTPUT_LIMITS, stdoutBytes: 32, stderrBytes: 32, totalBytes: 64 }); + assert.equal(receipt.stdout.truncated, true); + assert.ok(receipt.stdout.emittedBytes <= 32); + assert.equal(Buffer.from(receipt.stdout.text, "utf8").toString("utf8"), receipt.stdout.text); + assert.equal(receipt.stdout.originalBytes, Buffer.byteLength(value, "utf8")); + assert.match(receipt.stdout.text, /$/u); +}); + +test("M13-04F rejects invalid stream budgets and leaves empty streams explicit", () => { + assert.deepEqual(createServerJobOutputReceipt({ stdout: "", stderr: "" }).stdout, { + text: "", originalBytes: 0, emittedBytes: 0, redactionCount: 0, truncated: false, + }); + assert.throws(() => createServerJobOutputReceipt({ stdout: "x", stderr: "" }, { stdoutBytes: 10, stderrBytes: 10, totalBytes: 10 }), /SERVER_JOB_OUTPUT_INVALID/); + assert.throws(() => redactServerJobOutput(42, 10), /SERVER_JOB_OUTPUT_INVALID/); +}); diff --git a/web/tests/unit/server-job-process.test.mjs b/web/tests/unit/server-job-process.test.mjs new file mode 100644 index 00000000..980045ea --- /dev/null +++ b/web/tests/unit/server-job-process.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { cleanupServerJobDirectory, createServerJobDirectory } from "../../../tools/web/server-job-isolation.mjs"; +import { cancelServerJobProcess, startServerJobProcess } from "../../../tools/web/server-job-process.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + +test("M13-04G cancels the real process group and cleans the job directory", async () => { + const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "m13-04g-process-")); + const job = await createServerJobDirectory(temporary, "server:cancel"); + const childScript = "const {spawn}=require('node:child_process'); const c=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'}); setInterval(()=>{},1000);"; + const handle = startServerJobProcess(process.execPath, ["-e", childScript], { cwd: root }); + let cleanupCount = 0; + try { + const receipt = await cancelServerJobProcess(handle, async () => { cleanupCount += 1; await cleanupServerJobDirectory(job); }); + assert.equal(receipt.state, "CANCELLED"); + assert.equal(receipt.cleanupCount, 1); + assert.equal(cleanupCount, 1); + assert.match(receipt.treeSignal, /GROUP|ALREADY_EXITED/); + await assert.rejects(fs.stat(job.path), { code: "ENOENT" }); + await assert.doesNotReject(handle.completion); + } finally { + await fs.rm(temporary, { recursive: true, force: true }); + } +}); + +test("M13-04G repeated cancellation is idempotent", async () => { + const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "m13-04g-idempotent-")); + const job = await createServerJobDirectory(temporary, "server:repeat"); + const handle = startServerJobProcess(process.execPath, ["-e", "setInterval(()=>{},1000)"], { cwd: root }); + let cleanupCount = 0; + const cleanup = async () => { cleanupCount += 1; await cleanupServerJobDirectory(job); }; + try { + const first = await cancelServerJobProcess(handle, cleanup); + const second = await cancelServerJobProcess(handle, cleanup); + assert.equal(first.state, "CANCELLED"); + assert.equal(second.state, "CANCELLED"); + assert.equal(first.cleanupCount, 1); + assert.equal(second.cleanupCount, 1); + assert.equal(cleanupCount, 1); + } finally { + await fs.rm(temporary, { recursive: true, force: true }); + } +}); diff --git a/web/tests/unit/server-job-resource-budget.test.mjs b/web/tests/unit/server-job-resource-budget.test.mjs new file mode 100644 index 00000000..6d0ce105 --- /dev/null +++ b/web/tests/unit/server-job-resource-budget.test.mjs @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createServerJobResourceReceipt, parseServerJobResourceBudget, SERVER_JOB_RESOURCE_LIMITS } from "../../../tools/web/server-job-resource-budget.mjs"; + +const budget = { schemaVersion: 1, ...SERVER_JOB_RESOURCE_LIMITS }; +const usage = { cpuMs: 10, memoryBytes: 1024, processCount: 1, fileCount: 2, wallMs: 20, outputBytes: 512 }; + +test("M13-04C accepts bounded usage and marks enforcement", () => { + assert.deepEqual(parseServerJobResourceBudget(budget), budget); + assert.deepEqual(createServerJobResourceReceipt(budget, usage), { schemaVersion: 1, status: "SUCCEEDED", budget, usage, enforced: true, exceeded: [] }); +}); + +test("M13-04C rejects each resource overage with a stable code", () => { + for (const field of Object.keys(SERVER_JOB_RESOURCE_LIMITS)) { + assert.throws(() => createServerJobResourceReceipt(budget, { ...usage, [field]: SERVER_JOB_RESOURCE_LIMITS[field] + 1 }), new RegExp(`SERVER_JOB_BUDGET_EXCEEDED.*${field}`)); + } + assert.throws(() => parseServerJobResourceBudget({ ...budget, unknown: 1 }), /SERVER_JOB_BUDGET_INVALID/); +}); diff --git a/web/tests/unit/server-job-result-binding.test.mjs b/web/tests/unit/server-job-result-binding.test.mjs new file mode 100644 index 00000000..c7d18a9a --- /dev/null +++ b/web/tests/unit/server-job-result-binding.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { commitServerJobResult, verifyServerJobResultReceipt } from "../../../tools/web/server-job-result-binding.mjs"; + +const h = (value) => crypto.createHash("sha256").update(value).digest("hex"); +const identity = { requestId: "request-1", projectId: "project-1", baseRevision: 7, sourceSha256: h("source"), settingsSha256: h("settings"), buildSha256: h("build") }; + +test("M13-04I commits only a readback-verified result and binds four identity hashes", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "m13-04i-binding-")); + try { + const receipt = await commitServerJobResult(identity, new Uint8Array([1, 2, 3]), { outputDirectory: directory }); + assert.equal(receipt.status, "COMMITTED"); + assert.equal(receipt.publish, true); + assert.equal((await verifyServerJobResultReceipt(receipt, identity, directory)).verified, true); + assert.equal(receipt.outputByteLength, 3); + } finally { await fs.rm(directory, { recursive: true, force: true }); } +}); + +test("M13-04I blocks tamper, stale identity and partial/quota failures", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "m13-04i-negative-")); + try { + await assert.rejects(commitServerJobResult({ ...identity, settingsSha256: h("changed") }, new Uint8Array([1]), { outputDirectory: directory, expectedIdentity: identity, faultAt: "AFTER_STAGE" }), /SERVER_JOB_RESULT_IDENTITY_MISMATCH/); + assert.deepEqual(await fs.readdir(directory), []); + const receipt = await commitServerJobResult(identity, new Uint8Array([4, 5]), { outputDirectory: directory }); + await fs.writeFile(receipt.outputPath, new Uint8Array([9])); + await assert.rejects(verifyServerJobResultReceipt(receipt, identity, directory), /SERVER_JOB_RESULT_HASH_MISMATCH/); + await assert.rejects(commitServerJobResult({ ...identity, requestId: "request-2" }, new Uint8Array([8]), { outputDirectory: directory, faultAt: "QUOTA" }), /SERVER_JOB_RESULT_STORAGE_QUOTA/); + } finally { await fs.rm(directory, { recursive: true, force: true }); } +}); diff --git a/web/tests/unit/stl-export.test.mjs b/web/tests/unit/stl-export.test.mjs new file mode 100644 index 00000000..81d5506b --- /dev/null +++ b/web/tests/unit/stl-export.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "stl-export-unit-")); +const sourcePath = path.join(root, "web/protocol/stl-export.ts"); +const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true }); +assert.deepEqual(transpiled.diagnostics, []); +const modulePath = path.join(temporary, "stl-export.mjs"); +fs.writeFileSync(modulePath, transpiled.outputText); +const protocol = await import(pathToFileURL(modulePath)); +const document = { + schemaVersion: 1, + variant: "STL_BINARY", + unitScale: 1, + declaredTriangleCount: 1, + triangleCount: 1, + removedDegenerateTriangles: 0, + normals: [[0, 1, 0]], + vertices: [[[-1, 0, 1], [1, 0, 1], [1, 0, -1]]], + bounds: { min: [-1, 0, -1], max: [1, 0, 1] }, +}; + +test("M12-07F serializes binary STL and reports material loss", () => { + const output = protocol.exportBinarySTL(document); + assert.equal(output.byteLength, 134); + assert.equal(new DataView(output).getUint32(80, true), 1); + assert.deepEqual(protocol.createSTLLossReport(2), { + schemaVersion: 1, + operation: "STL_EXPORT_LOSS_REPORT", + canRoundTrip: true, + warningCount: 1, + warnings: [{ code: "STL_MATERIAL_UNSUPPORTED", severity: "warning", message: "STL has no material slots; 2 source material assignments are omitted" }], + }); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/stl-import.test.mjs b/web/tests/unit/stl-import.test.mjs new file mode 100644 index 00000000..284dacb1 --- /dev/null +++ b/web/tests/unit/stl-import.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "stl-import-unit-")); +const sourcePath = path.join(root, "web/protocol/stl-import.ts"); +const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, +}); +assert.deepEqual(transpiled.diagnostics, []); +const modulePath = path.join(temporary, "stl-import.mjs"); +fs.writeFileSync(modulePath, transpiled.outputText); +const protocol = await import(pathToFileURL(modulePath)); +const fixtureRoot = path.join(root, "tests/files/web/m12_stl_edges_v1"); +const bytes = (name) => { + const value = fs.readFileSync(path.join(fixtureRoot, name)); + return value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength); +}; + +test("M12-07E parses binary/ASCII normals and explicit unit scales", () => { + const binary = protocol.importSTL(bytes("capability-binary.stl"), { variant: "STL_BINARY", unitScale: 1 }); + assert.equal(binary.triangleCount, 2); + assert.deepEqual(binary.normals, [[0, 1, 0], [0, 1, 0]]); + assert.deepEqual(binary.bounds, { min: [-1, 0, -1], max: [1, 0, 1] }); + const scaled = protocol.importSTL(bytes("capability-binary.stl"), { variant: "STL_BINARY", unitScale: 0.001 }); + assert.deepEqual(scaled.bounds, { min: [-0.001, 0, -0.001], max: [0.001, 0, 0.001] }); + const ascii = protocol.importSTL(bytes("../m12_stl_capability_v1/capability-ascii.stl"), { variant: "STL_ASCII", unitScale: 1 }); + assert.equal(ascii.triangleCount, 2); + assert.deepEqual(ascii.normals, binary.normals); + assert.deepEqual(ascii.bounds, binary.bounds); +}); + +test("M12-07E matches Blender's degenerate removal and blocks trailing bytes", () => { + const degenerate = protocol.importSTL(bytes("degenerate-binary.stl"), { variant: "STL_BINARY", unitScale: 1 }); + assert.equal(degenerate.declaredTriangleCount, 2); + assert.equal(degenerate.removedDegenerateTriangles, 1); + assert.equal(degenerate.triangleCount, 1); + assert.throws(() => protocol.importSTL(bytes("trailing-binary.stl"), { variant: "STL_BINARY", unitScale: 1 }), /STL_TRAILING_BYTES/); + assert.throws(() => protocol.importSTL(bytes("capability-binary.stl"), { variant: "STL_BINARY", unitScale: 0 }), /STL_UNIT_SCALE_INVALID/); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/viewport-dpr.test.mjs b/web/tests/unit/viewport-dpr.test.mjs new file mode 100644 index 00000000..945b7859 --- /dev/null +++ b/web/tests/unit/viewport-dpr.test.mjs @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import ts from "../../node_modules/typescript/lib/typescript.js"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const sourcePath = path.join(root, "web/protocol/viewport-dpr.ts"); +const output = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true }); +assert.deepEqual(output.diagnostics, []); +const dpr = await import(`data:text/javascript;base64,${Buffer.from(output.outputText).toString("base64")}`); + +test("M14-04B clamps DPR and computes stable backing dimensions", () => { + for (const [observed, expected] of [[1, 1], [1.5, 1.5], [2, 2], [3, 2], [0, 1], [Number.NaN, 1]]) { + const metrics = dpr.resolveViewportPixelMetrics(101, 57, observed); + assert.equal(metrics.pixelRatio, expected); + assert.deepEqual([metrics.backingWidth, metrics.backingHeight], [Math.floor(101 * expected), Math.floor(57 * expected)]); + } +}); + +test("M14-04B uses CSS bounds for DPR-independent NDC", () => { + assert.deepEqual(dpr.viewportNDC(150, 75, { left: 100, top: 25, width: 100, height: 100 }), { x: 0, y: 0 }); + assert.throws(() => dpr.viewportNDC(0, 0, { left: 0, top: 0, width: 0, height: 1 }), /VIEWPORT_BOUNDS_INVALID/); +}); diff --git a/后续工作.txt b/后续工作.txt index 85c23cf5..d62b4700 100644 --- a/后续工作.txt +++ b/后续工作.txt @@ -1,5 +1,5 @@ -Web Blender M6/M7/M8/M9/M10/M11 接续执行记录 -更新时间:2026-08-17(America/New_York) +Web Blender M6/M7/M8/M9/M10/M11/M12/M13 接续执行记录 +更新时间:2026-08-18(America/New_York) 执行规则 1. 本文件是当前接续入口;每轮先读本文件,再直接领取机器队列返回的最新 `nextTask`。 @@ -7,6 +7,11 @@ Web Blender M6/M7/M8/M9/M10/M11 接续执行记录 3. 每完成一段,写回实际命令、结果、hash 和当前计数;后续任务名只从机器队列的最新 `nextTask` 获取,不在本文手工维护或推导。 4. 不覆盖用户已有改动,不提交或改写 git 历史。 +5. 本文件下方为历史接续记录;新一轮只需先读 `docs/EXECUTION_QUEUE.md`、当前 manifest 和 + 对应 status 页,不重新加载全部历史。 +6. 铁律:浏览器执行、CI、验收证据和发布声明永久仅限 Chromium;禁止启动、探测或领取 + Firefox/WebKit 测试任务。历史报告仅作归档背景,缺失 WebGPU 只能在 Chromium 中 + fail-closed 为 `BLOCKED`。 当前状态 - M6 正式完成:71/71;剩余:0。 @@ -16,9 +21,10 @@ Web Blender M6/M7/M8/M9/M10/M11 接续执行记录 - M10 正式完成:15/15;剩余:0。 - M11 正式完成:14/14;剩余:0。 - 已完成到:M11-14。 -- M12 当前完成:M12-01A-I、M12-02A-H 与 M12-03A-D。 -- 已完成到:M12-03D。 -- 下一领取点:机器队列的最新 `nextTask` 为 `M12-03E`。 +- M12 当前完成:M12-01A-I、M12-02A-H、M12-03A-N、M12-04A-J、M12-05A-F、M12-06A-G 与 M12-07A-J。 +- 已完成到:M12-07J。 +- M13 当前完成:M13-01A-F、M13-02A-F、M13-03A-G、M13-04A-C;剩余脚本安全原子任务按机器队列继续。 +- 下一领取点:机器队列的最新 `nextTask` 为 `M13-04F`。 - quick lane:READY,7/7 命令通过,总命令耗时 11,455 ms,报告 release/ci-reports/quick.json。 - Chromium lane:READY,9/9 命令通过;每个 browser server 使用独立动态端口,报告 @@ -2787,3 +2793,1326 @@ M12-03D 实际验证(2026-08-17 America/New_York) - `docs/status/M12-03D.md`、N-023、当前/完整对标计划、项目状态、WBS 和本文件已同步;本项 保持 `parityStatus=BLOCKED`,ledger 仍为 195 completed/58 blocked。机器队列唯一下一任务切换为 `M12-03E`。 + +M12-03E 实际验证(2026-08-17 America/New_York) +- 领取 M12-03E:在 M12-03D 的 WASM Main append 之上,读取 M12-03C desktop canonical report, + 对 Object/Mesh/Material/Image 的依赖边、stable ID、ownership、geometry、UV、material slot、 + packed image metadata 与 Float32 pixel hash 做精确比较。图像比较规范化 Blender bottom-up + `Image.pixels` 与 Canvas top-down row order;当前 SceneIR 缺省 `Image.colorSpace` 使用 desktop + sRGB semantic default,若字段出现则必须匹配。 +- `node ../tools/web/check-library-main-append.mjs` 通过 manifest、source/target、desktop report + 与测试源码 hash 门;`WEB_TEST_PORT=5194 npm --prefix web run test:library-main-append` 通过 + checker 与 Chromium 1/1。append 为一个 transaction、revision 只增加 1;stale base revision + 返回 `REVISION_CONFLICT`,local ID collision 返回 `ASSET_MANIFEST_INVALID` 且 revision 不变。 +- undo 后 Object/Mesh/Material/Image 四个 closure ID 全部移除;redo 恢复 canonical graph;保存 + buffer 在独立 client 重开后依赖图和 packed image hash 仍与 desktop report 一致。像素 hash 为 + `6f0f8c231d65149e69e6ed12d370bcfa095ef90adc202065492ea8c8ef17e45a`。 +- hashes:checker `7fba72c5a38c0877f03682b51cbaaaf6d9a5f315f2beddfdef432bc54b2f1cd2`;test + `101f67d35c2320da57459f08a59687d769234b910dbd9aaec5a668cee7e0f9f0`;manifest + `beaa88ea0385225e712f9816072420bd8744c15da3229ddc352462abec407739`;package + `36a29c5be9bd6163b06cf3511edf77d932388fa0b75c08bdcec397e19c0ef45f`;source/target `.blend` + `5b60d02926efd588a6ca300ba31414cdf37dbc48786c17b383a70319057c0606` / + `9b1ecbcc3d7f8079ee5469de64193ffcaa0a7b01e0f2ac340bbb18aa88734a63`;desktop report + `b1d7b8b9e832d18d69081f63981062800e0f00f0c9aec025b18274510ed76e2a`。 +- `docs/status/M12-03E.md`、N-023、当前/完整对标计划、项目状态、WBS 和本文件已同步;本项 + 保持 `parityStatus=BLOCKED`,ledger 仍为 195 completed/58 blocked。机器队列唯一下一任务切换为 + `M12-03F`。 + +M12-03F 实际验证(2026-08-17 America/New_York) +- 领取 M12-03F:新增独立 Blender 5.2 desktop LINK fixture。source library 创建一个 Object, + 通过 Mesh material slot 和 Material Image Texture node 带出 Mesh/Material/packed Image 闭包; + target 用 `bpy.data.libraries.load(link=True)` 只链接 Object root。 +- `npm --prefix web run test:library-link-desktop` 通过 generator rerun、artifact hash、canonical + report 和 save/reopen 检查。四个 linked ID 的 `library` 均为 `m12_link_source.blend`, + `isLibraryOverride=false`,四条 stable mapping 均为 `SOURCE_LIBRARY/readOnly=true`;依赖边、 + 4/4/1 geometry、UVMap、material slot、2x2 sRGB packed image 与 Float32 pixel hash 均稳定。 +- hashes:generator `15a2e34c1c5b2a8ee63b10084dbb894e0f9677b9e1cf4adb7e2ddce6b4c965b1`;checker + `6a4c024bea227d7bc14f793467b91766b006459fe4d7717cc75dfee12f49f894`;source/target `.blend` + `fae97569e9d2e2066fc92749672f86cc42717cd9b97b012faeb9eb92015d7fd1` / + `acdaf0f297deb08c84e1a8beba30972e3414ef62e60e3b103fe0661199c438a1`;desktop report + `b278d4c254eff63d41c8cb3ea1d5e0984192137d45b1695ba0672e16f95b58ea`;manifest + `8220a8f5d560d45a75a62cbed645b3febc1390fe37b07c3c48871fefc1a9b159`;package + `336d8df1914aaaafaeccc181d4e73ed7058165f10e167efe539939c3ac1e0536`。 +- `docs/status/M12-03F.md`、N-023、当前/完整对标计划、项目状态、WBS 和本文件已同步;本项 + 保持 `enablingTask=true/parityStateChange=false`,ledger 仍为 195 completed/58 blocked。机器队列唯一下一任务切换为 + `M12-03G`。 + +M12-03G 实际验证(2026-08-17 America/New_York) +- 领取 M12-03G:新增 schema 1 linked data mutation gate,覆盖 object transform、mesh geometry、 + mesh material slot、material properties、material image node 与 packed image data 六类 writer。 + request 必须保持 `SOURCE_LIBRARY/linkedLibrary=true/readOnly=true`。 +- `npm --prefix web run test:library-linked-mutation` 通过 3/3。六类当前 revision writer 全部返回 + `BLOCKED/LINKED_DATA_MUTATION_BLOCKED` 且 `recoverable=false`;stale revision 返回 + `REVISION_CONFLICT`,未知字段、operation 和 ownership substitution 均在 Main writer 前阻断。 +- hashes:protocol `f629e0e7e04dc1f5437b6e2ca62fbe35484fc238830fa47e8358bcab46b7e104`;unit + `f19d47cefe89daf6123062e045ec717e6ffe60977e8a4b20c996dd62e49da211`;manifest + `caf17ce3c0fe9217ee8a727b35b6c479a1eb8b6cd634c4964eabfc24ecf8c756`;package + `51665ca48e57b7abc953f3012587300e2ac986bf24ce1a7f6b19ff109ecdcf33`。 +- `docs/status/M12-03G.md`、N-023、当前/完整对标计划、项目状态、WBS 和本文件已同步;本项 + 保持 `parityStateChange=false`,ledger 仍为 195 completed/58 blocked。机器队列唯一下一任务切换为 + `M12-03H`。 + +M12-03H 实际验证(2026-08-17 America/New_York) +- 领取 M12-03H:新增 schema 1 linked library reload protocol。请求绑定 source library、expected + generation/revision 和 replacement snapshot;replacement 必须保持 `SOURCE_LIBRARY/readOnly=true` + data-block closure,并且 generation 只能前进一代。 +- `npm --prefix web run test:library-linked-reload` 通过 4/4。匹配 generation 只替换一个 linked + snapshot,其他 library/generation 保持不变;stale、source substitution、duplicate identity、 + non-adjacent generation 和 undeclared field 均在发布前 fail-closed。 +- hashes:protocol `8be6f0b2abe36cd566ea7447d1b7de44c0e6a9a7351b863dfdd1372c1761060e`;unit + `dff866291468cc01e775fe3b3b95c632f5c0742566f79b956a0193bfd8fd43d2`;manifest + `271fea5f52fcc044f029588a4c2a431e7ab0329c7bcc1abfad8e376ee5b4b2d8`;package + `39aa1ca3e1988ebbb8e84bb60abeda43be4c99d475c1d8cd8f3a6d96b094d0c8`。 +- `docs/status/M12-03H.md`、N-023、当前/完整对标计划、项目状态、WBS 和本文件已同步;本项 + 保持 `parityStateChange=false`,ledger 仍为 195 completed/58 blocked。机器队列唯一下一任务切换为 + `M12-03I`。 + +M12-03I 实际验证(2026-08-17 America/New_York) +- 领取 M12-03I:新增 schema 1 missing-library protocol。匹配 source library/generation/revision + 时只把引用标记为 `MISSING` 并生成 `MISSING_LIBRARY` placeholder,原始 locator、source SHA、 + generation、revision 和 data-block IDs 全部保留。 +- `npm --prefix web run test:library-linked-missing` 通过 4/4。stale revision、source hash drift、 + undeclared field、duplicate identity 和 invalid placeholder 均在状态发布前阻断。 +- hashes:protocol `5833e8c943ef6bd866a93a0e1666c9521fa6d3e8358861df57b628874b679ec2`;unit + `4ab6d6ff4845b4ca963399e7eea214ceb412871dc1fdef5bac1ed0333596da4f`;manifest + `9f3a6aa36b6227cae4412333190f44fc3f5d971da1b36f8029c635833055b261`;package + `76454e86a7485ed0cb1fb3c59e034c328c23b02634425e883bac1cff463b182f`。 +- `docs/status/M12-03I.md`、N-023、当前/完整对标计划、项目状态、WBS 和本文件已同步;本项 + 保持 `parityStateChange=false`,ledger 仍为 195 completed/58 blocked。机器队列唯一下一任务切换为 + `M12-03J`。 + +M12-03J 实际验证(2026-08-17 America/New_York) +- 领取 M12-03J:用真实 Blender 5.2 library override API 生成 source/target fixture。reference 保持 + `SOURCE_LIBRARY/readOnly=true`,local hierarchy root 为 `LOCAL_OVERRIDE/readOnly=false`,并记录 + 唯一 `["m12_override_value"]` property path、`REPLACE` operation 与值 `2.5`。 +- `npm --prefix web run test:library-override-desktop` 通过;fixture 重新生成、保存重开后与 desktop + report 一致,source/reference、local owner、hierarchy root 和 property metadata 均稳定。 +- hashes:generator `2cdbac04cac7240360a9d70919380fd90f9479e2cb41d8032fb51626ed4b4dd6`;checker + `afebb3c9b8715b9d2e0c9b17f463f038bd23e8747074137bccbf1c09c4bfb5ba`;source/target `.blend` + `d7f8d78e7e91481bf46ecc9a6bcb01e900a6a397839dd31ab80a53def7675601` / + `b008a1608ff1e8f679e1e1281bf7be0360f1137e815dd890cbd93e1e98675495`;report + `19cb13a3417b4b65a8497b622337c42c4697b3f3d48de26a1f70f2d4527ddde0`;manifest + `89c1f84cdd7bcb8a2b104f50f4cdff6baf914db5620923b467c3747cd89a501e`;package + `25b89e621eab6484b9f9311b5f2f75af5dde035d21b8f43f190ef5a143d167c1`。 +- `docs/status/M12-03J.md`、N-023、当前/完整对标计划、项目状态、WBS 和本文件已同步;本项 + 保持 `parityStateChange=false`,ledger 仍为 195 completed/58 blocked。机器队列唯一下一任务切换为 + `M12-03K`。 + +M12-03K 实际验证(2026-08-17 America/New_York) +- 领取 M12-03K:新增 schema 1 override writer,只开放 J 冻结的 + `["m12_override_value"]` / `SET_M12_OVERRIDE_VALUE`,要求 `LOCAL_OVERRIDE/readOnly=false`、 + `referenceReadOnly=true` 与匹配 local/reference/hierarchy IDs。 +- `npm --prefix web run test:library-override-writer` 通过 4/4。合法请求只更新一个 value 并推进 + 一次 revision;stale、linked owner、identity drift、alternate property/operation、越界值和未知字段均阻断。 +- hashes:protocol `dd181c7e9946b98334a5d1c686887afecfe3fc11d732beec246e40bf11a155aa`;unit + `e41bd32eb4ce4d331a20ecb91f3325a27f461b9ae85e98940d5afd482ec21c4c`;manifest + `27b00abdd458f51c2143a137823ef4222eed4637b27bc8dc953d29bbfae57251`;package + `80cfff6337d0f8f095e2d4c1c111e9aba377b77010424c33f3a82b2df79f8858`。 +- `docs/status/M12-03K.md`、N-023、当前/完整对标计划、项目状态、WBS 和本文件已同步;本项 + 保持 `parityStateChange=false`,ledger 仍为 195 completed/58 blocked。机器队列唯一下一任务切换为 + `M12-03L`。 + +M12-03L 实际验证(2026-08-17 America/New_York) +- 领取 M12-03L:新增 schema 1 override freshness gate,绑定 source library ID、generation、revision、 + dependency closure SHA-256、invalidation token 与 local/reference/hierarchy IDs。 +- `npm --prefix web run test:library-override-freshness` 通过 4/4。完整匹配只返回 `READY`;stale + generation/revision、closure/token drift、identity drift、linked owner、alternate operation 和 + malformed fields 均在 Main commit 前阻断。 +- hashes:protocol `2eff7ea7605b1579d7551336d87d4f30adb996b585596ff9eee67aea04ca7d22`;unit + `d48344f31e1ba12e557ca30ece56643583efa633da556f5de3896a8e9175ef8f`;manifest + `3966a2555ee5cec3aeb95e70d23c960e0813727032967f726dc9901a533fb162`;package + `9e87817eb9ad3ffed878dc17b62d63b11a37ddfd1c203e72e1c1c9c945e3ebc6`。 +- `docs/status/M12-03L.md`、N-023、当前/完整对标计划、项目状态、WBS 和本文件已同步;本项 + 保持 `parityStateChange=false`,ledger 仍为 195 completed/58 blocked。机器队列唯一下一任务切换为 + `M12-03M`。 + +M12-03M 实际验证(2026-08-17 America/New_York) +- 领取 M12-03M:新增 schema 1 library negative-case validator,覆盖直接 dependency cycle、跨库环、 + data-block ID collision、missing source library 与 duplicate reload generation。 +- `npm --prefix web run test:library-negative-cases` 通过 4/4;cycle 返回 `LIBRARY_DEPENDENCY_CYCLE`, + collision 返回 `TASK_VALIDATION_FAILED`,duplicate reload 返回 `REVISION_CONFLICT`,missing source + 返回 `ASSET_SOURCE_HASH_MISMATCH`。 +- hashes:protocol `0aedd76a0081250d30a7da474896eb8445d2746d1ab9d7d4647440194bcef73a`;unit + `b7ae41b2c35b2fe1598bca4425dd434230bfe4bf342320c88214a060dcbb0a16`;manifest + `d699fc1f328ed966aa01cedfc58ba4f312a355ffebd3e16411da95b34ec5d879`;package + `e1c519d1ef3cf27df89e166b804dd280994b10f28cd736e11fa90b48478fd603`。 +- `docs/status/M12-03M.md`、N-023、当前/完整对标计划、项目状态、WBS 和本文件已同步;本项 + 保持 `parityStateChange=false`,ledger 仍为 195 completed/58 blocked。机器队列唯一下一任务切换为 + `M12-03N`。 + +M12-03N 实际验证(2026-08-17 America/New_York) +- 领取 M12-03N:为 APPEND、LINK、LIBRARY_OVERRIDE 各建立独立 desktop/WASM/Chromium 命令,共 9 条 + 独立入口;Chromium 使用独立端口。 +- `npm run test:library-operation-commands` 通过;九条实测命令全部通过:append desktop/WASM/Chromium、 + link desktop/WASM/Chromium、override desktop/WASM/Chromium。 +- hashes:command checker `3564347393134d835fdf279b8b8f58558ee24fe0165c3b4527195d094a451e98`;append + WASM protocol/unit `bfd98561cbe797d7b8f07c92c53a25460c839a64ab7222b7795cf58d54dff8d7` / + `75fc2d626f7ca7e820e9cacf659a453886e15e790cde43348828c03bed752ec7`;Chromium specs + `101f67d35c2320da57459f08a59687d769234b910dbd9aaec5a668cee7e0f9f0` / + `814e3486522fb4f0ffdd59acd385a4fca9c5660cdb07e5a2acb1cadd70376bff` / + `1c12982b4eb4fb4b9a3c88907a842a1fc413b3a3a760a9f57b350f57060d007f`;manifest + `054c197b6db96b56e3d041de2fe815b24133d1748c3238273af09b71b1c344e1`;package + `e1c519d1ef3cf27df89e166b804dd280994b10f28cd736e11fa90b48478fd603`。 +- `docs/status/M12-03N.md`、N-023、当前/完整对标计划、项目状态、WBS 和本文件已同步;本项 + 保持 `parityStateChange=false`,ledger 仍为 195 completed/58 blocked。机器队列唯一下一任务切换为 + `M12-04A`。 + +M12-04A 实际验证(2026-08-17 America/New_York) +- 领取 M12-04A:新增 schema 1 library source-origin admission。只接受声明的 credential-free HTTPS + origin、project-relative asset 或 user-selected file(selection ID、safe file name、bounded byte + length、source SHA-256)。 +- `npm --prefix web run test:library-source-origin` 通过 4/4;accepted source 返回 canonical locator, + undeclared origin、credentials、unsafe path、empty policy、malformed selection 和 unknown field 均阻断。 +- hashes:protocol `67a1808f225194a09a72987c5340c7494c5fa8d99dffde40664a11a779caedeb`;unit + `89b207c9cb13b4fb055a2dfed0237c0f8a00b13a39cc4175a378f5ef2abdb7ae`;manifest + `af80827d925ddd96d8541e446e0f70c956fd4c56e64ac984d187f5449df4cb0d`;package + `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c`。 +- `docs/status/M12-04A.md`、N-023、当前/完整对标计划、项目状态、WBS 和本文件已同步;本项 + 保持 `parityStateChange=false`,ledger 仍为 195 completed/58 blocked。机器队列唯一下一任务切换为 + `M12-04B`。 + +M12-04B 实际验证(2026-08-17 America/New_York) +- 领取 M12-04B:扩展共享 project-asset path normalizer,统一 POSIX/Windows separator,消解 `.` + 与安全的 `..`,单次解码 percent-encoded UTF-8,并以 Unicode NFC 输出稳定 canonical path;规范化 + 结果可重复调用且保持同一 locator。 +- `npm --prefix web run test:library-path-normalization` 通过 4/4。separator/dot alias、encoded + separator/dot、decomposed Unicode、project-root escape、malformed percent 和二次解码均有正负例; + `npm --prefix web run typecheck` 通过,并收口上轮 library wrapper/parser 的静态类型错误。 +- hashes:normalizer `6109d05251fc7355ac32d4c0d8298f85ea8b731767c76c394577c4e44652a6bf`;source + admission `67a1808f225194a09a72987c5340c7494c5fa8d99dffde40664a11a779caedeb`;unit + `abde64c60397541e92a83dd9e4a24e65faf340a8d046650604c101a21037c777`;manifest + `8cd29c3f5281082584fc6aefe2ec385a2eedf8116e837a4db54c391391ff8f98`;package + `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c`。 +- `docs/status/M12-04B.md`、N-023、当前/完整对标计划、项目状态、WBS 和本文件已同步;本项 + 保持 `parityStateChange=false`,ledger 仍为 195 completed/58 blocked。机器队列唯一下一任务切换为 + `M12-04C`。 + +M12-04C 实际验证(2026-08-18 America/New_York) +- 领取 M12-04C:在共享 project-asset normalizer 上收紧 absolute POSIX、UNC/drive、NUL/控制字符、 + malformed/residual percent 和 URI/origin-shaped escape;HTTPS policy origin 现在拒绝 path/query/ + fragment、encoded controls 和 backslash smuggling,不把不安全声明静默折叠为裸 `URL.origin`。 +- `npm --prefix web run test:library-path-security` 通过 4/4;absolute/UNC/drive/raw+encoded control、 + project origin escape、unsafe HTTPS URI、policy smuggling 和 duplicate canonical origin 均有负例, + declared HTTPS resource path 仍通过;library unit 集合 43/43、N-023 Chromium asset/IO gate 1/1 与 + `npm --prefix web run typecheck` 均通过。 +- hashes:normalizer `6109d05251fc7355ac32d4c0d8298f85ea8b731767c76c394577c4e44652a6bf`;source-origin + `67a1808f225194a09a72987c5340c7494c5fa8d99dffde40664a11a779caedeb`;unit + `ab302cacb24634a3225dda5cb8f5282cb80b3bd81ed5c8900ddf4ff18267b7e2`;manifest + `a7f3a45eb7f68d022f38185a1c1409e1db1b27647fef587d66d2ffa52d78f98e`;package + `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c`。 +- `docs/status/M12-04C.md`、N-023、当前/完整对标计划、项目状态、WBS 和本文件已同步;本项 + 保持 `parityStateChange=false`,ledger 仍为 195 completed/58 blocked。机器队列唯一下一任务切换为 + `M12-04D`。 + +M12-04D 实际验证(2026-08-18 America/New_York) +- 领取 M12-04D:新增 archive link-safety protocol,在任何写入前解析全部 FILE/DIRECTORY/ + SYMLINK/HARDLINK entry。symlink 相对其父目录解析,hardlink 从 archive root 解析且只能指向文件; + 每个输出都带 `withinTemporaryRoot=true`。 +- `npm --prefix web run test:library-link-safety` 通过 5/5;absolute/drive/URI/backslash/traversal、 + missing target、cycle、duplicate path、hardlink-to-directory 和 unknown field 均返回 + `IO_ARCHIVE_UNSAFE`。 +- hashes:protocol `d55a4ba762898aed22bdcc7aa6493c713ee94f6bb1bf58754fdc459d0ddf70d1`;unit + `7739275be91222d7bfb61d2f5a1daf812c6860fe34d0aea27df8380a77322120`;manifest + `1c09abd1f4bd5908d22ab3b22e3e71d050f694af0b82a7f78a351d7a1bf1e664`;package + `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c`。 +- 本项不改变 parity ledger;机器队列唯一下一任务切换为 `M12-04E`。 + +M12-04E 实际验证(2026-08-18 America/New_York) +- 领取 M12-04E:ZIP 只先计划 bounded CENTRAL_DIRECTORY read,TAR 只先计划 bounded MANIFEST + read;初始 plan 不含 payload range,trace 必须以声明的 exact metadata range 开始。 +- `npm --prefix web run test:library-metadata-first` 通过 4/4;payload-first、wrong range、duplicate + metadata、out-of-order 与 64 MiB metadata budget overflow 均 fail-closed。 +- hashes:protocol `869586b041e134c7d1cb2ebd7e13b35218b337223d401697a179f71cd1420f5b`;unit + `2da649722acee9cace8db6f337b4a93500a9c775a0b0f086bf38dd2b3e7f9e91`;manifest + `d935392fb23c2e6ad651fb6ad6cb67f8ead3d562e626bb230d2febd9d7f03b1c`。 +- 本项不改变 parity ledger;机器队列唯一下一任务切换为 `M12-04F`。 + +M12-04F 实际验证(2026-08-18 America/New_York) +- 领取 M12-04F:production IO parser 在 payload allocation 前执行 100,000 entry、2 GiB 单 entry、 + 4 GiB total、64 directory depth 与 255-byte UTF-8 filename budget,并保留 compression/source + byte gate。 +- `npm --prefix web run test:library-archive-budget` 通过 4/4,边界值接受且逐项 overflow 返回稳定错误。 +- hashes:protocol `e02efa79668f4ee10b1c28786709eba2c93691b28ccf1155c6213dda12f59a8f`;unit + `b0d8356c6fb348d98e28ce220052845a29439b34b3c976b21a4dbf370ea19593`;manifest + `cda905b1e27b62d9ea3a05170f975015480ce6739c15bdf1f5a1f0b79f93ce06`。 +- 本项不改变 parity ledger;机器队列唯一下一任务切换为 `M12-04G`。 + +M12-04G 实际验证(2026-08-18 America/New_York) +- 领取 M12-04G:archive range validator 拒绝超过 100:1 的展开比、zero-byte bomb、compressed + range overlap、source/global bound overflow、duplicate canonical path 与 file/directory prefix conflict。 +- `npm --prefix web run test:library-archive-conflicts` 通过 4/4;合法 non-overlapping range 返回 + deterministic compressed/uncompressed totals 和显式安全 invariant。 +- hashes:protocol `3bec372e4f67ec309f28534cebcbaf8b492144adfa82ff6c8603f88c3ba16254`;unit + `3f2d44dce33b1c17b3a48f58b04fdd821abc88d98ce3d3b5bb724859e0f0ab3c`;manifest + `c35e675e8f512e85b748f0b5578874fed8c99df7674a152e698236a67c9f4fa4`。 +- 本项不改变 parity ledger;机器队列唯一下一任务切换为 `M12-04H`。 + +M12-04H 实际验证(2026-08-18 America/New_York) +- 领取 M12-04H:新增 production archive extraction transaction,在 staging 前、每次 payload + read/write 周围与 atomic commit 前检查取消。提交前取消统一删除 staging、返回 + `IO_ARCHIVE_CANCELLED`、发布数为 0,并重读 committed revision/SHA-256 证明项目未改变。 +- `npm --prefix web run test:library-archive-cancellation` 通过 6/6 真实临时目录测试;覆盖 staging + 前、部分写入后、最后写入后取消、成功 commit、payload mismatch、rollback drift、stale revision、 + unsafe path、prefix conflict 和 unknown field。`npm --prefix web run typecheck` 通过。 +- hashes:protocol `c40adc1449172014cc1820db567e155828314abb404b66e4de13c26ddee06e4b`;error + `751c70c898540fa7e82fb1b1a79254756ec8db8e4201a88b6d817d7c3d92103f`;unit + `77e7d5bc64dd6b313006ebf523602601acdaf7949a1484da872ddcd046983786`;manifest + `7fce600a416aec5ade1a91a13d86caf4cb1f65b710054b59d525ff8b1d3c7409`。 +- `docs/status/M12-04D.md` 至 `M12-04H.md`、N-023、当前/完整对标计划、项目状态、WBS 和本文件 + 已同步;本项保持 `parityStateChange=false`,ledger 仍为 195 completed/58 blocked。机器队列唯一 + 下一任务切换为 `M12-04I`。 + +M12-04I 实际验证(2026-08-18 America/New_York) +- 领取 M12-04I:新增 archive extraction recovery wrapper。底层 M12-04H transaction 先完成 + staging discard 与 committed revision/SHA-256 复核,只有该回滚门通过后才把 + `QuotaExceededError`/`NotEnoughSpaceError` 映射为 `STORAGE_QUOTA`,把显式 + `WASM_OUT_OF_MEMORY`/`OutOfMemoryError`/OOM RangeError 映射为 `WASM_OUT_OF_MEMORY`;普通 + RangeError、普通 IO 和 cleanup/identity drift 不会被误分类。 +- `node --test web/tests/unit/library-archive-recovery.test.mjs` 通过 4/4 真实临时目录测试:quota + 与 OOM 都在 4 KiB payload 部分写入后注入,staging entry 归零,旧 revision=9、旧 SHA-256 和 + `old-project` bytes 不变;同一 storage instance 随后以 revision=10 提交 `small-project`, + staging 仍为零。资源 fault code 负例也通过。 +- `npm --prefix web run test:library-archive-cancellation` 通过 6/6;`npm --prefix web run typecheck` + 和 `git diff --check` 通过。为保持 M12-03G 至 M12-04H 已冻结的 package hash,不修改 + `web/package.json`;M12-04I 的独立入口为上面的 `node --test` 命令。 +- hashes:base transaction `c40adc1449172014cc1820db567e155828314abb404b66e4de13c26ddee06e4b`; + recovery protocol `0ae9801ea151403b244c5f58f7f99231bba7a25abb1f61e3669879510b92dccf`;unit + `a931edef8f3ca1243a80ec2b8e9ff5b8da1dfd339f3d8c162ad2ebd08d3db6a1`;manifest + `574afa68a787b9481d0e098d38b94cb810a9827fe185b0df796d62cc160ba7c5`;package + `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c`。 +- `docs/status/M12-04I.md`、N-023、当前/完整对标计划、项目状态、WBS 和本文件已同步;本项 + 保持 `parityStateChange=false`,ledger 仍为 195 completed/58 blocked。机器队列唯一下一任务 + 切换为 `M12-04J`。 + +M12-04J 实际验证(2026-08-18 America/New_York) +- 领取 M12-04J:新增确定性恶意 archive fixture 生成器,固定 3 个 ZIP(path traversal、101:1 + compression ratio、duplicate path)与 3 个 USTAR(path traversal、symlink escape、file/directory + prefix conflict)二进制容器。所有 header 时间/所有权字段固定,catalog 绑定逐文件 bytes/SHA-256。 +- checker 在新临时目录重建 fixture 并逐字节比较;ZIP 校验 EOCD、central/local header identity 与 + metadata/payload range,TAR 校验 USTAR magic、header checksum、512-byte alignment 与双零结束块。 + 真实 entry metadata 进入 M12-04D/G link/conflict gate 后 6/6 返回 `IO_ARCHIVE_UNSAFE`;全过程 + 不调用 `tar`、`unzip` 或 extraction API。 +- `node tools/web/check-malicious-archive-fixtures.mjs` 通过,输出 + `cases=6 zip=3 tar=3 extraction=disabled`;`WEB_TEST_PORT=5408 npm --prefix web run + test:asset-library` 通过 2/2,新 binary fixture spec 已进入现有 Chromium malicious-archive lane。 +- hashes:generator `b372ea8cb2f97b035ffb2cc18666dae9167dcfb349131851ad9f1ff0fc40ba16`; + checker `edda2f420f26e55f9990d24b755bb9b4f732ec32c2e072210144b3d0b6634d9b`;fixture manifest + `a93834316f6a23b8a0e6c1f1f806ec584d6f03aa339c2680cae2ce8133a40e58`;Chromium spec + `f327500808dd67359a152ce28134b58d027aebd6758416b5535b659b9900bbfe`;golden manifest + `989d417ab44e165849c7054f331f7038ec1d779349c39dd4f5b30050bbaecf56`。 +- `docs/status/M12-04J.md`、N-023、当前/完整对标计划、项目状态、WBS 和本文件已同步;本项 + 保持 `parityStateChange=false`,ledger 仍为 195 completed/58 blocked。M12.4 A-J 收口,机器 + 队列唯一下一任务切换为 `M12-05A`。 + +M12-05A 实际验证(2026-08-18 America/New_York) +- 领取 M12-05A:新增 `tools/web/generate-io-format-runtime-inventory.py`,由 pinned + `build_blender_5.2.0/bin/blender` 的真实 `bpy.app`/RNA runtime 生成 GLTF、GLB、OBJ、STL、PLY、 + USD、ALEMBIC 七条 format receipt。每个 import/export operator 记录注册状态、RNA identifier、 + canonical property/enum schema、extensions、variants 和对应 build option;runtime 记录 + Blender 5.2.0 LTS、build hash/branch/platform/type/date/time、commit timestamp、selected + build options 与 binary SHA-256。 +- 当前 pinned build 的 GLTF/GLB/OBJ/STL/PLY 为 `AVAILABLE`;`usd=false`、`alembic=false` 时 + USD/ALEMBIC 为 `OPERATOR_UNREGISTERED`,没有按扩展名放行。清单是 inventory evidence,不 + 声明 import/export round-trip parity。 +- `node tools/web/check-io-format-runtime-inventory.mjs` 通过;新 Blender 进程重复生成清单并 + 逐字节匹配,format count=7、available=5、build-disabled=2。binary SHA-256 为 + `d4483926610484ef9c2ad9241aae1469f934d955ebe791f1920e263e0ba85b82`。 +- hashes:generator `aa926397664240a5195f8864fc3ed5549bafcc963a03c513c7e37926b0559d7d`; + checker `12853306ea5eb2698ab636c7dfc2f27ae140295641473c57b84f93fa13d4864e`;inventory + `0d660b0fd8b647ebbf4e91afebd5006bd100973ab8e2507a5bfe759f477b33b4`;manifest + `202b144c91f8e2c39477e257aeb26f9bd0b1b05b459ff8a246668024e94deec7`。 +- `docs/status/M12-05A.md`、N-023、当前/完整对标计划、项目状态、WBS 和本文件已同步;本项 + 保持 `parityStateChange=false`,ledger 仍为 195 completed/58 blocked。机器队列唯一下一任务 + 切换为 `M12-05B`。 + +M12-05B 实际验证(2026-08-18 America/New_York) +- 领取 M12-05B:新增 schema 1 `io-format-capability-matrix`,绑定 M12-05A inventory SHA-256, + 为 GLTF/GLB/OBJ/STL/PLY/USD/ALEMBIC 各自声明 IMPORT/EXPORT 的 local/server route 与 + geometry/material/animation feature status。只有已有 bounded GLB EXPORT local route 为 + `READY/LOCAL`;7 条 import、7 条 server 和其余 6 条 local export 共 27 条 route 均为 + `BLOCKED/IO_FORMAT_UNSUPPORTED`,未实现 feature 保持 `UNVERIFIED`,GLB export feature 只标 + `PARTIAL` 并明确依赖 M12-06 round-trip。 +- `node --test web/tests/unit/io-format-capability-matrix.test.mjs` 通过 3/3,覆盖 duplicate + format、ready-without-executor、unverified-ready-feature 和 missing blocked-code 负例。 + `node tools/web/check-io-format-capability-matrix.mjs` 通过,matrix generator 新进程逐字节 + 重建并确认 runtime status 与 M12-05A receipt 一致。 +- hashes:protocol `3063ae5e45f5b1642d329aa554187d121998d816a5a6740a2b9662f00c3c5738`;generator + `746078caaf29fa5aa2281b901b9ea5fc66f9a935eb3f6e282d4fbfb018d4c390`;checker + `4a8b35cd7f87238c13627a00c3bb0b34c130fc34d597773574efac7d8909b804`;unit + `0cc4d8f1df1ecdab20d8100cf5f12b44cb2a68bca4384ef7964a032b2f74b36e`;matrix + `139c9d764736da176b32414ecda840c07eb0ba5f3864da2dc08ce04bae161366`;manifest + `8cc9517c9f25138c351bc5a79efe3b1ce6d9f6663d3b7c14397c5e4e8f11181a`。 +- `docs/status/M12-05B.md`、N-023、当前/完整对标计划、项目状态、WBS 和本文件已同步;本项 + 保持 `parityStateChange=false`,ledger 仍为 195 completed/58 blocked。机器队列唯一下一任务 + 切换为 `M12-05C`。 + +M12-05C 实际验证(2026-08-18 America/New_York) +- 领取 M12-05C:新增 schema 1 `io-format-ui-gate`,从 M12-05B capability matrix 的精确 + SHA-256 生成 UI registry;只有 runtime operator=`AVAILABLE` 且 local route=`READY` 的组合 + 才进入 registry。当前 import routes=0、local export routes=1(GLB),项目文件 accept 固定为 + `.blend,application/octet-stream`。 +- App 的 `.blend` file input 使用 registry-derived `accept` 与第二道 filename gate;未知或 + matrix-blocked extension 在 Engine 前返回 `IO_FORMAT_UNSUPPORTED`,不替换当前 SceneIR。 + operator search 通过同一 registry 过滤 format-tagged commands,blocked server/import/export + 组合不出现在可执行搜索结果。 +- `node --test web/tests/unit/io-format-ui-gate.test.mjs` 通过 3/3; + `node tools/web/check-io-format-ui-gate.mjs` 通过并逐字节复建 registry; + `WEB_TEST_PORT=5413 npm --prefix web run test:io-format-ui-gate` 通过 unit 3/3、checker 和 + Chromium 1/1。首次未指定端口的命令因 5173 已占用失败,动态端口重跑通过。 +- `npm --prefix web run typecheck`、`npm --prefix web run build` 通过; + `WEB_TEST_PORT=5412 npm --prefix web run test:asset-library` 通过 2/2,N-023 asset regression + 保持通过。`git diff --check` 通过。 +- hashes:protocol `fc397ceb947d4ede6c34c1d51438253a6b59244fc40f5eb77ce155bf1c477476`;generator + `1ef4ae8a3e8d2f73101a87cba1c42ea99a065e1f6846f6a591841b97c0c39e6f`;checker + `81d6f27df26aab7b633fbe61c6d7b37519668aff41e43314924dceaacb3affde`;unit + `84ca8fb6022da9f167daa104c44489a6c7d9f059699e57ce621b8d7f64f19082`;Chromium spec + `eda2cdb9ede3bcbd717800c9c6fdb28d8cebef96f0296a2ee847a05e60cd0eed`;registry + `6b410bc6f2cad032af55bf13e1c8ddd841b01e9913ffc0ba381da8b7204285fd`;package + `a9a24755d8e541942571a69eeb2e2105dc9555501a31ac70435ee7928953d2d1`。 +- manifest `f46fd394e2144255d8b4304e8ce4fe60aad4302a80e991fd83d50cd915235ee3`。 +- `docs/status/M12-05C.md`、N-023、当前/完整对标计划、项目状态、WBS 和本文件已同步;本项 + 保持 `parityStateChange=false`,ledger 仍为 195 completed/58 blocked。机器队列唯一下一任务 + 切换为 `M12-05D`。 +- 最终冻结复验:`WEB_TEST_PORT=5414 npm --prefix web run test:io-format-ui-gate` 通过(unit 3/3、 + checker、Chromium 1/1);8 个 manifest artifact 逐项 SHA-256 比对通过,输出 + `m12-05c-artifacts-ok count=8 next=M12-05D`。`npm --prefix web run test:status-consistency` + 输出 `families=12 parityBlocked=12 releaseBlocked=0 evidenceRecords=17 missing=0`; + `git diff --check` 通过。 + +M12-05D 实际验证(2026-08-18 America/New_York) +- 领取 M12-05D:新增 schema 1 `io-format-runtime-receipt`,从 pinned Blender 5.2 M12-05A + inventory 生成 14 条 import/export receipt;集合绑定 inventory SHA-256、runtime identity、 + operator path、RNA identifier、registered/build-option 状态、variants 和 extensions。 +- `resolveIOFormatRuntimeRoute` 只接受显式 `{format, operation}` 与 receipt=`AVAILABLE`、registered、 + RNA identity 和未禁用 build option;不读取 filename/extension。GLB EXPORT 返回 `READY`,USD/ + ALEMBIC 因 `OPERATOR_UNREGISTERED` 返回 `BLOCKED/IO_FORMAT_UNSUPPORTED`。 +- App 在 format-tagged operator search 过滤和 GLB export 执行前消费同一 receipt set;receipt JSON + 与 golden 逐字节相同,运行时 receipt gate 不修改现有 `.blend` file gate。 +- `node --test web/tests/unit/io-format-runtime-receipt.test.mjs` 通过 3/3; + `node tools/web/check-io-format-runtime-receipts.mjs` 通过;M12-05C direct unit/checker + Chromium + 1/1(5417)通过;N-023 asset/security 2/2(5418)通过;`npm --prefix web run typecheck`、 + `npm --prefix web run build`、`git diff --check` 均通过。 +- package hash 保持历史冻结值 `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c`, + 专项 lane 使用 direct commands,不改写既有 release evidence。 +- hashes:protocol `461a84557fa368c29dbc6707959b689faae7c8f7050dccc4d3f12e358b61bb22`;generator + `796cd641e86d8242c13317f771ae237cbf1692ff675a53bbdb689615edb5f372`;checker + `aaf9862041f155f96f50ea8481ff765089255bc59ecd8944f12362b81b8c1f1a`;unit + `a5f09277f5dcdacd25c44191f7407d6cee944f8022b1c467c2a69e172fc2ac6b`;runtime/app receipts + `f2c9a77e2cfad0ef3574b804fc06fb22972c07f55c72b0e6db2e359c4d95f57b`;App + `74216aac70992f8d879e983237ca324b998008582f0cd4658e90994cf9fae0a8`;manifest + `3d0049a7b0331f01bb71df043270c18d1a5c9ce6dc74353c49c8ce591761df1b`。 +- `docs/status/M12-05D.md`、N-023、当前/完整对标计划、项目状态、WBS 和本文件已同步;本项 + 保持 `parityStateChange=false`,ledger 仍为 195 completed/58 blocked。机器队列唯一下一任务 + 切换为 `M12-05E`。 +- 最终冻结复验:M12-05C/M12-05D artifact checker 分别输出 + `M12-05C-artifacts-ok count=8 next=M12-05D`、`M12-05D-artifacts-ok count=9 next=M12-05E`; + M12-05C+D unit/checker 全部 6/6;operator search 实际 Chromium 1/1(5419);N-023 asset/ + malicious archive 2/2(5420);`npm --prefix web run test:status-consistency` 输出 + `families=12 parityBlocked=12 releaseBlocked=0 evidenceRecords=17 missing=0`;typecheck 与 + `git diff --check` 通过。无 package hash 漂移。 + +M12-05E 实际验证(2026-08-18 America/New_York) +- 领取 M12-05E:新增 schema 1 `io-format-receipt-binding`,从 M12-05A inventory 与 M12-05D + receipt set 为 14 条 import/export receipt 生成 `sourceSha256`、`settingsSha256`、 + `runtimeSha256` 三重绑定;bound set 同时绑定 M12-05D parent receipt-set SHA-256 与 + M12-05A inventory SHA-256。 +- source hash 覆盖 format/family/operation/operator/registered/RNA identity;settings hash 覆盖 + build option、variants/extensions 和 Blender RNA property schema;runtime hash 覆盖完整 pinned + Blender runtime identity。校验器要求三个 SHA-256 和两个 parent identity 均存在后才解析 receipt。 +- `node --test web/tests/unit/io-format-receipt-binding.test.mjs` 通过 2/2; + `node tools/web/check-io-format-receipt-bindings.mjs` 通过,14 条 hash 独立复算且新进程逐字节 + 重建;`npm --prefix web run typecheck`、`git diff --check` 通过。package hash 保持冻结值。 +- hashes:protocol `681e719ab2b18eb85d056547fb70b461dd73b3a7fb4fdbe6295b8e49d5649e49`;generator + `eb7b5c499f141c1788bc37e53585662eedff3f2dabff870f4ad166f4a619796f`;checker + `ec2b22b468809ecc25ab2d4983065680a66ad3be6705d1827b44bf45ac622e26`;unit + `e9ae3e360ad41c32da3634a4efa915654853e79a35df36728c1ddf586a0bf7b5`;bound receipts + `7f765bf16b62466f579b3de00751a0e012fef1c788f229c8e9675a57caa18aad`;manifest + `2fbaaf39c6acd9f55fbdbadccc0b027e9e7763bf069c954a96ca49b193648990`。 +- `docs/status/M12-05E.md`、N-023、当前/完整对标计划、项目状态、WBS 和本文件已同步;本项 + 保持 `parityStateChange=false`,ledger 仍为 195 completed/58 blocked。机器队列唯一下一任务 + 切换为 `M12-05F`。 + +M12-05F 实际验证(2026-08-18 America/New_York) +- 领取 M12-05F:新增 schema 1 `io-format-receipt-freshness` envelope,封装 M12-05E bound + receipt set,并固定 M12-05E 原始 bytes 的 `parentBindingSha256`、canonical bound receipt + set 的 `boundReceiptSetSha256` 和 pinned Blender runtime 的 `runtimeSha256`。使用前同时核对 + parent binding、M12-05D receipt-set parent、M12-05A inventory、runtime identity、14 条 + receipt 的 runtime hash 和 GLB/其它 route 的注册状态;route 只在全部 freshness 条件满足时 + 返回 `READY`。 +- 新增 `web/protocol/io-format-receipt-freshness.ts`、生成器/checker、M12-05F golden、App + freshness receipt/expected identity artifact 与 unit。App 的 GLB export 和 format-tagged + operator search 已从未绑定 M12-05D receipt 切换到 M12-05F freshness route。 +- `node --test web/tests/unit/io-format-receipt-freshness.test.mjs` 通过 4/4;精确 trusted + receipt、canonical digest、伪造字段、过期 parent/inventory 和跨版本 runtime 均覆盖。 +- `node tools/web/check-io-format-receipt-freshness.mjs` 通过:14 receipts、App artifact + 逐字节一致、独立 canonical digest、deterministic regeneration、forged/stale/cross-version + 三类阻断全部通过,输出 + `io-format-receipt-freshness-ok receipts=14 forged=BLOCKED stale=BLOCKED cross-version=BLOCKED deterministic=true`。 +- 既有 M12-05D/M12-05E unit/checker 通过:5/5 unit,runtime receipt checker 与 binding checker + 均退出 0;`npm --prefix web run typecheck`、`npm --prefix web run build`、 + `npm --prefix web run test:status-consistency` 和 `git diff --check` 均通过。状态输出仍为 + `families=12 parityBlocked=12 releaseBlocked=0 evidenceRecords=17 missing=0`。 +- Chromium 定向 `WEB_TEST_PORT=5423 npm --prefix web exec playwright test --config + playwright.config.ts --workers=1 tests/e2e/io-format-ui-gate.spec.ts` 未执行到页面:本机 + Playwright 1.62.1 期望 `chromium_headless_shell-1234`,环境仅有已缓存的 1228,失败原因为 + `browserType.launch: Executable doesn't exist`;该环境阻断不改写 READY 证据,也未把 E2E 标为通过。 +- hashes:protocol `111a630c7beccd31989dc4f78932b7d7b741fb6bef03e45cb39a8139f774d235`;generator + `6a1e798ce46e1d14d833091d4d7ae452dccb13efe583594277785465eb725bbf`;checker + `7a8fe69ea1aa2c1e121be008a9fb60fc236f7ef776d2088a7b00b14f46fe3fba`;unit + `cd1c2adca81653f98f9a1c6c7d104ad0e3052283213fb7166dac827c956928fe`;freshness/app receipt + `0187ad0d9ea05fc4b152b7abd4945191dbe9ec24dfde4ca7dbe4aadfd1230efe`;expected identity + `8d65f78aa774ff252871cb1cc09e69b4ff04fbb45e61200eaf67534c9d2651b2`;App + `043ff3a2f39ef3a1303791081b80851b427e7db5dfd9af2161926dd6f1ea9ca4`;M12-05F manifest + `63e1506801ddee2f1eaf64cad68a9d5c918405f642ca237c6a1ec249ff297623`;package 保持 + `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c`。 +- `docs/status/M12-05F.md`、N-023、当前/完整对标计划、项目状态、WBS 和本文件已同步;本项 + 保持 `parityStateChange=false`,M12 当前完成更新为 47 项(M12-01A-I、M12-02A-H、M12-03A-N、 + M12-04A-J、M12-05A-F),ledger 仍为 195 completed/58 blocked。机器队列唯一下一任务切换为 + `M12-06A`。 + +M12-06A 接续开始(2026-08-18 America/New_York) +- 已读取项目主文档、当前执行计划和本文件;机器队列唯一 `nextTask` 为 `M12-06A`。 +- 本项范围冻结为 pinned Blender 5.2 desktop 生成五个独立 GLB fixture:mesh、pbr、uv、skin、animation;Web import 比较、保存重开和 loss report 留给后续 M12-06B-D。 +- 计划新增确定性 Blender 生成器、canonical semantic report、逐字节重生成 checker、M12-06A golden manifest 和 package command;完成前不改变 parity ledger。 + +M12-06A 实际验证(2026-08-18 America/New_York) +- pinned `build_blender_5.2.0/bin/blender` 生成 mesh、pbr、uv、skin、animation 五个独立 GLB 2.0 + fixture;总计 8,676 bytes,单文件均小于 512 KiB,五项 `extensionsUsed/Required` 均为空。 +- canonical report 固定 node、indexed triangle topology、POSITION/NORMAL/COLOR_0/TEXCOORD_0/ + JOINTS_0/WEIGHTS_0 accessor、PBR factor、embedded PNG、两关节 inverse bind matrix 和 25 帧 + translation/rotation animation;runtime binary SHA-256 与 M12-05A 一致。 +- `node tools/web/check-glb-desktop-fixtures.mjs` 通过:独立临时目录重新运行 Blender 后 report + 与五个 GLB 均逐字节一致,输出 `fixtures=5 bytes=8676 features=mesh,pbr,uv,skin,animation + deterministic=true next=M12-06B`。 +- hashes:generator `0247dd2405a68b42d99c2b005546ad2aa99b46416e3fe9489832467f6ea4eb4d`; + checker `211ebdb66bf2e2d349455d5303e889a4a1ae3323639a89dd78b3bda574dd820b`;report + `dea31cc861622166dc502b333bc177b70b66b54d9c62aaccc6522745dab5ae13`;mesh/PBR/UV/skin/ + animation 分别为 `e52b9268b6524744fb498691f976000635e544ba3b47e9f8ff22b03bcdf17a94`、 + `244cc8a992c5a70692b8bbc8333533718b3bac7022110715ce3b23d2e4c0b361`、 + `1e0397d2b69d8261b3252451b50ab4aba6525b94713719f35069a9a9d96fcfa2`、 + `4086ee4c8873aa03090338b676575b7a5335fb7c9384fec6ccb36108e71929f6`、 + `44f6cc47961a146dc97ea337ae31a8b64bb4ab213b716f81ec22129db704f1fb`;manifest + `e3554a1e739cbe3f217f6169130532365538b0c0eafbb97afc58d4d56c4287cb`。 +- package hash 保持 `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c`; + Web import、`.blend` 保存重开与 loss report 均未提前声明。本项保持 + `enablingTask=true/parityStateChange=false`,ledger 仍为 195 completed/58 blocked;M12 当前 + 完成 48 项,机器队列唯一下一任务切换为 `M12-06B`。 + +M12-06B 实际验证(2026-08-18 America/New_York) +- 直接领取 M12-06B:扩展生产 `web/protocol/glb-import.ts` 的 canonical desktop fixture import, + 在既有 GLB header/accessor/image/skin/animation validation 上增加 primitive topology、排序 + attributes、PBR/texture、node hierarchy/TRS、skin binding 和 animation sampler/channel 结构; + 既有 `importGLBSemantics` API 保持兼容。 +- `node --test web/tests/unit/glb-desktop-import.test.mjs` 通过 3/3:五 fixture exact comparison、 + 五域独立断言和 PBR 字段漂移稳定路径均通过。机器报告 checker 输出 + `glb-desktop-import-ok fixtures=5 domains=5 compatible=true + route=BLOCKED_UNTIL_MAIN_PERSISTENCE next=M12-06C`。 +- Chromium 首次从仓库根经 `npm exec` 调用时未解析 `web/playwright.config.ts`,误用缺失的 + `chromium_headless_shell-1234` 而在页面启动前失败;改为 `web/` 工作目录并显式 + `CHROME_PATH=/usr/bin/google-chrome-stable` 后,Chrome 150 Worker 用例 1/1(端口 5425)通过, + 五个输入均先验 source SHA-256 再完成 Web semantic exact comparison。 +- hashes:protocol `45d9a7912c4a59a552789a8ea0dfaff5f1849f8d213f14d238de024b77acdb0d`; + generator `6f97527b7da04c7b4f9b09c48b9f367d1270db9f7820498d33832a80754436c2`;checker + `804a4b5545d95d7ab1ba265469a981d0a10b71dab36f0c96283eb73b401443d2`;unit + `6bf2a96b3e43e5fae93589ea5dcf98e7a69b10266378e442988198925286a8d8`;Worker + `eb32049631113270fb62acb7ae9379e8ff9a03e38086db2fa309c6891cd707bf`;Chromium + `fa3e06419c918406f24b5a4260523bd94cfe729a84786e9434517fdfed624d45`;report + `79933888cc5aa2d1e3639ec349ca4c31d028fe89f751ebb8d4342f06d15ecfc2`;manifest + `5275310394b34726a05b30ab67658e1381662dddf9163a97cb02f47f646a8fa4`。 +- package hash 保持 `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c`; + file picker/receipt matrix 未开放 GLB IMPORT,权威 Main/save/reopen 留给 M12-06C。本项保持 + `enablingTask=true/parityStateChange=false`,ledger 仍为 195 completed/58 blocked;M12 当前 + 完成 49 项,机器队列唯一下一任务切换为 `M12-06C`。 + +M12-06C 实际验证(2026-08-18 America/New_York) +- 直接领取 M12-06C:使用 pinned Blender 5.2 对五个 M12-06A GLB fixture 执行真实 glTF import、 + `.blend` save 和新进程 reopen,固定 Object/Mesh/Material/Image/Armature/Action stable IDs。 + 生成器报告绑定 M12-06A GLB SHA-256;checker 在新临时目录重生成并忽略 Blender `.blend` + 容器的非语义字节差异,只要求完整 Main graph/stable-ID 报告确定性一致。 +- 新增 `tools/web/check-glb-main-persistence.mjs`;通过输出 + `glb-main-persistence-ok fixtures=5 stableIds=exact desktopReopen=exact deterministic=true next=M12-06D`。 + checker 同时验证 parent manifest、五个已冻结 `.blend` hash、GLB source hash、stable ID 前缀/去重 + 和重新生成的 desktop report。 +- 更新 Chromium 生产 E2E:每个 fixture 经 `WebEngineClient.openBlend` 建立 authoritative Main, + 以一次 `setObjectVisibility` Main transaction 改写后 `saveBlend`,再隔离 `openBlend` 重开; + 保存 bytes SHA-256 必须变化,visibility=false 与 stable IDs 均恢复,`openResourceStatus` 的 + activeRequests/liveInputBytes/liveStagingFiles 全为 0。`CHROME_PATH=/usr/bin/google-chrome-stable + WEB_TEST_PORT=5440 node_modules/.bin/playwright test --config playwright.config.ts --workers=1 + tests/e2e/glb-main-persistence.spec.ts` 通过 1/1(Chrome 150)。 +- `npm --prefix web run typecheck`、`node --check tools/web/check-glb-main-persistence.mjs`、 + `python3 -m py_compile tools/web/generate-glb-main-persistence-fixtures.py` 和 `git diff --check` + 通过。未修改 `web/package.json`,保持历史 M12 package hash 不失效。 +- hashes:generator `8989d6ce4196f140a7bfb44b863dc530a6aa462ca4a57fa7b77c468e06ab61ad`;checker + `5bbbbeaf2d20ff9bbdeb74c8dd10fa6ffc421d1d59f86540e95f29c2cfd10903`;Chromium test + `7c97877f1cbbfd0166e106c80faee53f474f78816cca68ea924df5aa3c47776d`;desktop report + `76b000454a9073ef762d25fa546d5c65a004659a93eb6ec82fad1e679b2e81be`;manifest + `e3a57636a47591e3b02dff5a07e8a0aec5e0dc43bf6b4829bffc811035dbbb31`;mesh/PBR/UV/skin/animation + `.blend` 分别为 `66e29adc016f07e220019b6f24eb7e5016c684dca4b3102b20c8e836968d422d`、 + `ba08aeb2876d82ddf731abe81d3a42041a1be36617d0b6324c870d5bcd4cc771`、 + `1270cb452d34d2fd7a19181a2b20f70b7a028bdd2db7cf1bba4e1003f7de0f48`、 + `23f175e44ec588c75db99d3f9134863fc3d7d1f192bbd815d5d1c4e3218bce0c`、 + `fa6baf3a67ff11fe3d2922210089a7c508e4ee28bfaabe4cf628ba6addee1ff5`。 +- `docs/status/M12-06C.md`、M12 当前计划、完整对标计划、WBS、项目状态已同步;本项 + `enablingTask=false/parityStateChange=false`,ledger 保持 195 completed/58 blocked;M12 当前 + 完成 50 项,机器队列唯一下一任务切换为 `M12-06D`。GLB file-picker、Web export loss report、 + desktop 再导入 Web GLB 和 sparse/Draco/URI 负例均未提前声明。 + +M12-06D 实际验证(2026-08-18 America/New_York) +- 直接领取 M12-06D:新增 schema-1 `GLBLossReport`,由生产 `exportGLB` warning 结果生成稳定 + 排序的 machine loss entries、error/warning 计数、SceneIR revision/数据块 surface;不把 + warning 静默丢弃,也不把失败输出伪造成可导出。 +- Chromium 生产 E2E 经 `WebEngineClient` 打开五个 M12-06C Main `.blend`,请求 packed image + asset,交给 `glb-loss-report-test.worker.ts` 的生产 exporter;mesh 的 Color Attribute shader + 返回 `canExport=false`、`SHADER_GRAPH_UNMAPPABLE` 且无 output,PBR/UV/skin/animation 生成 + 非空 GLB 并绑定 SHA-256。`UPDATE_GLB_LOSS_REPORT=1 CHROME_PATH=/usr/bin/google-chrome-stable + WEB_TEST_PORT=5444 node_modules/.bin/playwright test --config playwright.config.ts --workers=1 + tests/e2e/glb-export-loss-report.spec.ts` 生成 golden 后,默认模式 `WEB_TEST_PORT=5445` 重跑 + 1/1 且逐字节匹配报告。 +- `node --test web/tests/unit/glb-loss-report.test.mjs` 通过 1/1,稳定校验 loss entry 排序和计数; + `node tools/web/check-glb-loss-report.mjs` 通过,输出 + `glb-loss-report-ok fixtures=5 blocked=mesh warnings=3 deterministic=true next=M12-06E`; + `npm --prefix web run typecheck`、`git diff --check` 通过。未修改 package,保持历史 package hash。 +- hashes:export protocol `a5d342827860a9e55b49a3bb3a196a01b1e53546325d6e594778afb9360c3125`; + loss protocol `dce9c790b2f52d46efe0908ecb044d63d21b3c6c3f7d77ddb5e697b29a7a2d6e`;Worker + `6f6294d26fe7b717416a5dd25fe834fb4b9a2ecbe8b011395c9559a26701ec77`;Chromium test + `ce334b6bb5d82c133d317e916c03711029fba1c19c634dec06c06da9eddbd776`;checker + `a23346860fa26405b2cd24591b0ab36fea29cb561ec8c2991ecfeabcffc17ee1`;web loss report + `c6db52234d867985ef5fbcdc7c62298ec9aaa013028b5a54e2b9a16aa4133397`;unit + `dfcc2d968e4e0c0cef4496bc304cb481f10d1f0dc4c76a40ea1d6e2f11b6b708`;manifest + `c01c5861d493e1f177e8cde3038bc5283a7671fa39bf84484662d307623325a2`。 +- `docs/status/M12-06D.md`、M12 当前计划、完整对标计划、WBS、项目状态已同步;本项 + `enablingTask=false/parityStateChange=false`,ledger 保持 195 completed/58 blocked;M12 当前 + 完成 51 项,机器队列唯一下一任务切换为 `M12-06E`。desktop 再导入 Web GLB、lossless roundtrip、 + sparse/Draco/外部 URI 和取消/restart/quota 仍未提前声明。 + +M12-06E 实际验证(2026-08-18 America/New_York) +- 直接领取 M12-06E:将 M12-06D Chromium 生成的 PBR/UV/skin/animation 四个 Web GLB 固定到 + `tests/files/web/m12_glb_web_v1/`,由 pinned Blender 5.2 真实重新导入、保存、重开并生成 + `desktop-reimport-report.json`;mesh 因 M12-06D shader blocker 没有伪造输出。 +- `node tools/web/check-glb-web-reimport.mjs` 通过,输出 + `glb-web-reimport-ok fixtures=4 exact=2 mismatched=uv,skin deterministic=true next=M12-06F`。 + PBR/animation canonical graph exact;UV 4 条路径差异(corner-expanded vertex topology、Mix + node),skin 31 条路径差异(额外 armature helper mesh 与 Blender tessellation),均逐路径 + 记录并保持 N-023 parity BLOCKED。 +- checker 在全新临时目录重跑 Blender 生成器,report 逐字节一致;M12-06D export E2E 默认模式 + 已通过 1/1,Web GLB source/output hash 与 report 绑定。`npm --prefix web run typecheck`、 + `git diff --check` 通过。 +- hashes:generator `3ae1ba45e15cae5d0308fc3fcb3766764cd374c096e27eaaddba9228a3d75004`;checker + `c21b45a975ec70586da1b9e50b8cf4ccf74644300b4f381c3fee0ad735cfcdb9`;desktop report + `e4d03d25cfac3c4beff4d0af0769b1c39b058670c679c12f4a11ae30d2d91f3c`;manifest + `3d3b13fd54a314fd6f6907fc2d314e66ef74e8ca770a8accd823370d272737fc`;Web PBR/UV/skin/animation + GLB 分别为 `1ab7936fae6e0c28e1b90e8a6ae24b3893c075c9fc58ac8896f780fdefb8277e`、 + `8bd045388527ddad7cf886c0c7a2a45b6e7d6db603568a10a959bc6d423ae145`、 + `4a45d00dfa23638682bb61a4f74b283bcd986126da4890d068902e3c85efc332`、 + `b83de103df3f5a49487868f3ede3046875c90b0d084bbd998511c3a7c987a4fa`。 +- `docs/status/M12-06E.md`、M12 当前计划、完整对标计划、WBS、项目状态已同步;本项 + `enablingTask=false/parityStateChange=false`,ledger 保持 195 completed/58 blocked;M12 当前 + 完成 52 项,机器队列唯一下一任务切换为 `M12-06F`。sparse/Draco/extension/外部 URI、取消、 + Worker restart 和 OPFS quota 仍未提前声明。 + +M12-06F 实际验证(2026-08-18 America/New_York) +- 直接领取 M12-06F:GLB parser 增加 512 KiB byte、256 KiB JSON、4096 bufferView、8192 accessor + budget;对 sparse accessor、任意 extensionsUsed/Required、buffer/image external URI 统一 + fail-closed,稳定返回 `GLB_SPARSE_ACCESSOR_UNSUPPORTED`、`GLB_EXTENSION_UNSUPPORTED`、 + `GLB_EXTERNAL_URI_BLOCKED`、`GLB_IMPORT_BUDGET_EXCEEDED`。 +- `node --test web/tests/unit/glb-negative-cases.test.mjs` 通过 2/2;Chromium Worker + `CHROME_PATH=/usr/bin/google-chrome-stable WEB_TEST_PORT=5450 node_modules/.bin/playwright test + --config playwright.config.ts --workers=1 tests/e2e/glb-negative-cases.spec.ts` 通过 1/1,四类 + 真实二进制负例在生产 Worker 中返回同样的错误码。 +- `node tools/web/check-glb-negative-cases.mjs` 通过,输出 + `glb-negative-cases-ok cases=4 budget=524288 deterministic=true next=M12-06G`; + `npm --prefix web run typecheck`、`git diff --check` 通过,package hash 未变。 +- hashes:protocol `0b6329c08e6f3cd9af6f27ef7c463b037a7cab94192afb4538d3d6c4cfcbc147`;unit + `9af1c155143a0c685a62f569f705afd9fcc3bb49e0d724ebf3ad2c6356d63d6d`;Worker + `d84a6b884ce1bfedd598b2602d803493bf10f6ad62fdfac49d64fca3f30f3931`;Chromium test + `c6694689ce04ff2faea2c20be648f438a9f7eb25cdfd2f714b94c1f556ea1510`;checker + `08377c306fd7d1082f7fd734e37809960c9798d29d626681fe9ad3f94cb7a29e`;report + `7367a624b562a9613b4b908c894ab04c731ae0a348a3b58689075f4a9decd90c`;manifest + `27b42da0683dab11a884553440088c30cf58d51364268fdaac86668786aaedd4`。 +- `docs/status/M12-06F.md`、M12 当前计划、完整对标计划、WBS、项目状态已同步;本项 + `enablingTask=false/parityStateChange=false`,ledger 保持 195 completed/58 blocked;M12 当前 + 完成 53 项,机器队列唯一下一任务切换为 `M12-06G`。取消、Worker restart、OPFS quota 和 + full extension allowlist 仍未提前声明。 + +M12-06G 实际验证(2026-08-18 America/New_York) +- 直接领取 M12-06G:新增 schema-1 `glb-recovery` receipt,绑定 operation、Worker generation、 + base/candidate revision、输入/输出 SHA-256、临时字节、live request、commit 状态,以及稳定 + `GLB_OPERATION_CANCELLED`、`GLB_WORKER_RESTARTED`、`GLB_OPFS_QUOTA` code。 +- Chromium 生产 parser/exporter Worker 对 IMPORT 与 EXPORT 分别在发布前取消;两项均返回 + `CANCELLED`、`committed=false`、`temporaryBytes=0`、`liveRequests=0`。独立 generation 1/2 + Worker 对同一 PBR GLB 重跑,semantic output SHA-256 exact;generation 1 committed receipt + 以 schema transition 标记 `RECOVERED/GLB_WORKER_RESTARTED`。 +- GLB output 使用既有 content-addressed OPFS asset path。Chrome DevTools 将 origin quota 设为 + 64 KiB 后,128 KiB candidate 真实写入失败;旧 PBR GLB 在 Storage Worker 重开后 byte length/ + SHA-256 保持,asset metadata 仍只有 1 项。quota 恢复到 1 GiB 后,同一 project 成功提交 3-byte + 小 GLB asset,asset count 变为 2。 +- 第一次真实 quota Chromium 运行已经正确失败关闭,但断言只接受注入消息 + `QuotaExceededError`,浏览器实际消息为 `The operation failed because it would cause the + application to exceed its storage quota.`;放宽为两类 quota 文案后完整重跑 2/2 通过。 +- `node --test web/tests/unit/glb-recovery.test.mjs` 通过 2/2;最终 + `CHROME_PATH=/usr/bin/google-chrome-stable WEB_TEST_PORT=5455 node_modules/.bin/playwright test + --config playwright.config.ts --workers=1 tests/e2e/glb-recovery.spec.ts` 通过 2/2; + `node tools/web/check-glb-recovery.mjs` 输出 + `glb-recovery-ok cancelled=2 workerGeneration=2 quota=GLB_OPFS_QUOTA smallRecovery=true next=M12-07A`。 +- `npm --prefix web run typecheck`、checker syntax 和 `git diff --check` 通过;package hash 保持 + `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c`。 +- hashes:recovery protocol `da4e2a546d7598e80798cfebd105e52b7522c896acb545710c78bb8a5c3705aa`; + storage protocol/client/Worker `2c8ce9e32fcae973e9262a024fd849221680104bcd427ec308f99ef25112d951`/ + `00cd35e4632479e4cd153314113e201c822fd209ae6c8e9fbeff5f1399728565`/ + `80bf4d2ac59cbf7998c2a72c8961ae38abe43685b60df564d0746e1a45e9e1bc`;operation Worker/adapter + `947c4a768422725ff2f689b2eefa8068bee51bb7ff342af17e8d4a4d0a4a7ca2`/ + `2c753a04c153471c2bf7691073b836d968a1a1300e3da038f493983b46738cdf`;unit/Chromium + `6dd48f62a85c5aaf3a04b6e572a47a7986b23cbec84a099ea510af81e8dd3a3a`/ + `7d95e992f44fb913f70c104f0d6b23bd76f47d48db4a21899e35ae1d188e2093`;checker/report + `b185248fdcd6b3cee84252bd68fcb6921bb6385bfa4a00486e93c58e94755cce`/ + `1a008a76590573468446ca6e5cbdfe0ea5a8b27493291590d937b90db90d6e42`;manifest + `1c732b8d9f1a0bdb502f44e2e843e91efea12e93483a35658f8a9c70a69a2430`。 +- `docs/status/M12-06G.md`、N-023、当前/完整对标计划、项目状态、WBS 和本文件已同步;本项 + `enablingTask=false/parityStateChange=false`,ledger 保持 195 completed/58 blocked;M12 当前 + 完成 54 项,机器队列唯一下一任务切换为 `M12-07A`。OBJ 正例尚未提前声明。 + +M12-07A 实际验证(2026-08-18 America/New_York) +- 直接领取 M12-07A:读取 M12-06G 完成证据和 M12-05A pinned Blender runtime inventory;任务冻结为 + desktop-only OBJ single Mesh positive,不提前开放 Web parser、负例、多对象或 round-trip。 +- 新增 `tools/web/generate-obj-single-mesh-fixture.py`,调用 Blender 5.2 `wm.obj_export`,在 + `tests/files/web/m12_obj_desktop_v1/` 生成 `single-mesh.obj` 与 `single-mesh.mtl`。OBJ 有 4 个 + position、4 个 `vt`、1 个 `vn`、2 个 triangle face;两个 face 分别绑定 `M12_OBJ_Red`/ + `M12_OBJ_Blue`,并各自有稳定 material group。MTL 有 2 个 `newmtl`。 +- canonical report `tests/golden/M12-07A/desktop-fixture.json` 绑定 source anchor + `blender-5.2.0/source/blender/io/wavefront_obj`、operator `wm.obj_export`、轴向/UV/normal/ + material settings、OBJ/MTL bytes/hash 和语义索引。runtime identity 与 M12-05A 逐字段一致。 +- `node tools/web/check-obj-single-mesh-fixture.mjs` 通过全新临时目录二次运行 Blender,report 与 + OBJ/MTL 均逐字节一致,输出 + `obj-single-mesh-fixture-ok vertices=4 normals=1 uv=4 faces=2 materials=2 deterministic=true next=M12-07B`。 +- hashes:generator `604c3006f194c7c64a487b8f760693b66830f3cfa602928b46769955e7d4f358`;checker + `3d0208f61a86d4d29796d8284756f53931c90864b15b7dfe4fcc84d7f65a8040`;desktop report + `6c560a8eecf84973c2b05f9fd50d33bd45515e97c4f7970f1502de406c39f6a5`;OBJ/MTL + `a56694d1ee28735c68381ff18c3a52581612feebac0101a53e502956c27d144f`/ + `392f1b10ff5a0b142d520a9443b4c96191985f15d4244c79b36fdeda0f153715`;manifest + `d80b74502202effa7be15640c945dc7e04af703b58e7a15c3fe7babc43c17b46`。 +- `docs/status/M12-07A.md`、N-023、当前/完整对标计划、项目状态、WBS 和本文件已同步;本项 + `enablingTask=true/parityStateChange=false`,ledger 保持 195 completed/58 blocked;M12 当前 + 完成 55 项,机器队列唯一下一任务切换为 `M12-07B`。OBJ Web 解析、多对象/坏 face/MTL texture + origin 和 round-trip 均未提前声明。 + +M12-07B 实际验证(2026-08-18 America/New_York) +- 直接领取 M12-07B:冻结 OBJ 双对象、负索引、MTL/texture origin 和坏 face 四个输入面;仍不 + 开放 Web parser 或 OBJ import route。读取 M12-07A 与 M12-05A runtime inventory,继续使用 pinned + Blender 5.2 `wm.obj_export`。 +- 新增 `tools/web/generate-obj-multi-negative-fixtures.py`,生成两个 Mesh object、6 个 position、 + 6 个 UV、2 个 normal、2 个 triangle face、两个 material/material group;材质节点引用由 Blender + 生成的 2x2 `m12_obj_texture.png`,MTL 两条 `map_Kd` 均为相对文件名。随后从同一正例确定性派生 + `negative-index.obj`(每个 v/vt/vn index 为负)和 `malformed-face.obj`(首个 face 只有 2 个顶点)。 +- `node tools/web/check-obj-multi-negative-fixtures.mjs` 通过新临时目录完整重跑 Blender,验证 + OBJ/MTL/PNG/负例逐字节一致、双对象/group/material、PNG signature、负索引形状与 + `OBJ_FACE_ARITY_INVALID`,输出 + `obj-multi-negative-fixtures-ok objects=2 negative=true malformed=OBJ_FACE_ARITY_INVALID textureOrigin=relative deterministic=true next=M12-07C`。 +- hashes:generator `8d4faed82cba76e46bf639e5031b30568e8b9a15240cbddb1c22ee7ad11d697b`;checker + `61188d67efd6133e714f2d55c7c8516b9d04896de068f994523a3c398eebdd2f`;desktop report + `b60ff785676c0f0168588605ad442a650615191ac00770b7e5a308cc4ade83ce`;OBJ/MTL/PNG + `4ed3dced8445b03754cd3916ae2e8cf0dff85e6a5882f1c6f0a4a4e4c5251f6a`/ + `80338f569c67a8b1616515ba878ffc72b26e93ef2e8c361131023fb14f132f5f`/ + `44e4fd08bdda8403908349e2a62dcf103ce2d0851099fc61cd868d65ddbef78c`;negative/malformed + `9457954284cac1d68e23627e3ff734c0c591b3a24086ce5aa3d0dbbfc22f01bc`/ + `6dd508b5ba944c2d15e2889c9ad9a3693845145c3bf6c9bf7df992081c915864`;manifest + `785f593271058098704498cb0a7c948305087f1a83bf8f29b6e6fb9fe9341926`。 +- `docs/status/M12-07B.md`、N-023、当前/完整对标计划、项目状态、WBS 和本文件已同步;本项 + `enablingTask=true/parityStateChange=false`,ledger 保持 195 completed/58 blocked;M12 当前 + 完成 56 项,机器队列唯一下一任务切换为 `M12-07C`。Web import、OBJ round-trip 和 loss report + 仍未提前声明。 + +M12-07C 实际验证(2026-08-18 America/New_York) +- 直接领取 M12-07C:新增 schema-1 `obj-import`,固定 512 KiB OBJ、128 KiB MTL、16,384 行、 + 65,536 position/UV/normal、65,536 face 预算;正索引与负索引归一到 1-based canonical,坏 + arity/index 返回稳定 `OBJ_FACE_ARITY_INVALID`/`OBJ_INDEX_OUT_OF_RANGE`。`serializeOBJ` 输出 + 固定文本,`createOBJLossReport` 对未绑定 `map_Kd` 返回 `OBJ_TEXTURE_ORIGIN_UNRESOLVED`。 +- 新增生产 `obj-roundtrip-test.worker.ts`。Chromium 150 对 M12-07B 双对象 OBJ/MTL/PNG 输入执行 + Web parse/serialize 两次:绑定 texture 的 loss report warning=0,去掉 texture binding 的 + warning=2;browser canonical 为 2 objects、6 positions、6 UV、2 normals、2 faces、2 materials。 +- E2E 将 Web 输出写入独立临时目录并调用 pinned Blender 5.2 `wm.obj_import`(split groups); + 新进程报告 2 objects、2 mesh、2 triangles、每对象 UVMap 和 1 material,与 browser canonical + 的 object/triangle/UV/material 比较全部 exact。输出 golden report 绑定 source/output hash、 + desktop report 与 missing-texture loss。 +- `node --test web/tests/unit/obj-import.test.mjs` 通过 3/3;最终 + `CHROME_PATH=/usr/bin/google-chrome-stable WEB_TEST_PORT=5460 node_modules/.bin/playwright test + --config playwright.config.ts --workers=1 tests/e2e/obj-web-roundtrip.spec.ts` 通过 1/1; + `node tools/web/check-obj-web-roundtrip.mjs` 输出 + `obj-web-roundtrip-ok objects=2 triangles=2 lossWarnings=2 desktopExact=true next=M12-07D`。 +- 首次 Blender 重导入脚本因把 `mesh.materials` 元素误当作 slot,进程退出码仍为 0 但没有生成 + report;修正为直接读取 Material 后从头重跑。该失败未进入 READY golden。 +- hashes:protocol `e7240af65e0d90f3ee690a4e3f391416fe4744ac6c46edc8ac0d72386857cab9`;Worker + `bf1fcc60ec318cf6c2747d44f4af741b46f284cf5f5307e142f0ab6d50b14302`;desktop importer + `9f1eb4ab679255a0f1f596696e8befc33a4e30c0cbe6ea423e2aaa0314cec22d`;checker + `9d53014f4b89f786c516ebe8d300030c2728e4a36a86a5ef136b2769a12ac96b`;unit/Chromium + `485a669be5de8e370e9b5a3239357b028ba61ca5c4076819cd46aed52a5c210a`/ + `93785b4017ba14b007926b0f82442f8ae5968f242a2a762e5f5dc77d36110f5b`;report + `45eaffc9c2e50c84b557d13ebbe9f4f53b63e19e00bc69b272491ef6366dff81`;manifest + `0c639954900b1fcc3b8285d0f4c0ecfa5807df6cde6d1f2cd3b59c4636d71674`。 +- `docs/status/M12-07C.md`、N-023、当前/完整对标计划、项目状态、WBS 和本文件已同步;本项 + `enablingTask=false/parityStateChange=false`,ledger 保持 195 completed/58 blocked;M12 当前 + 完成 57 项,机器队列唯一下一任务切换为 `M12-07D`。OBJ UI route、STL、PLY 和完整材质/贴图 + semantics 仍未提前声明。 + +M12-07D 实际验证(2026-08-18 America/New_York) +- 直接领取 M12-07D:从 M12-05A runtime inventory 确认 `wm.stl_export` registered、build option + `io_stl=true` 且 variants 为 `STL_BINARY/STL_ASCII`;任务只冻结两种 encoding capability,不 + 依赖共同 `.stl` 扩展名推断,也不开放 Web parser。 +- 新增 `tools/web/generate-stl-capability-fixtures.py`,由 pinned Blender 5.2 对同一 selected + two-triangle Mesh 用相同 axis/scale/unit/evaluation settings 导出 binary 与 ASCII。binary 为 + 80-byte header + triangle count=2 + 2x50-byte records,总 184 bytes;ASCII 为 2 facet、6 vertex, + 总 213 bytes。 +- `node tools/web/check-stl-capability-fixtures.mjs` 验证 runtime identity、binary count/size、ASCII + wrapper/facet/vertex、artifact hash,并在新临时目录从头运行 Blender 后逐字节相同,输出 + `stl-capability-fixtures-ok binaryTriangles=2 asciiFacets=2 deterministic=true next=M12-07E`。 +- hashes:generator `8310104e66f246b32ac7cb4619e7f4da109baf2ece39ea670cbb6d33bd88c8b8`;checker + `b2ad901eaa9701436cf7bcc8aa4e40d1b2d6eda7c6c44a0af830347a37cc9ca8`;report + `29c7d2a6e6764440c99eec25bb5760c7e0880839691757fb3e607ed4f5050013`;binary/ASCII + `50161172cd12e4240067064db6f80e74b2df69c475e3e34154e9d0523928c1ca`/ + `a463a5add8be070fb67b34f00ef6e7b46025aed31fa429d4a033d75a11cf0113`;manifest + `bbc78e47f389562e7d648bf49c9b3bf8a08aaa36e914589a6eab0a1f6e72748d`。 +- `docs/status/M12-07D.md`、N-023、当前/完整对标计划、项目状态、WBS 和本文件已同步;本项 + `enablingTask=true/parityStateChange=false`,ledger 保持 195 completed/58 blocked;M12 当前 + 完成 58 项,机器队列唯一下一任务切换为 `M12-07E`。normal/unit/degenerate/trailing、Web + round-trip 与材质 loss 均未提前声明。 + +M12-07E 实际验证(2026-08-18 America/New_York) +- 直接领取 M12-07E:新增 `stl-import` schema 1,调用方必须显式给出 `STL_BINARY/STL_ASCII` + variant 和正 unit scale;预算固定 512 KiB、65,536 triangles、scale 上限 1,000,000。parser 逐 + facet 读取 normal/vertices,unitScale 只作用于 vertices,零面积 triangle 在发布前移除;binary + 声明 table 后有 bytes 时稳定返回 `STL_TRAILING_BYTES`。 +- 从 M12-07D binary fixture 派生一个第一 triangle 三顶点相同的 degenerate 文件和追加 + `de ad be ef` 的 trailing 文件。pinned Blender 5.2 实际 probe 表明:scale=1/0.001 world bounds + 比例约 1000;normal 均为 `[0,1,0]`;degenerate 导入警告并移除 1 triangle;trailing 文件被 + 接受为空 Mesh。Web 对 trailing 采用更严格阻断,并明确记录 `STRICTER_WEB_BLOCK`,不伪报 exact。 +- `node --test web/tests/unit/stl-import.test.mjs` 通过 2/2; + `CHROME_PATH=/usr/bin/google-chrome-stable WEB_TEST_PORT=5463 node_modules/.bin/playwright test + --config playwright.config.ts --workers=1 tests/e2e/stl-edge-parity.spec.ts` 通过 1/1;normal binary/ + ASCII/Desktop exact,unit ratio 在 Float32 容差内,degenerate triangle count exact。 +- `node tools/web/check-stl-edge-parity.mjs` 输出 + `stl-edge-parity-ok normals=exact unitRatio=1000 degenerate=removed trailing=STRICTER_WEB_BLOCK next=M12-07F`; + typecheck 与 `git diff --check` 通过。 +- hashes:fixture generator/desktop probe `018013347642603c21237ebb023bdc7a4e338f5a3f875fea536a996eb121e716`/ + `b3d6ea197ef77b5a14f60f5e6b43d4392e943ff5d56acf73915507fc9a4161b7`;protocol/Worker + `87eec72e2b8aa7ad0b168ca0ee8c4016c941f56f5f1ac53aa5fe71d0832f1dd8`/ + `d714f1f3a218a2226dd349aea81c6c54efa6246de1c4fe51aaa9f5c352ef44fc`;unit/Chromium/checker + `4af52833486421c017f3af2b833b0776e60a6ab57ed66fea2161cf9674f4b243`/ + `121d348250e26d29ad966f7427bbef9da77de2098e2e305827b11aae52002c5c`/ + `b5ed7fbb41bb46fba982d5726cb8c3eaecf360b772a01d6337c309b1c4a4f535`;fixture/desktop/Web reports + `545463a984356d6635cbaae3865d13fe95bd2d841c394ac9ddff496c95d46f18`/ + `2d3028a3b7d97f39654cff5fcef1d0c1c71e9f2d08e3e29c2e926651ed3860f1`/ + `e0686cc9be3b68e564651207443e0ebf3e3b0eb3d07a32915b03f44b1908357b`;manifest + `19a9f460d1b939c9504dadd364cb87dab3b1769b0599035e2d2b51b47091e034`。 +- `docs/status/M12-07E.md`、N-023、当前/完整对标计划、项目状态、WBS 和本文件已同步;本项 + `enablingTask=false/parityStateChange=false`,ledger 保持 195 completed/58 blocked;M12 当前 + 完成 59 项,机器队列唯一下一任务切换为 `M12-07F`。STL Web-to-desktop round-trip/material + loss 仍未提前声明。 + +M12-07F 实际验证(2026-08-18 America/New_York) +- 直接领取 M12-07F:新增独立 `stl-export` schema 1,使用 M12-07E parsed triangles 生成固定 + binary STL(80-byte header + count + Float32 facets),并由 `createSTLLossReport` 对源项目的 + 2 个 material assignment 返回 `STL_MATERIAL_UNSUPPORTED`,不静默丢材质。 +- Chromium 150 Worker 对 M12-07D binary fixture parse/serialize,输出仍为 184 bytes/2 triangles; + E2E 将 Web output 写入临时目录,由 pinned Blender 5.2 `wm.stl_import` 新进程读取,desktop + polygon/triangle=2、facet normals 与 Web exact。 +- `node --test web/tests/unit/stl-export.test.mjs` 通过 1/1;最终 + `CHROME_PATH=/usr/bin/google-chrome-stable WEB_TEST_PORT=5467 node_modules/.bin/playwright test + --config playwright.config.ts --workers=1 tests/e2e/stl-web-roundtrip.spec.ts` 通过 1/1; + `node tools/web/check-stl-web-roundtrip.mjs` 输出 + `stl-web-roundtrip-ok triangles=2 normals=exact materialLoss=1 desktopExact=true next=M12-07G`。 +- 首次 E2E 结果把 transferred ArrayBuffer 经 Playwright 结构化返回为 object,修正为在 page + evaluate 内转 `Array.from(Uint8Array)`;第二次 Blender report 的 `-0` 归一化后从头重跑通过。 + 两次失败均未进入 READY golden。 +- hashes:protocol/Worker `3b9c409de9fb3eaea34f82c1ddd466670d2b478845d8ff6af3c4e44cb4e04a52`/ + `1da8b8281cf8ebf9ccf5fd2f42da8ed5886001ab83daba8d9fcd88980257af87`;desktop importer + `c4cbff52ff2e7cecba7aeab47e865975955da23a0e1e8ff885ece56b894558d5`;unit/Chromium/checker + `f06af242df80ec26d06e92b749449cfebaf909476198ff825207cdd4b9484102`/ + `2e979dcd030f5316fcbe28e2c19b1c99fe5d111e849d3fc1d2ea316d2159032b`/ + `093de04bf80b0909eee56ea590e04e7aa4749168e2e49615c62ab39ddc0d16cf`;report + `0495c645b4280f6e7821f0ba02010e25d4accbc552e3cd7c58e052607799040e`;manifest + `3cbbcb541ee123da0ef8916ac2ca8805680d539bbf8db3b4a8d331aec418148c`。 +- `docs/status/M12-07F.md`、N-023、当前/完整对标计划、项目状态、WBS 和本文件已同步;本项 + `enablingTask=false/parityStateChange=false`,ledger 保持 195 completed/58 blocked;M12 当前 + 完成 60 项,机器队列唯一下一任务切换为 `M12-07G`。STL material/normal/unit 全域与 UI route + 仍未提前声明。 + +M12-07G 实际验证(2026-08-18 America/New_York) +- 直接领取 M12-07G:读取 M12-07F 完成证据和 M12-05A pinned Blender 5.2 runtime inventory;任务 + 冻结为 desktop-only PLY capability,不提前开放 PLY Web parser、属性映射、负例或 UI route。 +- 使用 `tools/web/generate-ply-capability-fixtures.py` 调用 pinned Blender 5.2 `wm.ply_export`,对 + 同一 selected four-vertex/two-face Mesh 分别生成 `PLY_ASCII` 与 + `PLY_BINARY_LITTLE_ENDIAN`。ASCII header 为 `format ascii 1.0`,binary header 为 + `format binary_little_endian 1.0`;两个文件各自声明 vertex=4、face=2,settings、source anchor、 + operator 和 runtime identity 写入 canonical report。 +- `node tools/web/check-ply-capability-fixtures.mjs` 通过 runtime identity 逐字段匹配、variant + encoding/element count/hash 校验,并在全新临时目录重新运行 Blender;report、ASCII 和 binary + little-endian 文件均逐字节一致,输出 + `ply-capability-fixtures-ok ascii=ascii binary=binary_little_endian vertices=4 faces=2 deterministic=true next=M12-07H`。 +- `python3 -m py_compile tools/web/generate-ply-capability-fixtures.py`、 + `node --check tools/web/check-ply-capability-fixtures.mjs` 通过。PLY capability 只冻结桌面 + encoding admission,不改变 N-023 的 `BLOCKED` parity,也不把扩展名推断为执行能力。 +- hashes:generator `581cd84057357e3a87997cf9c07d5d5633209648ac11e44611782eb254a38ebd`;checker + `5280d6e57b7a722ea881e27b792c6082c5113c1577ac0e87f87cc87fdf59516c`;desktop report + `26b1ce83334b41778cef5ce2a1f2c4d34254f63d7c7573cb3fb550f93ddeabb2`;ASCII/binary fixtures + `63646cab9bf6ccecb23092e5e24d04df1e0a690c866127c72a35791e99262331`/ + `e40fbb494b8b147c555a0fc06eb191415406bb9a6c061acf6befd8464c8c41a5`;manifest + `87df9c12f9c6eacf63051b70e9bb2eb43ebd1e5c4487b3512c45adb94cbb82ea`;package 保持 + `cfb232baa494ff59f6c8d65514a08c86332ee9354350b2a0392b6f932a7ee42c`。 +- 新增 `docs/status/M12-07G.md`,并同步 N-023、当前执行计划、完整对标计划、WBS、项目状态和本文件; + 本项 `enablingTask=true/parityStateChange=false`,ledger 保持 195 completed/58 blocked;M12 + 当前完成 61 项,机器队列唯一下一任务切换为 `M12-07H`。PLY vertex/face/color/custom-property + 映射、未知 property loss、big-endian/坏 list/超大 count 和三格式 recovery 均未提前声明。 + +M12-07H 实际验证(2026-08-18 America/New_York) +- 直接领取 M12-07H:读取 M12-07G PLY capability 与 pinned Blender 5.2 runtime;本项实现 bounded + `web/protocol/ply-import.ts`,显式接受 ASCII/binary little-endian,映射 vertex position/normal、 + RGBA color、numeric custom property、face indices 和 numeric face property。预算固定为 512 KiB、 + 64 KiB header、65,536 vertex/face、256 list entries;未知 vertex/face list 与未知 element 生成 + `PLY_UNKNOWN_PROPERTY`/`PLY_UNKNOWN_ELEMENT` loss warning,不静默丢弃已映射字段。 +- 新增 `serializePLYAscii`、`createPLYLossReport`、生产 `ply-roundtrip-test.worker.ts`,以及 + `generate-ply-mapping-fixtures.py`。pinned Blender 5.2 `wm.ply_export` 生成 4 vertex/2 face、 + RGBA、`temperature`/`label` 两个 custom attribute 的 ASCII 与 binary little-endian fixture;从 + ASCII 正例确定性派生 `unknown-property-ascii.ply`(`unknown_values` list)。 +- `node --test web/tests/unit/ply-import.test.mjs` 通过 3/3:两种 encoding 字段语义相同、颜色/ + custom 映射、未知 property loss、序列化重开和 format mismatch 均有断言。 +- `node tools/web/check-ply-mapping-fixtures.mjs` 通过,输出 + `ply-mapping-fixtures-ok vertices=4 faces=2 colors=rgba custom=2 unknown=PLY_UNKNOWN_PROPERTY deterministic=true next=M12-07I`; + 新临时目录再次运行 Blender,mapping report 与三份 fixture 均逐字节一致。 +- 最终 Chromium 命令: + `UPDATE_PLY_MAPPING_REPORT=1 CHROME_PATH=/usr/bin/google-chrome-stable WEB_TEST_PORT=5472 + node_modules/.bin/playwright test --config playwright.config.ts --workers=1 + tests/e2e/ply-web-roundtrip.spec.ts`,通过 1/1。生产 Worker 对 ASCII/binary/unknown 三输入执行 + parse/serialize,unknown 返回 1 条 `PLY_UNKNOWN_PROPERTY`;Web ASCII 输出由 pinned Blender 5.2 + `wm.ply_import` 重导入,vertex=4、triangle=2、position exact、RGBA attribute 和两个 custom + attribute 均存在。报告 `tests/golden/M12-07H/web-roundtrip-report.json`。 +- `npm --prefix web run typecheck`、`node --check tools/web/check-ply-mapping-fixtures.mjs`、 + `python3 -m py_compile tools/web/generate-ply-mapping-fixtures.py tools/web/check-ply-web-roundtrip.py` + 与 `git diff --check` 通过。新增 `web/package.json` 命令 `test:ply-mapping`。 +- hashes:protocol `19a4e8d4c5d0909ba3614b6c8a9cb3b57e8257008b3dd6143dc16ba1bd4a997f`;Worker + `f6bf5e39e83286d7b257bbdce88f4d7fa027c64651da9765567788b5cf298c71`;generator/checker/desktop + importer `ffc051eccfc6973ee00cc791cdbd641fcce308b4083741e3e6ae82291d9347b7` / + `2b055dbc705830848efdf4d8db8543a3ccc684de1f258732bcafefa86cf65c3a` / + `6edbc6e401a1a902dcfd11c4d767e8c8620d694e40eb4ca29dd8479f9c55ef37`;unit/e2e + `b03a5f4b4b2a974fa26e653763470b92d1433c5d352ae09ab5492b841e6e5e1f` / + `2cadebc89057655d78e9b520bb6adf9debb27c6d783cd002efee08d269636fb9`;mapping/web report + `8a02fc9e364ed79e50ef00897affa9ab63808d3cb3cdabd00fdb8ee4c26ec18a` / + `1ca9d3b7870fcc8c9e3c9bbbd90ef48bd13bbc9ae0462312c9482f24f05f13ec`;manifest + `7de271492103cc694d5815d0b81255139e9507578f87947c6ec493149a1fe8bc`;package + `c56c653effb38f9ac9c53aa19a531ca0cdab04d4457212f286349091d5b65c22`;fixtures ASCII/binary/ + unknown `acaa369c17252d4d089a2e3064d0c1fcdfdb2d066759ed160adf7869d3e859d5` / + `d0fc195fd1a101c42ac984a2382bccdddb7d0bf60dd618e9f637c964f1b30b9e` / + `a994c7126f235d0067ce4af35f8b0c6699cc83073e2a37e982edd45ece459f33`。 +- `docs/status/M12-07H.md`、N-023、当前/完整对标计划、WBS、项目状态和本文件已同步;本项 + `enablingTask=false/parityStateChange=false`,ledger 保持 195 completed/58 blocked;M12 当前 + 完成 62 项,机器队列唯一下一任务切换为 `M12-07I`。big-endian、坏 list、超大 count 和三格式 + recovery 仍未提前声明。 + +M12-07I 实际验证(2026-08-18 America/New_York) +- 直接领取 M12-07I:在 PLY header admission 增加 65,536 element record 上限;保留 + `binary_big_endian` 的稳定 `PLY_FORMAT_UNSUPPORTED`,ASCII/binary list 截断返回 + `PLY_DATA_TRUNCATED`,超大 vertex count 返回 `PLY_IMPORT_BUDGET_EXCEEDED: vertex`。 + 失败在 parse/map 前结算,不发布部分 document。 +- 新增 `tools/web/generate-ply-negative-fixtures.mjs`,生成 `big-endian.ply`、 + `malformed-list-ascii.ply`、`oversized-count-ascii.ply` 和 schema-1 negative report; + `node tools/web/check-ply-negative-fixtures.mjs` 在独立临时目录重生成并通过: + `ply-negative-fixtures-ok cases=3 bigEndian=PLY_FORMAT_UNSUPPORTED malformed=PLY_DATA_TRUNCATED oversized=PLY_IMPORT_BUDGET_EXCEEDED deterministic=true next=M12-07J`。 +- `node --test web/tests/unit/ply-negative.test.mjs` 通过 2/2;最终 Chromium 命令 + `CHROME_PATH=/usr/bin/google-chrome-stable WEB_TEST_PORT=5478 node_modules/.bin/playwright test + --config playwright.config.ts --workers=1 tests/e2e/ply-negative.spec.ts` 通过 1/1,生产 + Worker 对三类输入逐项返回预期 code 且 `ok=false`。 +- `npm --prefix web run typecheck`、Node syntax checks、`git diff --check` 通过。M12-07H 的 + parser unit/checker 在共享 budget guard 变更后重新通过;H manifest protocol hash 已更新到 + 当前可复验代码。 +- hashes:protocol `058a3263fde553637840a4e9ad4e8e9d923eb52c250f8000317c7f43a228881d`;generator/ + checker `9c09707bdfe73ba467bbfbf61ed3846fb59fd33ab5563a3a62c8032722ff6938` / + `b1d047d4cb1fa15dff7821687e7028ad073fdc62d4c76f60a2656732c0ec5e2b`;unit/e2e + `60439f9e6bc221df8866956bf926816a347e85828b5a93deb26ee23015cf449a` / + `6509a29d6842f3423d4dfcc40dbd52a959a5efa7ee1121143dce06e5bbc4a460`;report + `fbe2830d1b19294ea98eea66c3e00125bc0809a4bb8a750612939cbe1f5acca9`;manifest + `02be4b2e2cabecf4a239ba52be385b926a70e794c6f8c745d89dada568e6d674`;fixtures big-endian/ + malformed/oversized `cda92943a3690529fbb3463d328b6796ac0d07e19139450556f7411fc1f031e3` / + `a6a576b7a04d919b540520a6f43a89c089f0d706a17fd8b390711fff6b8b03df` / + `fcd99e0838025429420a47466251cfef80e638d2b5816ad14d5aa696364525bb`。 +- 新增 `docs/status/M12-07I.md`,并同步 N-023、当前/完整对标计划、WBS、项目状态和本文件; + 本项 `enablingTask=false/parityStateChange=false`,ledger 保持 195 completed/58 blocked; + M12 当前完成 63 项,机器队列唯一下一任务切换为 `M12-07J`。三格式 cancellation、OOM、 + Worker restart 和小文件 recovery 仍未提前声明。 + +M12-07J 实际验证(2026-08-18 America/New_York) +- 直接领取 M12-07J:新增 schema-1 `io-format-recovery` receipt,绑定格式、operation、输入/ + 输出 SHA-256、base/candidate revision、Worker generation、temporary bytes、live requests、 + published results 和 commit 状态。生产 `io-format-recovery-test.worker.ts` 复用 OBJ/STL/PLY + parser/serializer,取消返回 `IO_FORMAT_OPERATION_CANCELLED`,超过 512 KiB 的确定性预算故障 + 返回 `IO_FORMAT_OOM`,generation 2 recovery 返回 `IO_FORMAT_WORKER_RESTARTED`。 +- Chromium 命令: + `UPDATE_IO_FORMAT_RECOVERY_REPORT=1 CHROME_PATH=/usr/bin/google-chrome-stable WEB_TEST_PORT=5492 + node_modules/.bin/playwright test --config playwright.config.ts --workers=1 + tests/e2e/io-format-recovery.spec.ts` 通过 1/1;随后 WEB_TEST_PORT=5493 不更新 report 重跑仍 + 通过,固定 operationId 和 receipt 输出确定性成立。 +- OBJ/STL/PLY 各自均完成取消 1 次、OOM 1 次、generation 1->2 hash-stable restart 1 次和 + generation 3 small-file recovery 1 次,三格式输出 hash 均在重启/恢复后保持不变,取消/OOM + `publishedResults=0`、temporary/live 归零。 +- `node --test web/tests/unit/io-format-recovery.test.mjs` 通过 2/2; + `node tools/web/check-io-format-recovery.mjs` 输出 + `io-format-recovery-ok formats=OBJ,STL,PLY cancelled=3 oom=3 restart=3 smallRecovery=3 deterministic=true next=M13-01A`。 + `npm --prefix web run typecheck`、`git diff --check` 通过。 +- hashes:protocol `7e352539e3300969c7409c34d6056eb6ad31055431ffce84b1b0a459c335480a`;Worker/ + adapter `63b78fbf25132cad28147ef68731f2cd212a26c96b464fdec349a13ebaa1174f` / + `cfcebb6ec38936a66983957b0dede716871cfbfbea3cb53cddc16f3e24fb19b0`;unit/e2e + `aaed1f2abe4789b084c8a6a60bcce8595d38220d7e6678a93924cd05c5716b4f` / + `5c1750fa408e1297fc43f2e5749be3e9ac08a16b86265d22f0f06053f52b3bd7`;checker + `6ef6bc4a168f8675a4933a06c296f61076b9ea64cec25b295e961a9b610ab1a2`;report + `35d4fe10d8a4fa84d6e05a85f20ca50975d93a86df41e73c7bbc5875edc4fc70`;manifest + `a1c4cf9c2cc300ace388e6c430bd1aa991511a7d1c5482c638fb298bb362cd02`。 +- 新增 `docs/status/M12-07J.md`,并同步 N-023、当前/完整对标计划、WBS、项目状态和本文件; + 本项 `enablingTask=false/parityStateChange=false`,ledger 保持 195 completed/58 blocked; + M12 当前完成 64 项,机器队列唯一下一任务切换为 `M13-01A`。 + +M13-01A 实际验证(2026-08-18 America/New_York) +- 直接领取 M13-01A:读取 M13 scripting/security 任务定义及既有 N-025 默认拒绝 reader;新增 + pinned Blender 5.2 `generate-script-entry-inventory.py`,创建 3 个 Text datablock(internal、 + `use_module` autorun request、project-relative external path)和 1 个 driver expression,盘点 + Python Console 3 个 operator、39 个 handler groups、4 个 add-on operator。报告将 Text 固定为 + `READ_METADATA_ONLY`,Console/autorun/driver/handler/add-on 固定为 `DENY`,不执行任何用户脚本。 +- `node tools/web/check-script-entry-inventory.mjs` 通过 runtime identity、entry policy、semantic + inventory 与独立临时目录重生成: + `script-entry-inventory-ok texts=3 console=3 autorun=1 drivers=1 handlers=39 addonOps=4 deterministic=true next=M13-01B`。 + Blender `.blend` 保存含运行时 save bytes,checker 对语义报告和 fixture byte length 做确定性断言, + baseline fixture SHA-256 仍绑定 manifest。 +- `python3 -m py_compile tools/web/generate-script-entry-inventory.py`、Node syntax check、 + `git diff --check` 通过;本项是 `enablingTask=true/parityStateChange=false`,没有开放脚本执行。 +- hashes:generator `b72667493cfe9957161ef3fb9814180e0cfad5f8aaa1c60c9b6f132e915f3d6f`;checker + `68478f15de4d63ed175e6b580761fd478b1fe9bccbfcaeee82218d13063dfa51`;report + `e024995c53f01beaf725f281f74a6e5ec6018a53435da61a66f5e58a9e50973c`;fixture + `bd6375d02908bfcc57ff7cdeec3f9827bfc4336d1e46e165c5fbbf4d04f19645`;manifest + `d4a305033343ef5fb1ffc073617b59d9012f64b80ecb233e743fb0789a8b4893`。 +- 新增 `docs/status/M13-01A.md`,并同步当前/完整对标计划、项目状态和本文件;M12 仍为 64 项, + M13 当前完成 M13-01A,机器队列唯一下一任务切换为 `M13-01B`。脚本 metadata-open、policy + code、UI bypass、保存未知 Text、恶意 fixture 和 sandbox/server isolation 仍未提前声明。 +M13-01B 实际验证(2026-08-18 America/New_York) +- 直接领取 M13-01B:复用生产 Blender Main script reader 与真实 `tests/files/web/script_scene.blend`; + open 只重建 Text metadata/source、byte length 和 SHA-256,所有 source 保持 read-only 与 + `executionStatus=BLOCKED`,`ModuleAutorun.py` 的 `use_module` 请求返回 `SCRIPT_POLICY_DENIED`。 + 未调用 Python Console、driver、handler 或 add-on 执行入口。 +- `node tools/web/check-script-open-metadata.mjs` 通过,输出 + `script-open-metadata-ok blend=opened textMetadata=read sourceHash=verified execution=DENY autorun=DENY next=M13-01C`, + 并生成 `tests/golden/M13-01B/open-metadata-report.json`。 +- `CHROME_PATH=/usr/bin/google-chrome-stable WEB_TEST_PORT=5495 node_modules/.bin/playwright test + --config playwright.config.ts --workers=1 tests/e2e/script-open-metadata.spec.ts` 通过 1/1; + Chromium `WebEngineClient.openBlend` 真实路径验证 3 个 source、read-only、SHA-256 与 autorun deny。 +- `npm --prefix web run typecheck`、`git diff --check` 通过。hashes:checker + `66396d5c9a5294021ae077bb162278c74d46de8b52ac8a444a5de4f6d84cfe05`;e2e + `df6412cda113c830996e08c3d66a035dc1ac309e392f5946a762d11121b1926c`;report + `f92470e57c048452134664f7f6208e50b6f087dbcbc33369e6b882c51813990c`;fixture + `2b8fbdb05419e1e61d64777998d6a3b2562f726c17f548cd1852efc723e1a037`;manifest + `0bb0abdb9f4d250a15c3889a4fb5c2630b8c416f4eb5c6523aa30097c8e45fd3`。 +- 新增 `docs/status/M13-01B.md`,并同步当前/完整对标计划、项目状态和本文件;M13 当前完成 + M13-01A-B,机器队列唯一下一任务切换为 `M13-01C`。autorun/register/install/driver policy + code、UI bypass、未知 Text 保存和恶意 fixture 仍未提前声明。 +M13-01C 实际验证(2026-08-18 America/New_York) +- 直接领取 M13-01C:复用生产 `scripting-platform` manifest parser/gate,明确验证 autorun、 + driver expression、add-on install/register 三种请求的 fail-closed code;无批准 sandbox 时, + 即使签名 key 正确也返回 `SCRIPT_SANDBOX_UNAVAILABLE`。 +- `node --test web/tests/unit/script-policy-codes.test.mjs` 通过 1/1; + `node tools/web/check-script-policy-codes.mjs` 输出 + `script-policy-codes-ok autorun=SCRIPT_POLICY_DENIED driver=DRIVER_EXECUTION_BLOCKED addon=ADDON_INSTALL_BLOCKED sandbox=SCRIPT_SANDBOX_UNAVAILABLE next=M13-01D`。 +- `npm --prefix web run typecheck`、Node syntax check、`git diff --check` 通过。hashes:checker + `22588c2198bc8c425ab8e104e8559f0053654ae778aaea3783ec7d54822bc8f4`;unit + `b5a52b8e707963545f1cd71f1a148fa9c9b9d1e4b4c5f51c87d33f8bf3777430`;report + `956db548ee71688a4b89c9a993259d3e57129cd4abe9efd1b9397b3e30b1bf84`。 +- 新增 `docs/status/M13-01C.md`,并同步当前/完整对标计划、项目状态和本文件;M13 当前完成 + M13-01A-C,机器队列唯一下一任务切换为 `M13-01D`。UI bypass、Text 保存、恶意 fixture 和 + sandbox/server isolation 仍未提前声明。 +M13-01D 实际验证(2026-08-18 America/New_York) +- 直接领取 M13-01D:对生产 `web/app/src/app`、`web/app/src/workers`、`web/protocol` 共 176 个 + 非 vendor TypeScript 文件执行 direct-eval bypass scan,禁止 `eval(` 与 `new Function(`;扫描 + 无 violation,仅允许 `gateScriptExecution`、`gateServerScriptJob`、`parseScriptSourceInventory` + 三个声明 policy entry point。 +- `node tools/web/check-script-ui-bypass.mjs` 通过,输出 + `script-ui-bypass-ok scanned=176 violations=0 policyEntrypoints=3 report=8e234e128b90036548ab709b86b063de404250da2c5c0a6e25e28969b5cd5c73 next=M13-01E`。 + `git diff --check` 通过。 +- hashes:checker `f34cb64891c2b22bc3da353f6b864ba3e558d3c286cf716bcb778d9a542cb3d9`;report + `6b050092088508ebd5d769d95a2e66f0cd4020c97f3407724a19b9707f51f6dd`;manifest + `6b28688b3ebcce5629bcfc437d4ca903d0b1e053b32a796690ee4e021726c75b`。 +- 新增 `docs/status/M13-01D.md`,并同步当前/完整对标计划、项目状态和本文件;M13 当前完成 + M13-01A-D,机器队列唯一下一任务切换为 `M13-01E`。Text 保存/重开、恶意 Text/driver/handler + fixture、sandbox、server isolation 和完整 CSP 仍未提前声明。 +M13-01E 实际验证(2026-08-18 America/New_York) +- 直接领取 M13-01E:生产 `WebEngineClient` 对真实 `script_scene.blend` 执行 open -> saveBlend -> + 新 WebEngineClient reopen;3 个 Text source 的完整 metadata/source、byte length、SHA-256 exact, + 只读与 `executionStatus=BLOCKED` 保持,`ModuleAutorun.py` 仍为 `SCRIPT_POLICY_DENIED`。 +- `UPDATE_SCRIPT_SAVE_REOPEN_REPORT=1 CHROME_PATH=/usr/bin/google-chrome-stable WEB_TEST_PORT=5498 + node_modules/.bin/playwright test --config playwright.config.ts --workers=1 + tests/e2e/script-save-reopen.spec.ts` 通过 1/1;WEB_TEST_PORT=5497 不更新 report 重跑通过。 +- `node tools/web/check-script-save-reopen.mjs` 输出 + `script-save-reopen-ok sources=3 exact=true blocked=true savedBytes=490191 next=M13-01F`; + `npm --prefix web run typecheck`、`git diff --check` 通过。 +- hashes:checker `7fc9a370cb472636e2699565cb21a1450e3cc8a2c90ffdcdff96533b344afa7c`;e2e + `481f78f3a0cce923b67ab14436cc23cbcd2ce66725de29dc874fea4fb5801227`;report + `0f6869a8ec8342ed87649312d2872ab2c172cbf12d6be81bb0b770ebcbe8a398`;manifest + `889a4e06f7e8c0ea55b3ca4baa9fe85dccbe97053e497a45e3d364ce2b70a7c6`;fixture + `2b8fbdb05419e1e61d64777998d6a3b2562f726c17f548cd1852efc723e1a037`。 +- 新增 `docs/status/M13-01E.md`,并同步当前/完整对标计划、项目状态和本文件;M13 当前完成 + M13-01A-E,机器队列唯一下一任务切换为 `M13-01F`。恶意 Text/driver/handler fixture、sandbox、 + server isolation 和完整 CSP 仍未提前声明。 +M13-01F 实际验证(2026-08-18 America/New_York) +- 直接领取 M13-01F:pinned Blender 5.2 生成包含 OS command Text、driver import、handler + subprocess、embedded `register()` module 的四源恶意 fixture;每个 source 记录 SHA-256, + EmbeddedModule `use_module=true`,预期执行统一 `BLOCKED`。 +- `node tools/web/check-malicious-script-fixture.mjs` 输出 + `malicious-script-fixture-ok sources=4 module=1 execution=BLOCKED fixtureSha256=7b1921931afc5bb74a2b73e90b175017c583ea878cdbb66f131718b2d8cd23b5 next=M13-02A`。 +- `CHROME_PATH=/usr/bin/google-chrome-stable WEB_TEST_PORT=5499 node_modules/.bin/playwright test + --config playwright.config.ts --workers=1 tests/e2e/malicious-script.spec.ts` 通过 1/1;Chromium + 生产 Main reader 打开四个 source,全部 read-only/`BLOCKED`,embedded module 返回 + `SCRIPT_POLICY_DENIED`,未执行恶意内容。 +- `git diff --check` 通过。hashes:generator `013285befeb59de21a887cefd377adf8cf53ff1c785b28a9c87d29f76f092731`; + checker `36608da893ae59e2e03856a62866ea6e7c349ec4a63200f042b2329e5f61caa9`;e2e + `98fbde6728ddf55cce5262522197a5b4db1dfce618e52259bf0b0c9e0e9165f2`;report + `a628b73411d6f9a761f659ae28867ea9b0c0df30d47668353278b70d4b44180c`;fixture + `7b1921931afc5bb74a2b73e90b175017c583ea878cdbb66f131718b2d8cd23b5`;manifest + `9a0b7c4097b3755365a9fb92b4848e6ac2ddd36ee2c7e9df3cb8808ce247cf3d`。 +- 新增 `docs/status/M13-01F.md`,并同步当前/完整对标计划、项目状态和本文件;M13 当前完成 + M13-01A-F,机器队列唯一下一任务切换为 `M13-02A`。script manifest、sandbox、server isolation + 和 CSP 仍未提前声明。 + +M13-02A 实际验证(2026-08-18 America/New_York) +- 直接领取 M13-02A:读取 M13-01F 完成证据及 M13.2 规范,补齐生产 + `web/protocol/scripting-platform.ts` 的 bounded manifest parser。每个 script 现在必须声明 + `sourceByteLength` 与 `module=false`;解析器限制脚本数量、总源码字节、依赖数量、权限数量、 + CPU/内存/墙钟预算,canonicalize 项目内 entry/dependency path,并拒绝重复依赖、unsafe path、 + module execution、未知/重复 permission 和所有预算溢出。解析仍为 metadata/policy gate,不执行 + Python、module、driver、handler 或 add-on。 +- 新增生产 Worker `web/app/src/workers/scripting-manifest-budget-test.worker.ts`、Node unit + `web/tests/unit/script-manifest-budgets.test.mjs`、Chromium E2E + `web/tests/e2e/script-manifest-budgets.spec.ts`、checker + `tools/web/check-script-manifest-budgets.mjs`,并把 `test:script-manifest-budgets` 加入 + `web/package.json`。新增 `tests/golden/M13-02A/manifest-budget-report.json` 与 + `tests/golden/M13-02A/manifest.json`,同步 `docs/status/M13-02A.md`、N-025、当前执行计划、 + 完整执行计划、WBS、项目状态和本文件。 +- `node --test web/tests/unit/script-manifest-budgets.test.mjs`:4/4 通过;覆盖 canonical path、 + valid aggregate bytes、script count/total bytes、module/path/dependency/permission negative。 +- `WEB_TEST_PORT=5513 npm --prefix web run test:script-manifest-budgets`:unit 4/4,Chromium + Worker 1/1 通过。首次无独立端口的 npm 调用因宿主已有 127.0.0.1:5173 服务而未启动,未计入 + READY;5513 重跑为正式证据。`node tools/web/check-script-manifest-budgets.mjs` 输出: + `script-manifest-budgets-ok accepted=2 totalSourceBytes=256 blocked=6 deterministic=true next=M13-02B`。 +- `npm --prefix web run test:scripting-isolation` 通过;批准 key 仍稳定返回 + `SCRIPT_SANDBOX_UNAVAILABLE`,server 仍返回 `SERVER_JOB_UNAVAILABLE`,没有启用执行。 + `npm --prefix web run typecheck`、`npm --prefix web run lint`、`npm --prefix web run build` + (81 modules)和 `git diff --check` 通过。 +- hashes:protocol `0931023370e5ef97ad5fa5d396e03bfe67791fc98aab54d1a66bdfef1cbbd247`;worker + `8d8eae67fa6915f0337850e15247baf01f0abde0b3362fbb120f0b448f1a4cf5`;checker + `b3457324d72ab233cd17a142ea19fac6a0d024dd95de7b8fb5d26810437fb0d2`;unit + `6ce7847a0b745ba4081e4332d012e0b7f86e4906c71c95bdeda8c39977a630ee`;e2e + `1ef4fd4caaeb1fa3d832fc8881823d5284755372575eab186c751f408dc9314c`;report + `860672fe844b7969ca376d4b2708771189d1767efa819f7b97667d8a26438d3a`;manifest + `fb4a1d2ac82bf6892e9749c2cfae7b9e61ecac0b1523c75230c0cd5ef97cf97a`。 +- M13 当前完成 M13-01A-F、M13-02A;本项 `enablingTask=false/parityStateChange=false`,不改变 + 12 个全域 parity `BLOCKED` 或 V1 release 状态。机器队列唯一下一任务切换为 `M13-02B`;下一项 + 只从机器 `nextTask` 领取,当前仍不声明 canonical signature serialization、signer/key + rotation/revocation、sandbox、server isolation 或 CSP。 + +M13-02B 实际验证(2026-08-18 America/New_York) +- 直接领取 M13-02B:在生产 `web/protocol/scripting-platform.ts` 导出唯一 + `canonicalizeScriptingManifest` 与 `serializeScriptingManifest`,将 script、permission、dependency + 数组按 locale-independent code-unit 排序,stable JSON 固定 object key 顺序、无空白,parser + 先剥离 unknown field;sourceByteLength/sourceSha256/permissions/path/budget/policy 等安全声明 + 保留在签名输入。源 hash 或 schema mutation 不会得到相同输入;本项不验证签名、不执行脚本。 +- 新增 `web/tests/unit/script-manifest-canonical.test.mjs`(2/2)、生产 Worker + `web/app/src/workers/scripting-manifest-canonical-test.worker.ts`、Chromium E2E + `web/tests/e2e/script-manifest-canonical.spec.ts`、checker + `tools/web/check-script-manifest-canonical.mjs`、golden report/manifest 和 + `test:script-manifest-canonical` npm script。 +- `WEB_TEST_PORT=5514 npm --prefix web run test:script-manifest-canonical` 通过 unit 2/2、Chromium + Worker 1/1;checker 输出 + `script-manifest-canonical-ok equal=true bytes=1923 unknownDropped=true schemaMutation=blocked next=M13-02C`。 +- `npm --prefix web run typecheck`、`npm --prefix web run lint`、`npm --prefix web run build` + (81 modules)、`node tools/web/check-script-manifest-budgets.mjs`、 + `node tools/web/check-status-consistency.mjs` 和 `git diff --check` 通过。M13-02A 的 protocol + artifact hash 已从头按当前 additive parser 复验并刷新其 manifest/parent hash,预算行为、report + 和 parity 状态未改变。 +- hashes:protocol `4c74a4dec7d30f03a295a6b1c1059e97563d561ecda968aa07e15191822abdb9`;worker + `de27fdcd6d16e27a07806ac609f908edfdb7a93d653676174cdd5e06ffa394af`;checker + `5ed344cab6428ae5538a450171ba40c73ff738666515492298e30aaed1394f56`;unit + `33eae0f3ad2ae7243c9ce2835ab8e42186f65f574ab682099766f1d0f4c481f6`;e2e + `5342301165ae82a01b46f5fed0cc0ec34b9813a6ecbc8032900d90b89628e064`;report + `5f4bc0b098ea65de47f1255d30130376d74260e2b912bcd0e28354171fbd07df`;parent manifest + `e81b106b9428f507fff29d1a4ba90b73d417d71c46978cfbbf7d7de9fc11e9c5`;manifest + `452bcf729d0fe96c536a11ef45e8d2bbc61b023ae58465828329fb8eb2f804f7`。 +- M13 当前完成 M13-01A-F、M13-02A-B;本项 `enablingTask=false/parityStateChange=false`,不改变 + 12 个全域 parity `BLOCKED` 或 V1 release 状态。机器队列唯一下一任务切换为 `M13-02C`;下一项 + 只从机器 `nextTask` 领取,当前仍不声明 signer/key rotation/revocation、signature verification、 + sandbox、server isolation 或 CSP。 + +M13-02C 实际验证(2026-08-18 America/New_York) +- 直接领取 M13-02C:新增版本化 `ScriptTrustPolicyIR`、`parseScriptTrustPolicy`、 + `canonicalizeScriptTrustPolicy`、`serializeScriptTrustPolicy` 和 `resolveScriptSigner`。策略绑定 + ED25519 public key、publisher、key status、notBefore/notAfter、revokedAt、same-publisher + `replaces` rotation predecessor、acyclic chain 和 `maxClockSkewMs`;resolver 对 active key 只 + 返回 `ELIGIBLE` + `cryptographicVerification=REQUIRED`,revoked/missing/expired/not-yet-valid/ + publisher mismatch 均 `BLOCKED`。本项没有验签、签名批准或执行入口。 +- 新增 `web/tests/unit/script-trust-policy.test.mjs`(3/3)、生产 Worker + `web/app/src/workers/script-trust-policy-test.worker.ts`、Chromium E2E + `web/tests/e2e/script-trust-policy.spec.ts`、checker + `tools/web/check-script-trust-policy.mjs`、golden report/manifest 和 + `test:script-trust-policy` npm script。 +- `WEB_TEST_PORT=5516 npm --prefix web run test:script-trust-policy` 通过 unit 3/3、Chromium + Worker 1/1;checker 输出 + `script-trust-policy-ok active=key:new revoked=REVOKED crossPublisher=SCRIPT_POLICY_DENIED policyExpired=POLICY_EXPIRED crypto=REQUIRED next=M13-02D`。 +- `npm --prefix web run typecheck` 通过;M13-02A/B checker 在 additive protocol change 后重新 + 通过,未改变预算/canonical 行为和 parity 状态。`git diff --check` 通过。 +- hashes:protocol `cfc1e499e6cdc8d44d6f36c4d1ce3490491de8527585d7ac3707aacb4ccc08b2`;worker + `c6e206b29e7cacc3e23e2ac12a3d523a9c0b16ff29759126dab13c8665547421`;checker + `0e55ce20d142f2bcbc5ce9c6fb955ef2771d284dff636c79da3f59a33b8b0aeb`;unit + `59ac77bcc1086e0a0e914cb77d647db5d34c7160202e7aad52948b7277769551`;e2e + `614091bda15367090bc78a6f465fb10d02d0aa08775b5088699b12e2490034d2`;report + `0f59f733920edbbe5cb799e5f50ff31d19e55ced98e7a890828f6337a237e4bd`;parent manifest + `1a71bd636a92310094d80103ed35c237a23e79398c4af2d8fc5875b0c879b299`;manifest + `792e867e7ed8a1ca096a31d913d725fbc01280c511e9085bb7e95630588e6c9e`。 +- M13 当前完成 M13-01A-F、M13-02A-C;本项 `enablingTask=false/parityStateChange=false`,不改变 + 12 个全域 parity `BLOCKED` 或 V1 release 状态。机器队列唯一下一任务切换为 `M13-02D`;下一项 + 只从机器 `nextTask` 领取,当前仍不声明 cryptographic signature verification、permission + approval、sandbox、server isolation 或 CSP。 + +M13-03D 实际验证(2026-08-18 America/New_York) +- 直接领取 M13-03D:完成 sandbox crash/timeout isolation receipt。`terminateScriptSandboxJob` + 对 crash、wall-time timeout 和 cancellation 分别返回稳定错误码;`mainRevisionAfter` 保持 + `mainRevisionBefore`,temporary bytes、published results、late results 为 0,commit 保持 + false,执行仍为 `DISABLED`;终止后伪造结果返回 `SCRIPT_SANDBOX_LATE_RESULT`。 +- Chromium 生产 Worker E2E 真实启动 crash Worker(抛出 sandbox error)和 timeout Worker(延迟 + 发布消息);宿主观察 crash、10 ms 内终止 timeout Worker,并在迟到窗口确认 `lateMessages=0`。 +- `node --test web/tests/unit/script-sandbox-isolation.test.mjs` 通过 3/3; + `CHROME_PATH=/usr/bin/google-chrome-stable WEB_TEST_PORT=5532 npm --prefix web run + test:script-sandbox-isolation` 通过 unit 3/3 与 Chromium Worker 1/1; + `node tools/web/check-script-sandbox-isolation.mjs` 输出 + `script-sandbox-isolation-ok crash=SCRIPT_SANDBOX_CRASHED timeout=SCRIPT_SANDBOX_TIMEOUT revisionUnchanged=true late=SCRIPT_SANDBOX_LATE_RESULT next=M13-03E`。 +- `npm --prefix web run typecheck`、`git diff --check` 通过。hashes:protocol + `6699fc0a19e0736823f164e707a420655803e36019cf0071f7ff9c760d25e9f2`;worker + `8c810ee7c8bd15d83ecfb4981068f947536bfe8c9e63b906861f42b8465722da`;checker + `e5c0c3c7cd500c269ae43a1a9eb05589e82aad28199ab4f11ad1167b486f6937`;unit + `47986f93638fdc18b817b03222484f4691dc294185040eaebac8e1f386bc1549`;e2e + `2c16c42f09827a0c0100ef2cc6295ca6bc96933ed7475e08b1fa195d7940c141`;report + `4af91cdb21101039b379136c7559b46df55f4f5ab5478c05c1c57cd6e1624d2a`;manifest + `8066013f3d356ba438c36d1f1ea1cf692dbb4f60b086f552072e36b783a96d42`。 +- 新增 `docs/status/M13-03D.md`,同步 N-025、当前/完整对标计划、项目状态和执行队列;本项 + `enablingTask=false/parityStateChange=false`,12 个全域 parity 仍为 `BLOCKED`。机器队列 + 唯一下一任务切换为 `M13-03E`;cancellation/cache late-result、dispose、同会话恢复、server + isolation 和 CSP 仍未提前声明。 + +M13-03E 实际验证(2026-08-19 America/New_York) +- 直接领取 M13-03E:新增 cancellation Worker,取消 receipt 固定 `SCRIPT_SANDBOX_CANCELLED`, + Main revision 11->11、temporary/published/late resources 全为 0,执行保持 `DISABLED`; + 伪造迟到结果返回 `SCRIPT_SANDBOX_LATE_RESULT`。 +- Chromium 真实 Worker 在 10 ms 取消并终止,随后等待 80 ms 迟到窗口;报告固定 + `lateMessages=0`、`cacheWrites=0`、`cancelled=true`。这只完成 cancellation gate,不提前声明 + dispose 或同会话恢复。 +- `node --test web/tests/unit/script-sandbox-cancellation.test.mjs` 通过 2/2; + `CHROME_PATH=/usr/bin/google-chrome-stable WEB_TEST_PORT=5533 npm --prefix web run + test:script-sandbox-cancellation` 通过 unit 2/2 与 Chromium Worker 1/1; + `node tools/web/check-script-sandbox-cancellation.mjs` 输出 + `script-sandbox-cancellation-ok status=CANCELLED late=SCRIPT_SANDBOX_LATE_RESULT lateMessages=0 cacheWrites=0 next=M13-03F`。 +- `npm --prefix web run typecheck`、`npm --prefix web run lint`、`git diff --check` 通过。hashes: + protocol `6699fc0a19e0736823f164e707a420655803e36019cf0071f7ff9c760d25e9f2`;worker + `0feb70c8c491cc24ebfe6c3e267b92522d616b6f260efcecfa57cf489d0742c0`;checker + `b6fbe7102b7d0808673931c66797f8976e79b21bd4c32e429f21832c6090708e`;unit + `374cc87f66bd42226b750e387663ef8c86bef92348fa7cbc6ed7f0027945204d`;e2e + `b233d9cafa1f70798ec19d76ca936abb610681522264a12237f532eb98e09fca`;report + `066d9f19716b8229f2cf7755ec3009a3edef942ebab5f70159bec7f2afbaf71f`;manifest + `0cdf625e6bd3ed7aca43318a3b04353cb806c51cfa4cf3c5dae9a65a0eae2e69`。 +- 新增 `docs/status/M13-03E.md`,同步 N-025、当前/完整对标计划、项目状态和执行队列;本项 + `enablingTask=false/parityStateChange=false`,机器队列唯一下一任务切换为 `M13-03F`;dispose、 + 同会话恢复、server isolation 和 CSP 仍未提前声明。 + +M13-03F 实际验证(2026-08-19 America/New_York) +- 直接领取 M13-03F:补齐 sandbox Worker 的显式 dispose receipt。资源快照在 dispose 前固定为 + 两端 `MessagePort`、1 timer、1 AbortController、1 transferable buffer、1 pending request 和 + 1 cache reference;首次 dispose 后全部为 0,第二次 dispose 幂等,迟到 timer message 为 0。 +- Chromium 真实 Worker 通过 `init -> dispose -> dispose -> terminate`,两端 port 均 close,timer + clear、controller abort、buffer/cache reference/pending request 清除;执行继续保持 `DISABLED`。 +- `node --test web/tests/unit/script-sandbox-dispose.test.mjs` 通过 2/2; + `CHROME_PATH=/usr/bin/google-chrome-stable WEB_TEST_PORT=5534 npm --prefix web run test:script-sandbox-dispose` + 通过 unit 2/2 与 Chromium Worker 1/1;`node tools/web/check-script-sandbox-dispose.mjs` 输出 + `script-sandbox-dispose-ok ports=0 timers=0 abortControllers=0 buffers=0 pending=0 cacheReferences=0 idempotent=true next=M13-03G`。 +- `npm --prefix web run typecheck`、`npm --prefix web run lint`、`git diff --check` 通过。hashes:protocol + `f3d9f667c3809cfad0f52bc6028d93e36e25c4b639fb6081c9c933fbad0ebc0a`;worker + `2a074b05d301c4083e60534eb9452aabf5d95aea0ab316fa657441ef4bcd5c96`;checker + `6549bd10f588281d23c4bea705fd164252c9e3f44d8f4b93c893a2c410907135`;unit + `6e00aa6286aae0eabc1345c04c7628dcc9acb32d51c1c27436a0e1b4c170b64c`;e2e + `b240d92c90e0d81272cd9cb3c0eb4e7d77102ce2e49ad05c761f763d1812b18e`;report + `2dbd3e79939b500c0fc83216c51f258b88ead42a667ed1a6da755e893d7f73c6`;manifest 待文档同步后固定。 +- 新增 `docs/status/M13-03F.md`,同步 N-025、当前/完整对标计划、WBS、项目状态和执行队列;本项 + `enablingTask=false/parityStateChange=false`,机器队列唯一下一任务切换为 `M13-03G`;同会话 + 恢复、server isolation 和 CSP 仍未提前声明。 + +M13-03G 实际验证(2026-08-19 America/New_York) +- 直接领取 M13-03G:同一会话中以固定 Worker generation 4->5 恢复一个仍为 `DISABLED` 的脚本请求; + Main revision 11->11,source SHA-256 和 canonical manifest SHA-256 均保持不变。 +- 两条 default-deny audit entry 使用 `sandbox-recovery:g4/g5` 两个 request ID,sequence=1/2, + 第二条 `previousEntrySha256` 精确等于第一条 `entrySha256`;重复 request 和篡改 source hash + 均返回 `SCRIPT_MANIFEST_INVALID`。 +- `node --test web/tests/unit/script-sandbox-recovery.test.mjs` 通过 2/2; + `CHROME_PATH=/usr/bin/google-chrome-stable WEB_TEST_PORT=5535 npm --prefix web run test:script-sandbox-recovery` + 通过 unit 2/2 与 Chromium Worker 1/1;`node tools/web/check-script-sandbox-recovery.mjs` 输出 + `script-sandbox-recovery-ok generation=4->5 revision=11 sourceStable=true manifestStable=true sequence=1,2 chain=true replay=SCRIPT_MANIFEST_INVALID tamper=SCRIPT_MANIFEST_INVALID next=M13-04A`。 +- `npm --prefix web run typecheck`、`npm --prefix web run lint`、`git diff --check` 通过。hashes:protocol + `8034faded657d49e1376ea42051bc302a090b485024a9ff3781e46aa82638b64`;worker + `7b02331c375d4fb7dbadadd179f0ab9a0e336c8b95fabb071c06e67f3e7b3fe4`;checker + `612015a3fca44bae0f0b78e622f044a5297ed8aa3a0efc774a40f0d47ddbdc9e`;unit + `69a2d34e38d591043ff62b2d305bd9aae684ecebd9ecba718580d77318931226`;e2e + `73176e513f115ba917be74f62a5deb8fe2918fafa8a84c9294e80d2a86b8f1ed`;report + `9057b3f49c3bb15cf53d638ada4bd2743949d3914173cd3799414489d584111d`;manifest + `c8f3bb630f13d18d8a3a13b4407c86f8248a7110839a4d26c1c16f5e4a3ca677`。 +- 新增 `docs/status/M13-03G.md`,同步 N-025、当前/完整对标计划、WBS、项目状态和执行队列;本项 + `enablingTask=false/parityStateChange=false`,机器队列唯一下一任务切换为 `M13-04A`。M13.3 + 浏览器 sandbox 小里程碑已闭环;Python 执行、server isolation、CSP 和恶意输入仍保持阻断。 + +M13-04A 实际验证(2026-08-19 America/New_York) +- 直接领取 M13-04A:新增 Node 服务侧 `server-job-isolation.mjs`,每个 job 在绝对 root 下创建 + 随机 `.blender-job--` 目录,权限固定 `0700`,目录名不包含 request ID;成功/失败 + 路径都通过一次 cleanup 删除,重复 cleanup 幂等。 +- 两个并行 job 的目录名唯一,目录创建后真实 `fs.stat` 验证权限,清理后 `ENOENT`;相对 root、路径 + 穿越 ID 和 root 外目录均稳定返回 `SERVER_JOB_DIRECTORY_INVALID`。 +- `node --test web/tests/unit/server-job-isolation.test.mjs` 通过 2/2; + `npm --prefix web run test:server-job-directory` 通过 unit 2/2 与独立 checker;checker 输出 + `server-job-directory-ok allocated=2 cleaned=2 unique=1 requestIdsHidden=1 mode=0700 residual=0 idempotent=1 next=M13-04B`。 +- `npm --prefix web run typecheck`、`npm --prefix web run lint`、`git diff --check` 通过。hashes:protocol + `8bcb43261eee884814ddeaffc51db8b58cbad17004991711d8817157d3c01337`;checker + `b8d0e741144f742946246370bd35820d806686fb00dc0b040c925cf16f2179da`;unit + `a9f61dc24ec19924b5722533394c7a9f8f36d4f15adb31f447b94cc37560c18b`;report + `07ed66fe0b2e1f1bbdba1cfb1089b052a5961d38f761bfcf79d8362980d2d502`;manifest 待文档同步后固定。 +- 新增 `docs/status/M13-04A.md`,同步 N-025、当前/完整对标计划、WBS、项目状态和执行队列;本项 + `enablingTask=false/parityStateChange=false`,机器队列唯一下一任务切换为 `M13-04B`。source + mount、output 隔离、OS/container budget、network policy 和 Blender execution 仍未提前声明。 + +M13-04B 实际验证(2026-08-19 America/New_York) +- 直接领取 M13-04B:扩展 job workspace,在 job 目录下创建独立 `source/` 与 `output/`;source + directory/file 固定 `0555/0444`,output directory 固定 `0700`,source bytes 写入后立即收紧权限。 +- 真实 `fs.writeFile(sourcePath)` 返回 `EACCES`,output `result.bin` 可写入并读回;source/output + 路径不相同。cleanup 前恢复 source directory 为可删除权限,之后 source/output 均 `ENOENT`。 +- `node --test web/tests/unit/server-job-isolation.test.mjs` 通过 3/3; + `npm --prefix web run test:server-job-workspace` 通过 unit 3/3 与独立 checker;checker 输出 + `server-job-workspace-ok sourceDir=0555 sourceFile=0444 sourceWrite=EACCES outputDir=0700 outputWrite=OK distinct=1 residual=0 next=M13-04C`。 +- `npm --prefix web run typecheck`、`npm --prefix web run lint`、`git diff --check` 通过。hashes:protocol + `3aa5678a2c2769e4db5bb8f5c755b97e23045c4106e5636459748ab9b41929cd`;checker + `9490c2eeb9980073fc1fe77fdba1fb6e4ef528de8eb666d9eaf1fb582ba658c2`;unit + `b1ac3324332180f7b3522f135520e7b5dace334dc461c92f2bce48fbcba2e147`;report + `650a643cc92617d068cb96d8bd4303429ef169ea89e3d4cb63e3ce5e2428e6d2`;manifest + `de06f1f1c718c57efb2ae4dfbe8977ec929b4c1598d0e549bc11fe5919fab8ff`。 +- 新增 `docs/status/M13-04B.md`,同步 N-025、当前/完整对标计划、WBS、项目状态和执行队列;本项 + `enablingTask=false/parityStateChange=false`,机器队列唯一下一任务切换为 `M13-04C`。OS/container + budgets、network policy 和 Blender execution 仍未提前声明。 + +M13-04C 实际验证(2026-08-19 America/New_York) +- 直接领取 M13-04C:新增 server resource budget contract,固定 CPU 60s、memory 512 MiB、 + process=1、file=1024、wall=300s、output=512 MiB 六类上限;bounded usage receipt 标记 + `enforced=true`,任何单项超过上限都返回 `SERVER_JOB_BUDGET_EXCEEDED`,执行保持 `DISABLED`。 +- `node --test web/tests/unit/server-job-resource-budget.test.mjs` 通过 2/2; + `npm --prefix web run test:server-job-resource-budget` 通过 unit 2/2 与独立 checker;checker 输出 + `server-job-budget-ok cpu=bounded memory=bounded process=bounded files=bounded wall=bounded output=bounded overages=6 execution=DISABLED next=M13-04D`。 +- `npm --prefix web run typecheck`、`npm --prefix web run lint`、`git diff --check` 通过。hashes:protocol + `9746f0aef9dd76411320792dee1213c03755a3c03a87bbc4cbf88d4324c728d6`;checker + `5599f4e25fc3ceca18e04be322450a4dc5bb6ce9c72c3abe9f1afc9c81851ff8`;unit + `05212b2aa639f4c37e37e1cac0597d9b367a1280e519240f073762394914dc63`;report + `76ba684966bf7e680b0ebfc630ae9e080f04dd6daa6dd744bbb22dcff43f8eb1`;manifest + `fcc03bb5ea69f787ba24c66fd510068eb26bf40bf2543020ee198e71dd048f42`。 +- 新增 `docs/status/M13-04C.md`,同步 N-025、当前/完整对标计划、WBS、项目状态和执行队列;本项 + `enablingTask=false/parityStateChange=false`,机器队列唯一下一任务切换为 `M13-04D`。OS/container + actual enforcement、network policy、Blender execution、CSP 和恶意输入仍未提前声明。