|
| 1 | +#!/usr/bin/env python3 |
| 2 | +# SPDX-License-Identifier: MIT |
| 3 | +# Part of PyBLE (https://pyble.dev) — see /LICENSE. |
| 4 | +# |
| 5 | +# [red] Frozen destinations and generated BOARD_DIR source paths are distinct. |
| 6 | + |
| 7 | +from __future__ import annotations |
| 8 | + |
| 9 | +import importlib.util |
| 10 | +from pathlib import Path |
| 11 | +import unittest |
| 12 | + |
| 13 | + |
| 14 | +FIXTURE_PATH = Path(__file__).with_name( |
| 15 | + "test_release_license_policy_v2_integration.py" |
| 16 | +) |
| 17 | +SPEC = importlib.util.spec_from_file_location( |
| 18 | + "pyble_frozen_source_path_fixtures", FIXTURE_PATH |
| 19 | +) |
| 20 | +if SPEC is None or SPEC.loader is None: |
| 21 | + raise RuntimeError("cannot load frozen reconstruction fixtures") |
| 22 | +FIXTURES = importlib.util.module_from_spec(SPEC) |
| 23 | +SPEC.loader.exec_module(FIXTURES) |
| 24 | +RELEASE = FIXTURES.RELEASE |
| 25 | +BASE = FIXTURES.BASE |
| 26 | + |
| 27 | + |
| 28 | +class CanonicalPybleFixture(FIXTURES.ObservationV2Fixture): |
| 29 | + """Real retained board layout with deterministic synthetic compiler tools. |
| 30 | +
|
| 31 | + The observer, manifest resolver, descriptor snapshot, byte comparisons and |
| 32 | + reconstruction subprocesses are production code, not patched test models. |
| 33 | + Only the compiler inputs/tools are the existing explicitly synthetic ones. |
| 34 | + """ |
| 35 | + |
| 36 | + def _install_frozen_proof_fixture(self): |
| 37 | + super()._install_frozen_proof_fixture() |
| 38 | + canonical = self.firmware / "pyble" |
| 39 | + canonical.mkdir() |
| 40 | + (canonical / "pyble_workspace.py").write_text( |
| 41 | + "# SPDX-License-Identifier: MIT\nWORKSPACE = 'canonical'\n", |
| 42 | + encoding="utf-8", |
| 43 | + ) |
| 44 | + (canonical / "pyble_agent.py").write_text( |
| 45 | + "# SPDX-License-Identifier: MIT\n", encoding="utf-8" |
| 46 | + ) |
| 47 | + # Deliberately unlike the generated lock-derived copy: comparing this |
| 48 | + # placeholder instead of the lock output must not become acceptable. |
| 49 | + (canonical / "_version.py").write_text( |
| 50 | + "# SPDX-License-Identifier: MIT\nAGENT_VERSION = 'placeholder'\n", |
| 51 | + encoding="utf-8", |
| 52 | + ) |
| 53 | + generator = ( |
| 54 | + self.firmware / "upstream/micropython/tools/makemanifest.py" |
| 55 | + ) |
| 56 | + text = generator.read_text(encoding="utf-8") |
| 57 | + marker = 'manifest_bytes = b"".join(\n' |
| 58 | + if text.count(marker) != 1: |
| 59 | + raise AssertionError("synthetic generator insertion point changed") |
| 60 | + generator.write_text( |
| 61 | + text.replace( |
| 62 | + marker, |
| 63 | + 'sources += (\n' |
| 64 | + ' ("pyble_workspace.py", board / "pyble/pyble_workspace.py"),\n' |
| 65 | + ' ("_version.py", board / "pyble/_version.py"),\n' |
| 66 | + ')\n' + marker, |
| 67 | + ), |
| 68 | + encoding="utf-8", |
| 69 | + ) |
| 70 | + for _profile_id, target, _idf_target in FIXTURES.PROFILE_TARGETS: |
| 71 | + overlay = self.firmware / "board_overlays" / target |
| 72 | + # This selects firmware/pyble through the production resolver's |
| 73 | + # canonical fallback, not a same-named overlay source. |
| 74 | + (overlay / "pyble/pyble_agent.py").unlink() |
| 75 | + manifest = overlay / "manifest.py" |
| 76 | + manifest.write_text( |
| 77 | + manifest.read_text(encoding="utf-8") |
| 78 | + + 'module("pyble_workspace.py", base_path="$(BOARD_DIR)/pyble", opt=3)\n' |
| 79 | + + 'module("_version.py", base_path="$(BOARD_DIR)/pyble", opt=3)\n', |
| 80 | + encoding="utf-8", |
| 81 | + ) |
| 82 | + board = ( |
| 83 | + self.firmware |
| 84 | + / "upstream/micropython/ports/esp32/boards" |
| 85 | + / RELEASE.FROZEN_TARGET_SETTINGS[target]["board"] |
| 86 | + ) |
| 87 | + (board / "manifest.py").write_bytes(manifest.read_bytes()) |
| 88 | + (board / "pyble/pyble_workspace.py").write_bytes( |
| 89 | + (canonical / "pyble_workspace.py").read_bytes() |
| 90 | + ) |
| 91 | + (board / "pyble/_version.py").write_bytes( |
| 92 | + b"# SPDX-License-Identifier: MIT\n" |
| 93 | + b"# GENERATED by firmware/scripts/build.sh from " |
| 94 | + b"firmware/versions.lock [pyble] \xe2\x80\x94 do not edit.\n" |
| 95 | + b'AGENT_VERSION = "0.5.0"\n' |
| 96 | + b'PROTOCOL_VERSION = "PBLE/1"\n' |
| 97 | + ) |
| 98 | + self.regenerate_frozen_payload(target) |
| 99 | + |
| 100 | + |
| 101 | +class FrozenSourcePathTests(unittest.TestCase): |
| 102 | + @classmethod |
| 103 | + def setUpClass(cls): |
| 104 | + cls.fixture = CanonicalPybleFixture() |
| 105 | + |
| 106 | + @classmethod |
| 107 | + def tearDownClass(cls): |
| 108 | + cls.fixture.close() |
| 109 | + |
| 110 | + def setUp(self): |
| 111 | + self.target = "esp32" |
| 112 | + self.manifest = ( |
| 113 | + self.fixture.firmware / "board_overlays/esp32/manifest.py" |
| 114 | + ) |
| 115 | + self.board = ( |
| 116 | + self.fixture.build_root |
| 117 | + / ".sources/esp32/micropython/ports/esp32/boards" |
| 118 | + / RELEASE.FROZEN_TARGET_SETTINGS[self.target]["board"] |
| 119 | + ) |
| 120 | + self.workspace = self.board / "pyble/pyble_workspace.py" |
| 121 | + self.canonical = self.fixture.firmware / "pyble/pyble_workspace.py" |
| 122 | + |
| 123 | + def observe(self): |
| 124 | + # Same production observer called by the all-profile license collector; |
| 125 | + # pass the independently known synthetic compiler hash to avoid an |
| 126 | + # unrelated repeated compiler rebuild, not a frozen-proof mock. |
| 127 | + compiler = ( |
| 128 | + self.fixture.firmware |
| 129 | + / "upstream/micropython/mpy-cross/build/mpy-cross" |
| 130 | + ) |
| 131 | + return RELEASE._audit_manifest_evidence_record( |
| 132 | + self.manifest, |
| 133 | + self.fixture.build_root / self.target / "frozen_content.c", |
| 134 | + repo_root=self.fixture.repo, |
| 135 | + target=self.target, |
| 136 | + trusted_mpy_cross_sha256=BASE.sha256_path(compiler), |
| 137 | + ) |
| 138 | + |
| 139 | + def test_flat_workspace_and_prefixed_package_reach_full_observer(self): |
| 140 | + context = RELEASE._audit_observe_policy_v2_context( |
| 141 | + self.fixture.policy, |
| 142 | + repo_root=self.fixture.repo, |
| 143 | + build_root=self.fixture.build_root, |
| 144 | + ) |
| 145 | + self.assertEqual(len(context["manifest_evidence"]), 4) |
| 146 | + for record in context["manifest_evidence"]: |
| 147 | + selected = { |
| 148 | + item["destination"]: item for item in record["selections"] |
| 149 | + } |
| 150 | + workspace = selected["pyble_workspace.py"] |
| 151 | + self.assertEqual( |
| 152 | + workspace["source_path"], "firmware/pyble/pyble_workspace.py" |
| 153 | + ) |
| 154 | + self.assertEqual(workspace["sha256"], BASE.sha256_path(self.canonical)) |
| 155 | + self.assertEqual(workspace["optimization"], 3) |
| 156 | + self.assertEqual( |
| 157 | + selected["pyble/pyble_agent.py"]["source_path"], |
| 158 | + "firmware/pyble/pyble_agent.py", |
| 159 | + ) |
| 160 | + frozen = {item["destination"] for item in record["frozen_mpy"]} |
| 161 | + self.assertIn("pyble_workspace.mpy", frozen) |
| 162 | + self.assertIn("pyble/pyble_agent.mpy", frozen) |
| 163 | + self.assertNotIn("pyble/pyble_workspace.mpy", frozen) |
| 164 | + self.assertNotIn("pyble/pyble/pyble_agent.mpy", frozen) |
| 165 | + self.assertFalse((self.board / "pyble_workspace.py").exists()) |
| 166 | + self.assertFalse((self.board / "pyble/pyble").exists()) |
| 167 | + |
| 168 | + def test_missing_nested_workspace_is_rejected(self): |
| 169 | + with BASE.removed_file(self.workspace): |
| 170 | + with self.assertRaisesRegex(RELEASE.ReleaseError, "source.*missing"): |
| 171 | + self.observe() |
| 172 | + |
| 173 | + def test_matching_flat_decoy_cannot_replace_missing_nested_source(self): |
| 174 | + decoy = self.board / "pyble_workspace.py" |
| 175 | + decoy.write_bytes(self.canonical.read_bytes()) |
| 176 | + try: |
| 177 | + with BASE.removed_file(self.workspace): |
| 178 | + with self.assertRaisesRegex(RELEASE.ReleaseError, "source.*missing"): |
| 179 | + self.observe() |
| 180 | + finally: |
| 181 | + decoy.unlink() |
| 182 | + |
| 183 | + def test_matching_flat_decoy_cannot_hide_stale_nested_source(self): |
| 184 | + decoy = self.board / "pyble_workspace.py" |
| 185 | + decoy.write_bytes(self.canonical.read_bytes()) |
| 186 | + try: |
| 187 | + with BASE.patched_bytes(self.workspace, b"# stale nested bytes\n"): |
| 188 | + with self.assertRaisesRegex(RELEASE.ReleaseError, "source.*stale"): |
| 189 | + self.observe() |
| 190 | + finally: |
| 191 | + decoy.unlink() |
| 192 | + |
| 193 | + def test_nested_workspace_file_symlink_is_rejected(self): |
| 194 | + with BASE.removed_file(self.workspace): |
| 195 | + self.workspace.symlink_to(self.canonical) |
| 196 | + try: |
| 197 | + with self.assertRaisesRegex(RELEASE.ReleaseError, "symlink"): |
| 198 | + self.observe() |
| 199 | + finally: |
| 200 | + self.workspace.unlink() |
| 201 | + |
| 202 | + def test_nested_pyble_directory_symlink_is_rejected(self): |
| 203 | + nested = self.board / "pyble" |
| 204 | + backup = self.board.parent / "pyble-path-test-backup" |
| 205 | + nested.rename(backup) |
| 206 | + try: |
| 207 | + nested.symlink_to(backup, target_is_directory=True) |
| 208 | + with self.assertRaisesRegex(RELEASE.ReleaseError, "symlink"): |
| 209 | + self.observe() |
| 210 | + finally: |
| 211 | + nested.unlink() |
| 212 | + backup.rename(nested) |
| 213 | + |
| 214 | + def test_changed_canonical_workspace_is_rejected(self): |
| 215 | + with BASE.patched_bytes(self.canonical, b"# changed canonical bytes\n"): |
| 216 | + with self.assertRaisesRegex(RELEASE.ReleaseError, "source.*stale"): |
| 217 | + self.observe() |
| 218 | + |
| 219 | + def test_already_prefixed_package_copy_is_still_byte_checked(self): |
| 220 | + with BASE.patched_bytes( |
| 221 | + self.board / "pyble/pyble_agent.py", b"# stale package\n" |
| 222 | + ): |
| 223 | + with self.assertRaisesRegex(RELEASE.ReleaseError, "source.*stale"): |
| 224 | + self.observe() |
| 225 | + |
| 226 | + def test_lock_version_exception_does_not_compare_placeholder(self): |
| 227 | + version = self.board / "pyble/_version.py" |
| 228 | + placeholder = self.fixture.firmware / "pyble/_version.py" |
| 229 | + self.assertNotEqual(version.read_bytes(), placeholder.read_bytes()) |
| 230 | + self.observe() |
| 231 | + with BASE.patched_bytes(version, placeholder.read_bytes()): |
| 232 | + with self.assertRaisesRegex(RELEASE.ReleaseError, "lock-derived.*stale"): |
| 233 | + self.observe() |
| 234 | + |
| 235 | + def test_overlay_copy_keeps_its_existing_byte_check(self): |
| 236 | + with BASE.patched_bytes(self.board / "_boot.py", b"# stale boot\n"): |
| 237 | + with self.assertRaisesRegex(RELEASE.ReleaseError, "source.*stale"): |
| 238 | + self.observe() |
| 239 | + |
| 240 | + def test_duplicate_destination_remains_rejected(self): |
| 241 | + original = self.manifest.read_text(encoding="utf-8") |
| 242 | + with BASE.patched_text( |
| 243 | + self.manifest, |
| 244 | + original |
| 245 | + + 'module("pyble_workspace.py", base_path="$(BOARD_DIR)/pyble")\n', |
| 246 | + ): |
| 247 | + with self.assertRaisesRegex(RELEASE.ReleaseError, "duplicates destination"): |
| 248 | + self.observe() |
| 249 | + |
| 250 | + def test_historical_overlay_package_layout_still_reconstructs(self): |
| 251 | + historical = FIXTURES.ObservationV2Fixture() |
| 252 | + try: |
| 253 | + context = RELEASE._audit_observe_policy_v2_context( |
| 254 | + historical.policy, |
| 255 | + repo_root=historical.repo, |
| 256 | + build_root=historical.build_root, |
| 257 | + ) |
| 258 | + for record in context["manifest_evidence"]: |
| 259 | + selected = { |
| 260 | + item["destination"]: item for item in record["selections"] |
| 261 | + } |
| 262 | + self.assertNotIn("pyble_workspace.py", selected) |
| 263 | + self.assertEqual( |
| 264 | + selected["pyble/pyble_agent.py"]["source_path"], |
| 265 | + "firmware/board_overlays/%s/pyble/pyble_agent.py" |
| 266 | + % record["target"], |
| 267 | + ) |
| 268 | + finally: |
| 269 | + historical.close() |
| 270 | + |
| 271 | + |
| 272 | +if __name__ == "__main__": |
| 273 | + unittest.main() |
0 commit comments