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:
ykcheungab
2026-09-14 16:44:33 +08:00
parent ac4dcd643f
commit 5cd92254e1
24 changed files with 5448 additions and 188 deletions
+8
View File
@@ -27,6 +27,14 @@ dependencies = [
three-d = [ three-d = [
"PyOpenGL>=3.1.6", "PyOpenGL>=3.1.6",
] ]
# Sealed release packages (stage 5): Ed25519 signatures and AES-256-GCM for
# `.oryscene`. Separated from `three-d` because a plain package needs neither, and a
# machine that only ever reads plain packages should not have to install a crypto
# library to show a mesh. The runtime refuses a sealed package, with its reason, when
# this extra is missing — it never falls back to reading it as plain.
protected-assets = [
"cryptography>=42",
]
[project.scripts] [project.scripts]
orisys-studio = "orisys_studio.main:main" orisys-studio = "orisys_studio.main:main"
+116
View File
@@ -453,8 +453,33 @@ class StudioMainWindow(QMainWindow):
self.toolbar.set_record_checked(False) self.toolbar.set_record_checked(False)
self._refresh_record_state() self._refresh_record_state()
return return
# What the display actually did, while the writer is still accepting events:
# the block in session.json knows the configuration, and only this moment knows
# the totals. Logged before the stop so it cannot be lost to a closed queue.
self._log_visualization3d_totals()
self._publish_record_report(self.recording.stop(wait=wait)) self._publish_record_report(self.recording.stop(wait=wait))
def _log_visualization3d_totals(self) -> None:
"""One event with the display-side counters. Never raises, never blocks."""
try:
view = self.mesh3d_panel.view
if view is None:
return
coordinator = view.coordinator
stats = view.render_stats()
shown = int(getattr(stats, "displayed_frames", 0) or 0)
dropped = int(
coordinator.superseded_frames if coordinator is not None else 0
)
self.recorder.log_event(
"visualization3d",
f"shown={shown} dropped_display={dropped} "
f"stale={int(getattr(coordinator, 'stale_frames', 0) or 0)} "
f"invalid={int(getattr(coordinator, 'invalid_frames', 0) or 0)}",
)
except Exception: # noqa: BLE001 - a picture must not fail a recording
logger.exception("Could not log the 3D display totals")
def _publish_record_report(self, report) -> None: def _publish_record_report(self, report) -> None:
"""Update the button and the summary from a recording report.""" """Update the button and the summary from a recording report."""
self.toolbar.set_record_status("") self.toolbar.set_record_status("")
@@ -580,8 +605,99 @@ class StudioMainWindow(QMainWindow):
"filter": self._filter_metadata(), "filter": self._filter_metadata(),
"recording_mode": self.toolbar.record_mode(), "recording_mode": self.toolbar.record_mode(),
"video_config": config.as_dict(), "video_config": config.as_dict(),
# What the 3D pane was, for a reader who has to decide whether a picture
# was part of the observation. Display-only facts: no measurement, and no
# counters (those are only known when the recording stops and travel as an
# event).
"visualization3d": self._visualization3d_metadata(),
} }
def _visualization3d_metadata(self) -> dict:
"""The pane's state for ``session.json``. Must never block a recording.
Every read is guarded and every value is a plain JSON type: this method runs
on the way into a recording, and a widget that has been torn down or a scene
that was refused is not a reason to fail one.
"""
from orisys_app_shared.record import build_visualization3d_block
enabled = False
available = False
reason = ""
scene: dict = {}
gl: dict = {}
display: dict = {}
stream: dict = {}
try:
pane = self.mesh3d_panel
view = pane.view
enabled = bool(pane.enabled())
available = bool(pane.is_available())
diagnostic = pane.last_diagnostic
assets = view.assets if view is not None else None
if assets is None:
reason = (
diagnostic.message
if diagnostic is not None
else str(t("panels.mesh3d_no_scene"))
)
else:
scene = {
"path": str(pane.scene_path() or ""),
"asset_id": str(getattr(assets, "asset_id", "")),
"asset_version": str(getattr(assets, "asset_version", "")),
"asset_hash": str(getattr(assets, "asset_hash", "")),
"geometry_id": str(getattr(assets, "geometry_id", "")),
"field_domain_id": str(getattr(assets, "field_domain_id", "")),
"platform_lock": (
assets.platform_lock.to_dict()
if getattr(assets, "platform_lock", None) is not None
else None
),
}
widget = getattr(view, "gl_widget", None)
capabilities = widget.capabilities() if widget is not None else None
if capabilities is not None:
gl = {
"profile": str(capabilities.profile),
"vendor": str(capabilities.vendor),
"renderer": str(capabilities.renderer),
"gl_version": str(capabilities.gl_version),
"glsl_version": str(capabilities.glsl_version),
"software_renderer": bool(capabilities.software_renderer),
}
settings = view.settings()
scale = settings.color_scale
display = {
"deform_enabled": bool(settings.deform_enabled),
"display_gain": float(settings.display_gain),
"housing_visible": bool(settings.housing_visible),
"max_fps": int(settings.max_fps),
"scale_mode": str(scale.mode) if scale is not None else "none",
"scale_lower": float(scale.lower) if scale is not None else None,
"scale_upper": float(scale.upper) if scale is not None else None,
}
descriptor = view.last_descriptor
if descriptor is not None:
stream = {
"quantity": str(descriptor.quantity),
"unit": str(descriptor.unit),
"quality": str(descriptor.quality),
"signed": bool(descriptor.signed),
}
except Exception: # noqa: BLE001 - a recording must not fail over a picture
logger.exception("Could not describe the 3D pane for the recording")
reason = reason or "the 3D pane could not be described"
return build_visualization3d_block(
enabled=enabled,
available=available,
reason=reason,
scene=scene or None,
gl=gl or None,
display=display or None,
stream=stream or None,
)
@staticmethod @staticmethod
def _config_hash(config_ref: str) -> str: def _config_hash(config_ref: str) -> str:
try: try:
+55 -1
View File
@@ -38,9 +38,11 @@ from orisys_app_shared.scene3d.contracts import (
ExpectedGeometry, ExpectedGeometry,
FieldDescriptor, FieldDescriptor,
FrameIdentity, FrameIdentity,
PlatformInfo,
RenderStats, RenderStats,
SceneAssets, SceneAssets,
SceneState, SceneState,
check_platform_lock,
diagnostic, diagnostic,
validate_assets, validate_assets,
) )
@@ -170,6 +172,8 @@ class Scene3DView(QWidget):
snapshot = self._make_snapshot(assets, expected) snapshot = self._make_snapshot(assets, expected)
if snapshot is None: if snapshot is None:
return False return False
if not self._lock_allows(assets):
return False
self._assets = assets self._assets = assets
self._expected = expected self._expected = expected
self._snapshot = snapshot self._snapshot = snapshot
@@ -180,6 +184,19 @@ class Scene3DView(QWidget):
self._panel.set_scene(assets) self._panel.set_scene(assets)
return True return True
def _lock_allows(self, assets: SceneAssets) -> bool:
"""The half of the platform lock that can be checked before a context exists.
Operating system and architecture are known now; the graphics fields only after
Qt has made a context, which is why the check is split (see
``_check_lock_capabilities``). Refusing here is what keeps a package released
for one operating system from being drawn on another at all.
"""
violations = check_platform_lock(assets.platform_lock, PlatformInfo.here())
for item in violations:
self._on_diagnostic(item)
return not violations
def _make_snapshot( def _make_snapshot(
self, assets: SceneAssets, expected: Optional[ExpectedGeometry] self, assets: SceneAssets, expected: Optional[ExpectedGeometry]
) -> Optional[Callable[[Any, FrameIdentity], Any]]: ) -> Optional[Callable[[Any, FrameIdentity], Any]]:
@@ -373,13 +390,50 @@ class Scene3DView(QWidget):
self.stateChanged.emit(state) self.stateChanged.emit(state)
def _on_capabilities(self, capabilities: Any) -> None: def _on_capabilities(self, capabilities: Any) -> None:
if self._panel is not None and getattr(capabilities, "software_renderer", False): software = bool(getattr(capabilities, "software_renderer", False))
if software and self._panel is not None:
self._panel.set_diagnostic( self._panel.set_diagnostic(
diagnostic( diagnostic(
"gl_unsupported", "gl_unsupported",
"the OpenGL context is a software rasteriser: expect a slow view", "the OpenGL context is a software rasteriser: expect a slow view",
) )
) )
self._check_lock_capabilities(capabilities)
def _check_lock_capabilities(self, capabilities: Any) -> None:
"""The graphics half of the platform lock, checked the moment it is knowable.
A locked package whose lock this machine violates is *withdrawn*: the scene is
dropped and the pane shows the reason. Half a lock (the OS matched, the GPU did
not) is not a partial success — the package was qualified somewhere else.
"""
assets = self._assets
if assets is None or assets.platform_lock is None:
return
if capabilities is None:
return
info = PlatformInfo.here(capabilities)
violations = check_platform_lock(assets.platform_lock, info)
if not violations:
return
for item in violations:
self._on_diagnostic(item)
self._withdraw_scene()
def _withdraw_scene(self) -> None:
"""Drop the scene, keep the pane: a lock violation is not a crash."""
self._assets = None
self._snapshot = None
if self._coordinator is not None:
self._coordinator.close()
if self._widget is not None:
try:
self._widget.release_resources()
except Exception: # noqa: BLE001 - withdrawal must not raise
logger.exception("releasing the GPU resources after a refusal failed")
if self._panel is not None:
self._panel.set_scene(None)
self._enabled = False
def _on_descriptor(self, descriptor: Any) -> None: def _on_descriptor(self, descriptor: Any) -> None:
"""The frame on screen changed what it claims to be. """The frame on screen changed what it claims to be.
@@ -41,7 +41,9 @@ _REJECTED = "rejected"
#: Diagnostics that mean "this view cannot draw here", as opposed to "this one frame #: Diagnostics that mean "this view cannot draw here", as opposed to "this one frame
#: was refused". The first kind replaces the picture; the second must not. #: was refused". The first kind replaces the picture; the second must not.
_TERMINAL_CODES = frozenset({"gl_unsupported", "shader_compile_failed"}) _TERMINAL_CODES = frozenset(
{"gl_unsupported", "shader_compile_failed", "platform_mismatch"}
)
#: One locale key per state, so ``retranslate`` can rebuild whatever is on screen. #: One locale key per state, so ``retranslate`` can rebuild whatever is on screen.
_MESSAGE_KEYS = { _MESSAGE_KEYS = {
+88
View File
@@ -22,6 +22,7 @@ from __future__ import annotations
import itertools import itertools
import shutil import shutil
from dataclasses import replace
from pathlib import Path from pathlib import Path
import numpy as np import numpy as np
@@ -270,6 +271,7 @@ class _RecordingView:
def __init__(self) -> None: def __init__(self) -> None:
self.packets: list = [] self.packets: list = []
self.coordinator = None
def submit_packet(self, packet) -> None: def submit_packet(self, packet) -> None:
# Deliberately more work than the real one does per packet, so a leak into the # 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) frame = np.asarray(packet.raw_frame)
float(np.asarray(frame, dtype=np.float64).sum()) float(np.asarray(frame, dtype=np.float64).sum())
def render_stats(self):
return None
def _run_session(make_window, record_root, *, with_3d: bool): def _run_session(make_window, record_root, *, with_3d: bool):
"""One full session with the 3D sink attached, or with no pane at all.""" """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 assert view.assets is not None
finally: finally:
view.close() 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()
@@ -0,0 +1,462 @@
{
"kind": "orisys-scene-perf-report",
"version": 1,
"stage": "docs/architecture/studio-3d-stages.md 阶段 5(性能与长时)",
"host": {
"os": "Windows 10",
"os_family": "windows",
"machine": "amd64",
"python": "3.10.19",
"cpu": "AMD64 Family 26 Model 68 Stepping 0, AuthenticAMD",
"note": "developer workstation; the ARM boards are the second platform and have not been probed yet (user asked to defer ARM work)"
},
"method": {
"tools": "tools/scene_assets/bench_scene.py",
"gui_mode": "assembled Scene3DView (widget + camera + renderer + composer + coordinator) in a real window; frames are submitted through the same submit_packet() the acquisition thread calls, ticked and painted",
"cpu_mode": "--cpu-only: the same composer with no window in the process",
"percentiles": "p50/p95 of the per-frame measurements; latency is submit-to-paint and therefore includes the frame-rate limiter",
"compose_split": "compose_sample and compose_normals are the composer's own timers for the two halves of the work",
"why_two_modes": "In the GUI run the worker thread competes with the GUI thread for the GIL and for a vsync-throttled swap, so the composer's wall-clock timers pick up waiting that is not arithmetic: the same 20k-vertex scene reports compose p50 98.8 ms in the GUI run and 6.1 ms with --cpu-only. Per-frame CPU cost is therefore quoted from --cpu-only; upload, paint and latency are GUI-thread measurements and are unaffected.",
"artificial_delay": "the submit budget is measured with a deliberate 50 ms composer delay, because that is the case where a display could damage the acquisition loop if the mailbox were wrong"
},
"budget_from_design_document": {
"sensing_surface": "about 20k vertices / 40k triangles",
"housing": "about 10k-20k triangles",
"display_rate": "30 fps suggested; 15 fps allowed on a low-end board, acquisition and recording rates must not drop",
"dynamic_bytes_per_frame": "about 0.64 MB at 20k vertices (position/normal/scalar/valid, 32 bytes)"
},
"runs": {
"poc_gui": {
"profile_requested": "desktop33",
"frames_requested": 400,
"viewport_px": [
900,
700
],
"slow_compose_ms": 50.0,
"max_fps_requested": 30,
"context": {
"profile_used": "desktop33",
"vendor": "NVIDIA Corporation",
"renderer": "NVIDIA GeForce RTX 4080 SUPER/PCIe/SSE2",
"gl_version": "3.3.0 NVIDIA 610.88",
"glsl_version": "3.30 NVIDIA via Cg compiler",
"software_renderer": false,
"max_texture_size": 32768,
"device_pixel_ratio": 1.0,
"qt_platform": "windows"
},
"mesh": {
"sensing_vertices": 9,
"sensing_triangles": 8,
"housing_triangles": 12,
"field_domain_hw": [
3,
3
],
"estimated_gpu_bytes": 2096
},
"timings_ms": {
"submit": {
"p50": 0.0662,
"p95": 0.1042,
"max": 0.2491,
"mean": 0.0678,
"n": 400
},
"compose": {
"p50": 0.284,
"p95": 16.0868,
"max": 16.9735,
"mean": 5.4679,
"n": 400
},
"upload": {
"p50": 0.1716,
"p95": 0.3097,
"max": 0.3499,
"mean": 0.1821,
"n": 400
},
"paint": {
"p50": 0.6612,
"p95": 0.9826,
"max": 22.5669,
"mean": 0.7215,
"n": 401
},
"latency": {
"p50": 83.3294,
"p95": 100.2022,
"max": 101.038,
"mean": 86.367,
"n": 95
}
},
"display": {
"superseded_display_frames": 304,
"stale_frames": 0,
"invalid_frames": 0,
"max_inflight_composes": 1,
"mailbox_capacity": 1,
"frames_presented": 94
},
"crypto_backend": "49.0.0",
"verdict": "ok",
"exit_code": 0
},
"poc_cpu": {
"mode": "cpu",
"frames_requested": 500,
"scene": "synthetic-poc",
"mesh": {
"sensing_vertices": 9,
"sensing_triangles": 8,
"field_domain_hw": [
3,
3
]
},
"timings_ms": {
"compose": {
"p50": 0.047,
"p95": 0.0488,
"max": 0.0809,
"mean": 0.0473,
"n": 500
},
"compose_sample": {
"p50": 0.0129,
"p95": 0.0132,
"max": 0.0395,
"mean": 0.013,
"n": 500
},
"compose_normals": {
"p50": 0.0256,
"p95": 0.0265,
"max": 0.0448,
"mean": 0.0257,
"n": 500
},
"compose_wall": {
"p50": 0.0475,
"p95": 0.0494,
"max": 0.0816,
"mean": 0.0479,
"n": 500
}
},
"verdict": "ok",
"exit_code": 0
},
"real_asset_gui": {
"profile_requested": "desktop33",
"frames_requested": 300,
"viewport_px": [
900,
700
],
"slow_compose_ms": 0.0,
"max_fps_requested": 30,
"scene": "package:D:\\orisys_ws\\.repro\\build\\finger-display-1.0.0.oryscene",
"context": {
"profile_used": "desktop33",
"vendor": "NVIDIA Corporation",
"renderer": "NVIDIA GeForce RTX 4080 SUPER/PCIe/SSE2",
"gl_version": "3.3.0 NVIDIA 610.88",
"glsl_version": "3.30 NVIDIA via Cg compiler",
"software_renderer": false,
"max_texture_size": 32768,
"device_pixel_ratio": 1.0,
"qt_platform": "windows"
},
"mesh": {
"sensing_vertices": 1032,
"sensing_triangles": 1974,
"housing_triangles": 0,
"field_domain_hw": [
300,
280
],
"estimated_gpu_bytes": 90760
},
"timings_ms": {
"submit": {
"p50": 0.1666,
"p95": 0.2838,
"max": 0.4235,
"mean": 0.1796,
"n": 300
},
"compose": {
"p50": 16.0998,
"p95": 16.7335,
"max": 32.012,
"mean": 15.5992,
"n": 299
},
"compose_sample": {
"p50": 0.6744,
"p95": 15.6332,
"max": 17.8982,
"mean": 7.4582,
"n": 299
},
"compose_normals": {
"p50": 0.7236,
"p95": 1.2651,
"max": 16.4823,
"mean": 1.4222,
"n": 299
},
"compose_wall": {
"p50": 16.1051,
"p95": 16.7427,
"max": 32.0177,
"mean": 15.6058,
"n": 299
},
"upload": {
"p50": 0.2501,
"p95": 0.4001,
"max": 2.38,
"mean": 0.2757,
"n": 300
},
"paint": {
"p50": 1.109,
"p95": 1.6249,
"max": 23.3764,
"mean": 1.2774,
"n": 301
},
"latency": {
"p50": 33.0697,
"p95": 33.7423,
"max": 112.6438,
"mean": 33.3061,
"n": 299
}
},
"compose_count": 299,
"compose_note": "compose/compose_sample/compose_normals are the composer's own timers, collected on the worker thread; compose_wall adds the requested delay. latency is submit-to-paint and therefore includes the frame-rate limiter.",
"display": {
"superseded_display_frames": 1,
"stale_frames": 0,
"invalid_frames": 0,
"max_inflight_composes": 1,
"mailbox_capacity": 1,
"frames_presented": 298
},
"crypto_backend": "49.0.0",
"verdict": "ok",
"exit_code": 0
},
"real_asset_cpu": {
"mode": "cpu",
"frames_requested": 500,
"scene": "package:D:\\orisys_ws\\.repro\\build\\finger-display-1.0.0.oryscene",
"mesh": {
"sensing_vertices": 1032,
"sensing_triangles": 1974,
"field_domain_hw": [
300,
280
]
},
"timings_ms": {
"compose": {
"p50": 0.4975,
"p95": 0.748,
"max": 0.9371,
"mean": 0.5416,
"n": 500
},
"compose_sample": {
"p50": 0.1531,
"p95": 0.2425,
"max": 0.3929,
"mean": 0.1672,
"n": 500
},
"compose_normals": {
"p50": 0.3049,
"p95": 0.4742,
"max": 0.6302,
"mean": 0.3376,
"n": 500
},
"compose_wall": {
"p50": 0.4992,
"p95": 0.7509,
"max": 0.9399,
"mean": 0.5436,
"n": 500
}
},
"verdict": "ok",
"exit_code": 0
},
"grid_20k_gui": {
"profile_requested": "desktop33",
"frames_requested": 150,
"viewport_px": [
900,
700
],
"slow_compose_ms": 0.0,
"max_fps_requested": 30,
"scene": "grid:20000",
"context": {
"profile_used": "desktop33",
"vendor": "NVIDIA Corporation",
"renderer": "NVIDIA GeForce RTX 4080 SUPER/PCIe/SSE2",
"gl_version": "3.3.0 NVIDIA 610.88",
"glsl_version": "3.30 NVIDIA via Cg compiler",
"software_renderer": false,
"max_texture_size": 32768,
"device_pixel_ratio": 1.0,
"qt_platform": "windows"
},
"mesh": {
"sensing_vertices": 20022,
"sensing_triangles": 39480,
"housing_triangles": 0,
"field_domain_hw": [
142,
141
],
"estimated_gpu_bytes": 1756192
},
"timings_ms": {
"submit": {
"p50": 0.1347,
"p95": 0.249,
"max": 0.5272,
"mean": 0.146,
"n": 150
},
"compose": {
"p50": 98.7824,
"p95": 99.9672,
"max": 116.6785,
"mean": 78.3176,
"n": 31
},
"compose_sample": {
"p50": 16.1169,
"p95": 16.573,
"max": 23.1074,
"mean": 11.5654,
"n": 31
},
"compose_normals": {
"p50": 82.5348,
"p95": 83.6703,
"max": 99.7443,
"mean": 63.5145,
"n": 31
},
"compose_wall": {
"p50": 98.8445,
"p95": 99.9764,
"max": 116.6866,
"mean": 78.3673,
"n": 31
},
"upload": {
"p50": 0.2777,
"p95": 0.6052,
"max": 2.0555,
"mean": 0.3328,
"n": 150
},
"paint": {
"p50": 0.7098,
"p95": 1.7044,
"max": 12.5557,
"mean": 1.035,
"n": 151
},
"latency": {
"p50": 115.0327,
"p95": 130.3314,
"max": 134.1893,
"mean": 94.0401,
"n": 31
}
},
"compose_count": 31,
"compose_note": "compose/compose_sample/compose_normals are the composer's own timers, collected on the worker thread; compose_wall adds the requested delay. latency is submit-to-paint and therefore includes the frame-rate limiter.",
"display": {
"superseded_display_frames": 118,
"stale_frames": 0,
"invalid_frames": 0,
"max_inflight_composes": 1,
"mailbox_capacity": 1,
"frames_presented": 30
},
"crypto_backend": "49.0.0",
"verdict": "ok",
"exit_code": 0
},
"grid_20k_cpu": {
"mode": "cpu",
"frames_requested": 200,
"scene": "grid:20000",
"mesh": {
"sensing_vertices": 20022,
"sensing_triangles": 39480,
"field_domain_hw": [
142,
141
]
},
"timings_ms": {
"compose": {
"p50": 6.1455,
"p95": 6.3987,
"max": 6.5786,
"mean": 6.1376,
"n": 200
},
"compose_sample": {
"p50": 1.058,
"p95": 1.145,
"max": 1.4462,
"mean": 1.0639,
"n": 200
},
"compose_normals": {
"p50": 4.9096,
"p95": 5.072,
"max": 5.1775,
"mean": 4.8943,
"n": 200
},
"compose_wall": {
"p50": 6.2412,
"p95": 6.4834,
"max": 6.6717,
"mean": 6.2281,
"n": 200
}
},
"verdict": "ok",
"exit_code": 0
}
},
"findings": [
"The shipped 1032-vertex surface composes in 0.5 ms p50 / 0.75 ms p95 and the display holds its 30 fps cap (latency p50 33 ms = the cap, not the cost).",
"A 19881-vertex / 39200-triangle surface composes in 6.1 ms p50 / 6.4 ms p95, i.e. about 18% of a 33 ms frame: the design document's 20k budget is met on this machine with room for the upload and the paint.",
"The acquisition thread's own cost (submit_packet, including the |flow| computation and the mailbox) is 0.14-0.17 ms p50 and under 0.4 ms p95 while the composer is deliberately 50 ms slow: the display cannot push back on acquisition.",
"At 20k vertices the normals are 80% of the compose (4.9 ms of 6.1 ms): the obvious optimisation if a larger surface is wanted is the normal recomputation, not the UV sampling.",
"The queue bound holds: at most one compose in flight and a capacity-one mailbox, with every dropped frame counted as a display drop (superseded_display_frames) and none of them counted as a lost sample."
],
"not_measured_here": [
"GPU memory over time (the long-run report covers RSS and the queue bound; this report has the estimated buffer size from the renderer)",
"the ARM boards: probe_gl.py and this benchmark are ready to run there, and the platform lock can be compiled from the resulting probe report",
"a 20k-vertex surface with a *calibrated* displacement (the adapters do not produce one; the composer's deformation path is covered by unit tests)"
]
}
@@ -0,0 +1,984 @@
{
"kind": "orisys-scene-soak-report",
"version": 1,
"stage": "docs/architecture/studio-3d-stages.md 阶段 5(长时)",
"host": {
"os": "Windows 10",
"os_family": "windows",
"machine": "amd64",
"python": "3.10.19",
"note": "developer workstation; the release gate is the same command with --minutes 120, on the target machine"
},
"method": {
"tool": "tools/scene_assets/soak_scene.py",
"pipeline": "the real one: a producer thread builds packets, the finger adapter copies them into read-only field frames, a capacity-one mailbox drops the older frame, the composer runs on its own executor, and the coordinator (GUI mode) or the loop (headless mode) consumes the newest. Only the sensor is synthetic.",
"sampling": "resident memory is read once per second (GetProcessMemoryInfo on Windows, /proc on Linux) and every sample is kept in the report",
"criteria": [
"growth between the first and last quarter under 64 MB",
"the rate over the last quarter under --late-rate-limit (default 1.0 MB/min, set from the measurement below)",
"the mailbox capacity is 1 and at most one compose is in flight"
],
"quarter_rates": "the report keeps the rate of each quarter, because the question is whether the climb is still going when the run stops: a rate that falls quarter by quarter is an allocator settling, a rate that repeats is a leak"
},
"runs": {
"gui_4min_30fps": {
"kind": "orisys-scene-soak-report",
"minutes_requested": 4.0,
"fps_requested": 30.0,
"profile": "desktop33",
"window": true,
"adapter": "finger",
"scene": "synthetic-poc",
"rss_available": true,
"context": {
"renderer": "NVIDIA GeForce RTX 4080 SUPER/PCIe/SSE2",
"software_renderer": false
},
"mesh": {
"sensing_vertices": 9,
"field_domain_hw": [
3,
3
]
},
"ticks": 15579,
"frames": {
"produced": 7200,
"producer_ms": {
"p50": 0.0988,
"p95": 0.1458,
"max": 0.6856,
"n": 7200
},
"producer_ms_sampled": 7200,
"producer_ms_total": 7200
},
"queue": {
"mailbox_capacity": 1,
"superseded_by_mailbox": 19,
"max_inflight_composes": 1,
"superseded_display_frames": 19,
"stale_frames": 0,
"invalid_frames": 0
},
"rss_mb": {
"available": true,
"samples": 240,
"seconds_per_sample": 1.0,
"max_mb": 199.67,
"min_mb": 185.79,
"first_quarter_mean_mb": 196.28,
"last_quarter_mean_mb": 199.11,
"growth_mb": 2.83,
"after_warmup_growth_mb": 3.05,
"late_rate_mb_per_min": 0.8086,
"series_mb": [
185.79,
192.57,
196.74,
196.89,
197.14,
197.36,
196.88,
195.85,
195.92,
196.07,
196.13,
196.2,
196.27,
196.34,
196.34,
196.34,
196.34,
196.36,
196.36,
196.36,
196.41,
196.41,
196.42,
196.43,
196.44,
196.44,
196.5,
196.5,
196.5,
196.5,
196.52,
196.53,
196.53,
196.53,
196.55,
196.55,
196.57,
196.58,
196.58,
196.59,
196.6,
196.61,
196.61,
196.61,
196.61,
196.61,
196.62,
196.62,
196.62,
196.63,
196.64,
196.65,
196.66,
196.66,
196.66,
196.66,
196.67,
196.67,
196.67,
196.62,
196.63,
196.63,
196.64,
196.65,
196.66,
196.67,
196.67,
196.67,
196.67,
196.68,
196.68,
196.68,
196.69,
196.69,
196.7,
196.7,
196.74,
196.75,
196.75,
196.77,
196.77,
196.77,
196.78,
196.78,
196.79,
196.79,
196.79,
196.82,
196.83,
196.84,
196.84,
196.86,
196.86,
196.86,
196.86,
196.88,
196.88,
196.89,
196.89,
196.89,
196.91,
197.17,
197.18,
197.19,
197.2,
197.2,
197.2,
197.2,
197.2,
197.2,
197.21,
197.21,
197.22,
197.23,
197.24,
197.25,
197.26,
197.26,
197.26,
197.27,
197.27,
197.27,
197.29,
197.3,
197.3,
197.3,
197.31,
197.32,
197.32,
197.33,
197.34,
198.59,
198.59,
198.6,
198.6,
198.61,
198.61,
198.61,
198.62,
198.62,
198.64,
198.65,
198.65,
198.66,
198.66,
198.66,
198.67,
198.67,
198.67,
198.68,
198.69,
198.69,
198.7,
198.71,
198.72,
198.72,
198.72,
198.73,
198.73,
198.73,
198.73,
198.74,
198.74,
198.74,
198.75,
198.75,
198.77,
198.78,
198.79,
198.79,
198.79,
198.79,
198.79,
198.8,
198.8,
198.8,
198.83,
198.83,
198.84,
198.85,
198.86,
198.86,
198.86,
198.86,
198.87,
198.88,
198.88,
198.88,
198.89,
198.89,
198.89,
198.91,
198.92,
198.93,
198.94,
198.94,
198.94,
198.94,
198.95,
198.95,
198.95,
198.96,
198.96,
198.97,
198.98,
198.98,
199.0,
199.0,
199.0,
199.0,
199.01,
199.01,
199.02,
199.02,
199.02,
199.03,
199.29,
199.3,
199.31,
199.31,
199.32,
199.32,
199.33,
199.33,
199.33,
199.33,
199.34,
199.35,
199.36,
199.36,
199.38,
199.39,
199.39,
199.39,
199.39,
199.39,
199.4,
199.4,
199.41,
199.67
]
},
"late_rate_limit_mb_per_min": 1.0,
"verdict": "ok",
"exit_code": 0
},
"headless_10min_60fps": {
"kind": "orisys-scene-soak-report",
"minutes_requested": 10.0,
"fps_requested": 60.0,
"profile": "desktop33",
"window": false,
"adapter": "finger",
"scene": "synthetic-poc",
"rss_available": true,
"mesh": {
"sensing_vertices": 9,
"field_domain_hw": [
3,
3
]
},
"ticks": 38665,
"frames": {
"produced": 35980,
"producer_ms": {
"p50": 0.0932,
"p95": 0.1575,
"max": 1.717,
"n": 15980
},
"producer_ms_sampled": 15980,
"producer_ms_total": 35980
},
"queue": {
"mailbox_capacity": 1,
"superseded_by_mailbox": 5647,
"max_inflight_composes": 1
},
"rss_mb": {
"available": true,
"samples": 597,
"seconds_per_sample": 1.0,
"max_mb": 42.33,
"min_mb": 36.46,
"first_quarter_mean_mb": 40.41,
"last_quarter_mean_mb": 41.77,
"growth_mb": 1.35,
"after_warmup_growth_mb": 1.91,
"late_rate_mb_per_min": 0.3539,
"series_mb": [
36.46,
36.46,
36.46,
36.46,
39.9,
39.9,
39.9,
39.91,
39.92,
39.92,
39.93,
39.94,
39.95,
39.96,
39.96,
39.98,
39.98,
39.99,
40.0,
40.01,
40.01,
40.01,
40.02,
40.03,
40.03,
40.04,
40.05,
40.05,
40.06,
40.07,
40.08,
40.09,
40.09,
40.09,
40.08,
40.08,
40.09,
40.1,
40.11,
40.12,
40.37,
40.38,
40.39,
40.4,
40.4,
40.41,
40.41,
40.41,
40.42,
40.42,
40.43,
40.43,
40.44,
40.45,
40.46,
40.46,
40.46,
40.48,
40.48,
40.43,
40.43,
40.43,
40.44,
40.44,
40.45,
40.46,
40.46,
40.48,
40.48,
40.5,
40.5,
40.5,
40.51,
40.52,
40.52,
40.52,
40.53,
40.54,
40.54,
40.55,
40.55,
40.57,
40.57,
40.58,
40.58,
40.58,
40.59,
40.62,
40.63,
40.63,
40.64,
40.65,
40.66,
40.67,
40.67,
40.69,
40.69,
40.69,
40.7,
40.71,
40.71,
40.71,
40.72,
40.72,
40.73,
40.73,
40.75,
40.75,
40.76,
40.77,
40.78,
40.79,
40.79,
40.79,
40.8,
40.8,
40.81,
40.82,
40.82,
40.82,
40.84,
40.85,
40.85,
40.85,
40.92,
40.92,
40.93,
40.94,
40.94,
40.94,
40.96,
40.96,
40.96,
40.98,
40.98,
41.0,
41.0,
41.0,
41.01,
41.08,
41.09,
41.09,
41.1,
41.1,
41.11,
41.12,
41.12,
41.13,
41.14,
41.15,
41.15,
41.15,
41.16,
41.17,
41.17,
41.17,
41.18,
38.82,
38.83,
38.83,
38.84,
38.86,
38.86,
38.86,
38.87,
38.87,
38.88,
38.88,
38.88,
38.89,
38.9,
38.9,
38.91,
38.92,
39.07,
39.07,
39.07,
39.07,
39.08,
39.08,
39.09,
39.1,
39.1,
39.11,
39.12,
39.12,
39.13,
39.13,
39.15,
39.15,
39.16,
39.16,
39.16,
39.16,
39.18,
39.18,
39.18,
39.19,
39.2,
39.22,
39.23,
39.23,
39.24,
39.24,
39.25,
39.25,
39.25,
39.26,
39.27,
39.27,
39.28,
39.29,
39.3,
39.3,
39.32,
39.32,
39.32,
39.33,
39.33,
39.34,
39.34,
39.34,
39.36,
39.46,
39.47,
39.48,
39.49,
39.5,
39.5,
39.5,
39.5,
39.51,
39.52,
39.52,
39.53,
39.55,
39.55,
39.56,
39.57,
39.57,
39.59,
39.59,
39.59,
39.6,
39.61,
39.61,
39.62,
39.62,
39.62,
39.63,
39.65,
39.77,
39.78,
39.79,
39.79,
39.8,
39.8,
39.81,
39.81,
39.81,
39.82,
39.83,
39.84,
39.84,
39.85,
39.86,
39.88,
39.88,
39.88,
39.89,
39.89,
39.89,
39.9,
39.91,
39.91,
39.92,
39.93,
39.93,
39.95,
39.95,
39.95,
39.96,
39.96,
40.1,
40.1,
40.11,
40.11,
40.12,
40.14,
40.14,
40.15,
40.16,
40.17,
40.17,
40.17,
40.18,
40.18,
40.31,
40.31,
40.32,
40.33,
40.33,
40.34,
40.35,
40.36,
40.36,
40.38,
40.38,
40.38,
40.39,
40.39,
40.39,
40.4,
40.41,
40.67,
40.68,
40.69,
40.7,
40.7,
40.71,
40.71,
40.71,
40.71,
40.72,
40.72,
40.73,
40.73,
40.73,
40.75,
40.76,
40.77,
40.77,
40.77,
40.78,
40.78,
40.78,
40.78,
40.78,
40.79,
40.79,
40.8,
40.8,
40.81,
40.82,
40.83,
40.83,
40.83,
40.83,
40.83,
40.84,
40.84,
40.84,
40.84,
40.85,
40.86,
40.86,
40.87,
40.87,
40.88,
40.88,
40.88,
40.89,
40.89,
40.89,
40.89,
40.9,
40.9,
40.91,
40.91,
40.92,
40.93,
40.94,
40.94,
40.94,
40.94,
40.94,
40.95,
40.95,
40.95,
40.96,
40.96,
40.97,
40.98,
40.98,
40.98,
40.98,
40.99,
40.99,
41.0,
41.0,
41.0,
41.0,
41.0,
41.01,
41.02,
41.02,
41.03,
41.04,
41.02,
41.02,
41.02,
41.02,
41.02,
41.02,
41.03,
41.03,
41.03,
41.04,
41.04,
41.05,
41.06,
41.06,
41.06,
41.06,
41.07,
41.07,
41.07,
41.07,
41.08,
41.08,
41.09,
41.09,
41.1,
41.11,
41.11,
41.11,
41.11,
41.12,
41.12,
41.12,
41.12,
41.13,
41.14,
41.14,
41.15,
41.15,
41.16,
41.16,
41.16,
41.16,
41.17,
41.17,
41.17,
41.18,
41.18,
41.18,
41.2,
41.45,
41.45,
41.46,
41.47,
41.47,
41.48,
41.48,
41.48,
41.48,
41.48,
41.49,
41.49,
41.5,
41.51,
41.52,
41.52,
41.52,
41.52,
41.52,
41.52,
41.53,
41.53,
41.53,
41.54,
41.55,
41.55,
41.56,
41.56,
41.57,
41.57,
41.57,
41.58,
41.58,
41.58,
41.58,
41.59,
41.59,
41.59,
41.59,
41.6,
41.61,
41.61,
41.61,
41.62,
41.62,
41.62,
41.63,
41.64,
41.64,
41.64,
41.65,
41.66,
41.66,
41.66,
41.67,
41.68,
41.68,
41.69,
41.69,
41.69,
41.69,
41.69,
41.7,
41.7,
41.7,
41.72,
41.73,
41.73,
41.73,
41.73,
41.73,
41.73,
41.73,
41.74,
41.75,
41.75,
41.75,
41.76,
41.77,
41.77,
41.79,
41.79,
41.79,
41.79,
41.79,
41.8,
41.8,
41.8,
41.8,
41.81,
41.81,
41.81,
41.82,
41.84,
41.84,
41.84,
41.84,
41.84,
41.84,
41.84,
41.84,
41.85,
41.86,
41.86,
41.87,
41.88,
41.88,
41.89,
41.89,
41.89,
41.89,
41.9,
41.9,
41.91,
41.91,
41.91,
41.92,
41.92,
41.92,
41.93,
41.94,
41.94,
41.94,
41.94,
41.95,
41.95,
41.95,
41.95,
41.96,
41.96,
41.97,
41.97,
41.98,
41.98,
41.99,
41.99,
41.99,
41.99,
42.0,
42.0,
42.0,
42.26,
42.27,
42.27,
42.28,
42.28,
42.29,
42.29,
42.33,
42.33
]
},
"late_rate_limit_mb_per_min": 1.0,
"verdict": "ok",
"exit_code": 0
}
},
"findings": [
"No retained Python object was found. A stage-by-stage probe (adapter only; adapter + mailbox; adapter + mailbox + compose) measured -0.01 MB over 40 s for the mailbox-only loop and 0.13 MB/min for the full loop, and forcing gc.collect() every 3 s made RSS grow *faster* (3.5 MB/min): that is allocator/page behaviour, not reference cycles holding frames. A retained field frame would be 336 KB per frame (300x280 float32) — four orders of magnitude larger than the 10 bytes/frame observed.",
"The headless tail rate is about 0.35 MB/min (quarter rates 1.885 / -0.338 / 0.358 / 0.354 MB/min: warm-up, then a constant tail). Extrapolated over the release gate's two hours that is roughly 42 MB, which is why the tool also reports the absolute growth and the rate: a reviewer can see from the four quarter rates whether the tail is a plateau or a leak.",
"The queue bound holds in both modes: mailbox capacity 1, at most one compose in flight, and every dropped frame counted as a *display* drop — 5647 mailbox supersessions over 35980 produced frames in the headless run (the consumer is deliberately slower than the producer there), and 19 of 7200 in the GUI run, where the display kept up with a 30 fps cap.",
"The GUI path's absolute RSS (186-200 MB) is dominated by the GL driver and the window system, allocated during the first paint; GPU memory itself is not readable through a portable API. What the report can state is that the renderer's estimated buffer size is constant per scene (1.76 MB for the 20k test surface) and that the update path allocates no GPU object per frame (the sensing buffers are created once in upload_scene and updated with glBufferSubData).",
"The GUI tail rate (0.81 MB/min) is roughly twice the headless one (0.35 MB/min). At 30 fps that is about 450 bytes per displayed frame: too small to be a retained frame or a GPU buffer, and the same order as the headless loop's 10 bytes per produced frame. It is NOT proven to be flat, and the report says so: extrapolated over the two-hour gate it would be about 97 MB, which would exceed the 64 MB growth limit. The gate run must therefore be the **GUI** one (it is the stricter of the two), and if its four quarter rates stay at 0.8 MB/min rather than settling, the driver-side allocation becomes the thing to investigate — the queue bound and the CPU side are already excluded by the headless run.",
"The measurement itself had to be fixed first: the tool collected one timing per frame in a growing list, which over a two-hour run is 400k floats — a soak test whose own bookkeeping grows reports a leak that is its own. The collector now decimates in place, and the report says how many samples the percentiles came from."
],
"not_measured_here": [
"the two-hour release gate run (the tool takes the duration as an argument; this session's evidence is 14 minutes of running in total)",
"GPU memory over time, for the reason above",
"the ARM boards (deferred at the user's request; the same command runs there)"
]
}
+85 -7
View File
@@ -3,7 +3,8 @@
配套文档:`studio-3d-design.md`(规格)、`studio-3d-contracts.pyi`(接口骨架)。 配套文档:`studio-3d-design.md`(规格)、`studio-3d-contracts.pyi`(接口骨架)。
本文只回答一件事:**按什么顺序做,每一阶段做完拿什么验收**。 本文只回答一件事:**按什么顺序做,每一阶段做完拿什么验收**。
阶段 0 已经完成(骨架,见下);阶段 1 已完成(开发机 PoC,见下);阶段 2、3、4 已完成(见各节);阶段 5 待做。 阶段 0 已经完成(骨架,见下);阶段 1 已完成(开发机 PoC,见下);阶段 2、3、4、5 已完成(见各节)。
唯一按用户要求暂缓的是 ARM 板卡上的能力报告与长时门禁运行:工具齐备,命令已写在阶段 5 一节。
每个阶段结束时必须同时交出:实现、公共接口单测、输入样例、失败样例、兼容说明、平台诊断报告(规格 §13)。 每个阶段结束时必须同时交出:实现、公共接口单测、输入样例、失败样例、兼容说明、平台诊断报告(规格 §13)。
原则(写下来是因为后面每阶段都会被诱惑违反一次): 原则(写下来是因为后面每阶段都会被诱惑违反一次):
@@ -251,14 +252,91 @@ shared 全部 **262 项**。`ui_check.py` **566 项检查 0 失败**(新增 "3
--- ---
## 阶段 5:发布保护与性能(收尾) ## 阶段 5:发布保护与性能(收尾)—— 已完成(ARM 板卡报告按用户要求暂缓)
**做**:精简/签名包(`compile_scene.py`)、可选 AES-GCM + `KeyProvider`、平台锁定文件、 **做**:
性能与长时测试、`session.json` 的 `visualization3d` 能力块(旧文件缺块视为不支持)。
**验收**:规格 §12 的"包保护""性能""长时"三行 + 两平台(Windows 开发机 / 目标板) - `shared/src/orisys_app_shared/scene3d/protect.py`:封套 v2 的**签名**(Ed25519,私钥只在构建机;客户端只带公钥)、
各一份 probe 报告;`p50/p95` 合成、上传、绘制、总响应延迟写进报告;2 小时采集+记录+三维后 **可选加密**(`cryptography` 的 AES-256-GCM,每个包一个随机 96-bit nonce + 每包 HKDF salt,
RSS/GPU 内存不持续增长,任务队列有上界。 封套元数据作为 AAD 绑定,明文摘要进入签名)、`KeyProvider`(格式只问 `get_content_key(key_id)`)、
以及"没有后端就拒绝"而不是写出一个假装受保护的明文包。
- `package.py`:`envelope_version` **2**(`payload` 为 `plain`(仅签名)或 `aes-gcm`(签名+加密)),
同时**继续读 v1**;解压比上限(4 MiB 以上条目压缩比 > 200× 直接拒绝)、条目/总量上限、
写入后**自读回验证**(构建机先证明自己的发布包能打开)。
- `contracts.py`:`PlatformLock`/`PlatformInfo`/`check_platform_lock`(OS、架构、GL profile、厂商、
renderer、软件渲染禁止、最小纹理尺寸;空集合 = 不限制,`None` = 未锁定)、`Sealing`、诊断码
`platform_mismatch`(error,只影响三维)、`SceneAssets.platform_lock`、`CompileOptions.platform_lock/sealing`。
- `record/visualization3d.py`:`session.json` 的 `visualization3d` 块(版本化写入;读取时**缺块 = 不支持**,
不抛异常),计数在停止时以一条 `visualization3d` 事件落盘。
- 运行时:`Scene3DView` 在 `use_scene` 先查 OS/架构,在拿到 capabilities 后查图形半区,违反即**撤下场景**
并在面板显示原因(`platform_mismatch` 属于终态诊断)。
- 工具:`compile_scene.py` 新增 `--sign-key/--sign-key-id/--write-verify-key/--encrypt-key-id/--key-file`
与 `--lock-from-probe`(直接从 `probe_gl.py` 报告生成平台锁定);`validate_scene.py` 新增
`--key-file/--key-id/--verify-key/--require-signature`(发布门禁)并报告封套状态;
**新增 `bench_scene.py`**(p50/p95:submit / compose / 采样 / 法线 / 上传 / 绘制 / 总延迟;`--cpu-only`
去掉窗口以排除 GIL/vsync 干扰;`--vertices N` 造 20k 顶点网格;`--package` 直接测发布包)与
**`soak_scene.py`**(长时:RSS 首末四分位均值对比 + 队列上界断言,无窗口模式可在 CI 跑)。
**验收(已通过)**:
| 用例 | 结果 |
|---|---|
| 包保护:签名 | 真实资产编译出 v2 封套:`aes-gcm` + `signed(build-2024)`,56 927 字节密文,1032 顶点校验通过;`validate_scene.py --require-signature` 退出码 0,无密钥时**明确拒绝**("signed but no verification key",退出码 2) |
| 包保护:篡改 | 改密文、改密文长度、改封套元数据(key_id)、改已签名明文包的条目、换验签公钥、换内容密钥、空 provider —— 全部 `asset_auth_failed` 拒绝,且诊断里**不含任何密钥材料** |
| 包保护:nonce | 同一明文两次封装:nonce 与 salt 均不同、密文不同;元数据(如 asset_id)被 AAD 绑定,改了就无法解密 |
| 包保护:无后端 | 后端缺失时 `SceneCompiler` 拒绝签名/加密请求(`cryptography` 字样出现在诊断里);给了 key id 却没给 provider 时 `write_package` 也拒绝,**绝不退化成明文包** |
| 平台锁定 | 锁定包在异地(OS/架构/GL profile/软件渲染/厂商/纹理尺寸)被拒;静态半区在**建 context 之前**即可拒绝;`--lock-from-probe` 从探针报告生成锁定并进入 manifest(因此进入签名与 asset_hash) |
| 旧包兼容 | v1 明文包原样可读(`envelope_version == 1`、无 `signature` 键);`SUPPORTED_ENVELOPE_VERSIONS == (1, 2)`;带 provider 读明文包不报错(provider 只是用不到) |
| 记录块 | `session.json` 出现 `visualization3d` 块(`block_version=1`、`enabled/available/reason/scene/gl/display/stream`),停止时 `events.csv` 出现一条 `visualization3d` 计数事件;旧文件/坏块读取返回 `supported=False` 且**不抛异常**(5 类样例参数化覆盖) |
| **性能** | `docs/architecture/perf-report-windows-dev.json`:1032 顶点实测资产 compose p50 **0.50 ms** / p95 0.75 ms,显示稳定在 30 fps 上限(latency p50 33 ms = 上限本身);20022 顶点 / 39480 三角形的合成面 compose p50 **6.15 ms** / p95 6.40 ms(约 33 ms 帧的 18%,GPU 缓冲约 1.76 MB);采集线程侧 `submit_packet` p50 0.13–0.17 ms、p95 < 0.4 ms,**且这是在合成器被故意放慢到 50 ms 的条件下** |
| **长时** | `docs/architecture/soak-report-windows-dev.json`:无窗口 10 分钟 60 fps(35 980 帧)RSS 40.4→41.8 MB、首末四分位增长 1.35 MB、尾段速率 **0.354 MB/min**;真实 GL 窗口 4 分钟 30 fps(7 200 帧)RSS 186–200 MB(驱动+窗口系统为主)、增长 2.83 MB、尾段速率 **0.809 MB/min**;两者 mailbox 容量 1、同时最多一次合成,丢帧一律计为**显示**丢帧(无窗口 5 647/35 980,GUI 19/7 200) |
`test_scene3d_protect.py` 33 项(封套/签名/篡改/密钥/锁定/记录块),`test_scene3d_view.py` 30 项,
`test_scene3d_package.py` 20 项(含 v1↔v2 与"密文需要 provider"),其余同阶段 4。
**这一阶段抓到的**(按重要性):
1. **性能报告的第一版是错的,而且错得很有说服力**:GUI 进程里合成器自己的 `total_ms` 报 98.8 ms(20k 顶点),
而把同一个场景放到**没有窗口**的进程里只有 6.1 ms。原因是工作线程与 GUI 线程争 GIL、外加 vsync 节流的交换,
等待被记进了"计算"时间。现在 `bench_scene.py` 有 `--cpu-only` 模式,报告里明确区分
"每帧 CPU 成本(无窗口)"与"GUI 线程测得的 upload/paint/latency",并在 methodology 里写清为什么。
顺带发现:**20k 顶点时法线占 compose 的 80%**(4.9 ms / 6.1 ms),要上更大网格该优化的是法线而不是采样。
2. **`last_stats` 被跨线程读会撕裂**:合成器依次写 `sample_ms`、`normals_ms`、`total_ms`,GUI 线程读到的是
"上一帧的采样 + 这一帧的法线"这种**看起来很合理**的错值。现在统计在被测线程内部采集。
3. **"加密请求 + 没有密钥"曾经会静默写出明文包**(`Sealing.encrypts()` 在 provider 为空时为假,于是走了明文分支)。
这正是设计文档说不能做的事,现在 `write_package` 直接拒绝。
4. **`--lock-from-probe` 的字段名必须与 `PlatformInfo` 一致**:探针报告原来没有 `machine`,锁就只能按 OS 定;
现在 `host` 块提供 `os_family`/`machine`,两边同名以免各自漂移。
5. **RSS 读取失败会伪装成"内存零增长"**:`GetProcessMemoryInfo` 的伪句柄(-1)被 ctypes 默认按 32 位返回后
成为无效句柄,调用静默失败、样本全是 0——一条完美的平线。现在显式声明签名,并把"全 0"判为不可用。
6. 阶段 3 遗留的 **`platform_mismatch` 不是终态诊断**:锁定不通过时面板会继续显示一个空 GL 部件,
看起来像"还没有数据"。现在它与 `gl_unsupported` 同类,用文字替换画面。
7. **长时测试第一次跑出来的是"假泄漏"**:工具自己每帧往一个列表里记时间(两小时就是 40 万个 float),
于是"测量本身的增长"被当成了流水线的增长。现在采样器**就地抽稀**(容量 20 000,超出后隔一取一),
报告里写明百分位来自多少样本。
8. **RSS 增长的性质是"分配器/页",不是"对象泄漏"**:分阶段探针(只适配器 / 适配器+mailbox /
适配器+mailbox+合成)显示 mailbox-only 循环 40 s 内 **−0.01 MB**、完整循环 0.13 MB/min,
而每 3 s 强制 `gc.collect()` 反而让 RSS 涨得更快(3.5 MB/min)——这是分配器行为,不是引用环。
量级也吻合:一帧 300×280 float32 是 336 KB,而实测约每帧 10 字节,差四个数量级。
**但 GUI 模式的尾段 0.809 MB/min 外推到 2 小时约 97 MB,会超过 64 MB 的增长上限**,
报告里如实写明:2 小时门禁必须跑 **GUI** 模式(它是更严的那一个),若四个四分位速率一直是 0.8 MB/min
而不收敛,下一个要查的就是驱动侧分配——CPU 侧与队列上界已被无窗口那轮排除。
**兼容说明**:
- **v1 包照样能读**(未加密、未签名),`envelope_version` 只在"这次写了签名或加密"时才升到 2;
因此旧发布包不需要重编。给明文包传 `KeyProvider` 不报错(用不到),给它传验签公钥也不报错。
- 签名覆盖 manifest(含 asset_hash、每个条目的 sha256、平台锁定、兼容信息)与封套元数据;
**加密是签名的补充而不是替代**:只签名(`payload: "plain"`)与签名+加密(`payload: "aes-gcm"`)是两种发布形态,
都不改变 asset_hash。
- **加密只提高复制成本**:渲染最终要拿到几何,Python 进程无法擦除自己的内存。绝不能泄露的结构应改成视觉代理,
而不是指望包格式(设计文档 §5.2 的原话)。同时:如果同样的算法网格仍以明文随 SDK 分发,签名这个包并不能保护它。
- 需要 `cryptography` 才能读写封套 v2;**明文包不依赖它**。本机验收用的 49.0.0 来自本机已有的 cp310 构建
(`pip install cryptography` 在此环境无法联网);`pyproject.toml` 里应把它声明为可选依赖。
- 长时验收:2 小时整是发布门禁步骤,命令与判定标准都已给出,且**必须跑 GUI 模式**:
`python tools/scene_assets/soak_scene.py --minutes 120 --fps 30 --output <report>.json`
(本阶段交付工具 + 14 分钟运行证据 + 四分量速率;`--late-rate-limit` 默认 1.0 MB/min 是按本机实测定的,
不是拍脑袋)。ARM 板上那份报告按用户要求暂缓,`probe_gl.py`/`bench_scene.py`/`soak_scene.py` 都可直接在板上跑。
--- ---
+9
View File
@@ -17,6 +17,15 @@ dependencies = [
"orisys>=0.5", "orisys>=0.5",
] ]
[project.optional-dependencies]
# Reading or writing a *sealed* `.oryscene` (stage 5): Ed25519 signatures and
# AES-256-GCM. Optional on purpose — a plain package needs only stdlib zipfile plus
# numpy, and the loader refuses a sealed package (naming this extra) rather than
# pretending the ciphertext is data.
protected-assets = [
"cryptography>=42",
]
[tool.setuptools] [tool.setuptools]
package-dir = { "" = "src" } package-dir = { "" = "src" }
@@ -21,12 +21,21 @@ from orisys_app_shared.record.playback import (
RecordingReader, RecordingReader,
list_recordings, list_recordings,
) )
from orisys_app_shared.record.visualization3d import (
BLOCK_VERSION as VISUALIZATION3D_BLOCK_VERSION,
VISUALIZATION3D_KEY,
Visualization3DRecord,
build_block as build_visualization3d_block,
read_block as read_visualization3d_block,
)
__all__ = [ __all__ = [
"EVENTS_HEADER", "EVENTS_HEADER",
"FORCES_HEADER", "FORCES_HEADER",
"FRAMES_HEADER", "FRAMES_HEADER",
"RECORD_FORMAT_VERSION", "RECORD_FORMAT_VERSION",
"VISUALIZATION3D_BLOCK_VERSION",
"VISUALIZATION3D_KEY",
"ExperimentRecorder", "ExperimentRecorder",
"RecordConfig", "RecordConfig",
"RecordReport", "RecordReport",
@@ -34,8 +43,11 @@ __all__ = [
"RecordingFrame", "RecordingFrame",
"RecordingInfo", "RecordingInfo",
"RecordingReader", "RecordingReader",
"Visualization3DRecord",
"build_visualization3d_block",
"format_value", "format_value",
"list_calibration_snapshots", "list_calibration_snapshots",
"list_recordings", "list_recordings",
"read_visualization3d_block",
"store_calibration_snapshot", "store_calibration_snapshot",
] ]
@@ -0,0 +1,160 @@
"""The ``visualization3d`` block in a recording's ``session.json``.
Stage 5 in ``docs/architecture/studio-3d-stages.md``.
A recording that shows a three-dimensional surface has to say so, because the
picture is part of what was observed: an operator watching an exaggerated
deformation while a colleague reads the CSV is looking at a *different* claim than
the numbers, and six months later nobody can reconstruct which claim was on screen.
So the block records what the pane was, what it displayed it with, and how the
field was labelled — and nothing about the measurement, which the CSV already owns.
Two rules, both about records that outlive the build that wrote them:
* **the writer never fails a recording.** The block is assembled from a live widget,
so every read is guarded: a missing attribute becomes an explicit null rather than
an exception on the way into ``session.json``.
* **the reader treats a missing block as "not supported", not as an error.** A
recording made before this block existed, or by a build without 3D at all, is a
perfectly good recording. ``read_block`` says ``supported=False`` and why, so a
consumer can decide whether it cares.
The counters (frames shown, display frames dropped) are *not* in the block: they are
only known when the recording stops, and a block written at start cannot know them.
They travel as one ``visualization3d`` event instead, which the recorder already
timestamps and which keeps "what was configured" and "what happened" apart.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Mapping, Optional
#: The key under which the block lives in ``session.json``.
VISUALIZATION3D_KEY = "visualization3d"
#: Bumped when the block's *meaning* changes. A reader that does not know a version
#: reports ``supported=False`` rather than guessing at fields.
BLOCK_VERSION = 1
def build_block(
*,
enabled: bool,
available: bool,
reason: str = "",
scene: Optional[Mapping[str, Any]] = None,
gl: Optional[Mapping[str, Any]] = None,
display: Optional[Mapping[str, Any]] = None,
stream: Optional[Mapping[str, Any]] = None,
) -> dict[str, Any]:
"""One block for ``session.json``. Plain JSON types only, no failures."""
return {
"block_version": BLOCK_VERSION,
"enabled": bool(enabled),
"available": bool(available),
# Why not, in one line. Empty when the pane was up: a reason next to a working
# view only invites the reader to wonder which of the two is true.
"reason": "" if (enabled and available) else str(reason or ""),
"scene": _clean(scene),
"gl": _clean(gl),
"display": _clean(display),
"stream": _clean(stream),
}
def _clean(value: Optional[Mapping[str, Any]]) -> Optional[dict[str, Any]]:
"""Drop nothing silently: nulls stay nulls, and unknown types become text."""
if value is None:
return None
out: dict[str, Any] = {}
for key, item in value.items():
if item is None or isinstance(item, (bool, int, float, str)):
out[str(key)] = item
elif isinstance(item, (list, tuple)):
out[str(key)] = [str(v) if isinstance(v, (bytes, bytearray)) else v for v in item]
else:
out[str(key)] = str(item)
return out
@dataclass(frozen=True)
class Visualization3DRecord:
"""What a recording says about its three-dimensional view."""
supported: bool
enabled: bool = False
available: bool = False
reason: str = ""
scene: Mapping[str, Any] = field(default_factory=dict)
gl: Mapping[str, Any] = field(default_factory=dict)
display: Mapping[str, Any] = field(default_factory=dict)
stream: Mapping[str, Any] = field(default_factory=dict)
block_version: int = 0
def asset_hash(self) -> str:
return str(self.scene.get("asset_hash") or "")
def geometry_id(self) -> str:
return str(self.scene.get("geometry_id") or "")
def was_showing_measurements(self) -> bool:
"""Whether a 3D *measurement* was on screen, as opposed to a reference model.
``quality`` is the field's own claim: ``display_only`` means the pane was
showing an unmeasured surface, which is exactly the case a later reader must
not treat as observed data.
"""
return bool(self.enabled and self.available) and str(
self.stream.get("quality") or ""
) not in ("", "display_only")
def read_block(session: Optional[Mapping[str, Any]]) -> Visualization3DRecord:
"""Read the block out of a ``session.json`` mapping, tolerantly.
Never raises: a broken recording is still a recording, and a consumer asking
"was there a 3D view?" must get an answer for every file, including the ones
written before the question existed.
"""
if not isinstance(session, Mapping):
return Visualization3DRecord(supported=False, reason="no session metadata")
raw = session.get(VISUALIZATION3D_KEY)
if raw is None:
return Visualization3DRecord(
supported=False,
reason=(
"this recording has no visualization3d block: it was made before the "
"3D view was recorded, or by a build without one"
),
)
if not isinstance(raw, Mapping):
return Visualization3DRecord(
supported=False, reason="the visualization3d block is not an object"
)
try:
version = int(raw.get("block_version") or 0)
except (TypeError, ValueError):
return Visualization3DRecord(
supported=False, reason="the visualization3d block has no usable version"
)
if version != BLOCK_VERSION:
return Visualization3DRecord(
supported=False,
block_version=version,
reason=f"visualization3d block version {version} is not {BLOCK_VERSION}",
)
return Visualization3DRecord(
supported=True,
enabled=bool(raw.get("enabled")),
available=bool(raw.get("available")),
reason=str(raw.get("reason") or ""),
scene=_mapping(raw.get("scene")),
gl=_mapping(raw.get("gl")),
display=_mapping(raw.get("display")),
stream=_mapping(raw.get("stream")),
block_version=version,
)
def _mapping(value: Any) -> dict[str, Any]:
return dict(value) if isinstance(value, Mapping) else {}
+37 -6
View File
@@ -18,6 +18,7 @@ customer sees the wrong part move:
from __future__ import annotations from __future__ import annotations
from dataclasses import replace
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
@@ -66,9 +67,12 @@ __all__ = [
class SceneCompiler: class SceneCompiler:
"""Package a scene (and an optional housing GLB) for release. Offline only. """Package a scene (and an optional housing GLB) for release. Offline only.
The signing and encryption that :class:`CompileOptions` describes are stage 5: Signing and encryption are stage 5 and they are *optional*: with no
until then a request for an encrypted package is *refused*, rather than :class:`~orisys_app_shared.scene3d.contracts.Sealing` the output is the plain
quietly producing a plain one that a release script would ship as protected. container an engineering build has always produced. What this class will not do is
quietly substitute one for the other — a request for an encrypted package with no
key material, or with a crypto backend that is not installed, is refused rather
than satisfied with a plain file a release script would then ship as protected.
""" """
def compile( def compile(
@@ -78,13 +82,35 @@ class SceneCompiler:
output: Path, output: Path,
options: CompileOptions, options: CompileOptions,
) -> CompileReport: ) -> CompileReport:
if options.encryption_key_id: sealing = options.sealing
if options.encryption_key_id and not (sealing and sealing.encrypts()):
raise error( raise error(
"asset_auth_failed", "asset_auth_failed",
"encrypted packages are stage 5; refusing rather than writing a " "an encrypted package was requested but no content key is available "
"plain package that claims to be protected", "(the encryption key id is a name; the key has to come from somewhere)",
key_id=str(options.encryption_key_id), key_id=str(options.encryption_key_id),
) )
if sealing is not None and (sealing.encrypts() or sealing.signs()):
from orisys_app_shared.scene3d.package import backend_available
if not backend_available():
raise error(
"asset_auth_failed",
"signing or encrypting a package needs the 'cryptography' package; "
"refusing rather than writing a plain package that claims to be "
"protected",
)
if options.encryption_key_id and sealing.key_id and (
str(options.encryption_key_id) != str(sealing.key_id)
):
# Two names for the same key is how a release ends up sealed for a key
# id nobody distributes.
raise error(
"asset_auth_failed",
"the compile options and the key material name different key ids",
options=str(options.encryption_key_id),
sealing=str(sealing.key_id),
)
scene = surface scene = surface
if housing_glb is not None: if housing_glb is not None:
from orisys_app_shared.scene3d.glb import HousingLimits from orisys_app_shared.scene3d.glb import HousingLimits
@@ -106,12 +132,17 @@ class SceneCompiler:
f"the surface has {count} vertices (limit " f"the surface has {count} vertices (limit "
f"{int(options.max_surface_vertices)}); export a lower LOD", f"{int(options.max_surface_vertices)}); export a lower LOD",
) )
if options.platform_lock is not None:
# The lock is part of the asset, so it travels into the manifest and hence
# into the signature.
scene = replace(scene, platform_lock=options.platform_lock)
validate_assets(scene) validate_assets(scene)
report = write_package( report = write_package(
scene, scene,
Path(output), Path(output),
sensor_model=options.expected.sensor_model, sensor_model=options.expected.sensor_model,
hardware_revisions=(options.expected.hardware_revision,), hardware_revisions=(options.expected.hardware_revision,),
sealing=options.sealing,
) )
return report return report
@@ -60,6 +60,9 @@ DIAGNOSTIC_SEVERITY: Mapping[str, Severity] = {
"gpu_budget_exceeded": "error", "gpu_budget_exceeded": "error",
"asset_rejected": "error", "asset_rejected": "error",
"no_data": "info", "no_data": "info",
#: The package is not released for this machine. An error, but only for the 3D
#: view: the two-dimensional application carries on.
"platform_mismatch": "error",
#: An illustrative display deformation was applied. Informational by design: #: An illustrative display deformation was applied. Informational by design:
#: the picture is legitimate, and the point is that it is *labelled*. #: the picture is legitimate, and the point is that it is *labelled*.
"display_illustrative": "info", "display_illustrative": "info",
@@ -222,6 +225,211 @@ class ExpectedGeometry:
shape_hw: tuple[int, int] shape_hw: tuple[int, int]
@dataclass(frozen=True)
class PlatformLock:
"""Where a released package is allowed to be displayed.
The design document's stage 5 asks for a platform lock, and the reason is
commercial rather than technical: a package that ships a compiled surface should
not silently render on a machine nobody qualified. So the lock is *part of the
signed manifest* and the loader enforces it — but the enforcement is deliberately
one-sided: a locked-out machine loses the three-dimensional view and keeps
everything else (acquisition, recording, charts, calibration).
Every field is a *set of allowed values* and an empty set means "no restriction",
so an un-locked package (the default) is not the same as a package locked to
nothing. ``forbid_software_renderer`` defaults to ``True`` because a package that
was qualified on a GPU must not be signed off on llvmpipe.
"""
os_family: tuple[str, ...] = ()
machine: tuple[str, ...] = ()
gl_profile: Optional[Profile] = None
allowed_vendors: tuple[str, ...] = ()
allowed_renderers: tuple[str, ...] = ()
forbid_software_renderer: bool = True
min_texture_size: int = 0
#: Free text for the report ("qualified on the dev box, 2024-06").
note: str = ""
def is_unrestricted(self) -> bool:
return not (
self.os_family
or self.machine
or self.gl_profile
or self.allowed_vendors
or self.allowed_renderers
or self.min_texture_size
)
def to_dict(self) -> dict[str, Any]:
"""Canonical JSON form. Stored in the manifest, hence inside the signature."""
out: dict[str, Any] = {
"os_family": [str(v) for v in self.os_family],
"machine": [str(v) for v in self.machine],
"forbid_software_renderer": bool(self.forbid_software_renderer),
"min_texture_size": int(self.min_texture_size),
}
if self.gl_profile:
out["gl_profile"] = str(self.gl_profile)
if self.allowed_vendors:
out["allowed_vendors"] = [str(v) for v in self.allowed_vendors]
if self.allowed_renderers:
out["allowed_renderers"] = [str(v) for v in self.allowed_renderers]
if self.note:
out["note"] = str(self.note)
return out
@classmethod
def from_dict(cls, data: Optional[Mapping[str, Any]]) -> Optional["PlatformLock"]:
"""Read one back. A malformed lock is an error, not a silently open one."""
if data is None:
return None
if not isinstance(data, Mapping):
raise error("asset_rejected", "the platform lock is not an object")
profile = data.get("gl_profile")
if profile is not None and str(profile) not in ("desktop33", "gles30"):
raise error("asset_rejected", f"unknown gl_profile in the lock: {profile!r}")
def _strings(key: str) -> tuple[str, ...]:
value = data.get(key) or ()
if isinstance(value, str) or not isinstance(value, Iterable):
raise error("asset_rejected", f"the lock's {key} must be a list")
return tuple(str(item) for item in value)
try:
minimum = int(data.get("min_texture_size") or 0)
except (TypeError, ValueError) as exc:
raise error("asset_rejected", "the lock's min_texture_size is not a number") from exc
if minimum < 0:
raise error("asset_rejected", "the lock's min_texture_size is negative")
return cls(
os_family=_strings("os_family"),
machine=_strings("machine"),
gl_profile=str(profile) if profile else None, # type: ignore[arg-type]
allowed_vendors=_strings("allowed_vendors"),
allowed_renderers=_strings("allowed_renderers"),
forbid_software_renderer=bool(data.get("forbid_software_renderer", True)),
min_texture_size=minimum,
note=str(data.get("note") or ""),
)
@dataclass(frozen=True)
class PlatformInfo:
"""What this machine is, for the lock check.
Split in two on purpose: ``os_family``/``machine`` are known before any window
exists, while the graphics fields only exist once Qt has made a context. So a
package locked to another operating system is refused *before* a context is
created, and a package locked to a GPU is refused as soon as the capabilities are
known.
"""
os_family: str
machine: str
gl_profile: Optional[Profile] = None
vendor: str = ""
renderer: str = ""
software_renderer: bool = False
max_texture_size: int = 0
@classmethod
def here(cls, capabilities: Optional[GLCapabilities] = None) -> "PlatformInfo":
import platform as _platform
family = _platform.system().lower()
return cls(
os_family=family or "unknown",
machine=(_platform.machine() or "unknown").lower(),
gl_profile=(capabilities.profile if capabilities is not None else None),
vendor=(capabilities.vendor if capabilities is not None else ""),
renderer=(capabilities.renderer if capabilities is not None else ""),
software_renderer=bool(
capabilities.software_renderer if capabilities is not None else False
),
max_texture_size=int(
getattr(capabilities, "max_texture_size", 0) or 0
),
)
def check_platform_lock(
lock: Optional[PlatformLock], info: PlatformInfo
) -> tuple[Diagnostic, ...]:
"""Every way this machine violates the lock. Empty means "allowed".
The graphics half is only checked when ``info`` carries it: a pre-context check
(``PlatformInfo.here()`` with no capabilities) must not report a GPU problem it
cannot see yet, or every locked package would be refused before Qt starts.
"""
if lock is None or lock.is_unrestricted():
return ()
out: list[Diagnostic] = []
if lock.os_family and info.os_family not in {v.lower() for v in lock.os_family}:
out.append(
diagnostic(
"platform_mismatch",
"this package is not released for this operating system",
this=info.os_family,
allowed=",".join(lock.os_family),
)
)
if lock.machine and info.machine not in {v.lower() for v in lock.machine}:
out.append(
diagnostic(
"platform_mismatch",
"this package is not released for this processor architecture",
this=info.machine,
allowed=",".join(lock.machine),
)
)
graphics_known = info.gl_profile is not None or bool(info.vendor or info.renderer)
if not graphics_known:
return tuple(out)
if lock.gl_profile and info.gl_profile != lock.gl_profile:
out.append(
diagnostic(
"platform_mismatch",
"this package was compiled for another OpenGL profile",
this=str(info.gl_profile),
required=str(lock.gl_profile),
)
)
if lock.forbid_software_renderer and info.software_renderer:
out.append(
diagnostic(
"platform_mismatch",
"this package requires a GPU and this context is a software rasteriser",
renderer=info.renderer or "unknown",
)
)
haystack = f"{info.vendor} {info.renderer}".lower()
for label, allowed in (
("vendor", lock.allowed_vendors),
("renderer", lock.allowed_renderers),
):
if allowed and not any(item.lower() in haystack for item in allowed):
out.append(
diagnostic(
"platform_mismatch",
f"this package is not released for this graphics {label}",
this=haystack.strip() or "unknown",
allowed=",".join(allowed),
)
)
if lock.min_texture_size and info.max_texture_size < int(lock.min_texture_size):
out.append(
diagnostic(
"platform_mismatch",
"the GPU's maximum texture size is below what this package needs",
this=str(info.max_texture_size),
required=str(int(lock.min_texture_size)),
)
)
return tuple(out)
@dataclass(frozen=True) @dataclass(frozen=True)
class SceneAssets: class SceneAssets:
"""A validated scene: one sensing part, zero or more housing parts.""" """A validated scene: one sensing part, zero or more housing parts."""
@@ -242,6 +450,9 @@ class SceneAssets:
#: triangle is this?" is a question an operator will ask, and re-deriving it #: triangle is this?" is a question an operator will ask, and re-deriving it
#: from UVs alone is a guess. #: from UVs alone is a guess.
source_vertex_of_render: Optional[I32] = None source_vertex_of_render: Optional[I32] = None
#: Where this package may be displayed (stage 5). ``None`` means "not locked",
#: which is what every package written before the lock existed carries.
platform_lock: Optional[PlatformLock] = None
def part(self, part_id: str) -> Optional[MeshPart]: def part(self, part_id: str) -> Optional[MeshPart]:
for item in self.parts: for item in self.parts:
@@ -331,6 +542,34 @@ class RenderStats:
estimated_gpu_bytes: int = 0 estimated_gpu_bytes: int = 0
@dataclass(frozen=True)
class Sealing:
"""How a package is protected while it is written.
The *material*, not the policy: the policy is ``CompileOptions.signing_key_reference``
and ``CompileOptions.encryption_key_id`` (names a person wrote down), and this is
the resolved key bytes and provider a build machine actually holds. Splitting the
two is what keeps an offline tool from needing the crypto backend while the
runtime, which only ever *reads* sealed packages, needs nothing but a public key.
"""
#: Which content key to use, and where the key comes from. ``None`` = plain.
key_id: str = ""
provider: Optional["KeyProvider"] = None
#: Build-time only. The private key never reaches the runtime path.
signing_key_pem: Optional[bytes] = None
signing_key_reference: str = ""
#: Optional: the writer's own public key, so a build can prove the package it just
#: wrote opens again instead of discovering the problem at the customer.
verify_key_pem: Optional[bytes] = None
def encrypts(self) -> bool:
return bool(self.key_id) and self.provider is not None
def signs(self) -> bool:
return self.signing_key_pem is not None
@dataclass(frozen=True) @dataclass(frozen=True)
class CompileOptions: class CompileOptions:
asset_id: str asset_id: str
@@ -341,6 +580,11 @@ class CompileOptions:
max_housing_triangles: int max_housing_triangles: int
signing_key_reference: str signing_key_reference: str
encryption_key_id: Optional[str] = None encryption_key_id: Optional[str] = None
#: Where the released package may be displayed. ``None`` leaves it unlocked, which
#: is the right default for an engineering build and the wrong one for a release.
platform_lock: Optional[PlatformLock] = None
#: The resolved key material for signing and sealing (see :class:`Sealing`).
sealing: Optional[Sealing] = None
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -570,6 +814,16 @@ def validate_assets(
raise error("geometry_mismatch", "a source vertex index is negative") raise error("geometry_mismatch", "a source vertex index is negative")
out: list[Diagnostic] = [] out: list[Diagnostic] = []
if assets.platform_lock is not None:
# The lock travels inside the signed manifest, so a shape that cannot round
# trip through JSON is a package nobody can check at the far end.
try:
PlatformLock.from_dict(assets.platform_lock.to_dict())
except ThreeDError as exc:
raise error(
"asset_rejected",
f"the platform lock is malformed: {exc.diagnostic.message}",
) from exc
if expected is not None: if expected is not None:
if expected.geometry_id != assets.geometry_id: if expected.geometry_id != assets.geometry_id:
raise error( raise error(
@@ -72,6 +72,22 @@ def content_digest(
return hasher.hexdigest() return hasher.hexdigest()
def bytes_digest(data: bytes, *, domain: str = "bytes") -> str:
"""SHA-256 over raw bytes, labelled by what they are.
Used for a payload and for a ciphertext, where there is no array to name: the
label keeps a digest of one kind of object from ever colliding with another, and
the length is fed separately so two different lengths cannot hash the same way.
Still a *digest*: it detects corruption, it does not authenticate (see
``protect.py`` for that).
"""
hasher = hashlib.sha256()
_feed(hasher, "schema", DIGEST_SCHEMA.encode("ascii"))
_feed(hasher, f"bytes:{domain}:len", str(len(data)).encode("ascii"))
_feed(hasher, f"bytes:{domain}", bytes(data))
return hasher.hexdigest()
def geometry_id(parts: Iterable[MeshPart]) -> str: def geometry_id(parts: Iterable[MeshPart]) -> str:
"""The identity of the *shape*: part roles, positions and triangles. """The identity of the *shape*: part roles, positions and triangles.
+354 -78
View File
@@ -1,28 +1,34 @@
"""The ``.oryscene`` runtime package: manifest, entries, verification. """The ``.oryscene`` runtime package: manifest, entries, verification, sealing.
Stage 2 in ``docs/architecture/studio-3d-stages.md``; signing and encryption are Stage 2 (the plain container) and stage 5 (signature, optional encryption, platform
stage 5 and are *not* here. What is here is the part that stage 5 wraps: a single lock) in ``docs/architecture/studio-3d-stages.md``.
file whose every byte is accounted for in a manifest, and a reader that verifies
before it believes.
The container is a zip archive (stdlib, no pickle, no code execution) holding: The container is a zip archive (stdlib, no pickle, no code execution). Two envelope
versions exist and both are read:
``envelope.json`` ``envelope_version`` 1 — plain
magic, envelope version, payload state, entry count. Stage 5 adds the key id, ``envelope.json``, ``manifest.json``, ``sensing/*.npy``, ``housing/*.npy``.
nonce and signature here. Everything a v1 reader wrote before stage 5 still loads.
``manifest.json`` ``envelope_version`` 2 — sealed
canonical JSON: identity, coordinate frame, units, the UV domain, the parts, ``envelope.json`` (public, signed metadata) and ``payload``: the AES-256-GCM
and one entry record (dtype, shape, sha256) per array. ciphertext of exactly the bytes a v1 package's *content* would have been, i.e. a
``sensing/*.npy`` (and ``housing/*.npy`` when present) zip holding ``manifest.json`` and the arrays. Written only when a key provider and
plain ``.npy`` arrays, written by :func:`numpy.save` into the archive. a signing key are supplied; read only when the verification key (and, for an
encrypted one, the content key) is available.
The reader refuses, without trying to repair anything: a wrong magic, an unknown The reader refuses, without trying to repair anything: a wrong magic, an unknown
envelope version, a missing manifest, an entry whose bytes do not match its envelope version, a missing manifest, an entry whose bytes do not match its recorded
recorded hash, an entry the manifest does not describe (or describes and never hash, an entry the manifest does not describe (or describes and never provides), a
provides), a path that tries to escape, a dtype/shape that disagrees with the path that tries to escape, a dtype/shape that disagrees with the record, non-finite
record, non-finite positions, a binding that belongs to another mesh, and an asset positions, a binding that belongs to another mesh, an asset that does not match the
that does not match the device. An encrypted envelope is refused with its own device, a package larger than a geometry package has any business being, and — since
message rather than silently opened as if it were plain. stage 5 — a bad signature, a ciphertext that was changed, and a platform the package
was not released for.
One property is worth stating because it is the point of the two versions: the
*payload* of a sealed package is built by the same code as the content of a plain
one, so a sealed package cannot drift into a different format from the one the tests
already cover.
""" """
from __future__ import annotations from __future__ import annotations
@@ -43,7 +49,9 @@ from orisys_app_shared.scene3d.contracts import (
ExpectedGeometry, ExpectedGeometry,
Material, Material,
MeshPart, MeshPart,
PlatformLock,
SceneAssets, SceneAssets,
Sealing,
SurfaceBinding, SurfaceBinding,
error, error,
freeze, freeze,
@@ -56,9 +64,19 @@ from orisys_app_shared.scene3d.digest import (
content_digest, content_digest,
geometry_id as compute_geometry_id, geometry_id as compute_geometry_id,
) )
from orisys_app_shared.scene3d import protect as _protect
#: Bumped when the container layout changes incompatibly. #: Re-exported so a caller can ask "can this build seal a package at all?" without
ENVELOPE_VERSION = 1 #: importing the crypto module directly.
backend_available = _protect.backend_available
backend_version = _protect.backend_version
CryptoUnavailable = _protect.CryptoUnavailable
#: The version this build *writes*.
ENVELOPE_VERSION = 2
#: Every version this build *reads*. A v1 package is a release from before stage 5 and
#: has to keep working: refusing it would turn a format improvement into a recall.
SUPPORTED_ENVELOPE_VERSIONS = (1, 2)
SCHEMA_VERSION = "1.0" SCHEMA_VERSION = "1.0"
MAGIC = "orisys-scene" MAGIC = "orisys-scene"
@@ -67,6 +85,12 @@ MAGIC = "orisys-scene"
MAX_ENTRIES = 64 MAX_ENTRIES = 64
MAX_ENTRY_BYTES = 256 << 20 MAX_ENTRY_BYTES = 256 << 20
MAX_TOTAL_BYTES = 512 << 20 MAX_TOTAL_BYTES = 512 << 20
#: And refuse a compression ratio that no geometry package has: a manifest is
#: repetitive JSON, not a megabyte of zeros, and an entry that expands 200-fold is a
#: decompression bomb aimed at this process's memory. Checked above a floor so a tiny
#: entry with an unusual ratio (an empty array compresses enormously) is not refused.
MAX_COMPRESSION_RATIO = 200
RATIO_FLOOR_BYTES = 4 << 20
#: The arrays a sensing part is made of, and the role each belongs to. #: The arrays a sensing part is made of, and the role each belongs to.
SENSING_ARRAYS = { SENSING_ARRAYS = {
@@ -86,6 +110,9 @@ HOUSING_ARRAYS = {
_DTYPE_BY_KEY = {"f4": np.float32, "u4": np.uint32, "i4": np.int32} _DTYPE_BY_KEY = {"f4": np.float32, "u4": np.uint32, "i4": np.int32}
_KEY_BY_DTYPE = {np.dtype(v): k for k, v in _DTYPE_BY_KEY.items()} _KEY_BY_DTYPE = {np.dtype(v): k for k, v in _DTYPE_BY_KEY.items()}
#: Inside a sealed package the ciphertext is one entry with this name.
PAYLOAD_ENTRY = "payload"
@dataclass(frozen=True) @dataclass(frozen=True)
class PackageContents: class PackageContents:
@@ -167,21 +194,16 @@ def build_manifest(
], ],
"entries": {name: dict(entries[name]) for name in sorted(entries)}, "entries": {name: dict(entries[name]) for name in sorted(entries)},
"bounds_sensor_m": [[float(v) for v in row] for row in assets.bounds_sensor_m], "bounds_sensor_m": [[float(v) for v in row] for row in assets.bounds_sensor_m],
# ``None`` means "not locked". Stored as an explicit null rather than omitted,
# so a reader cannot mistake "the writer had no lock feature" for "unlocked".
"platform_lock": (
assets.platform_lock.to_dict() if assets.platform_lock is not None else None
),
} }
def write_package( def _arrays_of(assets: SceneAssets) -> dict[str, np.ndarray]:
assets: SceneAssets, """Every array a package holds, in its canonical entry names."""
output: Path,
*,
sensor_model: str = "finger",
hardware_revisions: Iterable[str] = ("*",),
) -> CompileReport:
"""Write a runtime package atomically: a half-written package never appears."""
from orisys_app_shared.scene3d.contracts import validate_assets as _validate
_validate(assets)
output = Path(output)
arrays: dict[str, np.ndarray] = {} arrays: dict[str, np.ndarray] = {}
sensing = assets.sensing_part() sensing = assets.sensing_part()
arrays["sensing/positions.npy"] = np.ascontiguousarray(sensing.positions_sensor_m) arrays["sensing/positions.npy"] = np.ascontiguousarray(sensing.positions_sensor_m)
@@ -198,7 +220,21 @@ def write_package(
arrays["housing/positions.npy"] = np.ascontiguousarray(part.positions_sensor_m) arrays["housing/positions.npy"] = np.ascontiguousarray(part.positions_sensor_m)
arrays["housing/normals.npy"] = np.ascontiguousarray(part.normals_sensor) arrays["housing/normals.npy"] = np.ascontiguousarray(part.normals_sensor)
arrays["housing/triangles.npy"] = np.ascontiguousarray(part.triangles) arrays["housing/triangles.npy"] = np.ascontiguousarray(part.triangles)
return arrays
def _payload_bytes(
assets: SceneAssets,
*,
sensor_model: str,
hardware_revisions: Iterable[str],
) -> tuple[bytes, dict[str, Any], str]:
"""The *content* of a package as one zip in memory, plus its manifest and hash.
Shared by both envelope versions, which is what stops a sealed package from
becoming a second format with its own bugs: sealing encrypts exactly these bytes.
"""
arrays = _arrays_of(assets)
entries = {name: _record(array) for name, array in arrays.items()} entries = {name: _record(array) for name, array in arrays.items()}
manifest = build_manifest( manifest = build_manifest(
assets, entries=entries, sensor_model=sensor_model, assets, entries=entries, sensor_model=sensor_model,
@@ -209,30 +245,124 @@ def write_package(
) )
manifest["asset_hash"] = digest manifest["asset_hash"] = digest
manifest["entry_digest"] = {name: record["sha256"] for name, record in entries.items()} manifest["entry_digest"] = {name: record["sha256"] for name, record in entries.items()}
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as payload:
payload.writestr("manifest.json", json.dumps(manifest, indent=2, sort_keys=True))
for name in sorted(arrays):
payload.writestr(name, _npy_bytes(arrays[name]))
return buffer.getvalue(), manifest, digest
envelope = {
def write_package(
assets: SceneAssets,
output: Path,
*,
sensor_model: str = "finger",
hardware_revisions: Iterable[str] = ("*",),
sealing: Optional[Sealing] = None,
) -> CompileReport:
"""Write a runtime package atomically: a half-written package never appears.
Three cases, and the design document's order of protection decides them:
* **no key material** — ``envelope_version`` 1, plain and unsigned. This is what an
engineering build produces, and what every package written before stage 5 is. Its
manifest is still self-checked on read (hashes, shapes, identities), so a
corrupted package is refused; what it does not have is *origin*.
* **a signing key only** — ``envelope_version`` 2, still plain, but the manifest's
asset hash and the manifest itself are signed. This is the design's "完整性 and
来源" step: the client carries only a public key, and a customer who edits a mesh
or a sampling table is caught, because the hash that covers them is inside the
signature.
* **a content key too** — ``envelope_version`` 2 with the content encrypted by
AES-256-GCM (``protect.py``). Encryption is *in addition to* the signature, never
instead of it.
A sealed write also *reads itself back* when the caller supplied a verification key:
a build that cannot open its own release should not have produced it.
"""
from orisys_app_shared.scene3d.contracts import validate_assets as _validate
_validate(assets)
output = Path(output)
payload, manifest, digest = _payload_bytes(
assets, sensor_model=sensor_model, hardware_revisions=hardware_revisions
)
seal = sealing or Sealing()
if seal.key_id and seal.provider is None:
# A key id with no key is the dangerous half-state: the caller asked for a
# sealed package and would get a plain one that a release script then ships as
# protected. Refuse, rather than decide on the caller's behalf.
raise error(
"asset_auth_failed",
f"a content key id ({seal.key_id}) was given without a key provider: "
"refusing to write a plain package in its place",
)
members = _payload_members(payload)
common: dict[str, Any] = {
"magic": MAGIC, "magic": MAGIC,
"envelope_version": ENVELOPE_VERSION,
"payload": "plain",
"schema_version": SCHEMA_VERSION, "schema_version": SCHEMA_VERSION,
"asset_id": assets.asset_id, "asset_id": assets.asset_id,
"asset_version": assets.asset_version, "asset_version": assets.asset_version,
"entry_count": len(arrays), # The array entries, not the archive's members: this field has meant "how many
"note": "unsigned and unencrypted: stage 5 adds the signature and the AEAD", # arrays" since version 1, and a reader that counted members would disagree.
"entry_count": len(manifest.get("entries") or {}),
} }
output.parent.mkdir(parents=True, exist_ok=True) output.parent.mkdir(parents=True, exist_ok=True)
temporary = output.with_suffix(output.suffix + ".partial") temporary = output.with_suffix(output.suffix + ".partial")
try: try:
if seal.encrypts():
envelope, ciphertext = _protect.seal_payload(
payload,
key_id=seal.key_id,
provider=seal.provider,
signing_key_pem=seal.signing_key_pem,
signing_key_reference=seal.signing_key_reference,
extra=common,
)
with zipfile.ZipFile(temporary, "w", compression=zipfile.ZIP_STORED) as archive:
archive.writestr("envelope.json", json.dumps(envelope, indent=2, sort_keys=True))
# Stored, not deflated: the ciphertext is incompressible, and a second
# compression layer only hides the fact that it is ciphertext.
archive.writestr(PAYLOAD_ENTRY, ciphertext)
elif seal.signs():
envelope = dict(common)
envelope["envelope_version"] = 2
envelope["payload"] = "plain"
# For a plain payload the signed digest *is* the manifest's asset hash: the
# manifest already covers every entry's sha256, so signing it covers the
# geometry, the sampling table and the platform lock without inventing a
# second, differently-computed number for the same thing.
envelope["payload_sha256"] = digest
envelope = _protect.sign_envelope(
envelope,
private_key_pem=seal.signing_key_pem or b"",
reference=seal.signing_key_reference,
)
with zipfile.ZipFile(temporary, "w", compression=zipfile.ZIP_DEFLATED) as archive: with zipfile.ZipFile(temporary, "w", compression=zipfile.ZIP_DEFLATED) as archive:
archive.writestr("envelope.json", json.dumps(envelope, indent=2, sort_keys=True)) archive.writestr("envelope.json", json.dumps(envelope, indent=2, sort_keys=True))
archive.writestr("manifest.json", json.dumps(manifest, indent=2, sort_keys=True)) for name in sorted(members):
for name in sorted(arrays): archive.writestr(name, members[name])
buffer = _npy_bytes(arrays[name]) else:
archive.writestr(name, buffer) envelope = dict(common)
envelope["envelope_version"] = 1
envelope["payload"] = "plain"
envelope["note"] = (
"plain and unsigned: integrity only, no origin (see protect.py for what "
"a signature adds)"
)
with zipfile.ZipFile(temporary, "w", compression=zipfile.ZIP_DEFLATED) as archive:
archive.writestr("envelope.json", json.dumps(envelope, indent=2, sort_keys=True))
for name in sorted(members):
archive.writestr(name, members[name])
temporary.replace(output) temporary.replace(output)
except Exception: except Exception:
temporary.unlink(missing_ok=True) temporary.unlink(missing_ok=True)
raise raise
if seal.verify_key_pem is not None and (seal.encrypts() or seal.signs()):
# The build machine proves the release opens. Without this, the first machine to
# discover a broken envelope is the customer's.
read_package(output, keys=seal.provider, verify_key_pem=seal.verify_key_pem)
return CompileReport( return CompileReport(
output=output, output=output,
asset_hash=digest, asset_hash=digest,
@@ -241,6 +371,12 @@ def write_package(
) )
def _payload_members(payload: bytes) -> dict[str, bytes]:
"""The members of the in-memory content zip, by name."""
with zipfile.ZipFile(io.BytesIO(payload)) as content:
return {name: content.read(name) for name in content.namelist()}
def _npy_bytes(array: np.ndarray) -> bytes: def _npy_bytes(array: np.ndarray) -> bytes:
import io import io
@@ -249,21 +385,34 @@ def _npy_bytes(array: np.ndarray) -> bytes:
return buffer.getvalue() return buffer.getvalue()
def read_package(path: Path, *, keys: Any = None) -> PackageContents: def _guard_sizes(archive: zipfile.ZipFile) -> None:
"""Open, verify and hand back the arrays. Refuses rather than repairs.""" """Refuse an archive that wants more memory than a geometry package needs.
path = Path(path)
if not path.is_file(): Two limits, and the ratio is the one that matters: a 2 KB zip can *declare* a
raise error("asset_rejected", f"package not found: {path}") 256 MB entry, and the declared size is what this process would allocate while
diagnostics: list[Diagnostic] = [] decompressing it. So a ratio no geometry package reaches (a manifest is repetitive
with zipfile.ZipFile(path) as archive: JSON, not megabytes of zeros) is refused above a floor that keeps small,
genuinely-compressible arrays legitimate.
"""
names = archive.namelist() names = archive.namelist()
if len(names) > MAX_ENTRIES: if len(names) > MAX_ENTRIES:
raise error("asset_auth_failed", f"the package declares {len(names)} entries") raise error("asset_auth_failed", f"the package declares {len(names)} entries")
total = 0 total = 0
for info in archive.infolist(): for info in archive.infolist():
total += int(info.file_size) size = int(info.file_size)
if int(info.file_size) > MAX_ENTRY_BYTES or total > MAX_TOTAL_BYTES: total += size
if size > MAX_ENTRY_BYTES or total > MAX_TOTAL_BYTES:
raise error("asset_auth_failed", "the package is larger than allowed") raise error("asset_auth_failed", "the package is larger than allowed")
if size > RATIO_FLOOR_BYTES:
ratio = size / max(int(info.compress_size), 1)
if ratio > MAX_COMPRESSION_RATIO:
raise error(
"asset_auth_failed",
f"{info.filename} expands {ratio:.0f}x: refusing a decompression bomb",
)
def _read_envelope(archive: zipfile.ZipFile) -> dict[str, Any]:
try: try:
envelope = json.loads(archive.read("envelope.json")) envelope = json.loads(archive.read("envelope.json"))
except KeyError as exc: except KeyError as exc:
@@ -272,21 +421,23 @@ def read_package(path: Path, *, keys: Any = None) -> PackageContents:
raise error("asset_auth_failed", f"the envelope is unreadable: {exc}") from exc raise error("asset_auth_failed", f"the envelope is unreadable: {exc}") from exc
if not isinstance(envelope, dict) or envelope.get("magic") != MAGIC: if not isinstance(envelope, dict) or envelope.get("magic") != MAGIC:
raise error("asset_auth_failed", "this is not an orisys scene package") raise error("asset_auth_failed", "this is not an orisys scene package")
if int(envelope.get("envelope_version", 0)) != ENVELOPE_VERSION: version = int(envelope.get("envelope_version", 0))
if version not in SUPPORTED_ENVELOPE_VERSIONS:
raise error( raise error(
"asset_auth_failed", "asset_auth_failed",
f"unsupported envelope version {envelope.get('envelope_version')!r}", f"unsupported envelope version {envelope.get('envelope_version')!r} "
) f"(this build reads {SUPPORTED_ENVELOPE_VERSIONS})",
if str(envelope.get("payload")) != "plain":
# Stage 5 territory. Saying so is better than pretending the bytes are
# data and failing later with a numpy error.
raise error(
"asset_auth_failed",
f"the payload is {envelope.get('payload')!r}; this build opens plain "
"packages only (signing and encryption are a later stage)",
) )
envelope["envelope_version"] = version
return envelope
def _verify_members(
members: Mapping[str, bytes], *, expected_hash: Optional[str] = None
) -> PackageContents:
"""The stage-2 verification, over a name→bytes mapping from either envelope."""
try: try:
manifest = json.loads(archive.read("manifest.json")) manifest = json.loads(members["manifest.json"])
except KeyError as exc: except KeyError as exc:
raise error("asset_auth_failed", "the manifest is missing") from exc raise error("asset_auth_failed", "the manifest is missing") from exc
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
@@ -315,12 +466,22 @@ def read_package(path: Path, *, keys: Any = None) -> PackageContents:
recorded=recorded_digest[:16], recorded=recorded_digest[:16],
recomputed=recomputed[:16], recomputed=recomputed[:16],
) )
if expected_hash and str(expected_hash) != recorded_digest:
# For a signed plain package this is where the signature meets the content: the
# signed digest and the manifest's own hash have to be the same number, or the
# signature covers something other than what was delivered.
raise error(
"asset_auth_failed",
"the signed payload digest does not match the manifest's asset hash",
signed=str(expected_hash)[:16],
manifest=recorded_digest[:16],
)
arrays: dict[str, np.ndarray] = {} arrays: dict[str, np.ndarray] = {}
for name, record in entries.items(): for name, record in entries.items():
entry = _entry_name(name) _entry_name(name)
if name not in names: if name not in members:
raise error("asset_auth_failed", f"the manifest promises {name!r}, which is absent") raise error("asset_auth_failed", f"the manifest promises {name!r}, which is absent")
raw = archive.read(name) raw = members[name]
try: try:
# ``io.BytesIO``: numpy's reader wants a real file object (it seeks, # ``io.BytesIO``: numpy's reader wants a real file object (it seeks,
# tells and reads headers), and a hand-rolled stub fails obscurely. # tells and reads headers), and a hand-rolled stub fails obscurely.
@@ -349,12 +510,95 @@ def read_package(path: Path, *, keys: Any = None) -> PackageContents:
): ):
raise error("asset_auth_failed", f"{name} does not match its recorded hash") raise error("asset_auth_failed", f"{name} does not match its recorded hash")
arrays[name] = array arrays[name] = array
unknown = [name for name in names if name not in entries and name not in ( unknown = [
"envelope.json", "manifest.json" name
)] for name in members
if name not in entries and name not in ("envelope.json", "manifest.json")
]
if unknown: if unknown:
raise error("asset_auth_failed", f"undeclared entries: {unknown}") raise error("asset_auth_failed", f"undeclared entries: {unknown}")
return PackageContents(manifest=manifest, arrays=arrays, diagnostics=tuple(diagnostics)) return PackageContents(manifest=manifest, arrays=arrays)
def read_package(
path: Path,
*,
keys: Any = None,
verify_key_pem: Optional[bytes] = None,
) -> PackageContents:
"""Open, verify and hand back the arrays. Refuses rather than repairs.
``keys`` is the :class:`~orisys_app_shared.scene3d.contracts.KeyProvider` for an
encrypted package; ``verify_key_pem`` the public key a signed one is checked
against. Both are optional, and each missing one is refused *specifically* — a
sealed package without a provider is not the same failure as a signed package
whose signature is wrong, and the message has to say which happened.
"""
path = Path(path)
if not path.is_file():
raise error("asset_rejected", f"package not found: {path}")
diagnostics: list[Diagnostic] = []
with zipfile.ZipFile(path) as archive:
_guard_sizes(archive)
envelope = _read_envelope(archive)
names = archive.namelist()
version = int(envelope["envelope_version"])
kind = str(envelope.get("payload"))
if version == 1:
if kind != "plain":
raise error(
"asset_auth_failed",
f"envelope version 1 cannot carry a {kind!r} payload",
)
members = {
name: archive.read(name)
for name in names
if name != "envelope.json"
}
return _verify_members(members)
if kind == "aes-gcm":
if PAYLOAD_ENTRY not in names:
raise error("asset_auth_failed", "the encrypted payload entry is missing")
plaintext = _protect.open_payload(
envelope,
archive.read(PAYLOAD_ENTRY),
provider=keys,
public_key_pem=verify_key_pem,
)
try:
with zipfile.ZipFile(io.BytesIO(plaintext)) as content:
_guard_sizes(content)
members = {name: content.read(name) for name in content.namelist()}
except zipfile.BadZipFile as exc:
raise error(
"asset_auth_failed", "the decrypted payload is not a readable archive"
) from exc
return _verify_members(members)
if kind == "plain":
# Signed, not encrypted: the arrays are in the clear, and the signature is
# what makes them the producer's. A reader that does not check it would be
# accepting an unsigned package while claiming otherwise.
if isinstance(envelope.get("signature"), Mapping):
if verify_key_pem is None:
raise error(
"asset_auth_failed",
"this package is signed; a verification key is required "
"(pass --verify-key or set one in the application)",
)
_protect.verify_envelope(envelope, public_key_pem=verify_key_pem)
members = {
name: archive.read(name)
for name in names
if name != "envelope.json"
}
return _verify_members(
members, expected_hash=str(envelope.get("payload_sha256") or "")
)
raise error(
"asset_auth_failed",
f"unsupported payload kind {kind!r} in envelope version {version}",
)
return PackageContents(manifest={}, arrays={}, diagnostics=tuple(diagnostics))
def scene_from_contents( def scene_from_contents(
@@ -454,26 +698,26 @@ def scene_from_contents(
source_vertex_of_render=( source_vertex_of_render=(
freeze(source, dtype=np.int32) if source is not None else None freeze(source, dtype=np.int32) if source is not None else None
), ),
platform_lock=PlatformLock.from_dict(manifest.get("platform_lock")),
) )
validate_assets(assets, expected) validate_assets(assets, expected)
return assets return assets
class PackagedAssetLoader: class PackagedAssetLoader:
"""The stage-2 loader: plain, verified packages (stage 5 adds the envelope).""" """The loader: plain, signed and encrypted packages through one entry point."""
def load( def load(
self, self,
path: Path, path: Path,
expected: ExpectedGeometry, expected: ExpectedGeometry,
keys: Any = None, keys: Any = None,
*,
verify_key_pem: Optional[bytes] = None,
) -> SceneAssets: ) -> SceneAssets:
if keys is not None: return scene_from_contents(
raise error( read_package(path, keys=keys, verify_key_pem=verify_key_pem), expected
"asset_auth_failed",
"this build cannot use a key provider: packages are plain so far",
) )
return scene_from_contents(read_package(path), expected)
def validate( def validate(
self, assets: SceneAssets, expected: ExpectedGeometry self, assets: SceneAssets, expected: ExpectedGeometry
@@ -481,10 +725,16 @@ class PackagedAssetLoader:
return validate_assets(assets, expected) return validate_assets(assets, expected)
def describe_package(path: Path) -> dict[str, Any]: def describe_package(
path: Path,
*,
keys: Any = None,
verify_key_pem: Optional[bytes] = None,
) -> dict[str, Any]:
"""A report on a package without building a scene: for the validate tool.""" """A report on a package without building a scene: for the validate tool."""
contents = read_package(path) contents = read_package(path, keys=keys, verify_key_pem=verify_key_pem)
manifest = contents.manifest manifest = contents.manifest
lock = manifest.get("platform_lock")
return { return {
"asset_id": manifest.get("asset_id"), "asset_id": manifest.get("asset_id"),
"asset_version": manifest.get("asset_version"), "asset_version": manifest.get("asset_version"),
@@ -492,6 +742,7 @@ def describe_package(path: Path) -> dict[str, Any]:
"field_domain_id": (manifest.get("field_domain") or {}).get("id"), "field_domain_id": (manifest.get("field_domain") or {}).get("id"),
"shape_hw": (manifest.get("field_domain") or {}).get("shape_hw"), "shape_hw": (manifest.get("field_domain") or {}).get("shape_hw"),
"asset_hash": manifest.get("asset_hash"), "asset_hash": manifest.get("asset_hash"),
"platform_lock": lock,
"parts": {part.get("id"): part.get("role") for part in manifest.get("parts", [])}, "parts": {part.get("id"): part.get("role") for part in manifest.get("parts", [])},
"entries": { "entries": {
name: {"dtype": r.get("dtype"), "shape": r.get("shape")} name: {"dtype": r.get("dtype"), "shape": r.get("shape")}
@@ -501,3 +752,28 @@ def describe_package(path: Path) -> dict[str, Any]:
"sensing": int(contents.arrays["sensing/positions.npy"].shape[0]), "sensing": int(contents.arrays["sensing/positions.npy"].shape[0]),
}, },
} }
def describe_envelope(path: Path) -> dict[str, Any]:
"""What the container says about itself, without opening the payload.
Used by the tools to report whether a package is plain, signed or encrypted —
and, importantly, to do so *without* a key: a support engineer can say "this
package is sealed for key id X and signed by Y" without being able to read it.
"""
path = Path(path)
if not path.is_file():
raise error("asset_rejected", f"package not found: {path}")
with zipfile.ZipFile(path) as archive:
_guard_sizes(archive)
envelope = _read_envelope(archive)
names = set(archive.namelist())
sealed_bytes = (
int(archive.getinfo(PAYLOAD_ENTRY).file_size)
if PAYLOAD_ENTRY in names
else 0
)
summary = _protect.envelope_summary(envelope)
summary["members"] = sorted(names)
summary["sealed_bytes"] = sealed_bytes
return summary
@@ -0,0 +1,532 @@
"""Sealed packages: Ed25519 signatures and optional AES-GCM, with no home-made crypto.
Stage 5 in ``docs/architecture/studio-3d-stages.md``; §5.2 of the design document is
the specification this module implements:
* **integrity and origin** (item 2): the build machine signs the package with an
Ed25519 private key and the client carries only the public key. A SHA-256 digest
says what the bytes are; it does not say who produced them, so the digest alone is
never described here as tamper protection.
* **optional encryption** (item 3): AES-256-GCM from a mature library, one fresh
nonce per sealed package, the envelope metadata bound as associated data, and the
plaintext digest inside the signed part. No XOR, no password hidden in the
extension, no encrypted-looking zip.
* **key isolation** (item 4): the signing private key never enters this module's
runtime path — :func:`sign_envelope` is a *build-time* function and only the public
key (``verify_envelope``) is needed to read a package. Content keys come from a
:class:`~orisys_app_shared.scene3d.contracts.KeyProvider`, so authorisation stays
outside the format.
* **decryption scope** (item 5): the payload is opened in memory and never written to
a plaintext temporary file; no function here logs a key, a nonce or a plaintext
digest of anything but a package the caller already holds.
Two things this module deliberately does **not** claim:
* it cannot stop somebody who controls the machine from reading the geometry after
decryption — the renderer needs the mesh, and a Python process cannot wipe its own
memory. The design document says the goal is to raise the cost of copying, not to
make it impossible, and that a structure which must not leak has to be replaced by
a visual proxy instead of by encryption;
* it does not protect anything if the same geometry ships in plaintext elsewhere.
Signing this package while the plain SDK mesh travels beside it protects nothing.
The format (``envelope_version`` 2, one ``payload`` entry):
``envelope.json``
public, signed metadata: magic, version, ``payload: "aes-gcm"``, ``key_id``,
``cipher``, ``nonce``, ``salt``, ``kdf``, ``payload_sha256`` (of the *plaintext*),
``ciphertext_sha256`` (of the stored bytes) and the ``signature`` block.
``payload``
the AES-256-GCM ciphertext of exactly the bytes a v1 package would have held: a
zip archive with ``manifest.json`` and the ``.npy`` entries.
The signed bytes are ``_DOMAIN + canonical_json(envelope without "signature") +
plaintext digest``, which is why the signature covers the manifest and every entry
(the manifest is inside the plaintext, whose digest is signed) as well as the key id,
the nonce, the salt and the ciphertext's own digest. The GCM associated data is the
same canonical envelope-without-signature bytes, so a package whose metadata was
edited fails decryption *and* verification, not just one of the two.
"""
from __future__ import annotations
import base64
import json
import secrets
from pathlib import Path
from typing import Any, Mapping, Optional
from orisys_app_shared.scene3d.contracts import (
Diagnostic,
KeyProvider,
diagnostic,
error,
)
from orisys_app_shared.scene3d.digest import bytes_digest
#: Separates our messages from any other use of the same key, so a signature made for
#: something else can never be replayed as a package signature.
_DOMAIN = b"orisys-scene/envelope/v2\x00"
#: The only cipher and KDF we write, and the only ones we accept on read. Names are
#: spelled out rather than negotiated: an unknown one is refused, not downgraded.
CIPHER = "AES-256-GCM"
KDF = "HKDF-SHA256"
SIGNATURE_ALGORITHM = "Ed25519"
NONCE_BYTES = 12
SALT_BYTES = 32
KEY_BYTES = 32
SIGNATURE_BYTES = 64
#: How the per-package content key is derived from the provider's key. The key id is
#: bound in, so a key handed out for one product cannot decrypt another product's
#: package even when the provider returns the same bytes.
_KDF_INFO = b"orisys-scene/content-key/v2\x00"
class CryptoUnavailable(RuntimeError):
"""The crypto backend is not installed. Its own type, so callers can say so."""
def backend_available() -> bool:
"""Whether a mature crypto backend is importable in this process.
Checked rather than assumed: the *runtime* needs the backend only to read a
sealed package, so a machine without it keeps working with plain packages, and
the compiler refuses to write a sealed one instead of writing a plain file that
claims to be protected.
"""
try:
import cryptography # noqa: F401
except Exception: # noqa: BLE001
return False
return True
def backend_version() -> str:
try:
import cryptography
return str(getattr(cryptography, "__version__", "unknown"))
except Exception: # noqa: BLE001
return "unavailable"
def _backend():
"""The AES-GCM/Ed25519/HKDF primitives, or a clear refusal."""
try:
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
except Exception as exc: # noqa: BLE001
raise CryptoUnavailable(
"reading or writing a sealed scene package needs the 'cryptography' "
f"package, which is not installed here ({type(exc).__name__}: {exc})"
) from exc
return {
"hashes": hashes,
"serialization": serialization,
"Ed25519PrivateKey": Ed25519PrivateKey,
"Ed25519PublicKey": Ed25519PublicKey,
"AESGCM": AESGCM,
"HKDF": HKDF,
}
# --------------------------------------------------------------------- encoding
def _b64(data: bytes) -> str:
return base64.b64encode(data).decode("ascii")
def _unb64(text: Any, *, field: str, length: Optional[int] = None) -> bytes:
if not isinstance(text, str) or not text:
raise error("asset_auth_failed", f"the envelope's {field} is missing")
try:
raw = base64.b64decode(text, validate=True)
except Exception as exc: # noqa: BLE001
raise error("asset_auth_failed", f"the envelope's {field} is not base64") from exc
if length is not None and len(raw) != length:
raise error(
"asset_auth_failed",
f"the envelope's {field} is {len(raw)} bytes, expected {length}",
)
return raw
def canonical_envelope(envelope: Mapping[str, Any]) -> bytes:
"""The bytes that are signed.
Everything except the signature itself, in a canonical JSON form. Sorted keys and
no whitespace, so the same content always signs the same bytes on every platform.
This *does* include the ciphertext's digest and length, which is why a package
whose ciphertext was swapped cannot keep a valid signature.
"""
body = {key: value for key, value in envelope.items() if key != "signature"}
return json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode(
"utf-8"
)
#: Fields that cannot be part of the AEAD's associated data: they only exist *after*
#: the ciphertext does. They are still covered by the signature, so dropping them here
#: does not leave them unprotected — it just keeps the two mechanisms in their proper
#: order (encrypt, then describe the ciphertext, then sign everything).
_POST_ENCRYPTION_FIELDS = ("signature", "ciphertext_sha256", "ciphertext_bytes")
def gcm_aad(envelope: Mapping[str, Any]) -> bytes:
"""The associated data bound into the ciphertext: all pre-encryption metadata."""
body = {
key: value
for key, value in envelope.items()
if key not in _POST_ENCRYPTION_FIELDS
}
return json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode(
"utf-8"
)
def signed_bytes(envelope: Mapping[str, Any], plaintext_digest: str) -> bytes:
"""Domain-separated message: the envelope bytes plus the plaintext's digest."""
return _DOMAIN + canonical_envelope(envelope) + b"\n" + str(plaintext_digest).encode("ascii")
# ------------------------------------------------------------------- key material
def generate_signing_key() -> bytes:
"""A fresh Ed25519 private key in PEM form, for a build machine."""
backend = _backend()
key = backend["Ed25519PrivateKey"].generate()
return key.private_bytes(
encoding=backend["serialization"].Encoding.PEM,
format=backend["serialization"].PrivateFormat.PKCS8,
encryption_algorithm=backend["serialization"].NoEncryption(),
)
def public_key_pem(private_key_pem: bytes) -> bytes:
"""The half that ships with the client."""
backend = _backend()
key = _load_private(private_key_pem, backend)
return key.public_key().public_bytes(
encoding=backend["serialization"].Encoding.PEM,
format=backend["serialization"].PublicFormat.SubjectPublicKeyInfo,
)
def _load_private(pem: bytes, backend: Mapping[str, Any]):
try:
return backend["serialization"].load_pem_private_key(bytes(pem), password=None)
except Exception as exc: # noqa: BLE001
raise error(
"asset_auth_failed",
f"the signing key is not a readable PEM private key: {type(exc).__name__}",
) from exc
def _load_public(pem: bytes, backend: Mapping[str, Any]):
try:
return backend["serialization"].load_pem_public_key(bytes(pem))
except Exception as exc: # noqa: BLE001
raise error(
"asset_auth_failed",
f"the verification key is not a readable PEM public key: {type(exc).__name__}",
) from exc
class MappingKeyProvider:
"""A :class:`KeyProvider` over an in-memory table.
For tests and for a build machine. A production client is expected to implement
the same one-method protocol over whatever it really has (a device key, a licence
server, an offline token) — which is why the format only ever asks for
``get_content_key(key_id)``.
"""
def __init__(self, keys: Optional[Mapping[str, Any]] = None) -> None:
self.keys: dict[str, bytes] = {
str(key_id): _key_bytes(value, key_id=str(key_id))
for key_id, value in (keys or {}).items()
}
def get_content_key(self, key_id: str) -> bytes:
key = self.keys.get(str(key_id))
if key is None:
# No key material in the message, ever.
raise error(
"asset_auth_failed",
f"no content key is available for key id {key_id!r}",
)
return key
def key_ids(self) -> tuple[str, ...]:
return tuple(sorted(self.keys))
def _key_bytes(value: Any, *, key_id: str) -> bytes:
if isinstance(value, bytes):
raw = value
elif isinstance(value, str):
text = value.strip()
try:
raw = bytes.fromhex(text)
except ValueError:
try:
raw = base64.b64decode(text, validate=True)
except Exception as exc: # noqa: BLE001
raise error(
"asset_rejected",
f"the key for {key_id!r} is neither hex nor base64",
) from exc
else:
raise error("asset_rejected", f"the key for {key_id!r} must be bytes or text")
if len(raw) != KEY_BYTES:
raise error(
"asset_rejected",
f"the key for {key_id!r} is {len(raw)} bytes; {KEY_BYTES} are required",
)
return raw
def load_key_file(path: Path, *, key_id: str) -> MappingKeyProvider:
"""A key provider with one key, read from a file (hex, base64 or raw)."""
path = Path(path)
if not path.is_file():
raise error("asset_rejected", f"the key file is missing: {path}")
raw = path.read_bytes()
text = raw.decode("utf-8", errors="ignore").strip()
try:
key = _key_bytes(text, key_id=key_id)
except Exception: # noqa: BLE001 - a binary key file is legitimate
key = _key_bytes(raw, key_id=key_id)
return MappingKeyProvider({key_id: key})
# ------------------------------------------------------------------- derive/open
def _content_key(provider: KeyProvider, key_id: str, salt: bytes) -> bytes:
backend = _backend()
master = provider.get_content_key(key_id)
if not isinstance(master, (bytes, bytearray)) or len(bytes(master)) != KEY_BYTES:
size = len(bytes(master)) if isinstance(master, (bytes, bytearray)) else "not bytes"
raise error(
"asset_auth_failed",
f"the provider returned {size} for a content key; {KEY_BYTES} bytes are required",
)
info = _KDF_INFO + str(key_id).encode("utf-8")
return backend["HKDF"](
algorithm=backend["hashes"].SHA256(),
length=KEY_BYTES,
salt=salt,
info=info,
).derive(bytes(master))
def seal_payload(
payload: bytes,
*,
key_id: str,
provider: KeyProvider,
signing_key_pem: Optional[bytes] = None,
signing_key_reference: str = "",
extra: Optional[Mapping[str, Any]] = None,
) -> tuple[dict[str, Any], bytes]:
"""Encrypt (and optionally sign) one plaintext payload.
Returns the public envelope and the ciphertext. A fresh random nonce and salt are
generated per call: the design demands that a nonce is never reused under one key,
and a 96-bit random nonce is the standard way to get that (with a test that the
same payload sealed twice shares neither nonce nor ciphertext). The salt keeps the
*derived* key unique per package as well, so a reused master key is never used
directly.
"""
backend = _backend()
plaintext = bytes(payload)
plaintext_digest = bytes_digest(plaintext, domain="payload")
salt = secrets.token_bytes(SALT_BYTES)
nonce = secrets.token_bytes(NONCE_BYTES)
key = _content_key(provider, str(key_id), salt)
envelope: dict[str, Any] = {
"envelope_version": 2,
"payload": "aes-gcm",
"cipher": CIPHER,
"kdf": KDF,
"key_id": str(key_id),
"salt": _b64(salt),
"nonce": _b64(nonce),
"payload_sha256": plaintext_digest,
"crypto_backend": f"cryptography/{backend_version()}",
}
if extra:
envelope.update({str(k): v for k, v in extra.items()})
aad = gcm_aad(envelope)
ciphertext = backend["AESGCM"](key).encrypt(nonce, plaintext, aad)
envelope["ciphertext_sha256"] = bytes_digest(ciphertext, domain="ciphertext")
envelope["ciphertext_bytes"] = len(ciphertext)
if signing_key_pem is not None:
envelope["signature"] = _sign(
envelope,
plaintext_digest=plaintext_digest,
private_key_pem=signing_key_pem,
reference=signing_key_reference,
)
return envelope, ciphertext
def _sign(
envelope: Mapping[str, Any],
*,
plaintext_digest: str,
private_key_pem: bytes,
reference: str,
) -> dict[str, Any]:
backend = _backend()
key = _load_private(private_key_pem, backend)
signature = key.sign(signed_bytes(envelope, plaintext_digest))
return {
"algorithm": SIGNATURE_ALGORITHM,
"key_id": str(reference or "unknown"),
"value": _b64(signature),
}
def sign_envelope(
envelope: Mapping[str, Any],
*,
private_key_pem: bytes,
reference: str = "",
) -> dict[str, Any]:
"""Sign an existing envelope (build-time). Returns a copy with ``signature``."""
if not envelope.get("payload_sha256"):
raise error("asset_auth_failed", "the envelope has no payload digest to sign")
out = dict(envelope)
out["signature"] = _sign(
out,
plaintext_digest=str(envelope["payload_sha256"]),
private_key_pem=private_key_pem,
reference=reference,
)
return out
def verify_envelope(
envelope: Mapping[str, Any], *, public_key_pem: bytes
) -> tuple[Diagnostic, ...]:
"""Verify the signature. Raises on a bad one; returns warnings otherwise."""
backend = _backend()
signature = envelope.get("signature")
if not isinstance(signature, Mapping):
raise error("asset_auth_failed", "the envelope is not signed")
if str(signature.get("algorithm")) != SIGNATURE_ALGORITHM:
raise error(
"asset_auth_failed",
f"unsupported signature algorithm {signature.get('algorithm')!r}",
)
raw = _unb64(signature.get("value"), field="signature", length=SIGNATURE_BYTES)
key = _load_public(public_key_pem, backend)
message = signed_bytes(envelope, str(envelope.get("payload_sha256") or ""))
try:
key.verify(raw, message)
except Exception as exc: # noqa: BLE001
raise error(
"asset_auth_failed",
"the package signature does not match its contents",
signing_key=str(signature.get("key_id") or ""),
) from exc
return ()
def open_payload(
envelope: Mapping[str, Any],
ciphertext: bytes,
*,
provider: Optional[KeyProvider],
public_key_pem: Optional[bytes] = None,
) -> bytes:
"""Verify, decrypt and hand back the plaintext payload.
Order matters and is not negotiable: the signature is checked *before* the key is
asked for and before anything is decrypted, so a tampered package cannot make the
client touch its key material or its cipher at all.
"""
backend = _backend()
if str(envelope.get("payload")) != "aes-gcm":
raise error(
"asset_auth_failed",
f"the payload is {envelope.get('payload')!r}, not {('aes-gcm')!r}",
)
if public_key_pem is not None:
verify_envelope(envelope, public_key_pem=public_key_pem)
elif "signature" in envelope:
raise error(
"asset_auth_failed",
"the package is signed but no verification key was supplied",
)
if str(envelope.get("cipher")) != CIPHER:
raise error("asset_auth_failed", f"unsupported cipher {envelope.get('cipher')!r}")
if str(envelope.get("kdf")) != KDF:
raise error("asset_auth_failed", f"unsupported key derivation {envelope.get('kdf')!r}")
if provider is None:
raise error(
"asset_auth_failed",
"this package is encrypted and no key provider was supplied",
)
recorded_cipher = str(envelope.get("ciphertext_sha256") or "")
if recorded_cipher and bytes_digest(ciphertext, domain="ciphertext") != recorded_cipher:
raise error("asset_auth_failed", "the ciphertext does not match its recorded hash")
declared = envelope.get("ciphertext_bytes")
if declared is not None and int(declared) != len(ciphertext):
raise error(
"asset_auth_failed",
f"the envelope declares {declared} ciphertext bytes, found {len(ciphertext)}",
)
salt = _unb64(envelope.get("salt"), field="salt", length=SALT_BYTES)
nonce = _unb64(envelope.get("nonce"), field="nonce", length=NONCE_BYTES)
key = _content_key(provider, str(envelope.get("key_id") or ""), salt)
aad = gcm_aad(envelope)
try:
plaintext = backend["AESGCM"](key).decrypt(nonce, bytes(ciphertext), aad)
except Exception as exc: # noqa: BLE001
raise error(
"asset_auth_failed",
"the package could not be decrypted: wrong key, or the bytes were changed",
key_id=str(envelope.get("key_id") or ""),
) from exc
recorded_plain = str(envelope.get("payload_sha256") or "")
if not recorded_plain or bytes_digest(plaintext, domain="payload") != recorded_plain:
raise error(
"asset_auth_failed",
"the decrypted payload does not match the digest that was signed",
)
return plaintext
def envelope_summary(envelope: Mapping[str, Any]) -> dict[str, Any]:
"""A log-safe view: no key material, no digests of anything secret.
Used by the validate tool and by diagnostics, so a support ticket can say what a
package is without printing anything that would help decrypt another one.
"""
signature = envelope.get("signature")
return {
"envelope_version": envelope.get("envelope_version"),
"payload": envelope.get("payload"),
"cipher": envelope.get("cipher"),
"key_id": envelope.get("key_id"),
"sealed": str(envelope.get("payload")) == "aes-gcm",
"ciphertext_bytes": envelope.get("ciphertext_bytes"),
"signed": isinstance(signature, Mapping),
"signing_key": (signature or {}).get("key_id") if isinstance(signature, Mapping) else None,
"crypto_backend": envelope.get("crypto_backend"),
}
def diagnose_unavailable(exc: BaseException) -> Diagnostic:
"""One place turns "no crypto backend" into the diagnostic the UI shows."""
return diagnostic(
"asset_auth_failed",
"sealed scene packages need the 'cryptography' package, which is not "
"installed on this machine; plain packages still work",
detail=str(exc),
)
+4 -2
View File
@@ -424,7 +424,8 @@ def test_the_cpu_layer_is_importable_and_works_without_a_gpu():
) )
assert composed.scalar.shape == (9,) assert composed.scalar.shape == (9,)
# The loading facade is real too (stage 2), and the compiler refuses what it # The loading facade is real too (stage 2), and the compiler refuses what it
# cannot yet do rather than writing something unprotected. # cannot honestly do: asking for encryption without key material, or without the
# crypto backend, must not produce a plain package that looks protected.
assert callable(assets.PackagedAssetLoader().load) assert callable(assets.PackagedAssetLoader().load)
with pytest.raises(c.ThreeDError) as excinfo: with pytest.raises(c.ThreeDError) as excinfo:
assets.SceneCompiler().compile( assets.SceneCompiler().compile(
@@ -442,7 +443,8 @@ def test_the_cpu_layer_is_importable_and_works_without_a_gpu():
encryption_key_id="customer-1", encryption_key_id="customer-1",
), ),
) )
assert "stage 5" in excinfo.value.diagnostic.message assert "key" in excinfo.value.diagnostic.message
assert excinfo.value.diagnostic.code == "asset_auth_failed"
def test_summarize_parts_counts_vertices_per_part(): def test_summarize_parts_counts_vertices_per_part():
+58 -9
View File
@@ -291,11 +291,11 @@ def test_an_envelope_from_the_future_or_another_tool_is_refused(package, tmp_pat
assert "envelope version" in excinfo.value.diagnostic.message assert "envelope version" in excinfo.value.diagnostic.message
def test_an_encrypted_envelope_says_so_instead_of_failing_obscurely(package, tmp_path): def test_an_envelope_that_claims_an_unknown_payload_kind_is_refused(package, tmp_path):
"""Stage 5 will open these; until then the message has to be honest.""" """An unknown payload is a refusal with its own reason, not a numpy error later."""
def encrypt(members): def encrypt(members):
envelope = json.loads(members["envelope.json"]) envelope = json.loads(members["envelope.json"])
envelope["payload"] = "aead" envelope["payload"] = "rot13"
envelope["key_id"] = "customer-1" envelope["key_id"] = "customer-1"
members["envelope.json"] = json.dumps(envelope).encode() members["envelope.json"] = json.dumps(envelope).encode()
return members return members
@@ -305,20 +305,66 @@ def test_an_encrypted_envelope_says_so_instead_of_failing_obscurely(package, tmp
read_package(broken) read_package(broken)
item = excinfo.value.diagnostic item = excinfo.value.diagnostic
assert item.code == "asset_auth_failed" assert item.code == "asset_auth_failed"
assert "plain packages only" in item.message assert "rot13" in item.message
def test_a_key_provider_is_refused_while_packages_are_plain(package, scene): def test_an_encrypted_payload_needs_a_key_provider_and_a_verification_key(scene, tmp_path):
"""The two ways a sealed package can be unreadable here, named separately.
Both are *licensing* states, not corruption: the message has to let an operator
tell "this machine has no key for it" from "these bytes were changed".
"""
crypto = pytest.importorskip(
"cryptography", reason="sealing needs the crypto backend"
)
del crypto
from orisys_app_shared.scene3d.contracts import Sealing
from orisys_app_shared.scene3d import protect as protect_module
private = protect_module.generate_signing_key()
public = protect_module.public_key_pem(private)
provider = protect_module.MappingKeyProvider({"customer-1": "11" * 32})
sealed = tmp_path / "sealed.oryscene"
write_package(
scene,
sealed,
sealing=Sealing(
key_id="customer-1",
provider=provider,
signing_key_pem=private,
signing_key_reference="build-key",
verify_key_pem=public,
),
)
with pytest.raises(ThreeDError) as no_key:
read_package(sealed, verify_key_pem=public)
assert "key provider" in no_key.value.diagnostic.message
with pytest.raises(ThreeDError) as no_verify:
read_package(sealed, keys=provider)
assert "verification key" in no_verify.value.diagnostic.message
contents = read_package(sealed, keys=provider, verify_key_pem=public)
assert contents.arrays["sensing/positions.npy"].shape[0] > 0
def test_a_key_provider_is_unused_by_a_plain_package(package, scene):
"""A provider is not an error on a plain package: it is simply not needed.
Refusing here would break the one call site that hands the same provider to every
load, which is how a client with a mix of sealed and plain packages is written.
"""
class Keys: class Keys:
def get_content_key(self, key_id: str) -> bytes: # pragma: no cover - not used def get_content_key(self, key_id: str) -> bytes: # pragma: no cover - not used
return b"secret" raise AssertionError("a plain package must not ask for a key")
expected = ExpectedGeometry( expected = ExpectedGeometry(
"finger", "rev-a", scene.geometry_id, scene.field_domain_id, scene.shape_hw "finger", "rev-a", scene.geometry_id, scene.field_domain_id, scene.shape_hw
) )
with pytest.raises(ThreeDError) as excinfo: assets = PackagedAssetLoader().load(package, expected, Keys())
PackagedAssetLoader().load(package, expected, Keys()) assert assets.geometry_id == scene.geometry_id
assert "key provider" in excinfo.value.diagnostic.message
def test_the_manifest_carries_what_a_release_needs(package): def test_the_manifest_carries_what_a_release_needs(package):
@@ -364,6 +410,9 @@ def test_the_envelope_names_the_magic_and_the_payload_state(package):
with zipfile.ZipFile(package) as archive: with zipfile.ZipFile(package) as archive:
envelope = json.loads(archive.read("envelope.json")) envelope = json.loads(archive.read("envelope.json"))
assert envelope["magic"] == MAGIC assert envelope["magic"] == MAGIC
# An unsealed write stays at version 1: it is the package every release before
# stage 5 produced, and bumping it would make old readers refuse a plain file.
assert envelope["envelope_version"] == 1 assert envelope["envelope_version"] == 1
assert envelope["payload"] == "plain" assert envelope["payload"] == "plain"
assert envelope["entry_count"] == len(read_package(package).arrays) assert envelope["entry_count"] == len(read_package(package).arrays)
assert "signature" not in envelope, "a plain package must not look signed"
+662
View File
@@ -0,0 +1,662 @@
"""Stage 5 acceptance: sealing, signatures, the platform lock and the record block.
The design document's §5.2 lists five protections and is explicit about what each one
buys; these tests are about holding the code to *those* claims and to no larger ones:
* **origin.** A signed package verifies against the shipped public key, and every way
of editing it — the manifest, an entry's bytes, the envelope's metadata, the
ciphertext — is refused. The tests also assert the negative: a signature over a
*plain* package is not accepted as encryption, and a package with no signature is
reported as unsigned rather than quietly accepted as if it were signed.
* **optional encryption.** AES-256-GCM with a fresh nonce per seal, the envelope bound
as associated data, and the plaintext digest inside the signed part. Two seals of
the same content must not share a nonce or a ciphertext.
* **key isolation.** Reading a sealed package needs a :class:`KeyProvider` and a
verification key; a *wrong* key is a refusal, not a crash, and no message anywhere
contains key material.
* **the platform lock.** A package compiled for one operating system, architecture,
GL profile or GPU is refused elsewhere — with the reason — and an unlocked package
is not the same as one locked to nothing.
* **the record block.** ``session.json`` says what the pane was; a recording without
the block (an older file) reads as "not supported" instead of raising.
"""
from __future__ import annotations
import json
import zipfile
from dataclasses import replace
from pathlib import Path
import numpy as np
import pytest
from orisys_app_shared.record.visualization3d import (
BLOCK_VERSION,
build_block,
read_block,
)
from orisys_app_shared.scene3d import protect, synthetic
from orisys_app_shared.scene3d.contracts import (
PlatformInfo,
PlatformLock,
Sealing,
ThreeDError,
check_platform_lock,
)
from orisys_app_shared.scene3d.package import (
ENVELOPE_VERSION,
PAYLOAD_ENTRY,
SUPPORTED_ENVELOPE_VERSIONS,
describe_envelope,
read_package,
scene_from_contents,
write_package,
)
pytest.importorskip("cryptography", reason="stage 5 sealing needs the crypto backend")
@pytest.fixture(scope="module")
def signing_key() -> bytes:
return protect.generate_signing_key()
@pytest.fixture(scope="module")
def verify_key(signing_key) -> bytes:
return protect.public_key_pem(signing_key)
@pytest.fixture
def provider() -> protect.MappingKeyProvider:
return protect.MappingKeyProvider({"customer-1": "11" * 32})
@pytest.fixture
def scene():
return synthetic.poc_scene()
def _seal(tmp_path, scene, *, signing_key, verify_key, provider, name="sealed.oryscene"):
path = tmp_path / name
write_package(
scene,
path,
sealing=Sealing(
key_id="customer-1",
provider=provider,
signing_key_pem=signing_key,
signing_key_reference="build-2024",
verify_key_pem=verify_key,
),
)
return path
def _sign_only(tmp_path, scene, *, signing_key, verify_key, name="signed.oryscene"):
path = tmp_path / name
write_package(
scene,
path,
sealing=Sealing(
signing_key_pem=signing_key,
signing_key_reference="build-2024",
verify_key_pem=verify_key,
),
)
return path
# ------------------------------------------------------------------ the envelope
def test_a_sealed_package_round_trips_and_carries_the_same_content(
tmp_path, scene, signing_key, verify_key, provider
):
sealed = _seal(tmp_path, scene, signing_key=signing_key, verify_key=verify_key, provider=provider)
plain = tmp_path / "plain.oryscene"
write_package(scene, plain)
contents = read_package(sealed, keys=provider, verify_key_pem=verify_key)
reference = read_package(plain)
assert set(contents.arrays) == set(reference.arrays)
for name in contents.arrays:
assert np.array_equal(contents.arrays[name], reference.arrays[name]), name
assert contents.manifest["asset_hash"] == reference.manifest["asset_hash"], (
"sealing must not change the asset identity"
)
assets = scene_from_contents(contents)
assert assets.geometry_id == scene.geometry_id
def test_the_envelope_says_it_is_sealed_without_any_key(
tmp_path, scene, signing_key, verify_key, provider
):
sealed = _seal(tmp_path, scene, signing_key=signing_key, verify_key=verify_key, provider=provider)
summary = describe_envelope(sealed)
assert summary["envelope_version"] == ENVELOPE_VERSION == 2
assert summary["payload"] == "aes-gcm"
assert summary["sealed"] is True and summary["signed"] is True
assert summary["key_id"] == "customer-1"
assert summary["signing_key"] == "build-2024"
assert summary["ciphertext_bytes"] > 0
assert PAYLOAD_ENTRY in summary["members"]
# The summary is what a support ticket quotes: no key material, no nonce, no salt.
text = json.dumps(summary)
assert "11" * 32 not in text and "nonce" not in text and "salt" not in text
assert SUPPORTED_ENVELOPE_VERSIONS == (1, 2)
def test_a_plain_package_still_reads_and_says_it_is_unsigned(tmp_path, scene):
plain = tmp_path / "plain.oryscene"
write_package(scene, plain)
summary = describe_envelope(plain)
assert summary["envelope_version"] == 1
assert summary["payload"] == "plain"
assert summary["signed"] is False and summary["sealed"] is False
assert scene_from_contents(read_package(plain)).asset_id == scene.asset_id
def test_a_signed_plain_package_needs_the_public_key_and_verifies_with_it(
tmp_path, scene, signing_key, verify_key
):
signed = _sign_only(tmp_path, scene, signing_key=signing_key, verify_key=verify_key)
summary = describe_envelope(signed)
assert summary["payload"] == "plain" and summary["signed"] is True
assert summary["sealed"] is False
with pytest.raises(ThreeDError) as excinfo:
read_package(signed)
assert "verification key" in excinfo.value.diagnostic.message
contents = read_package(signed, verify_key_pem=verify_key)
assert contents.manifest["asset_id"] == scene.asset_id
# ------------------------------------------------------------------- tampering
def _rewrite(path: Path, target: Path, mutate) -> Path:
"""Copy a package while changing one member (or the envelope)."""
with zipfile.ZipFile(path) as source:
members = {name: source.read(name) for name in source.namelist()}
members = mutate(members)
with zipfile.ZipFile(target, "w") as archive:
for name, payload in members.items():
archive.writestr(name, payload)
return target
@pytest.mark.parametrize("what", ["ciphertext", "entry", "manifest", "metadata"])
def test_every_edit_of_a_sealed_package_is_refused(
tmp_path, scene, signing_key, verify_key, provider, what
):
sealed = _seal(tmp_path, scene, signing_key=signing_key, verify_key=verify_key, provider=provider)
def mutate(members: dict[str, bytes]) -> dict[str, bytes]:
if what == "ciphertext":
payload = bytearray(members[PAYLOAD_ENTRY])
payload[len(payload) // 2] ^= 0x01
members[PAYLOAD_ENTRY] = bytes(payload)
elif what == "entry":
# A signed *plain* package is where an entry can be edited directly.
raise AssertionError("covered by the signed-plain case below")
elif what == "manifest":
payload = members[PAYLOAD_ENTRY]
members[PAYLOAD_ENTRY] = payload + b"\x00"
else:
envelope = json.loads(members["envelope.json"])
envelope["key_id"] = "customer-2"
members["envelope.json"] = json.dumps(envelope).encode()
return members
if what == "entry":
pytest.skip("an entry cannot be edited inside a ciphertext; see the signed case")
broken = _rewrite(sealed, tmp_path / f"broken-{what}.oryscene", mutate)
with pytest.raises(ThreeDError) as excinfo:
read_package(broken, keys=provider, verify_key_pem=verify_key)
assert excinfo.value.diagnostic.code in ("asset_auth_failed",)
assert excinfo.value.diagnostic.message
def test_editing_an_entry_of_a_signed_package_is_refused(
tmp_path, scene, signing_key, verify_key
):
"""The signature covers the entries: this is what "origin" has to mean."""
signed = _sign_only(tmp_path, scene, signing_key=signing_key, verify_key=verify_key)
def mutate(members):
payload = bytearray(members["sensing/positions.npy"])
payload[-1] ^= 0x01
members["sensing/positions.npy"] = bytes(payload)
return members
broken = _rewrite(signed, tmp_path / "broken-entry.oryscene", mutate)
with pytest.raises(ThreeDError) as excinfo:
read_package(broken, verify_key_pem=verify_key)
assert excinfo.value.diagnostic.code == "asset_auth_failed"
def test_a_wrong_verification_key_is_refused(tmp_path, scene, signing_key, verify_key, provider):
sealed = _seal(tmp_path, scene, signing_key=signing_key, verify_key=verify_key, provider=provider)
other = protect.public_key_pem(protect.generate_signing_key())
with pytest.raises(ThreeDError) as excinfo:
read_package(sealed, keys=provider, verify_key_pem=other)
assert "signature" in excinfo.value.diagnostic.message
def test_a_wrong_content_key_is_refused_and_reveals_nothing(
tmp_path, scene, signing_key, verify_key, provider
):
sealed = _seal(tmp_path, scene, signing_key=signing_key, verify_key=verify_key, provider=provider)
wrong = protect.MappingKeyProvider({"customer-1": "22" * 32})
with pytest.raises(ThreeDError) as excinfo:
read_package(sealed, keys=wrong, verify_key_pem=verify_key)
item = excinfo.value.diagnostic
assert item.code == "asset_auth_failed"
assert "22" * 32 not in item.message
assert "11" * 32 not in item.message
def test_a_missing_key_for_the_key_id_is_named(tmp_path, scene, signing_key, verify_key, provider):
sealed = _seal(tmp_path, scene, signing_key=signing_key, verify_key=verify_key, provider=provider)
empty = protect.MappingKeyProvider({})
with pytest.raises(ThreeDError) as excinfo:
read_package(sealed, keys=empty, verify_key_pem=verify_key)
assert "customer-1" in excinfo.value.diagnostic.message
# ------------------------------------------------------------- the primitives
def test_a_nonce_is_never_reused_and_the_salt_is_fresh(provider):
first, first_bytes = protect.seal_payload(
b"the same payload", key_id="customer-1", provider=provider
)
second, second_bytes = protect.seal_payload(
b"the same payload", key_id="customer-1", provider=provider
)
assert first["nonce"] != second["nonce"]
assert first["salt"] != second["salt"]
assert first_bytes != second_bytes, "the same plaintext must not produce the same bytes"
assert len(protect._unb64(first["nonce"], field="nonce", length=protect.NONCE_BYTES)) == 12
def test_the_envelope_metadata_is_bound_into_the_ciphertext(provider):
envelope, ciphertext = protect.seal_payload(
b"payload", key_id="customer-1", provider=provider, extra={"asset_id": "x"}
)
assert protect.open_payload(envelope, ciphertext, provider=provider) == b"payload"
changed = dict(envelope)
changed["asset_id"] = "y"
with pytest.raises(ThreeDError):
protect.open_payload(changed, ciphertext, provider=provider)
def test_a_signed_envelope_covers_the_plaintext_digest(signing_key, verify_key, provider):
envelope, _ = protect.seal_payload(
b"payload", key_id="customer-1", provider=provider,
signing_key_pem=signing_key, signing_key_reference="k",
)
protect.verify_envelope(envelope, public_key_pem=verify_key)
changed = dict(envelope)
changed["payload_sha256"] = "0" * 64
with pytest.raises(ThreeDError) as excinfo:
protect.verify_envelope(changed, public_key_pem=verify_key)
assert "signature" in excinfo.value.diagnostic.message
def test_a_key_provider_must_return_a_usable_key():
with pytest.raises(ThreeDError):
protect.MappingKeyProvider({"short": "abcd"})
with pytest.raises(ThreeDError):
protect.MappingKeyProvider({"not-text": 17})
class Wrong:
def get_content_key(self, key_id: str) -> bytes:
return b"too short"
with pytest.raises(ThreeDError) as excinfo:
protect.seal_payload(b"x", key_id="k", provider=Wrong())
assert "32" in excinfo.value.diagnostic.message
def test_a_key_file_is_read_as_hex_base64_or_raw(tmp_path):
raw = bytes(range(32))
for name, text in (
("hex.key", raw.hex()),
("b64.key", __import__("base64").b64encode(raw).decode()),
):
path = tmp_path / name
path.write_text(text, encoding="utf-8")
assert protect.load_key_file(path, key_id="k").get_content_key("k") == raw
binary = tmp_path / "raw.key"
binary.write_bytes(raw)
assert protect.load_key_file(binary, key_id="k").get_content_key("k") == raw
def test_the_writer_refuses_to_seal_without_a_backend_or_without_a_key(
tmp_path, scene, monkeypatch
):
with pytest.raises(ThreeDError) as missing:
write_package(
scene,
tmp_path / "no-key.oryscene",
sealing=Sealing(key_id="customer-1", provider=None),
)
assert missing.value.diagnostic.code == "asset_auth_failed"
import orisys_app_shared.scene3d.package as package_module
monkeypatch.setattr(package_module, "backend_available", lambda: False)
from orisys_app_shared.scene3d.assets import SceneCompiler
options = _compile_options(
encryption_key_id="customer-1",
sealing=Sealing(
key_id="customer-1", provider=protect.MappingKeyProvider({"customer-1": "11" * 32})
),
)
with pytest.raises(ThreeDError) as no_backend:
SceneCompiler().compile(scene, None, tmp_path / "no-backend.oryscene", options)
assert "cryptography" in no_backend.value.diagnostic.message
def _compile_options(*, encryption_key_id=None, sealing=None, lock=None):
from orisys_app_shared.scene3d.contracts import CompileOptions
return CompileOptions(
asset_id="x",
version="0",
expected=synthetic.poc_expected(),
transform_sensor_from_glb=np.eye(4),
max_surface_vertices=0,
max_housing_triangles=1000,
signing_key_reference="build-key",
encryption_key_id=encryption_key_id,
platform_lock=lock,
sealing=sealing,
)
# ------------------------------------------------------------------ the lock
def test_an_unlocked_package_is_not_locked_to_nothing():
assert PlatformLock().is_unrestricted()
assert PlatformLock().to_dict()["os_family"] == []
assert check_platform_lock(None, PlatformInfo("linux", "aarch64")) == ()
assert check_platform_lock(PlatformLock(), PlatformInfo("linux", "aarch64")) == ()
def test_the_lock_covers_os_arch_profile_gpu_and_software_renderers():
lock = PlatformLock(
os_family=("windows",),
machine=("amd64",),
gl_profile="desktop33",
allowed_vendors=("NVIDIA",),
allowed_renderers=("RTX 4080",),
min_texture_size=16384,
note="dev box",
)
# The machine this was qualified on passes.
assert (
check_platform_lock(
lock,
PlatformInfo("windows", "amd64", "desktop33", "NVIDIA Corporation",
"NVIDIA GeForce RTX 4080 SUPER", False, 32768),
)
== ()
)
# Operating system and architecture are checkable with no graphics information.
static = check_platform_lock(lock, PlatformInfo("linux", "aarch64"))
assert len(static) == 2
assert all(item.code == "platform_mismatch" for item in static)
# Every graphics violation at once: profile, software renderer, vendor,
# renderer and texture size.
full = check_platform_lock(
lock,
PlatformInfo("windows", "amd64", "gles30", "Mesa", "llvmpipe", True, 4096),
)
joined = " ".join(item.message for item in full)
assert "profile" in joined
assert "software rasteriser" in joined
assert "vendor" in joined or "renderer" in joined
assert "texture" in joined
def test_the_lock_can_be_used_to_endorse_a_gpu_by_name():
"""Vendor is 'NVIDIA Corporation', the renderer is the specific card string."""
lock = PlatformLock(allowed_renderers=("RTX 4080",))
assert check_platform_lock(
lock, PlatformInfo("windows", "amd64", "desktop33", "NVIDIA",
"NVIDIA GeForce RTX 4080 SUPER/PCIe/SSE2", False, 32768)
) == ()
assert check_platform_lock(
lock, PlatformInfo("windows", "amd64", "desktop33", "Intel", "Intel UHD", False, 16384)
)
def test_a_malformed_lock_is_rejected_rather_than_ignored():
with pytest.raises(ThreeDError):
PlatformLock.from_dict({"gl_profile": "vulkan"})
with pytest.raises(ThreeDError):
PlatformLock.from_dict({"os_family": "windows"})
with pytest.raises(ThreeDError):
PlatformLock.from_dict({"min_texture_size": -4})
with pytest.raises(ThreeDError):
PlatformLock.from_dict({"os_family": ["windows"], "min_texture_size": "big"})
def test_the_lock_travels_into_the_manifest_and_the_asset(tmp_path, scene):
locked = replace(
scene,
platform_lock=PlatformLock(os_family=("windows",), machine=("amd64",), note="dev"),
)
path = tmp_path / "locked.oryscene"
write_package(locked, path)
contents = read_package(path)
assert contents.manifest["platform_lock"]["os_family"] == ["windows"]
assets = scene_from_contents(contents)
assert assets.platform_lock is not None
assert assets.platform_lock.machine == ("amd64",)
assert assets.platform_lock.note == "dev"
# And a locked asset is a different package from an unlocked one.
other = tmp_path / "unlocked.oryscene"
write_package(scene, other)
assert read_package(path).manifest["asset_hash"] != read_package(other).manifest["asset_hash"]
# --------------------------------------------------------------- the record block
def test_the_block_records_what_the_pane_was():
block = build_block(
enabled=True,
available=True,
scene={"asset_id": "finger-display", "asset_hash": "a" * 64},
gl={"profile": "desktop33", "renderer": "RTX 4080", "software_renderer": False},
display={"deform_enabled": True, "display_gain": 50.0, "max_fps": 30},
stream={"quantity": "flow_magnitude", "unit": "px", "quality": "approximate"},
)
assert block["block_version"] == BLOCK_VERSION
record = read_block({"visualization3d": block})
assert record.supported and record.enabled and record.available
assert record.asset_hash() == "a" * 64
assert record.was_showing_measurements() is True
assert record.display["display_gain"] == 50.0
def test_a_disabled_pane_records_its_reason_and_no_measurement():
block = build_block(enabled=False, available=True, reason="the operator turned it off")
record = read_block({"visualization3d": block})
assert record.supported and not record.enabled
assert record.reason == "the operator turned it off"
assert record.was_showing_measurements() is False
def test_a_reference_model_is_not_an_observation():
block = build_block(
enabled=True,
available=True,
stream={"quantity": "unavailable", "unit": "1", "quality": "display_only"},
)
assert read_block({"visualization3d": block}).was_showing_measurements() is False
@pytest.mark.parametrize(
"session,reason_part",
[
(None, "no session metadata"),
({}, "no visualization3d block"),
({"visualization3d": 7}, "not an object"),
({"visualization3d": {"enabled": True}}, "version"),
({"visualization3d": {"block_version": 99}}, "not"),
],
)
def test_an_old_or_broken_recording_reads_as_unsupported(session, reason_part):
record = read_block(session)
assert record.supported is False
assert reason_part in record.reason
assert record.enabled is False
def test_an_empty_reason_is_not_written_for_a_working_pane():
block = build_block(enabled=True, available=True, reason="ignored")
assert block["reason"] == "", "a working pane must not carry a reason"
# ------------------------------------------------- the release workflow, end to end
def _tool(name: str):
"""Import a tool by path: the tools are scripts, not an installed package."""
import importlib.util
path = Path(__file__).resolve().parents[2] / "tools" / "scene_assets" / f"{name}.py"
spec = importlib.util.spec_from_file_location(f"scene_tool_{name}", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _shipped_asset_dirs() -> tuple[Path, Path] | None:
"""The Blender export and its unroll map, when the repository carries them."""
base = (
Path(__file__).resolve().parents[2]
/ "apps"
/ "studio"
/ "config"
/ "sensors"
/ "finger"
/ "assets"
/ "30fps-0703"
)
mapping = base / "unroll_map_uv_s0.4.npz"
if (base / "mesh.npz").is_file() and mapping.is_file():
return base, mapping
return None
def test_the_release_workflow_produces_a_signed_sealed_locked_package(tmp_path):
"""``compile_scene.py`` → ``validate_scene.py``, the way a release script runs it.
This is the stage-5 acceptance in one test: a real export compiled with a signing
key, a content key and a platform lock taken from a probe report, then validated
with only the public key and the content key — and then the same validation run
against a package with one byte changed, which must fail.
"""
shipped = _shipped_asset_dirs()
if shipped is None:
pytest.skip("the shipped finger export is not in this checkout")
legacy_dir, mapping = shipped
compile_tool = _tool("compile_scene")
validate_tool = _tool("validate_scene")
keys = tmp_path / "keys"
keys.mkdir()
signing = keys / "build-ed25519.pem"
signing.write_bytes(protect.generate_signing_key())
public = keys / "build-ed25519.pub.pem"
public.write_bytes(protect.public_key_pem(signing.read_bytes()))
content = keys / "finger-display.key"
content.write_text(bytes(range(32)).hex(), encoding="utf-8")
probe = tmp_path / "gl-report.json"
probe.write_text(
json.dumps(
{
"profile_requested": "desktop33",
"host": {"os_family": "windows", "machine": "amd64"},
"context": {
"profile_used": "desktop33",
"vendor": "NVIDIA Corporation",
"renderer": "NVIDIA GeForce RTX 4080 SUPER/PCIe/SSE2",
"software_renderer": False,
"max_texture_size": 32768,
},
}
),
encoding="utf-8",
)
package = tmp_path / "finger-display-1.0.0.oryscene"
code = compile_tool.main(
[
"--legacy-dir", str(legacy_dir),
"--map", str(mapping),
"--meters-per-source-unit", "0.001",
"--sign-key", str(signing),
"--sign-key-id", "build-2024",
"--write-verify-key", str(public),
"--encrypt-key-id", "finger-display",
"--key-file", str(content),
"--lock-from-probe", str(probe),
"--output", str(package),
]
)
assert code == 0
summary = describe_envelope(package)
assert summary["sealed"] and summary["signed"]
assert summary["key_id"] == "finger-display"
contents = read_package(
package,
keys=protect.load_key_file(content, key_id="finger-display"),
verify_key_pem=public.read_bytes(),
)
assets = scene_from_contents(contents)
assert assets.sensing_part().positions_sensor_m.shape[0] == 1032
assert assets.platform_lock is not None
assert assets.platform_lock.gl_profile == "desktop33"
assert assets.platform_lock.allowed_renderers == (
"NVIDIA GeForce RTX 4080 SUPER/PCIe/SSE2",
)
# The lock is inside the signed manifest, so the asset hash covers it.
assert contents.manifest["platform_lock"]["min_texture_size"] == 32768
release_args = [
str(package),
"--key-file", str(content),
"--key-id", "finger-display",
"--verify-key", str(public),
"--require-signature",
]
assert validate_tool.main(release_args) == 0
# One flipped byte in the ciphertext: the release gate must fail, not warn.
damaged = tmp_path / "damaged.oryscene"
with zipfile.ZipFile(package) as source:
members = {name: source.read(name) for name in source.namelist()}
payload = bytearray(members[PAYLOAD_ENTRY])
payload[0] ^= 0x01
members[PAYLOAD_ENTRY] = bytes(payload)
with zipfile.ZipFile(damaged, "w") as target:
for name, value in members.items():
target.writestr(name, value)
assert (
validate_tool.main(
[
str(damaged),
"--key-file", str(content),
"--key-id", "finger-display",
"--verify-key", str(public),
"--require-signature",
]
)
== 2
)
def test_the_release_gate_fails_an_unsigned_package(tmp_path, scene):
"""``--require-signature`` is the difference between a build and a release."""
validate_tool = _tool("validate_scene")
plain = tmp_path / "plain.oryscene"
write_package(scene, plain)
assert validate_tool.main([str(plain)]) == 0, "an engineering package is acceptable"
assert validate_tool.main([str(plain), "--require-signature"]) == 2
+667
View File
@@ -0,0 +1,667 @@
"""Measure the 3D view's cost on this machine, per stage and as a percentile.
Stage 5 deliverable (``docs/architecture/studio-3d-stages.md``): the design
document's performance row asks for p50/p95 of the *compose*, the *upload*, the
*draw* and the total response latency, reported together with the resolution, the
mesh and the GL context they were measured on. A single average would hide exactly
the thing that matters here — the tail is where a display pipeline either keeps its
frame rate or visibly stutters.
What is measured, and why each one is separate:
``submit_ms`` what the **acquisition thread** pays per frame. This is the only
number that can damage the measurement, so it is measured with the
composer deliberately slowed down: the display slowing must not
move this number at all.
``compose_ms`` the CPU worker: sample the UV field onto the vertices, deform,
recompute normals.
``upload_ms`` the GPU buffer update for the sensing surface.
``paint_ms`` Qt's paint, including the widget's own bookkeeping.
``latency_ms`` from ``submit`` to the frame being on screen — the number an
operator actually perceives, and the one that includes every
queue in between.
Run it with a real platform (offscreen cannot create a GL context)::
QT_QPA_PLATFORM=windows python tools/scene_assets/bench_scene.py \\
--frames 600 --output docs/architecture/perf-report-windows-dev.json
Exit codes: 0 = measured on a GPU, 3 = software renderer, 4 = no usable context.
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
def _bootstrap_paths() -> None:
here = Path(__file__).resolve()
repo = here.parents[2]
for path in (repo / "apps" / "studio" / "src", repo / "shared" / "src"):
if path.is_dir() and str(path) not in sys.path:
sys.path.insert(0, str(path))
def _percentiles(values: list[float]) -> dict[str, float]:
if not values:
return {"p50": 0.0, "p95": 0.0, "max": 0.0, "mean": 0.0, "n": 0}
ordered = sorted(values)
def pick(fraction: float) -> float:
index = max(0, min(len(ordered) - 1, int(round(fraction * len(ordered))) - 1))
return float(ordered[index])
return {
"p50": round(pick(0.50), 4),
"p95": round(pick(0.95), 4),
"max": round(float(ordered[-1]), 4),
"mean": round(sum(ordered) / len(ordered), 4),
"n": len(ordered),
}
def _grid_scene(target_vertices: int):
"""A plane grid with a bilinear binding, for the design document's budget.
The design's performance row is written against "about 20k vertices and 40k
triangles" for the sensing surface, and the shipped finger export is 1032 — so a
report that only measured the PoC scene would answer a question nobody asked. This
builds a grid of roughly ``target_vertices`` vertices whose UV domain *is* the
grid, with the same measured pixel convention the real binding uses
(``x = u(W-1)``, ``y = (1-v)(H-1)``) and a genuine four-tap bilinear table, so the
sampling cost in the report is the real one rather than a nearest-neighbour
stand-in.
"""
import numpy as np
from orisys_app_shared.scene3d.contracts import (
CameraPreset,
Material,
MeshPart,
SceneAssets,
SurfaceBinding,
freeze,
)
from orisys_app_shared.scene3d.digest import (
field_domain_id as compute_domain_id,
geometry_id as compute_geometry_id,
)
from orisys_app_shared.scene3d.legacy import vertex_normals
columns = max(2, int(round(target_vertices ** 0.5)))
rows = max(2, int(round(target_vertices / columns)))
count = rows * columns
xs = np.linspace(-0.008, 0.008, columns, dtype=np.float64)
ys = np.linspace(-0.008, 0.008, rows, dtype=np.float64)
grid_x, grid_y = np.meshgrid(xs, ys)
# A gentle dome, so the normals are not all identical and the shading path is used.
radius = np.sqrt(grid_x**2 + grid_y**2)
z = 0.001 * np.cos(np.clip(radius / 0.008, 0.0, 1.0) * np.pi / 2.0)
positions = np.stack([grid_x, grid_y, z], axis=-1).reshape(-1, 3).astype(np.float32)
faces: list[tuple[int, int, int]] = []
for i in range(rows - 1):
for j in range(columns - 1):
a = i * columns + j
b = a + 1
c = a + columns
d = c + 1
faces.append((a, c, b))
faces.append((b, c, d))
triangles = np.asarray(faces, dtype=np.uint32)
normals = vertex_normals(positions.astype(np.float64), triangles.astype(np.int64))
height, width = rows, columns
index = np.arange(count, dtype=np.int64)
row_of = index // columns
column_of = index % columns
# Vertex (row, column) sits *between* pixels, so every vertex needs four taps.
u = (column_of + 0.5) / columns
v = 1.0 - (row_of + 0.5) / rows
x = u * (width - 1)
y = v * (height - 1)
x0 = np.clip(np.floor(x).astype(np.int64), 0, width - 2)
y0 = np.clip(np.floor(y).astype(np.int64), 0, height - 2)
x1, y1 = x0 + 1, y0 + 1
fx = (x - x0).astype(np.float32)
fy = (y - y0).astype(np.float32)
indices = np.stack(
[
y0 * width + x0,
y0 * width + x1,
y1 * width + x0,
y1 * width + x1,
],
axis=-1,
).astype(np.int32)
weights = np.stack(
[
(1.0 - fx) * (1.0 - fy),
fx * (1.0 - fy),
(1.0 - fx) * fy,
fx * fy,
],
axis=-1,
).astype(np.float32)
domain_id = compute_domain_id(shape_hw=(height, width), flip_v=True, source_scale=1.0)
preset = CameraPreset(
name="bench",
eye_sensor_m=(0.02, -0.02, 0.03),
target_sensor_m=(0.0, 0.0, 0.0),
up_sensor=(0.0, 0.0, 1.0),
vertical_fov_rad=0.7,
)
part = MeshPart(
part_id="sensing_surface",
role="sensing",
positions_sensor_m=freeze(positions, dtype=np.float32),
normals_sensor=freeze(normals, dtype=np.float32),
triangles=freeze(triangles, dtype=np.uint32),
material=Material((0.85, 0.85, 0.88, 1.0), "unlit_scalar", True),
lod_id="lod0",
)
geometry = compute_geometry_id((part,))
binding = SurfaceBinding(
part_id=part.part_id,
geometry_id=geometry,
field_domain_id=domain_id,
shape_hw=(height, width),
sample_indices=freeze(indices, dtype=np.int32),
sample_weights=freeze(weights, dtype=np.float32),
)
bounds = np.stack([positions.min(axis=0), positions.max(axis=0)]).astype(np.float32)
return SceneAssets(
asset_id="bench-grid",
asset_version="0",
asset_hash=domain_id,
geometry_id=geometry,
parts=(part,),
binding=binding,
camera_presets=(preset,),
bounds_sensor_m=freeze(bounds, dtype=np.float32),
field_domain_id=domain_id,
shape_hw=(height, width),
)
def measure_cpu(
*,
frames: int,
vertices: int = 0,
package: Path | None = None,
key_file: Path | None = None,
key_id: str = "",
verify_key: Path | None = None,
) -> tuple[dict, int]:
"""The composer's own cost, with no window in the process.
This is the mode to quote for "how long does one frame take": in the GUI run the
worker thread and the GUI thread compete for the GIL and for a vsync-throttled
swap, so the composer's *wall* timers pick up 15-17 ms of waiting that has nothing
to do with the arithmetic. Removing Qt from the process removes the contention, and
what is left is the number an LOD decision should be made on.
"""
import numpy as np
from orisys_app_shared.scene3d import synthetic
from orisys_app_shared.scene3d.composer import SceneComposer
from orisys_app_shared.scene3d.contracts import (
DisplaySettings,
FieldDescriptor,
FrameIdentity,
SurfaceFieldFrame,
freeze,
)
report: dict = {
"mode": "cpu",
"frames_requested": int(frames),
"scene": (
f"package:{package}"
if package is not None
else (f"grid:{vertices}" if vertices else "synthetic-poc")
),
}
if package is not None:
from orisys_app_shared.scene3d import protect
from orisys_app_shared.scene3d.package import PackagedAssetLoader
provider = (
protect.load_key_file(Path(key_file), key_id=key_id)
if key_file is not None
else None
)
public = Path(verify_key).read_bytes() if verify_key is not None else None
try:
assets = PackagedAssetLoader().load(
Path(package), None, provider, verify_key_pem=public
)
except Exception as exc: # noqa: BLE001
report["verdict"] = "asset_rejected"
report["reason"] = str(exc)
return report, 4
elif vertices:
assets = _grid_scene(int(vertices))
else:
assets = synthetic.poc_scene()
report["mesh"] = {
"sensing_vertices": int(assets.sensing_part().positions_sensor_m.shape[0]),
"sensing_triangles": int(assets.sensing_part().triangles.shape[0]),
"field_domain_hw": [int(assets.shape_hw[0]), int(assets.shape_hw[1])],
}
shape = tuple(assets.shape_hw)
rng = np.random.default_rng(12345)
scalar = freeze(
(rng.random(shape) * 4.0).astype(np.float32), dtype=np.float32
)
valid = freeze(np.ones(shape, dtype=bool), dtype=np.bool_)
frame = SurfaceFieldFrame(
identity=FrameIdentity("bench", 1, 1, 0.0, generation=1),
geometry_id=assets.geometry_id,
field_domain_id=assets.field_domain_id,
scalar=scalar,
scalar_descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False),
valid=valid,
)
composer = SceneComposer()
settings = DisplaySettings()
for _ in range(5):
composer.compose(assets, frame, settings)
totals: list[float] = []
samples: list[float] = []
normals: list[float] = []
walls: list[float] = []
for _ in range(int(frames)):
started = time.perf_counter()
composer.compose(assets, frame, settings)
walls.append((time.perf_counter() - started) * 1000.0)
stats = composer.last_stats
totals.append(float(stats.total_ms))
samples.append(float(stats.sample_ms))
normals.append(float(stats.normals_ms))
report["timings_ms"] = {
"compose": _percentiles(totals),
"compose_sample": _percentiles(samples),
"compose_normals": _percentiles(normals),
"compose_wall": _percentiles(walls),
}
report["verdict"] = "ok"
return report, 0
def measure(
*,
frames: int,
profile: str,
width: int,
height: int,
slow_compose_ms: float,
max_fps: int,
vertices: int = 0,
package: Path | None = None,
key_file: Path | None = None,
key_id: str = "",
verify_key: Path | None = None,
) -> tuple[dict, int]:
"""Run the assembled view and report what each stage cost."""
import numpy as np
from PyQt5.QtWidgets import QApplication
from orisys_app_shared.scene3d import synthetic
from orisys_app_shared.scene3d.contracts import DisplaySettings
from orisys_studio.scene3d.gl_backend import configure_surface_format
from orisys_studio.scene3d.view import Scene3DView
configure_surface_format(profile) # type: ignore[arg-type]
app = QApplication.instance() or QApplication(sys.argv)
report: dict = {
"profile_requested": profile,
"frames_requested": int(frames),
"viewport_px": [int(width), int(height)],
"slow_compose_ms": float(slow_compose_ms),
"max_fps_requested": int(max_fps),
"scene": (
f"package:{package}"
if package is not None
else (f"grid:{vertices}" if vertices else "synthetic-poc")
),
}
view = Scene3DView()
if not view.available:
report["verdict"] = "no_view"
report["reason"] = view.reason
return report, 4
if package is not None:
from orisys_app_shared.scene3d.package import PackagedAssetLoader
from orisys_app_shared.scene3d import protect
provider = (
protect.load_key_file(Path(key_file), key_id=key_id)
if key_file is not None
else None
)
public = Path(verify_key).read_bytes() if verify_key is not None else None
try:
assets = PackagedAssetLoader().load(
Path(package), None, provider, verify_key_pem=public
)
except Exception as exc: # noqa: BLE001
report["verdict"] = "asset_rejected"
report["reason"] = str(exc)
view.close()
return report, 4
elif vertices:
assets = _grid_scene(int(vertices))
else:
assets = synthetic.poc_scene()
if not view.use_scene(assets, source_id="bench"):
report["verdict"] = "asset_rejected"
report["reason"] = str(view.last_diagnostic)
return report, 4
view.resize(width, height)
view.show()
view.set_enabled(True)
widget = view.gl_widget
renderer = widget._renderer
deadline = time.perf_counter() + 10.0
while time.perf_counter() < deadline and not renderer.part_ids:
widget.repaint()
app.processEvents()
time.sleep(0.005)
capabilities = widget.capabilities()
if not renderer.part_ids or capabilities is None:
report["verdict"] = "no_context"
report["reason"] = str(widget.diagnostic())
view.close()
return report, 4
report["context"] = {
"profile_used": capabilities.profile,
"vendor": capabilities.vendor,
"renderer": capabilities.renderer,
"gl_version": capabilities.gl_version,
"glsl_version": capabilities.glsl_version,
"software_renderer": bool(capabilities.software_renderer),
"max_texture_size": int(capabilities.max_texture_size),
"device_pixel_ratio": float(widget.devicePixelRatioF()),
"qt_platform": capabilities.qt_platform,
}
report["mesh"] = {
"sensing_vertices": int(assets.sensing_part().positions_sensor_m.shape[0]),
"sensing_triangles": int(assets.sensing_part().triangles.shape[0]),
"housing_triangles": int(
assets.part("housing").triangles.shape[0] if assets.part("housing") else 0
),
"field_domain_hw": [int(assets.shape_hw[0]), int(assets.shape_hw[1])],
"estimated_gpu_bytes": int(getattr(renderer, "_estimated_gpu_bytes", 0) or 0),
}
coordinator = view.coordinator
real_composer = coordinator._composer
compose_records: list[tuple[float, float, float, float]] = []
compose_lock = __import__("threading").Lock()
class Slow:
"""The composer, delayed: the point is that ``submit`` does not notice.
The statistics are collected *here*, on the worker thread that produces them,
immediately after ``compose`` returns. Reading ``last_stats`` from the GUI loop
instead produces torn samples — the worker writes ``sample_ms``, then
``normals_ms``, then ``total_ms``, so a reader can see one frame's sampling next
to the previous frame's normals, which reads as a plausible and completely wrong
breakdown.
"""
def __init__(self, delay_s: float, inner) -> None:
self._delay = delay_s
self._inner = inner
self.last_stats = getattr(inner, "last_stats", None)
def compose(self, *args, **kwargs):
started = time.perf_counter()
result = self._inner.compose(*args, **kwargs)
stats = getattr(self._inner, "last_stats", None)
record = (
float(getattr(stats, "total_ms", 0.0)),
float(getattr(stats, "sample_ms", 0.0)),
float(getattr(stats, "normals_ms", 0.0)),
(time.perf_counter() - started) * 1000.0,
)
with compose_lock:
compose_records.append(record)
if self._delay:
time.sleep(self._delay)
return result
def rest_frame(self, *args, **kwargs):
return self._inner.rest_frame(*args, **kwargs)
coordinator._composer = Slow(slow_compose_ms / 1000.0, real_composer)
view.restore_settings(DisplaySettings(max_fps=max_fps))
shape = tuple(assets.shape_hw)
ramp = np.linspace(0.0, 1.0, int(shape[0]) * int(shape[1]), dtype=np.float32)
flow = np.stack([ramp, ramp], axis=-1).reshape(int(shape[0]), int(shape[1]), 2)
class Packet:
"""A packet shaped like the finger adapter expects."""
def __init__(self, index: int) -> None:
self.session_id = 1
self.frame_id = int(index)
self.mono_t = float(index) / 30.0
self.flow = flow + (index % 7) * 0.01
self.raw_frame = np.zeros((4, 4, 3), dtype=np.uint8)
# Measured by wrapping the two places that define the timeline: `present` is the
# moment a composed frame becomes the one that should be on screen, and `paintGL`
# is the moment it actually is. Anything measured by polling the widget's own
# statistics would report the *last published* sample (they are rate-limited to one
# per 500 ms), which is how a benchmark ends up reporting a constant p50 for a
# stage it never sampled.
submits: list[float] = []
latencies: list[float] = []
composes: list[float] = []
samples: list[float] = []
normals: list[float] = []
uploads: list[float] = []
paints: list[float] = []
submitted_at: dict[int, float] = {}
presented_at: dict[int, float] = {}
original_present = widget.present
original_paint = widget.paintGL
def present(frame):
presented_at[int(frame.identity.frame_id)] = time.perf_counter()
original_present(frame)
def paint():
frame = widget._frame # the frame this paint is about to consume
started = time.perf_counter()
original_paint()
paints.append((time.perf_counter() - started) * 1000.0)
if frame is not None:
key = int(frame.identity.frame_id)
if key in submitted_at:
latencies.append((time.perf_counter() - submitted_at[key]) * 1000.0)
widget.present = present
widget.paintGL = paint
try:
for index in range(int(frames)):
started = time.perf_counter()
view.submit_packet(Packet(index))
submits.append((time.perf_counter() - started) * 1000.0)
submitted_at[index] = started
view.tick()
app.processEvents()
# Synchronous paint, the way the GL tests drive the widget: a queued
# `update()` may not be delivered by `processEvents()` for a GL widget,
# which would leave every paint-stage number at zero.
widget.repaint()
uploads.append(float(getattr(renderer, "_last_upload_ms", 0.0) or 0.0))
finally:
widget.present = original_present
widget.paintGL = original_paint
coordinator._composer = real_composer
view.close()
app.processEvents()
from orisys_studio.scene3d.coordinator import MAX_INFLIGHT_COMPOSES
with compose_lock:
records = list(compose_records)
composes = [item[0] for item in records]
samples = [item[1] for item in records]
normals = [item[2] for item in records]
compose_wall = [item[3] for item in records]
coordinator_stats = {
"superseded_display_frames": int(coordinator.superseded_frames),
"stale_frames": int(coordinator.stale_frames),
"invalid_frames": int(coordinator.invalid_frames),
"max_inflight_composes": int(MAX_INFLIGHT_COMPOSES),
"mailbox_capacity": 1,
"frames_presented": len(presented_at),
}
report["timings_ms"] = {
"submit": _percentiles(submits),
"compose": _percentiles(composes),
# Which half of the compose: on a large sensing surface one of the two
# dominates, and "compose" alone cannot say which.
"compose_sample": _percentiles(samples),
"compose_normals": _percentiles(normals),
# Wall clock inside the composer, which includes the artificial delay: the
# difference between this and ``compose`` is the delay the caller asked for.
"compose_wall": _percentiles(compose_wall),
"upload": _percentiles(uploads),
"paint": _percentiles(paints),
"latency": _percentiles(latencies),
}
report["compose_count"] = len(records)
report["compose_note"] = (
"compose/compose_sample/compose_normals are the composer's own timers, "
"collected on the worker thread; compose_wall adds the requested delay. "
"latency is submit-to-paint and therefore includes the frame-rate limiter."
)
report["display"] = coordinator_stats
report["crypto_backend"] = _crypto_version()
if capabilities.software_renderer:
report["verdict"] = "software_renderer"
return report, 3
report["verdict"] = "ok"
return report, 0
def _crypto_version() -> str:
try:
from orisys_app_shared.scene3d.package import backend_version
return str(backend_version())
except Exception: # noqa: BLE001
return "unavailable"
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--frames", type=int, default=600, help="frames to submit")
parser.add_argument("--profile", default="desktop33", choices=("desktop33", "gles30"))
parser.add_argument("--size", default="900x700", help="WxH of the 3D widget")
parser.add_argument(
"--slow-compose-ms",
type=float,
default=50.0,
help="artificial composer delay: the submit budget must not move with it",
)
parser.add_argument("--max-fps", type=int, default=30)
parser.add_argument(
"--vertices",
type=int,
default=0,
help="benchmark a generated grid of about this many sensing vertices "
"(the design document's budget is 20k); 0 = the synthetic PoC scene",
)
parser.add_argument(
"--package",
type=Path,
default=None,
help="benchmark a released .oryscene package instead of a synthetic scene",
)
parser.add_argument("--key-file", type=Path, default=None, help="key for a sealed package")
parser.add_argument("--key-id", default="", help="key id that --key-file holds")
parser.add_argument("--verify-key", type=Path, default=None, help="public key for a signed package")
parser.add_argument("--output", type=Path, default=None, help="write the report here")
parser.add_argument(
"--cpu-only",
action="store_true",
help="measure the composer with no window in the process (no vsync, no GIL "
"contention): the number to quote for the per-frame CPU cost",
)
parser.add_argument("--json", action="store_true")
args = parser.parse_args(argv)
_bootstrap_paths()
width, _, height = args.size.partition("x")
if args.cpu_only:
report, code = measure_cpu(
frames=args.frames,
vertices=args.vertices,
package=args.package,
key_file=args.key_file,
key_id=args.key_id,
verify_key=args.verify_key,
)
else:
report, code = measure(
frames=args.frames,
profile=args.profile,
width=int(width),
height=int(height or 700),
slow_compose_ms=args.slow_compose_ms,
max_fps=args.max_fps,
vertices=args.vertices,
package=args.package,
key_file=args.key_file,
key_id=args.key_id,
verify_key=args.verify_key,
)
report["exit_code"] = int(code)
text = json.dumps(report, indent=2, ensure_ascii=False, default=str)
if args.output is not None:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(text + "\n", encoding="utf-8")
if args.json or args.output is None:
print(text)
else:
timings = report.get("timings_ms", {})
print(
f"verdict={report['verdict']} frames={report['frames_requested']} "
f"renderer={(report.get('context') or {}).get('renderer', '')}"
)
for stage in (
"submit",
"compose",
"compose_sample",
"compose_normals",
"compose_wall",
"upload",
"paint",
"latency",
):
if stage in timings:
item = timings[stage]
print(
f" {stage:8s} p50={item['p50']:8.3f} p95={item['p95']:8.3f} "
f"max={item['max']:8.3f} n={item['n']}"
)
print(f" wrote {args.output}")
return code
if __name__ == "__main__":
sys.exit(main())
+149 -4
View File
@@ -1,20 +1,39 @@
"""Compile a legacy export into a runtime package (stage 2 of the 3D plan). """Compile a legacy export into a runtime package (stages 2 and 5 of the 3D plan).
Offline only: this is the tool that bakes the coordinate transform, the unit Offline only: this is the tool that bakes the coordinate transform, the unit
scale, the UV sampling table and the identity hashes into one file, so the runtime scale, the UV sampling table and the identity hashes into one file, so the runtime
never repeats that work. never repeats that work. Stage 5 adds the three release protections the design
document asks for, and each is optional:
* ``--sign-key``: sign the package with an Ed25519 private key that stays on the
build machine. The client carries only the matching public key.
* ``--encrypt-key-id`` + ``--key-file``: additionally seal the content with
AES-256-GCM. Encryption never replaces signing; it is added to it.
* ``--lock-*`` (or ``--lock-from-probe``): record where the package may be
displayed. A release compiled for a GPU must not silently render on llvmpipe.
Units are an argument, never a default. The legacy mesh is 15.8 x 15.0 x 7.5 source Units are an argument, never a default. The legacy mesh is 15.8 x 15.0 x 7.5 source
units; whether one of those is a millimetre is a physical claim about the sensor, units; whether one of those is a millimetre is a physical claim about the sensor,
and ``validate_scene.py`` prints the measured candidates so a person can decide. and ``validate_scene.py`` prints the measured candidates so a person can decide.
Example:: Examples::
# an engineering package: plain, unsigned, unlocked
python tools/scene_assets/compile_scene.py \\ python tools/scene_assets/compile_scene.py \\
--legacy-dir sensors/finger/assets/30fps-0703 \\ --legacy-dir sensors/finger/assets/30fps-0703 \\
--map sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz \\ --map sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz \\
--meters-per-source-unit 0.001 \\ --meters-per-source-unit 0.001 \\
--output build/finger-display-1.0.0.oryscene --output build/finger-display-1.0.0.oryscene
# a release package: signed, sealed, locked to the machine we probed
python tools/scene_assets/compile_scene.py \\
--legacy-dir sensors/finger/assets/30fps-0703 \\
--map sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz \\
--meters-per-source-unit 0.001 \\
--sign-key keys/build-ed25519.pem --sign-key-id build-2024 \\
--encrypt-key-id finger-display --key-file keys/finger-display.key \\
--lock-from-probe docs/architecture/gl-report-windows-dev.json \\
--output build/finger-display-1.0.0.oryscene
""" """
from __future__ import annotations from __future__ import annotations
@@ -33,6 +52,77 @@ def _bootstrap_paths() -> None:
sys.path.insert(0, str(path)) sys.path.insert(0, str(path))
def _platform_lock_from_args(args):
"""The lock the command line describes, from a probe report or from flags."""
from orisys_app_shared.scene3d.contracts import PlatformLock
if args.lock_from_probe is not None:
report = json.loads(Path(args.lock_from_probe).read_text(encoding="utf-8"))
context = report.get("context") or {}
host = report.get("host") or {}
os_family = host.get("os_family") or host.get("system") or ""
machine = host.get("machine") or ""
return PlatformLock(
os_family=(str(os_family).lower(),) if os_family else (),
machine=(str(machine).lower(),) if machine else (),
gl_profile=(
str(context.get("profile_used")) if context.get("profile_used") else None
),
allowed_vendors=(str(context["vendor"]),) if context.get("vendor") else (),
allowed_renderers=(str(context["renderer"]),) if context.get("renderer") else (),
forbid_software_renderer=not args.allow_software_renderer,
min_texture_size=int(context.get("max_texture_size") or 0),
note=str(args.lock_note or f"qualified from {Path(args.lock_from_probe).name}"),
)
lock = PlatformLock(
os_family=tuple(v.lower() for v in (args.lock_os or ())),
machine=tuple(v.lower() for v in (args.lock_machine or ())),
gl_profile=args.lock_gl_profile,
allowed_vendors=tuple(args.lock_vendor or ()),
allowed_renderers=tuple(args.lock_renderer or ()),
forbid_software_renderer=not args.allow_software_renderer,
min_texture_size=int(args.lock_min_texture or 0),
note=str(args.lock_note or ""),
)
return None if lock.is_unrestricted() and not args.lock_note else lock
def _sealing_from_args(args):
"""The key material the command line points at, or ``None`` for a plain package."""
from orisys_app_shared.scene3d.contracts import Sealing
from orisys_app_shared.scene3d import protect
signing_pem = None
if args.sign_key is not None:
path = Path(args.sign_key)
if not path.is_file():
raise SystemExit(f"the signing key is missing: {path}")
signing_pem = path.read_bytes()
# Prove the key is usable before spending a compile on it.
protect.public_key_pem(signing_pem)
provider = None
if args.encrypt_key_id:
if args.key_file is None:
raise SystemExit("--encrypt-key-id also needs --key-file")
provider = protect.load_key_file(Path(args.key_file), key_id=args.encrypt_key_id)
if signing_pem is None and provider is None:
return None
verify = protect.public_key_pem(signing_pem) if signing_pem is not None else None
if verify is not None and args.write_verify_key is not None:
# The client's half of the pair. Publishing it is part of the release, and
# writing it here keeps it from being re-derived differently somewhere else.
target = Path(args.write_verify_key)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(verify)
return Sealing(
key_id=str(args.encrypt_key_id or ""),
provider=provider,
signing_key_pem=signing_pem,
signing_key_reference=str(args.sign_key_id or "build-key"),
verify_key_pem=verify,
)
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--legacy-dir", type=Path, required=True, help="legacy NPZ export") parser.add_argument("--legacy-dir", type=Path, required=True, help="legacy NPZ export")
@@ -54,6 +144,37 @@ def main(argv: list[str] | None = None) -> int:
default=None, default=None,
help="optional 4x4 rigid transform (npy) from source to sensor coordinates", help="optional 4x4 rigid transform (npy) from source to sensor coordinates",
) )
protection = parser.add_argument_group(
"release protection (stage 5)", "all optional; a plain package is the default"
)
protection.add_argument("--sign-key", type=Path, default=None, help="Ed25519 private key (PEM)")
protection.add_argument("--sign-key-id", default="", help="name recorded in the envelope")
protection.add_argument(
"--write-verify-key",
type=Path,
default=None,
help="write the public half of --sign-key here (this is what the client ships)",
)
protection.add_argument("--encrypt-key-id", default="", help="content key id to seal with")
protection.add_argument("--key-file", type=Path, default=None, help="key for --encrypt-key-id")
protection.add_argument(
"--lock-from-probe",
type=Path,
default=None,
help="take the platform lock from a probe_gl.py report",
)
protection.add_argument("--lock-os", action="append", default=None, help="repeatable")
protection.add_argument("--lock-machine", action="append", default=None, help="repeatable")
protection.add_argument("--lock-gl-profile", default=None, choices=(None, "desktop33", "gles30"))
protection.add_argument("--lock-vendor", action="append", default=None, help="repeatable")
protection.add_argument("--lock-renderer", action="append", default=None, help="repeatable")
protection.add_argument("--lock-min-texture", type=int, default=0)
protection.add_argument("--lock-note", default="")
protection.add_argument(
"--allow-software-renderer",
action="store_true",
help="do not forbid a software rasteriser (the design document says do)",
)
parser.add_argument("--json", action="store_true") parser.add_argument("--json", action="store_true")
args = parser.parse_args(argv) args = parser.parse_args(argv)
@@ -62,13 +183,19 @@ def main(argv: list[str] | None = None) -> int:
from orisys_app_shared.scene3d.contracts import ThreeDError from orisys_app_shared.scene3d.contracts import ThreeDError
from orisys_app_shared.scene3d.legacy import LegacyFingerImporter from orisys_app_shared.scene3d.legacy import LegacyFingerImporter
from orisys_app_shared.scene3d.package import write_package from orisys_app_shared.scene3d.package import describe_envelope, write_package
transform = ( transform = (
np.load(args.transform, allow_pickle=False) np.load(args.transform, allow_pickle=False)
if args.transform is not None if args.transform is not None
else np.eye(4) else np.eye(4)
) )
try:
sealing = _sealing_from_args(args)
lock = _platform_lock_from_args(args)
except ThreeDError as exc:
print(f"rejected: {exc.diagnostic.code}: {exc.diagnostic.message}", file=sys.stderr)
return 2
try: try:
assets = LegacyFingerImporter().convert( assets = LegacyFingerImporter().convert(
args.legacy_dir, args.legacy_dir,
@@ -79,14 +206,21 @@ def main(argv: list[str] | None = None) -> int:
asset_version=args.version, asset_version=args.version,
lod_id=args.lod_id, lod_id=args.lod_id,
) )
if lock is not None:
from dataclasses import replace
# Part of the asset, so it travels into the manifest and the signature.
assets = replace(assets, platform_lock=lock)
report = write_package( report = write_package(
assets, args.output, assets, args.output,
sensor_model=args.sensor_model, sensor_model=args.sensor_model,
hardware_revisions=("*",), hardware_revisions=("*",),
sealing=sealing,
) )
except ThreeDError as exc: except ThreeDError as exc:
print(f"rejected: {exc.diagnostic.code}: {exc.diagnostic.message}", file=sys.stderr) print(f"rejected: {exc.diagnostic.code}: {exc.diagnostic.message}", file=sys.stderr)
return 2 return 2
envelope = describe_envelope(report.output)
summary = { summary = {
"status": "ok", "status": "ok",
"output": str(report.output), "output": str(report.output),
@@ -95,6 +229,10 @@ def main(argv: list[str] | None = None) -> int:
"triangles": report.part_triangle_counts, "triangles": report.part_triangle_counts,
"geometry_id": assets.geometry_id, "geometry_id": assets.geometry_id,
"field_domain_id": assets.field_domain_id, "field_domain_id": assets.field_domain_id,
"envelope": envelope,
"platform_lock": (
assets.platform_lock.to_dict() if assets.platform_lock is not None else None
),
} }
if args.json: if args.json:
print(json.dumps(summary, indent=2, ensure_ascii=False)) print(json.dumps(summary, indent=2, ensure_ascii=False))
@@ -105,6 +243,13 @@ def main(argv: list[str] | None = None) -> int:
) )
print(f" geometry_id={assets.geometry_id}") print(f" geometry_id={assets.geometry_id}")
print(f" asset_hash={report.asset_hash}") print(f" asset_hash={report.asset_hash}")
print(
f" envelope=v{envelope['envelope_version']} {envelope['payload']}"
f"{' signed(' + str(envelope['signing_key']) + ')' if envelope['signed'] else ' unsigned'}"
+ (f" sealed for key {envelope['key_id']}" if envelope["sealed"] else "")
)
if summary["platform_lock"]:
print(f" platform_lock={json.dumps(summary['platform_lock'], ensure_ascii=False)}")
return 0 return 0
+8
View File
@@ -64,6 +64,14 @@ def collect(profile: str, *, keep_open_s: float) -> dict:
"python": sys.version.split()[0], "python": sys.version.split()[0],
"os": f"{platform.system()} {platform.release()}", "os": f"{platform.system()} {platform.release()}",
}, },
# The facts a platform lock is built from (`compile_scene.py
# --lock-from-probe`), named as `PlatformInfo` names them so the two cannot
# drift: changing one of these keys changes what a release can be locked to.
"host": {
"os_family": platform.system().lower(),
"machine": (platform.machine() or "unknown").lower(),
"os": f"{platform.system()} {platform.release()}",
},
} }
try: try:
from OpenGL import __version__ as pyopengl_version from OpenGL import __version__ as pyopengl_version
+560
View File
@@ -0,0 +1,560 @@
"""Long-run check: does the 3D pipeline leak, and is the queue bounded?
Stage 5 deliverable (``docs/architecture/studio-3d-stages.md``). The design
document asks for a two-hour run of acquisition + recording + 3D with a report of the
maximum RSS and the queue bound, and for memory that does not *keep* growing.
Two things make this more than "run it and look at Task Manager":
* **the pipeline is the real one.** Frames are produced on a worker thread, adapted by
the same adapter the acquisition thread uses, offered to the same capacity-one
mailbox, composed on the same executor and dropped by the same counters. What is
synthetic is the *sensor*: a monotonic ramp instead of a camera, because a soak test
that needs hardware cannot run in CI or overnight.
* **the question is the trend, not the number.** A process that allocates 200 MB in
its first minute and then holds steady is healthy; one that grows 1 MB a minute for
two hours is not. So the report keeps every sample and compares the *first* and
*last* quarter means, which is why the verdict can be a fact rather than an
impression.
Run it for the release gate's two hours, or for a few minutes as a smoke test::
python tools/scene_assets/soak_scene.py --minutes 120 \\
--output docs/architecture/soak-report-windows-dev.json
python tools/scene_assets/soak_scene.py --minutes 3 --fps 30 --output short.json
Exit codes: 0 = no growth and the queue stayed bounded, 2 = a check failed,
1 = the arguments were wrong.
"""
from __future__ import annotations
import argparse
import ctypes
import json
import sys
import threading
import time
from pathlib import Path
def _bootstrap_paths() -> None:
here = Path(__file__).resolve()
repo = here.parents[2]
for path in (repo / "apps" / "studio" / "src", repo / "shared" / "src"):
if path.is_dir() and str(path) not in sys.path:
sys.path.insert(0, str(path))
def resident_bytes() -> int:
"""This process's resident set, without a third-party dependency.
Windows reports it through ``GetProcessMemoryInfo``; Linux through ``/proc``. A
missing measurement returns 0 rather than raising: a soak run that cannot read its
own memory is still a soak run, and the report says which was available.
"""
if sys.platform == "win32":
class PROCESS_MEMORY_COUNTERS(ctypes.Structure):
_fields_ = [
("cb", ctypes.c_ulong),
("PageFaultCount", ctypes.c_ulong),
("PeakWorkingSetSize", ctypes.c_size_t),
("WorkingSetSize", ctypes.c_size_t),
("QuotaPeakPagedPoolUsage", ctypes.c_size_t),
("QuotaPagedPoolUsage", ctypes.c_size_t),
("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t),
("QuotaNonPagedPoolUsage", ctypes.c_size_t),
("PagefileUsage", ctypes.c_size_t),
("PeakPagefileUsage", ctypes.c_size_t),
]
counters = PROCESS_MEMORY_COUNTERS()
counters.cb = ctypes.sizeof(counters)
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
psapi = ctypes.WinDLL("psapi", use_last_error=True)
# Both signatures have to be declared: the current-process handle is a
# pseudo-handle (-1), and letting ctypes guess a 32-bit return type turns it
# into an invalid handle whose call fails silently — which is how a memory
# report ends up full of zeros that look like a healthy flat line.
kernel32.GetCurrentProcess.restype = ctypes.c_void_p
psapi.GetProcessMemoryInfo.argtypes = [
ctypes.c_void_p,
ctypes.POINTER(PROCESS_MEMORY_COUNTERS),
ctypes.c_ulong,
]
psapi.GetProcessMemoryInfo.restype = ctypes.c_bool
handle = kernel32.GetCurrentProcess()
if psapi.GetProcessMemoryInfo(handle, ctypes.byref(counters), counters.cb):
return int(counters.WorkingSetSize)
return 0
try:
with open("/proc/self/statm", encoding="ascii") as handle:
pages = int(handle.read().split()[1])
return pages * 4096
except Exception: # noqa: BLE001
return 0
def _quarter_means(samples: list[float]) -> tuple[float, float]:
if len(samples) < 8:
return (samples[0] if samples else 0.0, samples[-1] if samples else 0.0)
quarter = max(1, len(samples) // 4)
first = sum(samples[:quarter]) / quarter
last = sum(samples[-quarter:]) / quarter
return first, last
def run(
*,
minutes: float,
fps: float,
profile: str,
use_gui: bool,
mode: str,
growth_limit_mb: float,
late_rate_limit_mb_per_min: float,
) -> tuple[dict, int]:
"""Produce frames through the real pipeline and watch what it costs over time."""
import numpy as np
from concurrent.futures import ThreadPoolExecutor
from orisys_app_shared.scene3d import synthetic
from orisys_app_shared.scene3d.composer import SceneComposer
from orisys_app_shared.scene3d.contracts import (
DisplaySettings,
FrameIdentity,
validate_assets,
)
report: dict = {
"kind": "orisys-scene-soak-report",
"minutes_requested": float(minutes),
"fps_requested": float(fps),
"profile": profile,
"window": bool(use_gui),
"adapter": "finger",
"scene": mode,
"rss_available": resident_bytes() > 0,
}
app = None
view = None
widget = None
if use_gui:
from PyQt5.QtWidgets import QApplication
from orisys_studio.scene3d.gl_backend import configure_surface_format
from orisys_studio.scene3d.view import Scene3DView
configure_surface_format(profile) # type: ignore[arg-type]
app = QApplication.instance() or QApplication(sys.argv)
view = Scene3DView()
if not view.available:
report["verdict"] = "no_view"
report["reason"] = view.reason
return report, 2
assets = synthetic.poc_scene()
if not view.use_scene(assets, source_id="soak"):
report["verdict"] = "asset_rejected"
report["reason"] = str(view.last_diagnostic)
return report, 2
view.resize(640, 480)
view.show()
view.set_enabled(True)
widget = view.gl_widget
deadline = time.perf_counter() + 10.0
while time.perf_counter() < deadline and not widget._renderer.part_ids:
widget.repaint()
app.processEvents()
time.sleep(0.005)
report["context"] = {
"renderer": getattr(widget.capabilities(), "renderer", ""),
"software_renderer": bool(
getattr(widget.capabilities(), "software_renderer", False)
),
}
else:
from orisys_studio.scene3d.adapters import FingerFrameAdapter
assets = synthetic.poc_scene()
validate_assets(assets)
report["mesh"] = {
"sensing_vertices": int(assets.sensing_part().positions_sensor_m.shape[0]),
"field_domain_hw": [int(assets.shape_hw[0]), int(assets.shape_hw[1])],
}
# The producer is its own thread, exactly like acquisition: the mailbox has to
# absorb a producer that does not care what the display is doing.
shape = tuple(assets.shape_hw)
ramp = np.linspace(0.0, 1.0, int(shape[0]) * int(shape[1]), dtype=np.float32)
flow = np.stack([ramp, ramp], axis=-1).reshape(int(shape[0]), int(shape[1]), 2)
class Packet:
def __init__(self, index: int) -> None:
self.session_id = 1
self.frame_id = int(index)
self.mono_t = float(index) / 30.0
self.flow = flow
self.raw_frame = np.zeros((4, 4, 3), dtype=np.uint8)
executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="soak")
stop = threading.Event()
produced = 0
add_produce, produce_samples, produce_seen = _bounded_collector()
produce_lock = threading.Lock()
if not use_gui:
# Without a window, drive the same adapter + mailbox + compose chain by hand so
# the soak still covers the queue bound (the GUI path is covered by `use_gui`).
from orisys_app_shared.scene3d.contracts import LatestFrameMailbox
adapter = FingerFrameAdapter(
geometry_id=assets.geometry_id,
field_domain_id=assets.field_domain_id,
shape_hw=shape,
)
mailbox: LatestFrameMailbox = LatestFrameMailbox()
composer = SceneComposer()
settings = DisplaySettings()
composed = {"frames": 0}
def work(frame) -> None:
composer.compose(assets, frame, settings)
with produce_lock:
composed["frames"] += 1
def producer() -> None:
nonlocal produced
period = 1.0 / max(fps, 1.0)
next_at = time.perf_counter()
while not stop.is_set():
started = time.perf_counter()
packet = Packet(produced)
identity = FrameIdentity(
source_id="soak", session_id=1, frame_id=produced,
t_rel_s=packet.mono_t, generation=1,
)
frame = adapter.snapshot(packet, None, identity)
if frame is not None:
mailbox.offer(frame)
produced += 1
with produce_lock:
add_produce((time.perf_counter() - started) * 1000.0)
next_at += period
delay = next_at - time.perf_counter()
if delay > 0:
time.sleep(delay)
else:
next_at = time.perf_counter()
thread = threading.Thread(target=producer, name="soak-producer", daemon=True)
thread.start()
samples = _sample_loop(
stop=stop,
executor=executor,
mailbox=mailbox,
work=work,
produced=lambda: produced,
report=report,
minutes=minutes,
)
stop.set()
thread.join(timeout=5.0)
executor.shutdown(wait=False)
inbox = mailbox
else:
def producer() -> None:
nonlocal produced
period = 1.0 / max(fps, 1.0)
next_at = time.perf_counter()
while not stop.is_set():
started = time.perf_counter()
view.submit_packet(Packet(produced))
produced += 1
with produce_lock:
add_produce((time.perf_counter() - started) * 1000.0)
next_at += period
delay = next_at - time.perf_counter()
if delay > 0:
time.sleep(delay)
else:
next_at = time.perf_counter()
thread = threading.Thread(target=producer, name="soak-producer", daemon=True)
thread.start()
samples = _sample_gui_loop(
stop=stop, view=view, app=app, widget=widget, report=report, minutes=minutes
)
stop.set()
thread.join(timeout=5.0)
view.close()
app.processEvents()
inbox = view.coordinator._incoming if view is not None else None
submitted = produce_samples()
report["frames"] = {
"produced": int(produced),
# ``sampled`` says how many timings the percentiles were computed from: the
# collector decimates instead of growing, and a report has to be explicit about
# which of the two numbers it is quoting.
"producer_ms": _percentiles(submitted),
"producer_ms_sampled": len(submitted),
"producer_ms_total": int(produce_seen()),
}
if inbox is not None:
report["queue"] = {
"mailbox_capacity": 1,
"superseded_by_mailbox": int(getattr(inbox, "superseded_frames", 0)),
"max_inflight_composes": 1,
}
if view is not None and view.coordinator is not None:
report["queue"].update(
{
"superseded_display_frames": int(view.coordinator.superseded_frames),
"stale_frames": int(view.coordinator.stale_frames),
"invalid_frames": int(view.coordinator.invalid_frames),
}
)
report["rss_mb"] = _rss_summary(samples, seconds_per_sample=1.0)
report["late_rate_limit_mb_per_min"] = float(late_rate_limit_mb_per_min)
ok, reason = _judge(report, growth_limit_mb)
report["verdict"] = "ok" if ok else "failed"
if not ok:
report["problems"] = [reason]
return report, (0 if ok else 2)
def _sample_loop(*, stop, executor, mailbox, work, produced, report, minutes):
"""CPU mode: tick the mailbox, compose in the background, sample RSS."""
from concurrent.futures import Future
samples: list[float] = []
inflight: list[Future] = []
deadline = time.perf_counter() + minutes * 60.0
next_sample = time.perf_counter()
composed = 0
while time.perf_counter() < deadline:
frame = mailbox.take()
if frame is not None and len(inflight) < 1:
inflight.append(executor.submit(work, frame))
inflight = [item for item in inflight if not item.done()]
composed += 1
now = time.perf_counter()
if now >= next_sample:
samples.append(resident_bytes() / (1024 * 1024))
next_sample = now + 1.0
time.sleep(0.002)
stop.set()
samples.append(resident_bytes() / (1024 * 1024))
report["ticks"] = composed
return samples
def _sample_gui_loop(*, stop, view, app, widget, report, minutes):
samples: list[float] = []
deadline = time.perf_counter() + minutes * 60.0
next_sample = time.perf_counter()
ticks = 0
while time.perf_counter() < deadline:
view.tick()
app.processEvents()
ticks += 1
now = time.perf_counter()
if now >= next_sample:
samples.append(resident_bytes() / (1024 * 1024))
next_sample = now + 1.0
time.sleep(0.004)
stop.set()
samples.append(resident_bytes() / (1024 * 1024))
report["ticks"] = ticks
return samples
def _bounded_collector(capacity: int = 20000):
"""A percentile sample that cannot itself grow with the run.
The first version appended every frame's timing to a list, which over a two-hour
run is 400k floats — a soak test whose own bookkeeping grows linearly reports a
leak that is its own. Samples are taken at a fixed stride once ``capacity`` is
reached, so the memory the measurement costs is bounded by construction.
"""
values: list[float] = []
seen = 0
def add(value: float) -> None:
nonlocal seen
seen += 1
if len(values) < capacity:
values.append(value)
return
# Decimate in place: drop every other retained sample and double the stride.
del values[1::2]
values.append(value)
return add, lambda: values, lambda: seen
def _percentiles(values: list[float]) -> dict[str, float]:
if not values:
return {"p50": 0.0, "p95": 0.0, "max": 0.0, "n": 0}
ordered = sorted(values)
index = max(0, min(len(ordered) - 1, int(round(0.95 * len(ordered))) - 1))
return {
"p50": round(ordered[len(ordered) // 2], 4),
"p95": round(ordered[index], 4),
"max": round(ordered[-1], 4),
"n": len(ordered),
}
def _rss_summary(samples: list[float], *, seconds_per_sample: float = 1.0) -> dict:
if not samples or max(samples) <= 0.0:
# All zeros means the platform call failed, not that the process uses no
# memory: reporting a flat line at 0 MB would be a false clean bill of health.
return {"available": False}
first, last = _quarter_means(samples)
quarter = max(1, len(samples) // 4)
# The rate that matters is the one *after* the process has stopped warming up: a
# run that allocates 5 MB in its first seconds and then holds is healthy, and
# comparing the first minute with the last would call it a leak. The warm-up is
# the first tenth of the run (at least a minute), and the late rate is measured
# over its last quarter.
warmup = max(1, min(len(samples) // 4, max(int(60 / seconds_per_sample), len(samples) // 10)))
late_window = samples[-quarter:]
late_rate = (
(late_window[-1] - late_window[0]) / max(len(late_window) * seconds_per_sample / 60.0, 1e-9)
)
# The rate of each quarter, because the question a soak test really answers is
# "is it still climbing when the run ends": a rate that falls quarter by quarter is
# an allocator settling; the same rate in every quarter is a leak.
quarter_rates = []
for index in range(4):
start = index * quarter
end = min(len(samples), (index + 1) * quarter)
if end - start < 2:
continue
minutes = (end - start) * seconds_per_sample / 60.0
quarter_rates.append(round((samples[end - 1] - samples[start]) / max(minutes, 1e-9), 4))
return {
"available": True,
"samples": len(samples),
"seconds_per_sample": round(float(seconds_per_sample), 3),
"max_mb": round(max(samples), 2),
"min_mb": round(min(samples), 2),
"first_quarter_mean_mb": round(first, 2),
"last_quarter_mean_mb": round(last, 2),
"growth_mb": round(last - first, 2),
"after_warmup_growth_mb": round(samples[-1] - samples[warmup - 1], 2),
"late_rate_mb_per_min": round(late_rate, 4),
"quarter_rates_mb_per_min": quarter_rates,
"series_mb": [round(value, 2) for value in samples],
}
def _judge(report: dict, growth_limit_mb: float) -> tuple[bool, str]:
"""The verdict: bounded growth after warm-up, and a queue that cannot grow at all.
Two memory questions, both asked because either alone can be fooled:
* absolute growth from the end of the warm-up to the end of the run, so a slow
climb is caught even when the per-minute rate is small;
* the *rate over the last quarter*, so a run that is still climbing when it stops
is not called flat just because the total is under the limit.
"""
rss = report.get("rss_mb") or {}
if not rss.get("available"):
return False, "resident memory could not be read on this platform"
growth = float(rss.get("growth_mb", 0.0))
if growth > growth_limit_mb:
return False, (
f"resident memory grew {growth:.1f} MB between the first and last quarter "
f"(limit {growth_limit_mb:.1f} MB): the pipeline is holding on to frames"
)
rate = float(rss.get("late_rate_mb_per_min", 0.0))
rate_limit = float(report.get("late_rate_limit_mb_per_min", 0.5))
if rate > rate_limit:
return False, (
f"resident memory is still climbing at {rate:.3f} MB/min in the last "
f"quarter (limit {rate_limit:.3f}): this is continuous growth, not "
"allocator noise"
)
queue = report.get("queue") or {}
if int(queue.get("mailbox_capacity", 0)) != 1:
return False, "the mailbox is not capacity one: the queue can grow"
if int(queue.get("max_inflight_composes", 0)) > 1:
return False, "more than one compose may be in flight: the queue can grow"
return True, ""
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--minutes", type=float, default=120.0, help="run length")
parser.add_argument("--fps", type=float, default=30.0, help="producer rate")
parser.add_argument("--profile", default="desktop33", choices=("desktop33", "gles30"))
parser.add_argument(
"--no-window",
action="store_true",
help="drive the adapter/mailbox/compose chain without a GL window (runs anywhere)",
)
parser.add_argument("--scene", default="synthetic-poc")
parser.add_argument(
"--growth-limit-mb",
type=float,
default=64.0,
help="allowed growth between the first and last quarter (allocator noise)",
)
parser.add_argument(
"--late-rate-limit",
type=float,
default=1.0,
help=(
"allowed MB/min in the last quarter: a run still climbing when it stops is "
"not flat, however small the total. The default is set from measurement, not "
"from hope: on the developer machine this pipeline shows about 0.35 MB/min "
"in its tail after warm-up (see docs/architecture/soak-report-windows-dev."
"json), which the stage-by-stage probe attributes to allocator/page "
"behaviour rather than to retained objects. Tighten it on a platform that "
"is quieter."
),
)
parser.add_argument("--output", type=Path, default=None)
parser.add_argument("--json", action="store_true")
args = parser.parse_args(argv)
_bootstrap_paths()
report, code = run(
minutes=args.minutes,
fps=args.fps,
profile=args.profile,
use_gui=not args.no_window,
mode=args.scene,
growth_limit_mb=args.growth_limit_mb,
late_rate_limit_mb_per_min=args.late_rate_limit,
)
report["exit_code"] = int(code)
text = json.dumps(report, indent=2, ensure_ascii=False, default=str)
if args.output is not None:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(text + "\n", encoding="utf-8")
if args.json or args.output is None:
print(text)
else:
rss = report.get("rss_mb", {})
print(
f"verdict={report['verdict']} minutes={report['minutes_requested']} "
f"frames={report['frames']['produced']} ticks={report.get('ticks')}"
)
print(
f" rss first={rss.get('first_quarter_mean_mb')} MB "
f"last={rss.get('last_quarter_mean_mb')} MB max={rss.get('max_mb')} MB "
f"growth={rss.get('growth_mb')} MB"
)
print(f" queue {json.dumps(report.get('queue', {}), ensure_ascii=False)}")
if report.get("problems"):
print(f" problems: {report['problems']}")
print(f" wrote {args.output}")
return code
if __name__ == "__main__":
sys.exit(main())
+92 -7
View File
@@ -143,9 +143,26 @@ def _legacy_report(args) -> tuple[int, dict]:
return (0 if report["status"] == "ok" else 2), report return (0 if report["status"] == "ok" else 2), report
def _load_keys(args):
"""The key provider and verification key the arguments describe, if any."""
from orisys_app_shared.scene3d import protect
provider = None
if args.key_file is not None:
provider = protect.load_key_file(Path(args.key_file), key_id=args.key_id)
verify = None
if args.verify_key is not None:
path = Path(args.verify_key)
if not path.is_file():
raise SystemExit(f"the verification key is missing: {path}")
verify = path.read_bytes()
return provider, verify
def _package_report(args) -> tuple[int, dict]: def _package_report(args) -> tuple[int, dict]:
from orisys_app_shared.scene3d.contracts import ExpectedGeometry, ThreeDError from orisys_app_shared.scene3d.contracts import ExpectedGeometry, ThreeDError
from orisys_app_shared.scene3d.package import ( from orisys_app_shared.scene3d.package import (
describe_envelope,
describe_package, describe_package,
read_package, read_package,
scene_from_contents, scene_from_contents,
@@ -157,14 +174,64 @@ def _package_report(args) -> tuple[int, dict]:
report["checks"].append({"check": name, "ok": bool(ok), "detail": detail}) report["checks"].append({"check": name, "ok": bool(ok), "detail": detail})
try: try:
contents = read_package(Path(args.asset)) provider, verify = _load_keys(args)
record("envelope, manifest and every entry hash", True) except SystemExit as exc:
except ThreeDError as exc: record("key material", False, str(exc))
record("envelope, manifest and every entry hash", False,
f"{exc.diagnostic.code}: {exc.diagnostic.message}")
report["status"] = "rejected" report["status"] = "rejected"
return 2, report return 2, report
report["manifest"] = describe_package(Path(args.asset))
# What the container says about itself, *without* a key: a support engineer can
# report "sealed for key id X, signed by Y" without being able to read it.
try:
report["envelope"] = describe_envelope(Path(args.asset))
record(
"envelope reads",
True,
f"v{report['envelope'].get('envelope_version')} "
f"{report['envelope'].get('payload')}"
f"{' signed' if report['envelope'].get('signed') else ' unsigned'}",
)
except ThreeDError as exc:
record("envelope reads", False, f"{exc.diagnostic.code}: {exc.diagnostic.message}")
report["status"] = "rejected"
return 2, report
if report["envelope"].get("signed"):
record("package is signed", True, str(report["envelope"].get("signing_key") or ""))
else:
# A warning by default, a hard failure when the caller says this is a release:
# an engineering package is legitimately plain, and a release package that is
# plain has no origin, which is the design document's step 2.
record(
"package is signed",
not args.require_signature,
"unsigned: integrity only, no origin (use --require-signature for a release "
"build) "
if not args.require_signature
else "unsigned: a release package must be signed",
)
try:
contents = read_package(Path(args.asset), keys=provider, verify_key_pem=verify)
record("signature, envelope, manifest and every entry hash", True)
except ThreeDError as exc:
record(
"signature, envelope, manifest and every entry hash",
False,
f"{exc.diagnostic.code}: {exc.diagnostic.message}",
)
report["status"] = "rejected"
return 2, report
report["manifest"] = describe_package(
Path(args.asset), keys=provider, verify_key_pem=verify
)
lock = report["manifest"].get("platform_lock")
if lock:
record(
"platform lock of this package",
True,
f"{lock.get('os_family') or 'any OS'} / {lock.get('machine') or 'any arch'}"
f"{' / ' + str(lock.get('gl_profile')) if lock.get('gl_profile') else ''}",
)
try: try:
expected = ( expected = (
ExpectedGeometry( ExpectedGeometry(
@@ -199,6 +266,24 @@ def main(argv: list[str] | None = None) -> int:
) )
parser.add_argument("--sensor-model", default="finger") parser.add_argument("--sensor-model", default="finger")
parser.add_argument("--hardware-revision", default="*") parser.add_argument("--hardware-revision", default="*")
parser.add_argument(
"--key-file",
type=Path,
default=None,
help="content key for a sealed package (hex, base64 or raw)",
)
parser.add_argument("--key-id", default="", help="key id that --key-file holds")
parser.add_argument(
"--verify-key",
type=Path,
default=None,
help="Ed25519 public key (PEM) a signed package must verify against",
)
parser.add_argument(
"--require-signature",
action="store_true",
help="treat an unsigned package as a failure: the release gate",
)
parser.add_argument("--json", action="store_true", help="machine-readable output") parser.add_argument("--json", action="store_true", help="machine-readable output")
args = parser.parse_args(argv) args = parser.parse_args(argv)
@@ -221,7 +306,7 @@ def main(argv: list[str] | None = None) -> int:
for item in report.get("checks", []): for item in report.get("checks", []):
mark = "ok " if item["ok"] else "FAIL" mark = "ok " if item["ok"] else "FAIL"
print(f" {mark} {item['check']} {item['detail']}") print(f" {mark} {item['check']} {item['detail']}")
for key in ("field_domain", "mesh", "uv_correspondence", "scale", "binding"): for key in ("envelope", "field_domain", "mesh", "uv_correspondence", "scale", "binding"):
if key in report: if key in report:
print(f" {key}: {json.dumps(report[key], ensure_ascii=False, default=str)}") print(f" {key}: {json.dumps(report[key], ensure_ascii=False, default=str)}")
if "note" in report: if "note" in report: