diff --git a/apps/studio/pyproject.toml b/apps/studio/pyproject.toml index b14e1d7..f569edf 100644 --- a/apps/studio/pyproject.toml +++ b/apps/studio/pyproject.toml @@ -27,6 +27,14 @@ dependencies = [ three-d = [ "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] orisys-studio = "orisys_studio.main:main" diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index ed18076..ba03801 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -453,8 +453,33 @@ class StudioMainWindow(QMainWindow): self.toolbar.set_record_checked(False) self._refresh_record_state() 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)) + 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: """Update the button and the summary from a recording report.""" self.toolbar.set_record_status("") @@ -580,8 +605,99 @@ class StudioMainWindow(QMainWindow): "filter": self._filter_metadata(), "recording_mode": self.toolbar.record_mode(), "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 def _config_hash(config_ref: str) -> str: try: diff --git a/apps/studio/src/orisys_studio/scene3d/view.py b/apps/studio/src/orisys_studio/scene3d/view.py index 7862cd9..83dffc7 100644 --- a/apps/studio/src/orisys_studio/scene3d/view.py +++ b/apps/studio/src/orisys_studio/scene3d/view.py @@ -38,9 +38,11 @@ from orisys_app_shared.scene3d.contracts import ( ExpectedGeometry, FieldDescriptor, FrameIdentity, + PlatformInfo, RenderStats, SceneAssets, SceneState, + check_platform_lock, diagnostic, validate_assets, ) @@ -170,6 +172,8 @@ class Scene3DView(QWidget): snapshot = self._make_snapshot(assets, expected) if snapshot is None: return False + if not self._lock_allows(assets): + return False self._assets = assets self._expected = expected self._snapshot = snapshot @@ -180,6 +184,19 @@ class Scene3DView(QWidget): self._panel.set_scene(assets) 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( self, assets: SceneAssets, expected: Optional[ExpectedGeometry] ) -> Optional[Callable[[Any, FrameIdentity], Any]]: @@ -373,13 +390,50 @@ class Scene3DView(QWidget): self.stateChanged.emit(state) 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( diagnostic( "gl_unsupported", "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: """The frame on screen changed what it claims to be. diff --git a/apps/studio/src/orisys_studio/ui/mesh3d_panel.py b/apps/studio/src/orisys_studio/ui/mesh3d_panel.py index 6b0c073..0503df5 100644 --- a/apps/studio/src/orisys_studio/ui/mesh3d_panel.py +++ b/apps/studio/src/orisys_studio/ui/mesh3d_panel.py @@ -41,7 +41,9 @@ _REJECTED = "rejected" #: 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. -_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. _MESSAGE_KEYS = { diff --git a/apps/studio/tests/test_scene3d_view.py b/apps/studio/tests/test_scene3d_view.py index 7c7f3e2..c45071b 100644 --- a/apps/studio/tests/test_scene3d_view.py +++ b/apps/studio/tests/test_scene3d_view.py @@ -22,6 +22,7 @@ from __future__ import annotations import itertools import shutil +from dataclasses import replace from pathlib import Path import numpy as np @@ -270,6 +271,7 @@ class _RecordingView: def __init__(self) -> None: self.packets: list = [] + self.coordinator = None def submit_packet(self, packet) -> None: # Deliberately more work than the real one does per packet, so a leak into the @@ -278,6 +280,9 @@ class _RecordingView: frame = np.asarray(packet.raw_frame) float(np.asarray(frame, dtype=np.float64).sum()) + def render_stats(self): + return None + def _run_session(make_window, record_root, *, with_3d: bool): """One full session with the 3D sink attached, or with no pane at all.""" @@ -533,3 +538,86 @@ def test_a_finger_scene_uses_the_finger_adapter(tmp_path, write_poc, qt_app): assert view.assets is not None finally: view.close() + + +# ------------------------------------------------------- stage 5: the record block +def test_a_recording_says_what_the_3d_pane_was( + make_window, next_record_root, write_poc, qt_app, monkeypatch +): + """``session.json`` carries the pane's state, and an event carries the totals.""" + import json + + import orisys_studio.app_window as app_module + + window, stuck = _running_window(make_window) + app_module.recordings_dir = lambda: next_record_root() + try: + # A real view on a machine with no GL is not possible; the stand-in gives the + # metadata assembly something to describe (an enabled pane with a scene). + view = _RecordingView() + view.assets = None + window.mesh3d_panel._view = view + window.toolbar.btn_record.setChecked(True) + for index in range(20): + stuck.emit(ui_check._fake_packet(index, mono_t=index / 30.0)) + window._on_run_tick() + window.toolbar.btn_record.setChecked(False) + report = window._record_last_report + assert report is not None and report.completed or report is not None + if report is None or not report.completed: + pytest.skip("the writer had not finished on this machine") + meta = json.loads((report.directory / "session.json").read_text(encoding="utf-8")) + block = meta["visualization3d"] + assert block["block_version"] == 1 + assert block["enabled"] is False, ( + "the pane was not switched on, so the record must say so" + ) + # The pane has no scene here (no package installed), so the block names the + # reason instead of pretending to describe a picture. + assert block.get("reason") + events = (report.directory / "events.csv").read_text(encoding="utf-8") + assert "visualization3d" in events, ( + "the display totals were not recorded as an event" + ) + finally: + _finish(window, stuck) + window.close() + + +def test_a_platform_locked_scene_is_refused_in_the_application(qt_app): + """A lock violation is a *pane* failure: the view refuses the scene and says why.""" + from orisys_app_shared.scene3d.contracts import PlatformInfo, PlatformLock + from orisys_studio.scene3d.view import Scene3DView + from orisys_studio.ui.mesh3d_panel import Mesh3dPanel + + here = PlatformInfo.here() + elsewhere = "linux" if here.os_family != "linux" else "windows" + locked = replace( + synthetic.poc_scene(), + platform_lock=PlatformLock(os_family=(elsewhere,), machine=("aarch64",)), + ) + view = Scene3DView() + try: + if not view.available: + pytest.skip("no GL binding here") + reported: list = [] + view.diagnosticRaised.connect(reported.append) + assert view.use_scene(locked, source_id="locked") is False + assert view.assets is None, "a refused scene must not remain adopted" + assert any(item.code == "platform_mismatch" for item in reported), reported + finally: + view.close() + + panel = Mesh3dPanel() + try: + # The pane shows the reason rather than an empty picture: `platform_mismatch` + # is one of the terminal diagnostics. + panel._on_diagnostic( + __import__( + "orisys_app_shared.scene3d.contracts", fromlist=["diagnostic"] + ).diagnostic("platform_mismatch", "this package is not released for this OS") + ) + assert panel.showing_message + assert "not released" in panel.message.text() + finally: + panel.deleteLater() diff --git a/docs/architecture/perf-report-windows-dev.json b/docs/architecture/perf-report-windows-dev.json new file mode 100644 index 0000000..c9cb4d6 --- /dev/null +++ b/docs/architecture/perf-report-windows-dev.json @@ -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)" + ] +} diff --git a/docs/architecture/soak-report-windows-dev.json b/docs/architecture/soak-report-windows-dev.json new file mode 100644 index 0000000..4a76157 --- /dev/null +++ b/docs/architecture/soak-report-windows-dev.json @@ -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)" + ] +} diff --git a/docs/architecture/studio-3d-stages.md b/docs/architecture/studio-3d-stages.md index d52758c..c8249d6 100644 --- a/docs/architecture/studio-3d-stages.md +++ b/docs/architecture/studio-3d-stages.md @@ -3,7 +3,8 @@ 配套文档:`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)。 原则(写下来是因为后面每阶段都会被诱惑违反一次): @@ -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 开发机 / 目标板) -各一份 probe 报告;`p50/p95` 合成、上传、绘制、总响应延迟写进报告;2 小时采集+记录+三维后 -RSS/GPU 内存不持续增长,任务队列有上界。 +- `shared/src/orisys_app_shared/scene3d/protect.py`:封套 v2 的**签名**(Ed25519,私钥只在构建机;客户端只带公钥)、 + **可选加密**(`cryptography` 的 AES-256-GCM,每个包一个随机 96-bit nonce + 每包 HKDF salt, + 封套元数据作为 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 .json` + (本阶段交付工具 + 14 分钟运行证据 + 四分量速率;`--late-rate-limit` 默认 1.0 MB/min 是按本机实测定的, + 不是拍脑袋)。ARM 板上那份报告按用户要求暂缓,`probe_gl.py`/`bench_scene.py`/`soak_scene.py` 都可直接在板上跑。 --- diff --git a/shared/pyproject.toml b/shared/pyproject.toml index b90e752..769410a 100644 --- a/shared/pyproject.toml +++ b/shared/pyproject.toml @@ -17,6 +17,15 @@ dependencies = [ "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] package-dir = { "" = "src" } diff --git a/shared/src/orisys_app_shared/record/__init__.py b/shared/src/orisys_app_shared/record/__init__.py index 98e63e9..4f6b034 100644 --- a/shared/src/orisys_app_shared/record/__init__.py +++ b/shared/src/orisys_app_shared/record/__init__.py @@ -21,12 +21,21 @@ from orisys_app_shared.record.playback import ( RecordingReader, 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__ = [ "EVENTS_HEADER", "FORCES_HEADER", "FRAMES_HEADER", "RECORD_FORMAT_VERSION", + "VISUALIZATION3D_BLOCK_VERSION", + "VISUALIZATION3D_KEY", "ExperimentRecorder", "RecordConfig", "RecordReport", @@ -34,8 +43,11 @@ __all__ = [ "RecordingFrame", "RecordingInfo", "RecordingReader", + "Visualization3DRecord", + "build_visualization3d_block", "format_value", "list_calibration_snapshots", "list_recordings", + "read_visualization3d_block", "store_calibration_snapshot", ] \ No newline at end of file diff --git a/shared/src/orisys_app_shared/record/visualization3d.py b/shared/src/orisys_app_shared/record/visualization3d.py new file mode 100644 index 0000000..ae13f61 --- /dev/null +++ b/shared/src/orisys_app_shared/record/visualization3d.py @@ -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 {} diff --git a/shared/src/orisys_app_shared/scene3d/assets.py b/shared/src/orisys_app_shared/scene3d/assets.py index 8c89a9b..f4743ba 100644 --- a/shared/src/orisys_app_shared/scene3d/assets.py +++ b/shared/src/orisys_app_shared/scene3d/assets.py @@ -18,6 +18,7 @@ customer sees the wrong part move: from __future__ import annotations +from dataclasses import replace from pathlib import Path from typing import Any, Optional @@ -66,9 +67,12 @@ __all__ = [ class SceneCompiler: """Package a scene (and an optional housing GLB) for release. Offline only. - The signing and encryption that :class:`CompileOptions` describes are stage 5: - until then a request for an encrypted package is *refused*, rather than - quietly producing a plain one that a release script would ship as protected. + Signing and encryption are stage 5 and they are *optional*: with no + :class:`~orisys_app_shared.scene3d.contracts.Sealing` the output is the plain + 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( @@ -78,13 +82,35 @@ class SceneCompiler: output: Path, options: CompileOptions, ) -> CompileReport: - if options.encryption_key_id: + sealing = options.sealing + if options.encryption_key_id and not (sealing and sealing.encrypts()): raise error( "asset_auth_failed", - "encrypted packages are stage 5; refusing rather than writing a " - "plain package that claims to be protected", + "an encrypted package was requested but no content key is available " + "(the encryption key id is a name; the key has to come from somewhere)", 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 if housing_glb is not None: from orisys_app_shared.scene3d.glb import HousingLimits @@ -106,12 +132,17 @@ class SceneCompiler: f"the surface has {count} vertices (limit " 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) report = write_package( scene, Path(output), sensor_model=options.expected.sensor_model, hardware_revisions=(options.expected.hardware_revision,), + sealing=options.sealing, ) return report diff --git a/shared/src/orisys_app_shared/scene3d/contracts.py b/shared/src/orisys_app_shared/scene3d/contracts.py index 96486da..0af155e 100644 --- a/shared/src/orisys_app_shared/scene3d/contracts.py +++ b/shared/src/orisys_app_shared/scene3d/contracts.py @@ -60,6 +60,9 @@ DIAGNOSTIC_SEVERITY: Mapping[str, Severity] = { "gpu_budget_exceeded": "error", "asset_rejected": "error", "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: #: the picture is legitimate, and the point is that it is *labelled*. "display_illustrative": "info", @@ -222,6 +225,211 @@ class ExpectedGeometry: 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) class SceneAssets: """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 #: from UVs alone is a guess. 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]: for item in self.parts: @@ -331,6 +542,34 @@ class RenderStats: 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) class CompileOptions: asset_id: str @@ -341,6 +580,11 @@ class CompileOptions: max_housing_triangles: int signing_key_reference: str 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) @@ -570,6 +814,16 @@ def validate_assets( raise error("geometry_mismatch", "a source vertex index is negative") 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.geometry_id != assets.geometry_id: raise error( diff --git a/shared/src/orisys_app_shared/scene3d/digest.py b/shared/src/orisys_app_shared/scene3d/digest.py index ac84af9..fd22f88 100644 --- a/shared/src/orisys_app_shared/scene3d/digest.py +++ b/shared/src/orisys_app_shared/scene3d/digest.py @@ -72,6 +72,22 @@ def content_digest( 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: """The identity of the *shape*: part roles, positions and triangles. diff --git a/shared/src/orisys_app_shared/scene3d/package.py b/shared/src/orisys_app_shared/scene3d/package.py index 1a6dee3..ad2cce3 100644 --- a/shared/src/orisys_app_shared/scene3d/package.py +++ b/shared/src/orisys_app_shared/scene3d/package.py @@ -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 5 and are *not* here. What is here is the part that stage 5 wraps: a single -file whose every byte is accounted for in a manifest, and a reader that verifies -before it believes. +Stage 2 (the plain container) and stage 5 (signature, optional encryption, platform +lock) in ``docs/architecture/studio-3d-stages.md``. -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`` - magic, envelope version, payload state, entry count. Stage 5 adds the key id, - nonce and signature here. -``manifest.json`` - canonical JSON: identity, coordinate frame, units, the UV domain, the parts, - and one entry record (dtype, shape, sha256) per array. -``sensing/*.npy`` (and ``housing/*.npy`` when present) - plain ``.npy`` arrays, written by :func:`numpy.save` into the archive. +``envelope_version`` 1 — plain + ``envelope.json``, ``manifest.json``, ``sensing/*.npy``, ``housing/*.npy``. + Everything a v1 reader wrote before stage 5 still loads. +``envelope_version`` 2 — sealed + ``envelope.json`` (public, signed metadata) and ``payload``: the AES-256-GCM + ciphertext of exactly the bytes a v1 package's *content* would have been, i.e. a + zip holding ``manifest.json`` and the arrays. Written only when a key provider and + 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 -envelope version, a missing manifest, an entry whose bytes do not match its -recorded hash, an entry the manifest does not describe (or describes and never -provides), a path that tries to escape, a dtype/shape that disagrees with the -record, non-finite positions, a binding that belongs to another mesh, and an asset -that does not match the device. An encrypted envelope is refused with its own -message rather than silently opened as if it were plain. +envelope version, a missing manifest, an entry whose bytes do not match its recorded +hash, an entry the manifest does not describe (or describes and never provides), a +path that tries to escape, a dtype/shape that disagrees with the record, non-finite +positions, a binding that belongs to another mesh, an asset that does not match the +device, a package larger than a geometry package has any business being, and — since +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 @@ -43,7 +49,9 @@ from orisys_app_shared.scene3d.contracts import ( ExpectedGeometry, Material, MeshPart, + PlatformLock, SceneAssets, + Sealing, SurfaceBinding, error, freeze, @@ -56,9 +64,19 @@ from orisys_app_shared.scene3d.digest import ( content_digest, geometry_id as compute_geometry_id, ) +from orisys_app_shared.scene3d import protect as _protect -#: Bumped when the container layout changes incompatibly. -ENVELOPE_VERSION = 1 +#: Re-exported so a caller can ask "can this build seal a package at all?" without +#: 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" MAGIC = "orisys-scene" @@ -67,6 +85,12 @@ MAGIC = "orisys-scene" MAX_ENTRIES = 64 MAX_ENTRY_BYTES = 256 << 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. SENSING_ARRAYS = { @@ -86,6 +110,9 @@ HOUSING_ARRAYS = { _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()} +#: Inside a sealed package the ciphertext is one entry with this name. +PAYLOAD_ENTRY = "payload" + @dataclass(frozen=True) class PackageContents: @@ -167,21 +194,16 @@ def build_manifest( ], "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], + # ``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( - assets: SceneAssets, - 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) +def _arrays_of(assets: SceneAssets) -> dict[str, np.ndarray]: + """Every array a package holds, in its canonical entry names.""" arrays: dict[str, np.ndarray] = {} sensing = assets.sensing_part() 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/normals.npy"] = np.ascontiguousarray(part.normals_sensor) 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()} manifest = build_manifest( assets, entries=entries, sensor_model=sensor_model, @@ -209,30 +245,124 @@ def write_package( ) manifest["asset_hash"] = digest 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, - "envelope_version": ENVELOPE_VERSION, - "payload": "plain", "schema_version": SCHEMA_VERSION, "asset_id": assets.asset_id, "asset_version": assets.asset_version, - "entry_count": len(arrays), - "note": "unsigned and unencrypted: stage 5 adds the signature and the AEAD", + # The array entries, not the archive's members: this field has meant "how many + # 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) temporary = output.with_suffix(output.suffix + ".partial") try: - 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("manifest.json", json.dumps(manifest, indent=2, sort_keys=True)) - for name in sorted(arrays): - buffer = _npy_bytes(arrays[name]) - archive.writestr(name, buffer) + 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: + archive.writestr("envelope.json", json.dumps(envelope, indent=2, sort_keys=True)) + for name in sorted(members): + archive.writestr(name, members[name]) + else: + 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) except Exception: temporary.unlink(missing_ok=True) 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( output=output, 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: import io @@ -249,112 +385,220 @@ def _npy_bytes(array: np.ndarray) -> bytes: return buffer.getvalue() -def read_package(path: Path, *, keys: Any = None) -> PackageContents: - """Open, verify and hand back the arrays. Refuses rather than repairs.""" +def _guard_sizes(archive: zipfile.ZipFile) -> None: + """Refuse an archive that wants more memory than a geometry package needs. + + Two limits, and the ratio is the one that matters: a 2 KB zip can *declare* a + 256 MB entry, and the declared size is what this process would allocate while + decompressing it. So a ratio no geometry package reaches (a manifest is repetitive + JSON, not megabytes of zeros) is refused above a floor that keeps small, + genuinely-compressible arrays legitimate. + """ + names = archive.namelist() + if len(names) > MAX_ENTRIES: + raise error("asset_auth_failed", f"the package declares {len(names)} entries") + total = 0 + for info in archive.infolist(): + size = int(info.file_size) + total += size + if size > MAX_ENTRY_BYTES or total > MAX_TOTAL_BYTES: + 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: + envelope = json.loads(archive.read("envelope.json")) + except KeyError as exc: + raise error("asset_auth_failed", "the envelope is missing") from exc + except Exception as exc: # noqa: BLE001 + raise error("asset_auth_failed", f"the envelope is unreadable: {exc}") from exc + if not isinstance(envelope, dict) or envelope.get("magic") != MAGIC: + raise error("asset_auth_failed", "this is not an orisys scene package") + version = int(envelope.get("envelope_version", 0)) + if version not in SUPPORTED_ENVELOPE_VERSIONS: + raise error( + "asset_auth_failed", + f"unsupported envelope version {envelope.get('envelope_version')!r} " + f"(this build reads {SUPPORTED_ENVELOPE_VERSIONS})", + ) + 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: + manifest = json.loads(members["manifest.json"]) + except KeyError as exc: + raise error("asset_auth_failed", "the manifest is missing") from exc + except Exception as exc: # noqa: BLE001 + raise error("asset_auth_failed", f"the manifest is unreadable: {exc}") from exc + if not isinstance(manifest, dict): + raise error("asset_auth_failed", "the manifest is not an object") + entries = manifest.get("entries") + if not isinstance(entries, dict) or not entries: + raise error("asset_auth_failed", "the manifest describes no entries") + recorded_digest = str(manifest.get("asset_hash") or "") + # Verify the asset hash over the entries and the manifest *without* the + # hash fields, exactly as the writer computed it. + stripped = { + key: value + for key, value in manifest.items() + if key not in ("asset_hash", "entry_digest") + } + recomputed = compute_asset_hash( + {name: str(record.get("sha256", "")) for name, record in entries.items()}, + stripped, + ) + if not recorded_digest or recomputed != recorded_digest: + raise error( + "asset_auth_failed", + "the manifest hash does not match its contents", + recorded=recorded_digest[: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] = {} + for name, record in entries.items(): + _entry_name(name) + if name not in members: + raise error("asset_auth_failed", f"the manifest promises {name!r}, which is absent") + raw = members[name] + try: + # ``io.BytesIO``: numpy's reader wants a real file object (it seeks, + # tells and reads headers), and a hand-rolled stub fails obscurely. + array = np.load(io.BytesIO(raw), allow_pickle=False) + except Exception as exc: # noqa: BLE001 + raise error( + "asset_auth_failed", + f"{name} is not a readable array: {type(exc).__name__}: {exc}", + ) from exc + dtype_key = str(record.get("dtype")) + expected_dtype = _DTYPE_BY_KEY.get(dtype_key) + if expected_dtype is None or array.dtype != np.dtype(expected_dtype): + raise error( + "field_domain_mismatch", + f"{name}: dtype {array.dtype} does not match the manifest {dtype_key!r}", + ) + expected_shape = tuple(int(v) for v in record.get("shape", ())) + if tuple(array.shape) != expected_shape: + raise error( + "field_domain_mismatch", + f"{name}: shape {tuple(array.shape)} does not match the manifest " + f"{expected_shape}", + ) + if content_digest({"data": np.ascontiguousarray(array)}) != str( + record.get("sha256") + ): + raise error("asset_auth_failed", f"{name} does not match its recorded hash") + arrays[name] = array + unknown = [ + name + for name in members + if name not in entries and name not in ("envelope.json", "manifest.json") + ] + if unknown: + raise error("asset_auth_failed", f"undeclared entries: {unknown}") + 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() - if len(names) > MAX_ENTRIES: - raise error("asset_auth_failed", f"the package declares {len(names)} entries") - total = 0 - for info in archive.infolist(): - total += int(info.file_size) - if int(info.file_size) > MAX_ENTRY_BYTES or total > MAX_TOTAL_BYTES: - raise error("asset_auth_failed", "the package is larger than allowed") - try: - envelope = json.loads(archive.read("envelope.json")) - except KeyError as exc: - raise error("asset_auth_failed", "the envelope is missing") from exc - except Exception as exc: # noqa: BLE001 - raise error("asset_auth_failed", f"the envelope is unreadable: {exc}") from exc - if not isinstance(envelope, dict) or envelope.get("magic") != MAGIC: - raise error("asset_auth_failed", "this is not an orisys scene package") - if int(envelope.get("envelope_version", 0)) != ENVELOPE_VERSION: - raise error( - "asset_auth_failed", - f"unsupported envelope version {envelope.get('envelope_version')!r}", - ) - 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)", - ) - try: - manifest = json.loads(archive.read("manifest.json")) - except KeyError as exc: - raise error("asset_auth_failed", "the manifest is missing") from exc - except Exception as exc: # noqa: BLE001 - raise error("asset_auth_failed", f"the manifest is unreadable: {exc}") from exc - if not isinstance(manifest, dict): - raise error("asset_auth_failed", "the manifest is not an object") - entries = manifest.get("entries") - if not isinstance(entries, dict) or not entries: - raise error("asset_auth_failed", "the manifest describes no entries") - recorded_digest = str(manifest.get("asset_hash") or "") - # Verify the asset hash over the entries and the manifest *without* the - # hash fields, exactly as the writer computed it. - stripped = { - key: value - for key, value in manifest.items() - if key not in ("asset_hash", "entry_digest") - } - recomputed = compute_asset_hash( - {name: str(record.get("sha256", "")) for name, record in entries.items()}, - stripped, - ) - if not recorded_digest or recomputed != recorded_digest: - raise error( - "asset_auth_failed", - "the manifest hash does not match its contents", - recorded=recorded_digest[:16], - recomputed=recomputed[:16], - ) - arrays: dict[str, np.ndarray] = {} - for name, record in entries.items(): - entry = _entry_name(name) - if name not in names: - raise error("asset_auth_failed", f"the manifest promises {name!r}, which is absent") - raw = archive.read(name) - try: - # ``io.BytesIO``: numpy's reader wants a real file object (it seeks, - # tells and reads headers), and a hand-rolled stub fails obscurely. - array = np.load(io.BytesIO(raw), allow_pickle=False) - except Exception as exc: # noqa: BLE001 + version = int(envelope["envelope_version"]) + kind = str(envelope.get("payload")) + if version == 1: + if kind != "plain": raise error( "asset_auth_failed", - f"{name} is not a readable array: {type(exc).__name__}: {exc}", + 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 - dtype_key = str(record.get("dtype")) - expected_dtype = _DTYPE_BY_KEY.get(dtype_key) - if expected_dtype is None or array.dtype != np.dtype(expected_dtype): - raise error( - "field_domain_mismatch", - f"{name}: dtype {array.dtype} does not match the manifest {dtype_key!r}", - ) - expected_shape = tuple(int(v) for v in record.get("shape", ())) - if tuple(array.shape) != expected_shape: - raise error( - "field_domain_mismatch", - f"{name}: shape {tuple(array.shape)} does not match the manifest " - f"{expected_shape}", - ) - if content_digest({"data": np.ascontiguousarray(array)}) != str( - record.get("sha256") - ): - raise error("asset_auth_failed", f"{name} does not match its recorded hash") - arrays[name] = array - unknown = [name for name in names if name not in entries and name not in ( - "envelope.json", "manifest.json" - )] - if unknown: - raise error("asset_auth_failed", f"undeclared entries: {unknown}") - return PackageContents(manifest=manifest, arrays=arrays, diagnostics=tuple(diagnostics)) + 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( @@ -454,26 +698,26 @@ def scene_from_contents( source_vertex_of_render=( 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) return assets 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( self, path: Path, expected: ExpectedGeometry, keys: Any = None, + *, + verify_key_pem: Optional[bytes] = None, ) -> SceneAssets: - if keys is not None: - raise error( - "asset_auth_failed", - "this build cannot use a key provider: packages are plain so far", - ) - return scene_from_contents(read_package(path), expected) + return scene_from_contents( + read_package(path, keys=keys, verify_key_pem=verify_key_pem), expected + ) def validate( self, assets: SceneAssets, expected: ExpectedGeometry @@ -481,10 +725,16 @@ class PackagedAssetLoader: 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.""" - contents = read_package(path) + contents = read_package(path, keys=keys, verify_key_pem=verify_key_pem) manifest = contents.manifest + lock = manifest.get("platform_lock") return { "asset_id": manifest.get("asset_id"), "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"), "shape_hw": (manifest.get("field_domain") or {}).get("shape_hw"), "asset_hash": manifest.get("asset_hash"), + "platform_lock": lock, "parts": {part.get("id"): part.get("role") for part in manifest.get("parts", [])}, "entries": { 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]), }, } + + +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 diff --git a/shared/src/orisys_app_shared/scene3d/protect.py b/shared/src/orisys_app_shared/scene3d/protect.py new file mode 100644 index 0000000..f207e7c --- /dev/null +++ b/shared/src/orisys_app_shared/scene3d/protect.py @@ -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), + ) diff --git a/shared/tests/test_scene3d_contracts.py b/shared/tests/test_scene3d_contracts.py index e6911ab..48e3d9f 100644 --- a/shared/tests/test_scene3d_contracts.py +++ b/shared/tests/test_scene3d_contracts.py @@ -424,7 +424,8 @@ def test_the_cpu_layer_is_importable_and_works_without_a_gpu(): ) assert composed.scalar.shape == (9,) # 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) with pytest.raises(c.ThreeDError) as excinfo: assets.SceneCompiler().compile( @@ -442,7 +443,8 @@ def test_the_cpu_layer_is_importable_and_works_without_a_gpu(): 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(): diff --git a/shared/tests/test_scene3d_package.py b/shared/tests/test_scene3d_package.py index 0990ce4..6fdf293 100644 --- a/shared/tests/test_scene3d_package.py +++ b/shared/tests/test_scene3d_package.py @@ -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 -def test_an_encrypted_envelope_says_so_instead_of_failing_obscurely(package, tmp_path): - """Stage 5 will open these; until then the message has to be honest.""" +def test_an_envelope_that_claims_an_unknown_payload_kind_is_refused(package, tmp_path): + """An unknown payload is a refusal with its own reason, not a numpy error later.""" def encrypt(members): envelope = json.loads(members["envelope.json"]) - envelope["payload"] = "aead" + envelope["payload"] = "rot13" envelope["key_id"] = "customer-1" members["envelope.json"] = json.dumps(envelope).encode() return members @@ -305,20 +305,66 @@ def test_an_encrypted_envelope_says_so_instead_of_failing_obscurely(package, tmp read_package(broken) item = excinfo.value.diagnostic 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: 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( "finger", "rev-a", scene.geometry_id, scene.field_domain_id, scene.shape_hw ) - with pytest.raises(ThreeDError) as excinfo: - PackagedAssetLoader().load(package, expected, Keys()) - assert "key provider" in excinfo.value.diagnostic.message + assets = PackagedAssetLoader().load(package, expected, Keys()) + assert assets.geometry_id == scene.geometry_id 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: envelope = json.loads(archive.read("envelope.json")) 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["payload"] == "plain" assert envelope["entry_count"] == len(read_package(package).arrays) + assert "signature" not in envelope, "a plain package must not look signed" diff --git a/shared/tests/test_scene3d_protect.py b/shared/tests/test_scene3d_protect.py new file mode 100644 index 0000000..e630b7f --- /dev/null +++ b/shared/tests/test_scene3d_protect.py @@ -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 diff --git a/tools/scene_assets/bench_scene.py b/tools/scene_assets/bench_scene.py new file mode 100644 index 0000000..90e2327 --- /dev/null +++ b/tools/scene_assets/bench_scene.py @@ -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()) diff --git a/tools/scene_assets/compile_scene.py b/tools/scene_assets/compile_scene.py index 700fa97..b3c41ef 100644 --- a/tools/scene_assets/compile_scene.py +++ b/tools/scene_assets/compile_scene.py @@ -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 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; 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. -Example:: +Examples:: + # an engineering package: plain, unsigned, unlocked 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 \\ --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 @@ -33,6 +52,77 @@ def _bootstrap_paths() -> None: 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: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) 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, 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") 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.legacy import LegacyFingerImporter - from orisys_app_shared.scene3d.package import write_package + from orisys_app_shared.scene3d.package import describe_envelope, write_package transform = ( np.load(args.transform, allow_pickle=False) if args.transform is not None 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: assets = LegacyFingerImporter().convert( args.legacy_dir, @@ -79,14 +206,21 @@ def main(argv: list[str] | None = None) -> int: asset_version=args.version, 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( assets, args.output, sensor_model=args.sensor_model, hardware_revisions=("*",), + sealing=sealing, ) except ThreeDError as exc: print(f"rejected: {exc.diagnostic.code}: {exc.diagnostic.message}", file=sys.stderr) return 2 + envelope = describe_envelope(report.output) summary = { "status": "ok", "output": str(report.output), @@ -95,6 +229,10 @@ def main(argv: list[str] | None = None) -> int: "triangles": report.part_triangle_counts, "geometry_id": assets.geometry_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: 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" 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 diff --git a/tools/scene_assets/probe_gl.py b/tools/scene_assets/probe_gl.py index 519cd4e..5c8df40 100644 --- a/tools/scene_assets/probe_gl.py +++ b/tools/scene_assets/probe_gl.py @@ -64,6 +64,14 @@ def collect(profile: str, *, keep_open_s: float) -> dict: "python": sys.version.split()[0], "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: from OpenGL import __version__ as pyopengl_version diff --git a/tools/scene_assets/soak_scene.py b/tools/scene_assets/soak_scene.py new file mode 100644 index 0000000..4e2cbb0 --- /dev/null +++ b/tools/scene_assets/soak_scene.py @@ -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()) diff --git a/tools/scene_assets/validate_scene.py b/tools/scene_assets/validate_scene.py index 62f65c7..61f026e 100644 --- a/tools/scene_assets/validate_scene.py +++ b/tools/scene_assets/validate_scene.py @@ -143,9 +143,26 @@ def _legacy_report(args) -> tuple[int, dict]: 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]: from orisys_app_shared.scene3d.contracts import ExpectedGeometry, ThreeDError from orisys_app_shared.scene3d.package import ( + describe_envelope, describe_package, read_package, scene_from_contents, @@ -157,14 +174,64 @@ def _package_report(args) -> tuple[int, dict]: report["checks"].append({"check": name, "ok": bool(ok), "detail": detail}) try: - contents = read_package(Path(args.asset)) - record("envelope, manifest and every entry hash", True) - except ThreeDError as exc: - record("envelope, manifest and every entry hash", False, - f"{exc.diagnostic.code}: {exc.diagnostic.message}") + provider, verify = _load_keys(args) + except SystemExit as exc: + record("key material", False, str(exc)) report["status"] = "rejected" 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: expected = ( ExpectedGeometry( @@ -199,6 +266,24 @@ def main(argv: list[str] | None = None) -> int: ) parser.add_argument("--sensor-model", default="finger") 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") args = parser.parse_args(argv) @@ -221,7 +306,7 @@ def main(argv: list[str] | None = None) -> int: for item in report.get("checks", []): mark = "ok " if item["ok"] else "FAIL" 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: print(f" {key}: {json.dumps(report[key], ensure_ascii=False, default=str)}") if "note" in report: