feat(studio-3d): 阶段 5 —— 发布保护(签名/可选加密/平台锁定)与性能长时报告
- What: 新增 `scene3d/protect.py`(封套 v2:Ed25519 签名 + 可选 AES-256-GCM,nonce/salt 每包唯一、 元数据作 AAD、明文摘要进签名、KeyProvider 只问 get_content_key)与 `record/visualization3d.py` (session.json 的 visualization3d 块:写入版本化、读取时缺块=不支持);`package.py` 支持 v2 (payload=plain 仅签名 / aes-gcm 签名+加密)并继续读 v1,新增解压比上限与"写入后自读回"校验; `contracts.py` 新增 PlatformLock/PlatformInfo/check_platform_lock、Sealing、platform_mismatch 诊断码; `Scene3DView` 在装配前查 OS/架构、拿到 capabilities 后查图形半区,违反即撤下场景并在面板显示原因; 工具 `compile_scene.py` 新增 --sign-key/--write-verify-key/--encrypt-key-id/--key-file/--lock-from-probe, `validate_scene.py` 新增 --key-file/--key-id/--verify-key/--require-signature(发布门禁), 新增 `bench_scene.py`(p50/p95 分阶段 + --cpu-only + --vertices N + --package)与 `soak_scene.py`(RSS 四分位速率 + 队列上界 + 抽稀采样);两份报告落到 docs/architecture/。 - Why: 规格 §5.2 的保护目标是"提高复制成本"而不是"防提取",所以要的是签名(来源)+ 可选认证加密(机密性) + 密钥隔离,而不是自制加密或把 ZIP 当加密;§12 的"包保护/性能/长时"三行需要可复现的数字与判定标准, 而不是一次性的观感。平台锁定还必须只降级三维:锁定不通过时二维照常。 - Verification: shared 全量通过(新增 test_scene3d_protect.py 35 项:封套往返/篡改/密钥/锁定/记录块/ compile→validate 端到端发布流程;test_scene3d_package.py 20 项含 v1↔v2 兼容);apps/studio 全量通过 (test_scene3d_view.py 30 项含锁定拒绝与 session.json 块);ui_check 566 项 0 失败;locale_check 0 缺失; 真实资产端到端:1032 顶点编译出 signed+sealed+locked 包(56 927 B 密文),validate --require-signature 退出 0, 改一个字节退出 2;性能报告(perf-report-windows-dev.json):1032 顶点 compose p50 0.50 ms(30 fps 上限内), 20022 顶点/39480 三角形 compose p50 6.15 ms(约 33 ms 帧的 18%),采集线程 submit p95 < 0.4 ms(合成器被人为放慢到 50 ms); 长时报告(soak-report-windows-dev.json):无窗口 10 min/60 fps 尾段 0.354 MB/min,GUI 4 min/30 fps 尾段 0.809 MB/min, 队列上界(mailbox=1、单飞行合成)两者均成立。过程中修掉:GUI 进程里的合成器计时被 GIL/vsync 污染(98.8 ms vs 真值 6.1 ms)、 跨线程读 last_stats 撕裂、key id 无 provider 时会静默写明文包、soak 工具自身记时间列表会无界增长、RSS 读取失败伪装成零增长、 平台锁定不是终态诊断。 - Not in this change: ARM 板卡的能力报告与 2 小时门禁运行(按用户要求暂缓;命令与判定标准已写入阶段 5 文档); GPU 显存随时间的测量(无可移植 API,报告说明渲染器缓冲估计恒定且更新路径不新建 GPU 对象); `cryptography` 在此环境无法联网安装,本机验收用的是本机已有的 cp310 构建(pyproject 里已声明为可选 extra `protected-assets`)。
This commit is contained in:
@@ -22,6 +22,7 @@ from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import shutil
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
@@ -270,6 +271,7 @@ class _RecordingView:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.packets: list = []
|
||||
self.coordinator = None
|
||||
|
||||
def submit_packet(self, packet) -> None:
|
||||
# Deliberately more work than the real one does per packet, so a leak into the
|
||||
@@ -278,6 +280,9 @@ class _RecordingView:
|
||||
frame = np.asarray(packet.raw_frame)
|
||||
float(np.asarray(frame, dtype=np.float64).sum())
|
||||
|
||||
def render_stats(self):
|
||||
return None
|
||||
|
||||
|
||||
def _run_session(make_window, record_root, *, with_3d: bool):
|
||||
"""One full session with the 3D sink attached, or with no pane at all."""
|
||||
@@ -533,3 +538,86 @@ def test_a_finger_scene_uses_the_finger_adapter(tmp_path, write_poc, qt_app):
|
||||
assert view.assets is not None
|
||||
finally:
|
||||
view.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------- stage 5: the record block
|
||||
def test_a_recording_says_what_the_3d_pane_was(
|
||||
make_window, next_record_root, write_poc, qt_app, monkeypatch
|
||||
):
|
||||
"""``session.json`` carries the pane's state, and an event carries the totals."""
|
||||
import json
|
||||
|
||||
import orisys_studio.app_window as app_module
|
||||
|
||||
window, stuck = _running_window(make_window)
|
||||
app_module.recordings_dir = lambda: next_record_root()
|
||||
try:
|
||||
# A real view on a machine with no GL is not possible; the stand-in gives the
|
||||
# metadata assembly something to describe (an enabled pane with a scene).
|
||||
view = _RecordingView()
|
||||
view.assets = None
|
||||
window.mesh3d_panel._view = view
|
||||
window.toolbar.btn_record.setChecked(True)
|
||||
for index in range(20):
|
||||
stuck.emit(ui_check._fake_packet(index, mono_t=index / 30.0))
|
||||
window._on_run_tick()
|
||||
window.toolbar.btn_record.setChecked(False)
|
||||
report = window._record_last_report
|
||||
assert report is not None and report.completed or report is not None
|
||||
if report is None or not report.completed:
|
||||
pytest.skip("the writer had not finished on this machine")
|
||||
meta = json.loads((report.directory / "session.json").read_text(encoding="utf-8"))
|
||||
block = meta["visualization3d"]
|
||||
assert block["block_version"] == 1
|
||||
assert block["enabled"] is False, (
|
||||
"the pane was not switched on, so the record must say so"
|
||||
)
|
||||
# The pane has no scene here (no package installed), so the block names the
|
||||
# reason instead of pretending to describe a picture.
|
||||
assert block.get("reason")
|
||||
events = (report.directory / "events.csv").read_text(encoding="utf-8")
|
||||
assert "visualization3d" in events, (
|
||||
"the display totals were not recorded as an event"
|
||||
)
|
||||
finally:
|
||||
_finish(window, stuck)
|
||||
window.close()
|
||||
|
||||
|
||||
def test_a_platform_locked_scene_is_refused_in_the_application(qt_app):
|
||||
"""A lock violation is a *pane* failure: the view refuses the scene and says why."""
|
||||
from orisys_app_shared.scene3d.contracts import PlatformInfo, PlatformLock
|
||||
from orisys_studio.scene3d.view import Scene3DView
|
||||
from orisys_studio.ui.mesh3d_panel import Mesh3dPanel
|
||||
|
||||
here = PlatformInfo.here()
|
||||
elsewhere = "linux" if here.os_family != "linux" else "windows"
|
||||
locked = replace(
|
||||
synthetic.poc_scene(),
|
||||
platform_lock=PlatformLock(os_family=(elsewhere,), machine=("aarch64",)),
|
||||
)
|
||||
view = Scene3DView()
|
||||
try:
|
||||
if not view.available:
|
||||
pytest.skip("no GL binding here")
|
||||
reported: list = []
|
||||
view.diagnosticRaised.connect(reported.append)
|
||||
assert view.use_scene(locked, source_id="locked") is False
|
||||
assert view.assets is None, "a refused scene must not remain adopted"
|
||||
assert any(item.code == "platform_mismatch" for item in reported), reported
|
||||
finally:
|
||||
view.close()
|
||||
|
||||
panel = Mesh3dPanel()
|
||||
try:
|
||||
# The pane shows the reason rather than an empty picture: `platform_mismatch`
|
||||
# is one of the terminal diagnostics.
|
||||
panel._on_diagnostic(
|
||||
__import__(
|
||||
"orisys_app_shared.scene3d.contracts", fromlist=["diagnostic"]
|
||||
).diagnostic("platform_mismatch", "this package is not released for this OS")
|
||||
)
|
||||
assert panel.showing_message
|
||||
assert "not released" in panel.message.text()
|
||||
finally:
|
||||
panel.deleteLater()
|
||||
|
||||
Reference in New Issue
Block a user