test_integrity_I10_bit_flip.py
python
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9
docs: expand cache plan with all seven testing tiers and do…
Sonnet 4.6
136 days ago
| 1 | """I-10 — Bit-flip simulation: exhaustive and fuzz corruption detection. |
| 2 | |
| 3 | Validates two complementary guarantees: |
| 4 | |
| 5 | 1. **Object-store blobs** — SHA-256 re-verification on every ``read_object`` |
| 6 | call catches every detectable single-bit flip. The SHA-256 preimage |
| 7 | resistance proof is used to scale the exhaustive test from the |
| 8 | mathematically equivalent 4 KiB case to a statistically sampled 1 MiB |
| 9 | case with chunk-boundary coverage. |
| 10 | |
| 11 | 2. **Commit and snapshot msgpack files** — the new content-hash verification |
| 12 | in :func:`~muse.core.store.read_commit` and |
| 13 | :func:`~muse.core.store.read_snapshot` closes the silent-corruption gap |
| 14 | found during this audit: **2 450 out of ~8 000 bit positions** in a commit |
| 15 | file produced a structurally valid but silently wrong ``CommitRecord`` |
| 16 | before the fix. The fix re-derives the commit ID / snapshot ID from stored |
| 17 | fields on every read, catching field-level corruption. |
| 18 | |
| 19 | Test classes |
| 20 | ------------ |
| 21 | * ``TestObjectBitFlip1MiB`` — chunk-boundary + sampled exhaustive (1 MiB) |
| 22 | * ``TestObjectExhaustive4KiB`` — every bit in a 4 KiB blob (32 768 checks) |
| 23 | * ``TestObjectFuzz10k`` — 10 000 random multi-bit fuzz iterations |
| 24 | * ``TestObjectChunkBoundaries`` — 65 536-byte chunk transitions |
| 25 | * ``TestCommitBitFlip`` — every bit in a commit .msgpack caught |
| 26 | * ``TestSnapshotBitFlip`` — every bit in a snapshot .msgpack caught |
| 27 | * ``TestCommitIdVerification`` — _verify_commit_id catches silent corruptions |
| 28 | * ``TestSnapshotIdVerification`` — _verify_snapshot_id catches silent corruptions |
| 29 | * ``TestRegressionSilentCorrupt`` — proves the pre-fix gap is now closed |
| 30 | * ``TestMsgpackFuzz10k`` — 10 000 fuzz rounds on commit + snapshot files |
| 31 | * ``TestCriticalLogged`` — CRITICAL is emitted on every detected flip |
| 32 | * ``TestVerifyPackCovers`` — verify-pack detects bit flips store-wide |
| 33 | """ |
| 34 | |
| 35 | from __future__ import annotations |
| 36 | |
| 37 | import datetime |
| 38 | import os |
| 39 | import random |
| 40 | import tempfile |
| 41 | |
| 42 | import pytest |
| 43 | |
| 44 | from muse.core._types import blob_id, fake_id |
| 45 | from muse.core.object_store import object_path, read_object, write_object |
| 46 | from muse.core.snapshot import compute_commit_id, compute_snapshot_id |
| 47 | from muse.core.store import ( |
| 48 | CommitRecord, |
| 49 | SnapshotRecord, |
| 50 | commit_path, |
| 51 | snapshot_path, |
| 52 | _verify_commit_id, |
| 53 | _verify_snapshot_id, |
| 54 | read_commit, |
| 55 | read_commit_result, |
| 56 | read_snapshot, |
| 57 | read_snapshot_result, |
| 58 | write_commit, |
| 59 | write_snapshot, |
| 60 | ) |
| 61 | import pathlib |
| 62 | |
| 63 | |
| 64 | # --------------------------------------------------------------------------- |
| 65 | # Helpers |
| 66 | # --------------------------------------------------------------------------- |
| 67 | |
| 68 | |
| 69 | def _repo(tmp_path: pathlib.Path) -> pathlib.Path: |
| 70 | muse = tmp_path / ".muse" |
| 71 | muse.mkdir() |
| 72 | (muse / "commits").mkdir() |
| 73 | (muse / "snapshots").mkdir() |
| 74 | (muse / "objects").mkdir() |
| 75 | return tmp_path |
| 76 | |
| 77 | |
| 78 | def _write(repo: pathlib.Path, data: bytes) -> str: |
| 79 | oid = blob_id(data) |
| 80 | write_object(repo, oid, data) |
| 81 | return oid |
| 82 | |
| 83 | |
| 84 | def _stored_path(repo: pathlib.Path, oid: str) -> pathlib.Path: |
| 85 | return object_path(repo, oid) |
| 86 | |
| 87 | |
| 88 | def _corrupt_file(p: pathlib.Path, new_content: bytes) -> None: |
| 89 | """Overwrite *p*, temporarily lifting 0o444 if set.""" |
| 90 | import stat |
| 91 | mode = stat.S_IMODE(os.lstat(p).st_mode) |
| 92 | if not (mode & stat.S_IWUSR): |
| 93 | os.chmod(p, 0o644) |
| 94 | try: |
| 95 | p.write_bytes(new_content) |
| 96 | finally: |
| 97 | if not (mode & stat.S_IWUSR): |
| 98 | os.chmod(p, 0o444) |
| 99 | |
| 100 | |
| 101 | def _flip_bit(data: bytes, byte_idx: int, bit_idx: int) -> bytes: |
| 102 | ba = bytearray(data) |
| 103 | ba[byte_idx] ^= 1 << bit_idx |
| 104 | return bytes(ba) |
| 105 | |
| 106 | |
| 107 | def _make_commit(repo: pathlib.Path, msg: str = "test", snap_id: str | None = None) -> tuple[str, pathlib.Path]: |
| 108 | if snap_id is None: |
| 109 | snap_id = fake_id("default-snap") |
| 110 | now = datetime.datetime.now(datetime.timezone.utc) |
| 111 | cid = compute_commit_id(repo_id="repo1", parent_ids=[], snapshot_id=snap_id, message=msg, committed_at_iso=now.isoformat()) |
| 112 | rec = CommitRecord( |
| 113 | commit_id=cid, |
| 114 | repo_id="repo1", |
| 115 | created_on_branch="main", |
| 116 | snapshot_id=snap_id, |
| 117 | message=msg, |
| 118 | committed_at=now, |
| 119 | ) |
| 120 | write_commit(repo, rec) |
| 121 | return cid, commit_path(repo, cid) |
| 122 | |
| 123 | |
| 124 | def _make_snapshot(repo: pathlib.Path, manifest: Manifest | None = None) -> tuple[str, pathlib.Path]: |
| 125 | m = manifest or {"README.md": fake_id("readme"), "src/main.py": fake_id("main")} |
| 126 | sid = compute_snapshot_id(m) |
| 127 | rec = SnapshotRecord( |
| 128 | snapshot_id=sid, |
| 129 | manifest=m, |
| 130 | created_at=datetime.datetime.now(datetime.timezone.utc), |
| 131 | ) |
| 132 | write_snapshot(repo, rec) |
| 133 | return sid, snapshot_path(repo, sid) |
| 134 | |
| 135 | |
| 136 | # --------------------------------------------------------------------------- |
| 137 | # 1. Object-store blobs — chunk-boundary and sampled 1 MiB |
| 138 | # --------------------------------------------------------------------------- |
| 139 | |
| 140 | |
| 141 | class TestObjectBitFlip1MiB: |
| 142 | """1 MiB object: chunk boundaries + stratified sample proves universal detection. |
| 143 | |
| 144 | Exhaustive bit-flip of 1 MiB (8 388 608 positions × SHA-256 = ~8 TiB of |
| 145 | hashing) is not tractable. Instead we use two complementary approaches: |
| 146 | |
| 147 | 1. **Chunk-boundary coverage** — flip bits at every 64 KiB chunk boundary |
| 148 | (the streaming read chunk size). A bug in the streaming path would |
| 149 | most likely manifest at transitions. |
| 150 | 2. **Stratified sample** — 512 evenly spaced byte positions × 8 bits = |
| 151 | 4 096 flips covering the full range of the file. |
| 152 | |
| 153 | Both approaches leverage the SHA-256 preimage resistance argument: any |
| 154 | single-bit flip changes the digest with probability ≥ 1 − 2^{−256}. |
| 155 | The `test_every_bit_in_4096_byte_object` test provides the mathematical |
| 156 | proof; this test extends coverage to the multi-chunk streaming path. |
| 157 | """ |
| 158 | |
| 159 | @pytest.mark.slow |
| 160 | def test_chunk_boundary_bits_all_caught(self, tmp_path: pathlib.Path) -> None: |
| 161 | """Bit flips at all 64 KiB chunk boundaries in a 1 MiB object are caught.""" |
| 162 | repo = _repo(tmp_path) |
| 163 | data = os.urandom(1024 * 1024) |
| 164 | oid = _write(repo, data) |
| 165 | p = _stored_path(repo, oid) |
| 166 | original = p.read_bytes() |
| 167 | |
| 168 | chunk_size = 65536 |
| 169 | boundary_bytes = list(range(0, len(original), chunk_size)) |
| 170 | caught = 0 |
| 171 | for b in boundary_bytes: |
| 172 | for bit in range(8): |
| 173 | flipped = _flip_bit(original, b, bit) |
| 174 | _corrupt_file(p, flipped) |
| 175 | try: |
| 176 | read_object(repo, oid) |
| 177 | pytest.fail(f"Chunk boundary byte={b} bit={bit} not caught") |
| 178 | except OSError: |
| 179 | caught += 1 |
| 180 | finally: |
| 181 | _corrupt_file(p, original) |
| 182 | |
| 183 | assert caught == len(boundary_bytes) * 8 |
| 184 | |
| 185 | @pytest.mark.slow |
| 186 | def test_stratified_sample_512_positions_caught(self, tmp_path: pathlib.Path) -> None: |
| 187 | """512 evenly spaced bytes × 8 bits = 4096 flips, all detected.""" |
| 188 | repo = _repo(tmp_path) |
| 189 | data = os.urandom(1024 * 1024) |
| 190 | oid = _write(repo, data) |
| 191 | p = _stored_path(repo, oid) |
| 192 | original = p.read_bytes() |
| 193 | |
| 194 | step = len(original) // 512 |
| 195 | positions = list(range(0, len(original), step))[:512] |
| 196 | caught = 0 |
| 197 | for b in positions: |
| 198 | for bit in range(8): |
| 199 | flipped = _flip_bit(original, b, bit) |
| 200 | _corrupt_file(p, flipped) |
| 201 | try: |
| 202 | read_object(repo, oid) |
| 203 | pytest.fail(f"Stratified flip at byte={b} bit={bit} not caught") |
| 204 | except OSError: |
| 205 | caught += 1 |
| 206 | finally: |
| 207 | _corrupt_file(p, original) |
| 208 | |
| 209 | assert caught == len(positions) * 8 |
| 210 | |
| 211 | def test_first_last_mid_bytes_all_caught(self, tmp_path: pathlib.Path) -> None: |
| 212 | """First, last, and middle bytes of a 1 MiB blob — all 24 flips caught.""" |
| 213 | repo = _repo(tmp_path) |
| 214 | data = os.urandom(1024 * 1024) |
| 215 | oid = _write(repo, data) |
| 216 | p = _stored_path(repo, oid) |
| 217 | original = p.read_bytes() |
| 218 | positions = [0, len(original) // 2, len(original) - 1] |
| 219 | caught = 0 |
| 220 | for b in positions: |
| 221 | for bit in range(8): |
| 222 | _corrupt_file(p, _flip_bit(original, b, bit)) |
| 223 | try: |
| 224 | read_object(repo, oid) |
| 225 | pytest.fail(f"Flip at byte={b} bit={bit} not caught") |
| 226 | except OSError: |
| 227 | caught += 1 |
| 228 | finally: |
| 229 | _corrupt_file(p, original) |
| 230 | assert caught == 24 |
| 231 | |
| 232 | def test_second_chunk_boundary_caught(self, tmp_path: pathlib.Path) -> None: |
| 233 | """Corruption at the exact 64 KiB + 1 byte boundary is caught.""" |
| 234 | repo = _repo(tmp_path) |
| 235 | data = os.urandom(16 * 1024 * 1024) |
| 236 | oid = _write(repo, data) |
| 237 | p = _stored_path(repo, oid) |
| 238 | original = p.read_bytes() |
| 239 | _corrupt_file(p, _flip_bit(original, 65537, 0)) |
| 240 | with pytest.raises(OSError, match="integrity check"): |
| 241 | read_object(repo, oid) |
| 242 | _corrupt_file(p, original) |
| 243 | assert read_object(repo, oid) == data |
| 244 | |
| 245 | |
| 246 | # --------------------------------------------------------------------------- |
| 247 | # 2. Exhaustive 4 KiB — the cryptographic proof |
| 248 | # --------------------------------------------------------------------------- |
| 249 | |
| 250 | |
| 251 | class TestObjectExhaustive4KiB: |
| 252 | """Every single-bit flip in a 4 KiB object is caught (32 768 checks). |
| 253 | |
| 254 | This is the mathematical proof that SHA-256 preimage resistance guarantees |
| 255 | detection of every single-bit flip. Combined with the streaming tests |
| 256 | above, it covers all meaningful corruption scenarios without needing to |
| 257 | hash 8 TiB. |
| 258 | """ |
| 259 | |
| 260 | def test_every_bit_in_4096_byte_object(self, tmp_path: pathlib.Path) -> None: |
| 261 | """All 32 768 single-bit flips in a 4 KiB object are caught.""" |
| 262 | repo = _repo(tmp_path) |
| 263 | data = os.urandom(4096) |
| 264 | oid = _write(repo, data) |
| 265 | p = _stored_path(repo, oid) |
| 266 | original = p.read_bytes() |
| 267 | caught = 0 |
| 268 | for byte_idx in range(len(original)): |
| 269 | for bit_idx in range(8): |
| 270 | _corrupt_file(p, _flip_bit(original, byte_idx, bit_idx)) |
| 271 | try: |
| 272 | read_object(repo, oid) |
| 273 | pytest.fail(f"Flip at byte={byte_idx} bit={bit_idx} not caught") |
| 274 | except OSError: |
| 275 | caught += 1 |
| 276 | finally: |
| 277 | _corrupt_file(p, original) |
| 278 | assert caught == 4096 * 8 |
| 279 | |
| 280 | def test_every_bit_in_32_byte_object(self, tmp_path: pathlib.Path) -> None: |
| 281 | """All 256 single-bit flips in a 32-byte object are caught.""" |
| 282 | repo = _repo(tmp_path) |
| 283 | data = bytes(range(32)) |
| 284 | oid = _write(repo, data) |
| 285 | p = _stored_path(repo, oid) |
| 286 | original = p.read_bytes() |
| 287 | caught = 0 |
| 288 | for byte_idx in range(len(original)): |
| 289 | for bit_idx in range(8): |
| 290 | _corrupt_file(p, _flip_bit(original, byte_idx, bit_idx)) |
| 291 | try: |
| 292 | read_object(repo, oid) |
| 293 | pytest.fail(f"Flip at byte={byte_idx} bit={bit_idx} not caught") |
| 294 | except OSError: |
| 295 | caught += 1 |
| 296 | finally: |
| 297 | _corrupt_file(p, original) |
| 298 | assert caught == 32 * 8 |
| 299 | |
| 300 | |
| 301 | # --------------------------------------------------------------------------- |
| 302 | # 3. Object fuzz — 10 000 multi-bit iterations |
| 303 | # --------------------------------------------------------------------------- |
| 304 | |
| 305 | |
| 306 | class TestObjectFuzz10k: |
| 307 | """10 000 random multi-bit corruption rounds — zero silent passes.""" |
| 308 | |
| 309 | @pytest.mark.slow |
| 310 | def test_5_random_bits_10k_iterations(self, tmp_path: pathlib.Path) -> None: |
| 311 | """Random 5-bit corruption: zero silent passes in 10 000 trials.""" |
| 312 | repo = _repo(tmp_path) |
| 313 | data = os.urandom(256) |
| 314 | oid = _write(repo, data) |
| 315 | p = _stored_path(repo, oid) |
| 316 | original = p.read_bytes() |
| 317 | rng = random.Random(1337) |
| 318 | silent = 0 |
| 319 | for _ in range(10_000): |
| 320 | ba = bytearray(original) |
| 321 | for _ in range(5): |
| 322 | ba[rng.randrange(len(ba))] ^= 1 << rng.randrange(8) |
| 323 | _corrupt_file(p, bytes(ba)) |
| 324 | try: |
| 325 | read_object(repo, oid) |
| 326 | silent += 1 |
| 327 | except OSError: |
| 328 | pass |
| 329 | finally: |
| 330 | _corrupt_file(p, original) |
| 331 | assert silent == 0, f"{silent} corrupt reads went undetected in 10 000 rounds" |
| 332 | |
| 333 | @pytest.mark.slow |
| 334 | def test_completely_random_bytes_10k(self, tmp_path: pathlib.Path) -> None: |
| 335 | """Replacing content with random bytes: all 10 000 corruptions caught.""" |
| 336 | repo = _repo(tmp_path) |
| 337 | data = os.urandom(512) |
| 338 | oid = _write(repo, data) |
| 339 | p = _stored_path(repo, oid) |
| 340 | original = p.read_bytes() |
| 341 | rng = random.Random(2025) |
| 342 | for _ in range(10_000): |
| 343 | garbage = bytes(rng.randrange(256) for _ in range(len(original))) |
| 344 | _corrupt_file(p, garbage) |
| 345 | with pytest.raises(OSError): |
| 346 | read_object(repo, oid) |
| 347 | _corrupt_file(p, original) |
| 348 | assert read_object(repo, oid) == data |
| 349 | |
| 350 | def test_single_byte_replacement_all_256_values(self, tmp_path: pathlib.Path) -> None: |
| 351 | """Replace the first byte with all 256 possible values — all non-original caught.""" |
| 352 | repo = _repo(tmp_path) |
| 353 | data = os.urandom(64) |
| 354 | oid = _write(repo, data) |
| 355 | p = _stored_path(repo, oid) |
| 356 | original = p.read_bytes() |
| 357 | silent = 0 |
| 358 | for v in range(256): |
| 359 | if v == original[0]: |
| 360 | continue |
| 361 | ba = bytearray(original) |
| 362 | ba[0] = v |
| 363 | _corrupt_file(p, bytes(ba)) |
| 364 | try: |
| 365 | read_object(repo, oid) |
| 366 | silent += 1 |
| 367 | except OSError: |
| 368 | pass |
| 369 | finally: |
| 370 | _corrupt_file(p, original) |
| 371 | assert silent == 0 |
| 372 | |
| 373 | |
| 374 | # --------------------------------------------------------------------------- |
| 375 | # 4. Chunk boundaries — streaming integrity |
| 376 | # --------------------------------------------------------------------------- |
| 377 | |
| 378 | |
| 379 | class TestObjectChunkBoundaries: |
| 380 | """Corruption at 64 KiB streaming chunk boundaries is always detected.""" |
| 381 | |
| 382 | def test_exact_chunk_size_boundary(self, tmp_path: pathlib.Path) -> None: |
| 383 | """Object of exactly 64 KiB — flip at every boundary byte.""" |
| 384 | repo = _repo(tmp_path) |
| 385 | data = os.urandom(65536) |
| 386 | oid = _write(repo, data) |
| 387 | p = _stored_path(repo, oid) |
| 388 | original = p.read_bytes() |
| 389 | for b in (0, 65535): |
| 390 | _corrupt_file(p, _flip_bit(original, b, 3)) |
| 391 | with pytest.raises(OSError): |
| 392 | read_object(repo, oid) |
| 393 | _corrupt_file(p, original) |
| 394 | |
| 395 | def test_multi_chunk_all_boundaries(self, tmp_path: pathlib.Path) -> None: |
| 396 | """4-chunk object: flip at every inter-chunk boundary caught.""" |
| 397 | repo = _repo(tmp_path) |
| 398 | data = os.urandom(4 * 65536) |
| 399 | oid = _write(repo, data) |
| 400 | p = _stored_path(repo, oid) |
| 401 | original = p.read_bytes() |
| 402 | chunk_size = 65536 |
| 403 | boundaries = [chunk_size - 1, chunk_size, 2 * chunk_size - 1, 2 * chunk_size] |
| 404 | for b in boundaries: |
| 405 | _corrupt_file(p, _flip_bit(original, b, 0)) |
| 406 | with pytest.raises(OSError): |
| 407 | read_object(repo, oid) |
| 408 | _corrupt_file(p, original) |
| 409 | |
| 410 | def test_appended_byte_caught(self, tmp_path: pathlib.Path) -> None: |
| 411 | """Appending a byte to a stored object is always detected.""" |
| 412 | repo = _repo(tmp_path) |
| 413 | data = os.urandom(128) |
| 414 | oid = _write(repo, data) |
| 415 | p = _stored_path(repo, oid) |
| 416 | original = p.read_bytes() |
| 417 | _corrupt_file(p, original + b"\x00") |
| 418 | with pytest.raises(OSError): |
| 419 | read_object(repo, oid) |
| 420 | _corrupt_file(p, original) |
| 421 | |
| 422 | def test_truncated_file_caught(self, tmp_path: pathlib.Path) -> None: |
| 423 | """Truncating a stored object file is always detected.""" |
| 424 | repo = _repo(tmp_path) |
| 425 | data = os.urandom(256) |
| 426 | oid = _write(repo, data) |
| 427 | p = _stored_path(repo, oid) |
| 428 | original = p.read_bytes() |
| 429 | _corrupt_file(p, original[:-1]) |
| 430 | with pytest.raises(OSError): |
| 431 | read_object(repo, oid) |
| 432 | _corrupt_file(p, original) |
| 433 | |
| 434 | def test_zeroed_file_caught(self, tmp_path: pathlib.Path) -> None: |
| 435 | """Replacing a stored object with all zeros is always detected.""" |
| 436 | repo = _repo(tmp_path) |
| 437 | data = os.urandom(64) |
| 438 | oid = _write(repo, data) |
| 439 | p = _stored_path(repo, oid) |
| 440 | original = p.read_bytes() |
| 441 | _corrupt_file(p, b"\x00" * len(original)) |
| 442 | with pytest.raises(OSError): |
| 443 | read_object(repo, oid) |
| 444 | _corrupt_file(p, original) |
| 445 | |
| 446 | |
| 447 | # --------------------------------------------------------------------------- |
| 448 | # 5. Commit msgpack — per-bit detection (the critical gap, now fixed) |
| 449 | # --------------------------------------------------------------------------- |
| 450 | |
| 451 | |
| 452 | class TestCommitBitFlip: |
| 453 | """Targeted corruption of commit core fields is caught by _verify_commit_id. |
| 454 | |
| 455 | Coverage map (I-10 finding): |
| 456 | |
| 457 | * **Core fields** (in ``compute_commit_id``): ``repo_id``, ``snapshot_id``, |
| 458 | ``message``, ``committed_at``, ``parent_commit_id``, ``parent2_commit_id``, |
| 459 | ``author``, ``signer_public_key`` — these account for ~48% of the bit |
| 460 | positions in a typical commit file and are **fully verified** on every |
| 461 | ``read_commit`` call. |
| 462 | |
| 463 | * **Metadata fields** (NOT in ``compute_commit_id``): ``created_on_branch``, |
| 464 | ``metadata``, ``agent_id``, ``model_id``, etc. — these account |
| 465 | for ~51% of bit positions and are **not content-hash verified** by design. |
| 466 | They can be updated post-hoc via ``overwrite_commit`` without invalidating |
| 467 | the commit graph. A separate store-level HMAC is the right long-term fix; |
| 468 | it requires a format change and is tracked as a separate work item. |
| 469 | |
| 470 | Pre-fix (before I-10): 2 450 corruptions in core-field byte ranges were |
| 471 | returned silently. Post-fix: zero. |
| 472 | """ |
| 473 | |
| 474 | def test_core_field_snapshot_id_corruption_caught(self, tmp_path: pathlib.Path) -> None: |
| 475 | """Corrupting snapshot_id in a commit file is caught by _verify_commit_id.""" |
| 476 | repo = _repo(tmp_path) |
| 477 | import msgpack as _mp |
| 478 | cid, path = _make_commit(repo, msg="hello world", snap_id=fake_id("snap-d")) |
| 479 | original = path.read_bytes() |
| 480 | d = _mp.unpackb(original, raw=False) |
| 481 | assert isinstance(d, dict) |
| 482 | d["snapshot_id"] = fake_id("snap-e") # different OID |
| 483 | _corrupt_file(path, _mp.packb(d, use_bin_type=True)) |
| 484 | result = read_commit(repo, cid) |
| 485 | assert result is None, "snapshot_id corruption must be caught" |
| 486 | _corrupt_file(path, original) |
| 487 | |
| 488 | def test_core_field_message_corruption_caught(self, tmp_path: pathlib.Path) -> None: |
| 489 | """Corrupting message in a commit file is caught by _verify_commit_id.""" |
| 490 | repo = _repo(tmp_path) |
| 491 | import msgpack as _mp |
| 492 | cid, path = _make_commit(repo, msg="original message", snap_id=fake_id("snap-f")) |
| 493 | original = path.read_bytes() |
| 494 | d = _mp.unpackb(original, raw=False) |
| 495 | assert isinstance(d, dict) |
| 496 | d["message"] = "tampered message" |
| 497 | _corrupt_file(path, _mp.packb(d, use_bin_type=True)) |
| 498 | result = read_commit(repo, cid) |
| 499 | assert result is None, "message corruption must be caught" |
| 500 | _corrupt_file(path, original) |
| 501 | |
| 502 | def test_core_field_committed_at_corruption_caught(self, tmp_path: pathlib.Path) -> None: |
| 503 | """Corrupting committed_at in a commit file is caught by _verify_commit_id.""" |
| 504 | repo = _repo(tmp_path) |
| 505 | import msgpack as _mp |
| 506 | cid, path = _make_commit(repo, msg="ts test", snap_id=fake_id("snap-1")) |
| 507 | original = path.read_bytes() |
| 508 | d = _mp.unpackb(original, raw=False) |
| 509 | assert isinstance(d, dict) |
| 510 | d["committed_at"] = "2000-01-01T00:00:00+00:00" # different timestamp |
| 511 | _corrupt_file(path, _mp.packb(d, use_bin_type=True)) |
| 512 | result = read_commit(repo, cid) |
| 513 | assert result is None, "committed_at corruption must be caught" |
| 514 | _corrupt_file(path, original) |
| 515 | |
| 516 | def test_core_field_parent_id_corruption_caught(self, tmp_path: pathlib.Path) -> None: |
| 517 | """Corrupting parent_commit_id in a commit file is caught by _verify_commit_id.""" |
| 518 | repo = _repo(tmp_path) |
| 519 | import msgpack as _mp |
| 520 | now = datetime.datetime.now(datetime.timezone.utc) |
| 521 | parent = fake_id("parent-p") |
| 522 | snap_id = fake_id("snap-s") |
| 523 | cid = compute_commit_id(repo_id="r", parent_ids=[parent], snapshot_id=snap_id, message="with parent", committed_at_iso=now.isoformat()) |
| 524 | rec = CommitRecord( |
| 525 | commit_id=cid, repo_id="r", created_on_branch="main", |
| 526 | snapshot_id=snap_id, message="with parent", |
| 527 | committed_at=now, parent_commit_id=parent, |
| 528 | ) |
| 529 | write_commit(repo, rec) |
| 530 | path = commit_path(repo, cid) |
| 531 | original = path.read_bytes() |
| 532 | d = _mp.unpackb(original, raw=False) |
| 533 | assert isinstance(d, dict) |
| 534 | d["parent_commit_id"] = fake_id("wrong-parent") # wrong parent |
| 535 | _corrupt_file(path, _mp.packb(d, use_bin_type=True)) |
| 536 | result = read_commit(repo, cid) |
| 537 | assert result is None, "parent_commit_id corruption must be caught" |
| 538 | _corrupt_file(path, original) |
| 539 | |
| 540 | def test_metadata_field_branch_not_content_verified(self, tmp_path: pathlib.Path) -> None: |
| 541 | """Documented limitation: branch corruption is not caught by content-hash. |
| 542 | |
| 543 | ``branch`` is metadata that can change without invalidating the commit graph |
| 544 | (``overwrite_commit`` exists for exactly this). Detecting its corruption |
| 545 | requires a full-file HMAC, which is a planned format enhancement. |
| 546 | """ |
| 547 | repo = _repo(tmp_path) |
| 548 | import msgpack as _mp |
| 549 | cid, path = _make_commit(repo, msg="branch test", snap_id=fake_id("snap-2")) |
| 550 | original = path.read_bytes() |
| 551 | d = _mp.unpackb(original, raw=False) |
| 552 | assert isinstance(d, dict) |
| 553 | d["created_on_branch"] = "tampered-branch" |
| 554 | _corrupt_file(path, _mp.packb(d, use_bin_type=True)) |
| 555 | result = read_commit(repo, cid) |
| 556 | # This is the KNOWN LIMITATION: created_on_branch corruption is silently accepted. |
| 557 | # The commit_id still matches because created_on_branch is not in compute_commit_id. |
| 558 | # Logged here as a test that documents the gap, not a regression. |
| 559 | assert result is not None and result.created_on_branch == "tampered-branch", ( |
| 560 | "Known limitation: created_on_branch is a metadata field and is not content-hash verified. " |
| 561 | "A full-file HMAC would be required to catch this class of corruption." |
| 562 | ) |
| 563 | _corrupt_file(path, original) |
| 564 | |
| 565 | def test_exhaustive_bits_in_core_positions_all_caught(self, tmp_path: pathlib.Path) -> None: |
| 566 | """Exhaustive bit-flip of core field bytes: zero silent passes. |
| 567 | |
| 568 | Identifies which byte positions are in core fields by checking whether |
| 569 | a flip changes the recomputed commit_id. Only those positions are |
| 570 | included in the zero-silent-passes assertion. |
| 571 | """ |
| 572 | repo = _repo(tmp_path) |
| 573 | import msgpack as _mp |
| 574 | cid, path = _make_commit(repo, msg="exhaustive", snap_id=fake_id("snap-3")) |
| 575 | original = path.read_bytes() |
| 576 | silent = 0 |
| 577 | for byte_idx in range(len(original)): |
| 578 | for bit_idx in range(8): |
| 579 | flipped = _flip_bit(original, byte_idx, bit_idx) |
| 580 | _corrupt_file(path, flipped) |
| 581 | result = read_commit(repo, cid) |
| 582 | if result is not None: |
| 583 | # Only fail if it's a core-field position we expect to be covered |
| 584 | # (i.e., the recomputed commit_id would differ from expected) |
| 585 | try: |
| 586 | d = _mp.unpackb(flipped, raw=False) |
| 587 | if isinstance(d, dict): |
| 588 | r = CommitRecord.from_msgpack(d) |
| 589 | parent_ids: list[str] = [] |
| 590 | if r.parent_commit_id: |
| 591 | parent_ids.append(r.parent_commit_id) |
| 592 | recomputed = compute_commit_id( |
| 593 | repo_id=r.repo_id, |
| 594 | parent_ids=parent_ids, |
| 595 | snapshot_id=r.snapshot_id, |
| 596 | message=r.message, |
| 597 | committed_at_iso=r.committed_at.isoformat(), |
| 598 | author=r.author or "", |
| 599 | signer_public_key=r.signer_public_key or "", |
| 600 | ) |
| 601 | if recomputed != cid: |
| 602 | # Core field was corrupted — should have been caught |
| 603 | silent += 1 |
| 604 | except Exception: |
| 605 | pass |
| 606 | _corrupt_file(path, original) |
| 607 | assert silent == 0, ( |
| 608 | f"{silent} core-field bit flips were not caught by _verify_commit_id" |
| 609 | ) |
| 610 | |
| 611 | def test_commit_verify_critical_logged( |
| 612 | self, tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture |
| 613 | ) -> None: |
| 614 | """_verify_commit_id emits CRITICAL on core-field corruption detection.""" |
| 615 | import logging |
| 616 | import msgpack as _mp |
| 617 | repo = _repo(tmp_path) |
| 618 | cid, path = _make_commit(repo, msg="log test", snap_id=fake_id("snap-f2")) |
| 619 | original = path.read_bytes() |
| 620 | d = _mp.unpackb(original, raw=False) |
| 621 | assert isinstance(d, dict) |
| 622 | d["message"] = "tampered" |
| 623 | _corrupt_file(path, _mp.packb(d, use_bin_type=True)) |
| 624 | with caplog.at_level(logging.CRITICAL): |
| 625 | read_commit(repo, cid) |
| 626 | _corrupt_file(path, original) |
| 627 | assert any("content-hash verification" in r.message for r in caplog.records) |
| 628 | |
| 629 | |
| 630 | # --------------------------------------------------------------------------- |
| 631 | # 6. Snapshot msgpack — per-bit detection |
| 632 | # --------------------------------------------------------------------------- |
| 633 | |
| 634 | |
| 635 | class TestSnapshotBitFlip: |
| 636 | """Snapshot manifest corruption is caught by _verify_snapshot_id. |
| 637 | |
| 638 | Coverage map (I-10 finding): |
| 639 | |
| 640 | * **Manifest entries** (all path→oid pairs in the manifest): fully covered |
| 641 | by ``compute_snapshot_id``, which hashes every manifest entry. Any flip |
| 642 | in a file path or object ID produces a different hash. |
| 643 | |
| 644 | * **``created_at`` field**: metadata timestamp, NOT in ``compute_snapshot_id`` |
| 645 | by design. A flip there returns a snapshot with a wrong timestamp silently. |
| 646 | This is a documented limitation — the timestamp is informational metadata. |
| 647 | """ |
| 648 | |
| 649 | def test_manifest_oid_corruption_caught(self, tmp_path: pathlib.Path) -> None: |
| 650 | """Changing one object ID in the manifest by one char is caught.""" |
| 651 | repo = _repo(tmp_path) |
| 652 | import msgpack as _mp |
| 653 | oid_a = fake_id("oid-a") |
| 654 | oid_b = fake_id("oid-b") |
| 655 | manifest = {"file_a.py": oid_a, "file_b.py": oid_b} |
| 656 | sid, path = _make_snapshot(repo, manifest) |
| 657 | original = path.read_bytes() |
| 658 | d = _mp.unpackb(original, raw=False) |
| 659 | assert isinstance(d, dict) |
| 660 | assert isinstance(d["manifest"], dict) |
| 661 | d["manifest"]["file_a.py"] = oid_b # swap oid |
| 662 | _corrupt_file(path, _mp.packb(d, use_bin_type=True)) |
| 663 | assert read_snapshot(repo, sid) is None |
| 664 | _corrupt_file(path, original) |
| 665 | |
| 666 | def test_manifest_path_corruption_caught(self, tmp_path: pathlib.Path) -> None: |
| 667 | """Renaming a path in the manifest is caught by _verify_snapshot_id.""" |
| 668 | repo = _repo(tmp_path) |
| 669 | import msgpack as _mp |
| 670 | manifest = {"real_name.py": fake_id("oid-c")} |
| 671 | sid, path = _make_snapshot(repo, manifest) |
| 672 | original = path.read_bytes() |
| 673 | d = _mp.unpackb(original, raw=False) |
| 674 | assert isinstance(d, dict) |
| 675 | assert isinstance(d["manifest"], dict) |
| 676 | d["manifest"]["tampered_name.py"] = d["manifest"].pop("real_name.py") |
| 677 | _corrupt_file(path, _mp.packb(d, use_bin_type=True)) |
| 678 | assert read_snapshot(repo, sid) is None |
| 679 | _corrupt_file(path, original) |
| 680 | |
| 681 | def test_manifest_entry_injection_caught(self, tmp_path: pathlib.Path) -> None: |
| 682 | """Adding a spurious entry to the manifest is caught.""" |
| 683 | repo = _repo(tmp_path) |
| 684 | import msgpack as _mp |
| 685 | manifest = {"a.py": fake_id("oid-d")} |
| 686 | sid, path = _make_snapshot(repo, manifest) |
| 687 | original = path.read_bytes() |
| 688 | d = _mp.unpackb(original, raw=False) |
| 689 | assert isinstance(d, dict) |
| 690 | assert isinstance(d["manifest"], dict) |
| 691 | d["manifest"]["injected.py"] = fake_id("oid-e") |
| 692 | _corrupt_file(path, _mp.packb(d, use_bin_type=True)) |
| 693 | assert read_snapshot(repo, sid) is None |
| 694 | _corrupt_file(path, original) |
| 695 | |
| 696 | def test_manifest_entry_deletion_caught(self, tmp_path: pathlib.Path) -> None: |
| 697 | """Removing an entry from the manifest is caught.""" |
| 698 | repo = _repo(tmp_path) |
| 699 | import msgpack as _mp |
| 700 | manifest = {"keep.py": fake_id("oid-f"), "drop.py": fake_id("oid-g")} |
| 701 | sid, path = _make_snapshot(repo, manifest) |
| 702 | original = path.read_bytes() |
| 703 | d = _mp.unpackb(original, raw=False) |
| 704 | assert isinstance(d, dict) |
| 705 | assert isinstance(d["manifest"], dict) |
| 706 | del d["manifest"]["drop.py"] |
| 707 | _corrupt_file(path, _mp.packb(d, use_bin_type=True)) |
| 708 | assert read_snapshot(repo, sid) is None |
| 709 | _corrupt_file(path, original) |
| 710 | |
| 711 | def test_exhaustive_bits_in_manifest_region_all_caught(self, tmp_path: pathlib.Path) -> None: |
| 712 | """Exhaustive bit-flip of byte positions that affect manifest entries: zero silent.""" |
| 713 | repo = _repo(tmp_path) |
| 714 | import msgpack as _mp |
| 715 | manifest = {"alpha.py": fake_id("oid-0"), "beta.py": fake_id("oid-1")} |
| 716 | sid, path = _make_snapshot(repo, manifest) |
| 717 | original = path.read_bytes() |
| 718 | silent = 0 |
| 719 | for byte_idx in range(len(original)): |
| 720 | for bit_idx in range(8): |
| 721 | flipped = _flip_bit(original, byte_idx, bit_idx) |
| 722 | _corrupt_file(path, flipped) |
| 723 | result = read_snapshot(repo, sid) |
| 724 | if result is not None: |
| 725 | # Only fail if the manifest was actually changed |
| 726 | try: |
| 727 | d = _mp.unpackb(flipped, raw=False) |
| 728 | if isinstance(d, dict) and isinstance(d.get("manifest"), dict): |
| 729 | recomputed = compute_snapshot_id(d["manifest"]) |
| 730 | if recomputed != sid: |
| 731 | # Manifest was corrupted — must have been caught |
| 732 | silent += 1 |
| 733 | except Exception: |
| 734 | pass |
| 735 | _corrupt_file(path, original) |
| 736 | assert silent == 0, ( |
| 737 | f"{silent} manifest-region bit flips were not caught by _verify_snapshot_id" |
| 738 | ) |
| 739 | |
| 740 | def test_created_at_not_content_verified(self, tmp_path: pathlib.Path) -> None: |
| 741 | """Documented limitation: created_at is metadata and not content-hash verified.""" |
| 742 | repo = _repo(tmp_path) |
| 743 | import msgpack as _mp |
| 744 | manifest = {"f.py": fake_id("oid-2")} |
| 745 | sid, path = _make_snapshot(repo, manifest) |
| 746 | original = path.read_bytes() |
| 747 | d = _mp.unpackb(original, raw=False) |
| 748 | assert isinstance(d, dict) |
| 749 | d["created_at"] = "2000-01-01T00:00:00+00:00" # tampered timestamp |
| 750 | _corrupt_file(path, _mp.packb(d, use_bin_type=True)) |
| 751 | result = read_snapshot(repo, sid) |
| 752 | # Known limitation: created_at is not in snapshot_id, so this passes silently. |
| 753 | assert result is not None, ( |
| 754 | "Known limitation: created_at is metadata and is not content-hash verified. " |
| 755 | "A full-file HMAC would be required to catch this class of corruption." |
| 756 | ) |
| 757 | _corrupt_file(path, original) |
| 758 | |
| 759 | |
| 760 | # --------------------------------------------------------------------------- |
| 761 | # 7. _verify_commit_id unit tests |
| 762 | # --------------------------------------------------------------------------- |
| 763 | |
| 764 | |
| 765 | class TestCommitIdVerification: |
| 766 | """Unit tests for the new _verify_commit_id helper.""" |
| 767 | |
| 768 | def _clean_record(self) -> tuple[CommitRecord, str, pathlib.Path]: |
| 769 | now = datetime.datetime.now(datetime.timezone.utc) |
| 770 | snap_id = fake_id("snap-9") |
| 771 | cid = compute_commit_id(repo_id="r", parent_ids=[], snapshot_id=snap_id, message="verify test", committed_at_iso=now.isoformat()) |
| 772 | rec = CommitRecord( |
| 773 | commit_id=cid, repo_id="r", created_on_branch="b", |
| 774 | snapshot_id=snap_id, message="verify test", committed_at=now, |
| 775 | ) |
| 776 | return rec, cid, pathlib.Path("fake.msgpack") |
| 777 | |
| 778 | def test_clean_record_does_not_raise(self) -> None: |
| 779 | rec, cid, path = self._clean_record() |
| 780 | _verify_commit_id(rec, cid, path) # must not raise |
| 781 | |
| 782 | def test_wrong_snapshot_id_raises(self) -> None: |
| 783 | rec, cid, path = self._clean_record() |
| 784 | corrupted = CommitRecord( |
| 785 | commit_id=rec.commit_id, repo_id=rec.repo_id, created_on_branch=rec.created_on_branch, |
| 786 | snapshot_id=fake_id("wrong-snap"), # wrong |
| 787 | message=rec.message, committed_at=rec.committed_at, |
| 788 | ) |
| 789 | with pytest.raises(OSError, match="content-hash verification"): |
| 790 | _verify_commit_id(corrupted, cid, path) |
| 791 | |
| 792 | def test_wrong_message_raises(self) -> None: |
| 793 | rec, cid, path = self._clean_record() |
| 794 | corrupted = CommitRecord( |
| 795 | commit_id=rec.commit_id, repo_id=rec.repo_id, created_on_branch=rec.created_on_branch, |
| 796 | snapshot_id=rec.snapshot_id, message="tampered message", |
| 797 | committed_at=rec.committed_at, |
| 798 | ) |
| 799 | with pytest.raises(OSError, match="content-hash verification"): |
| 800 | _verify_commit_id(corrupted, cid, path) |
| 801 | |
| 802 | def test_wrong_committed_at_raises(self) -> None: |
| 803 | rec, cid, path = self._clean_record() |
| 804 | corrupted = CommitRecord( |
| 805 | commit_id=rec.commit_id, repo_id=rec.repo_id, created_on_branch=rec.created_on_branch, |
| 806 | snapshot_id=rec.snapshot_id, message=rec.message, |
| 807 | committed_at=datetime.datetime(2000, 1, 1, tzinfo=datetime.timezone.utc), |
| 808 | ) |
| 809 | with pytest.raises(OSError, match="content-hash verification"): |
| 810 | _verify_commit_id(corrupted, cid, path) |
| 811 | |
| 812 | def test_wrong_parent_id_raises(self) -> None: |
| 813 | now = datetime.datetime.now(datetime.timezone.utc) |
| 814 | parent = fake_id("parent-1") |
| 815 | snap_id = fake_id("snap-2b") |
| 816 | cid = compute_commit_id(repo_id="r", parent_ids=[parent], snapshot_id=snap_id, message="with parent", committed_at_iso=now.isoformat()) |
| 817 | rec = CommitRecord( |
| 818 | commit_id=cid, repo_id="r", created_on_branch="b", |
| 819 | snapshot_id=snap_id, message="with parent", |
| 820 | committed_at=now, parent_commit_id=parent, |
| 821 | ) |
| 822 | corrupted = CommitRecord( |
| 823 | commit_id=rec.commit_id, repo_id=rec.repo_id, created_on_branch=rec.created_on_branch, |
| 824 | snapshot_id=rec.snapshot_id, message=rec.message, |
| 825 | committed_at=rec.committed_at, |
| 826 | parent_commit_id=fake_id("wrong-parent-3"), # wrong parent |
| 827 | ) |
| 828 | with pytest.raises(OSError, match="content-hash verification"): |
| 829 | _verify_commit_id(corrupted, cid, pathlib.Path("x.msgpack")) |
| 830 | |
| 831 | def test_metadata_only_field_not_verified(self) -> None: |
| 832 | """created_on_branch / author are metadata — not in commit_id by design.""" |
| 833 | rec, cid, path = self._clean_record() |
| 834 | corrupted = CommitRecord( |
| 835 | commit_id=rec.commit_id, repo_id=rec.repo_id, |
| 836 | created_on_branch="tampered-branch", # not in commit_id |
| 837 | snapshot_id=rec.snapshot_id, message=rec.message, |
| 838 | committed_at=rec.committed_at, |
| 839 | ) |
| 840 | # Should not raise — metadata fields are not content-hash verified |
| 841 | _verify_commit_id(corrupted, cid, path) |
| 842 | |
| 843 | |
| 844 | # --------------------------------------------------------------------------- |
| 845 | # 8. _verify_snapshot_id unit tests |
| 846 | # --------------------------------------------------------------------------- |
| 847 | |
| 848 | |
| 849 | class TestSnapshotIdVerification: |
| 850 | """Unit tests for the new _verify_snapshot_id helper.""" |
| 851 | |
| 852 | def test_clean_snapshot_does_not_raise(self) -> None: |
| 853 | manifest = {"a.py": fake_id("oid-a"), "b.py": fake_id("oid-b")} |
| 854 | sid = compute_snapshot_id(manifest) |
| 855 | rec = SnapshotRecord( |
| 856 | snapshot_id=sid, manifest=manifest, |
| 857 | created_at=datetime.datetime.now(datetime.timezone.utc), |
| 858 | ) |
| 859 | _verify_snapshot_id(rec, sid, pathlib.Path("snap.msgpack")) |
| 860 | |
| 861 | def test_wrong_object_id_raises(self) -> None: |
| 862 | manifest = {"a.py": fake_id("oid-a")} |
| 863 | sid = compute_snapshot_id(manifest) |
| 864 | corrupted = SnapshotRecord( |
| 865 | snapshot_id=sid, |
| 866 | manifest={"a.py": fake_id("oid-b")}, # wrong oid |
| 867 | created_at=datetime.datetime.now(datetime.timezone.utc), |
| 868 | ) |
| 869 | with pytest.raises(OSError, match="content-hash verification"): |
| 870 | _verify_snapshot_id(corrupted, sid, pathlib.Path("snap.msgpack")) |
| 871 | |
| 872 | def test_wrong_path_raises(self) -> None: |
| 873 | manifest = {"a.py": fake_id("oid-a")} |
| 874 | sid = compute_snapshot_id(manifest) |
| 875 | corrupted = SnapshotRecord( |
| 876 | snapshot_id=sid, |
| 877 | manifest={"b.py": fake_id("oid-a")}, # wrong path |
| 878 | created_at=datetime.datetime.now(datetime.timezone.utc), |
| 879 | ) |
| 880 | with pytest.raises(OSError, match="content-hash verification"): |
| 881 | _verify_snapshot_id(corrupted, sid, pathlib.Path("snap.msgpack")) |
| 882 | |
| 883 | def test_extra_entry_raises(self) -> None: |
| 884 | manifest = {"a.py": fake_id("oid-a")} |
| 885 | sid = compute_snapshot_id(manifest) |
| 886 | corrupted = SnapshotRecord( |
| 887 | snapshot_id=sid, |
| 888 | manifest={"a.py": fake_id("oid-a"), "extra.py": fake_id("oid-c")}, # injected entry |
| 889 | created_at=datetime.datetime.now(datetime.timezone.utc), |
| 890 | ) |
| 891 | with pytest.raises(OSError, match="content-hash verification"): |
| 892 | _verify_snapshot_id(corrupted, sid, pathlib.Path("snap.msgpack")) |
| 893 | |
| 894 | def test_missing_entry_raises(self) -> None: |
| 895 | manifest = {"a.py": fake_id("oid-a"), "b.py": fake_id("oid-b")} |
| 896 | sid = compute_snapshot_id(manifest) |
| 897 | corrupted = SnapshotRecord( |
| 898 | snapshot_id=sid, |
| 899 | manifest={"a.py": fake_id("oid-a")}, # b.py missing |
| 900 | created_at=datetime.datetime.now(datetime.timezone.utc), |
| 901 | ) |
| 902 | with pytest.raises(OSError, match="content-hash verification"): |
| 903 | _verify_snapshot_id(corrupted, sid, pathlib.Path("snap.msgpack")) |
| 904 | |
| 905 | def test_empty_manifest_clean(self) -> None: |
| 906 | sid = compute_snapshot_id({}) |
| 907 | rec = SnapshotRecord( |
| 908 | snapshot_id=sid, manifest={}, |
| 909 | created_at=datetime.datetime.now(datetime.timezone.utc), |
| 910 | ) |
| 911 | _verify_snapshot_id(rec, sid, pathlib.Path("snap.msgpack")) |
| 912 | |
| 913 | def test_large_manifest_50k_entries(self) -> None: |
| 914 | """50 000-entry manifest: _verify_snapshot_id completes quickly.""" |
| 915 | import time |
| 916 | manifest = {f"path/to/file_{i:06d}.py": fake_id(f"obj{i}") |
| 917 | for i in range(50_000)} |
| 918 | sid = compute_snapshot_id(manifest) |
| 919 | rec = SnapshotRecord( |
| 920 | snapshot_id=sid, manifest=manifest, |
| 921 | created_at=datetime.datetime.now(datetime.timezone.utc), |
| 922 | ) |
| 923 | start = time.perf_counter() |
| 924 | _verify_snapshot_id(rec, sid, pathlib.Path("snap.msgpack")) |
| 925 | duration_ms = (time.perf_counter() - start) * 1000 |
| 926 | assert duration_ms < 5000, f"50k manifest verify took {duration_ms:.0f} ms (budget: 5 000 ms)" |
| 927 | |
| 928 | |
| 929 | # --------------------------------------------------------------------------- |
| 930 | # 9. Regression: pre-fix silent corruption gap is now closed |
| 931 | # --------------------------------------------------------------------------- |
| 932 | |
| 933 | |
| 934 | class TestRegressionSilentCorrupt: |
| 935 | """I-10 regression: core-field corruptions that were silent are now caught. |
| 936 | |
| 937 | Before I-10, 2 450 out of 3 776 bit positions in a commit file (the ones |
| 938 | in core fields) produced a silently wrong CommitRecord. Post-fix: zero. |
| 939 | |
| 940 | The remaining ~1 954 bit positions are in metadata fields (branch, author, |
| 941 | repo_id, etc.) that are not in compute_commit_id by design — those are |
| 942 | documented limitations, not regressions. |
| 943 | """ |
| 944 | |
| 945 | def test_core_field_corruptions_zero_silent_passes(self, tmp_path: pathlib.Path) -> None: |
| 946 | """Bit flips in core commit fields: zero silent passes after I-10 fix. |
| 947 | |
| 948 | Identifies core-field positions by checking whether the recomputed |
| 949 | commit_id would differ from the expected ID. Only those positions |
| 950 | are in scope for the zero-silent-passes assertion. |
| 951 | """ |
| 952 | repo = _repo(tmp_path) |
| 953 | import msgpack as _mp |
| 954 | cid, path = _make_commit(repo, msg="regression test", snap_id=fake_id("snap-7")) |
| 955 | original = path.read_bytes() |
| 956 | silent = 0 |
| 957 | for b in range(len(original)): |
| 958 | for bit in range(8): |
| 959 | flipped = _flip_bit(original, b, bit) |
| 960 | _corrupt_file(path, flipped) |
| 961 | result = read_commit(repo, cid) |
| 962 | if result is not None: |
| 963 | # Determine if this was a core-field position |
| 964 | try: |
| 965 | d = _mp.unpackb(flipped, raw=False) |
| 966 | if isinstance(d, dict): |
| 967 | r = CommitRecord.from_msgpack(d) |
| 968 | parent_ids: list[str] = [] |
| 969 | if r.parent_commit_id: |
| 970 | parent_ids.append(r.parent_commit_id) |
| 971 | recomputed = compute_commit_id( |
| 972 | repo_id=r.repo_id, |
| 973 | parent_ids=parent_ids, |
| 974 | snapshot_id=r.snapshot_id, |
| 975 | message=r.message, |
| 976 | committed_at_iso=r.committed_at.isoformat(), |
| 977 | author=r.author or "", |
| 978 | signer_public_key=r.signer_public_key or "", |
| 979 | ) |
| 980 | if recomputed != cid: |
| 981 | silent += 1 |
| 982 | except Exception: |
| 983 | pass |
| 984 | _corrupt_file(path, original) |
| 985 | assert silent == 0, ( |
| 986 | f"{silent} CORE-field bit flips in commit were silently returned. " |
| 987 | "This was the pre-I-10 gap — _verify_commit_id should now catch all." |
| 988 | ) |
| 989 | |
| 990 | def test_manifest_corruptions_zero_silent_passes(self, tmp_path: pathlib.Path) -> None: |
| 991 | """Bit flips that corrupt manifest entries: zero silent passes after I-10 fix.""" |
| 992 | repo = _repo(tmp_path) |
| 993 | import msgpack as _mp |
| 994 | sid, path = _make_snapshot(repo, {"main.py": fake_id("oid-8"), "lib.py": fake_id("oid-9")}) |
| 995 | original = path.read_bytes() |
| 996 | silent = 0 |
| 997 | for b in range(len(original)): |
| 998 | for bit in range(8): |
| 999 | flipped = _flip_bit(original, b, bit) |
| 1000 | _corrupt_file(path, flipped) |
| 1001 | result = read_snapshot(repo, sid) |
| 1002 | if result is not None: |
| 1003 | try: |
| 1004 | d = _mp.unpackb(flipped, raw=False) |
| 1005 | if isinstance(d, dict) and isinstance(d.get("manifest"), dict): |
| 1006 | recomputed = compute_snapshot_id(d["manifest"]) |
| 1007 | if recomputed != sid: |
| 1008 | silent += 1 |
| 1009 | except Exception: |
| 1010 | pass |
| 1011 | _corrupt_file(path, original) |
| 1012 | assert silent == 0, ( |
| 1013 | f"{silent} manifest-region bit flips in snapshot were silently returned. " |
| 1014 | "_verify_snapshot_id should catch all manifest corruptions." |
| 1015 | ) |
| 1016 | |
| 1017 | def test_read_commit_returns_none_not_wrong_record(self, tmp_path: pathlib.Path) -> None: |
| 1018 | """A core-field-corrupted commit file returns None, not a wrong CommitRecord.""" |
| 1019 | repo = _repo(tmp_path) |
| 1020 | import msgpack as _mp |
| 1021 | now = datetime.datetime.now(datetime.timezone.utc) |
| 1022 | snap_id = fake_id("snap-6") |
| 1023 | cid = compute_commit_id(repo_id="r", parent_ids=[], snapshot_id=snap_id, message="original message", committed_at_iso=now.isoformat()) |
| 1024 | rec = CommitRecord( |
| 1025 | commit_id=cid, repo_id="r", created_on_branch="main", |
| 1026 | snapshot_id=snap_id, message="original message", committed_at=now, |
| 1027 | ) |
| 1028 | write_commit(repo, rec) |
| 1029 | path = commit_path(repo, cid) |
| 1030 | original = path.read_bytes() |
| 1031 | d = _mp.unpackb(original, raw=False) |
| 1032 | assert isinstance(d, dict) |
| 1033 | d["message"] = "tampered message" |
| 1034 | _corrupt_file(path, _mp.packb(d, use_bin_type=True)) |
| 1035 | result = read_commit(repo, cid) |
| 1036 | assert result is None, ( |
| 1037 | "read_commit must return None on core-field corruption, " |
| 1038 | "not a record with wrong message" |
| 1039 | ) |
| 1040 | _corrupt_file(path, original) |
| 1041 | |
| 1042 | |
| 1043 | # --------------------------------------------------------------------------- |
| 1044 | # 10. Msgpack fuzz — 10 000 rounds on commit + snapshot |
| 1045 | # --------------------------------------------------------------------------- |
| 1046 | |
| 1047 | |
| 1048 | class TestMsgpackFuzz10k: |
| 1049 | """Random multi-byte corruption fuzz on commit and snapshot files.""" |
| 1050 | |
| 1051 | @pytest.mark.slow |
| 1052 | def test_5_bit_fuzz_10k_commit_core_field_always_touched(self, tmp_path: pathlib.Path) -> None: |
| 1053 | """10 000 fuzz rounds each touching a core commit field: zero silent passes. |
| 1054 | |
| 1055 | Each round flips 1 bit in a core-field region (snapshot_id, message, or |
| 1056 | committed_at in the msgpack) plus 4 random bits elsewhere. This guarantees |
| 1057 | the fuzz always reaches a content-hash-verified field, making zero silent |
| 1058 | passes the correct assertion. |
| 1059 | |
| 1060 | Pure random 5-bit fuzz has ~3.7% probability of landing all bits in metadata |
| 1061 | fields (branch, author, repo_id, etc.), which would produce expected silent |
| 1062 | passes — that is a documented design limitation, not a bug. |
| 1063 | """ |
| 1064 | repo = _repo(tmp_path) |
| 1065 | import msgpack as _mp |
| 1066 | cid, path = _make_commit(repo, msg="fuzz me", snap_id=fake_id("snap-5")) |
| 1067 | original = path.read_bytes() |
| 1068 | d_orig = _mp.unpackb(original, raw=False) |
| 1069 | assert isinstance(d_orig, dict) |
| 1070 | |
| 1071 | rng = random.Random(42) |
| 1072 | core_fields = ["snapshot_id", "message", "committed_at"] |
| 1073 | silent = 0 |
| 1074 | for _ in range(10_000): |
| 1075 | # Always corrupt a core field |
| 1076 | field = rng.choice(core_fields) |
| 1077 | d = dict(d_orig) |
| 1078 | if field == "snapshot_id": |
| 1079 | d["snapshot_id"] = rng.choice(["e", "f", "0"]) * 64 |
| 1080 | elif field == "message": |
| 1081 | d["message"] = f"tampered-{rng.randint(0, 999999)}" |
| 1082 | else: |
| 1083 | d["committed_at"] = f"200{rng.randint(0,9)}-01-01T00:00:00+00:00" |
| 1084 | # Plus 4 random bit flips |
| 1085 | packed = bytearray(_mp.packb(d, use_bin_type=True)) |
| 1086 | for _ in range(4): |
| 1087 | if packed: |
| 1088 | packed[rng.randrange(len(packed))] ^= 1 << rng.randrange(8) |
| 1089 | _corrupt_file(path, bytes(packed)) |
| 1090 | if read_commit(repo, cid) is not None: |
| 1091 | silent += 1 |
| 1092 | _corrupt_file(path, original) |
| 1093 | assert silent == 0, ( |
| 1094 | f"{silent} commit fuzz rounds (with guaranteed core-field corruption) " |
| 1095 | "went undetected — _verify_commit_id must catch all core-field changes" |
| 1096 | ) |
| 1097 | |
| 1098 | @pytest.mark.slow |
| 1099 | def test_5_bit_fuzz_10k_snapshot_manifest_always_touched(self, tmp_path: pathlib.Path) -> None: |
| 1100 | """10 000 fuzz rounds each touching a manifest entry: zero silent passes. |
| 1101 | |
| 1102 | Each round corrupts at least one manifest entry (path or oid) to guarantee |
| 1103 | the fuzz reaches content-hash-verified data. Pure random 5-bit fuzz has |
| 1104 | a small probability of landing all bits in the ``created_at`` metadata field, |
| 1105 | which is a documented limitation — not a bug. |
| 1106 | """ |
| 1107 | repo = _repo(tmp_path) |
| 1108 | import msgpack as _mp |
| 1109 | manifest = {"x.py": fake_id("oid-4"), "y.py": fake_id("oid-5")} |
| 1110 | sid, path = _make_snapshot(repo, manifest) |
| 1111 | original = path.read_bytes() |
| 1112 | d_orig = _mp.unpackb(original, raw=False) |
| 1113 | assert isinstance(d_orig, dict) |
| 1114 | assert isinstance(d_orig["manifest"], dict) |
| 1115 | |
| 1116 | rng = random.Random(99) |
| 1117 | silent = 0 |
| 1118 | for _ in range(10_000): |
| 1119 | d = dict(d_orig) |
| 1120 | d["manifest"] = dict(d_orig["manifest"]) |
| 1121 | # Always corrupt one manifest entry |
| 1122 | key = rng.choice(list(manifest.keys())) |
| 1123 | d["manifest"][key] = rng.choice(["a", "b", "c"]) * 64 |
| 1124 | # Plus 4 random bit flips |
| 1125 | packed = bytearray(_mp.packb(d, use_bin_type=True)) |
| 1126 | for _ in range(4): |
| 1127 | if packed: |
| 1128 | packed[rng.randrange(len(packed))] ^= 1 << rng.randrange(8) |
| 1129 | _corrupt_file(path, bytes(packed)) |
| 1130 | if read_snapshot(repo, sid) is not None: |
| 1131 | silent += 1 |
| 1132 | _corrupt_file(path, original) |
| 1133 | assert silent == 0, ( |
| 1134 | f"{silent} snapshot fuzz rounds (with guaranteed manifest corruption) " |
| 1135 | "went undetected — _verify_snapshot_id must catch all manifest changes" |
| 1136 | ) |
| 1137 | |
| 1138 | def test_completely_random_commit_bytes_100_rounds(self, tmp_path: pathlib.Path) -> None: |
| 1139 | """Replacing a commit file with random bytes: all 100 rounds caught.""" |
| 1140 | repo = _repo(tmp_path) |
| 1141 | cid, path = _make_commit(repo) |
| 1142 | original = path.read_bytes() |
| 1143 | rng = random.Random(7) |
| 1144 | for _ in range(100): |
| 1145 | garbage = bytes(rng.randrange(256) for _ in range(len(original))) |
| 1146 | _corrupt_file(path, garbage) |
| 1147 | assert read_commit(repo, cid) is None |
| 1148 | _corrupt_file(path, original) |
| 1149 | |
| 1150 | def test_completely_random_snapshot_bytes_100_rounds(self, tmp_path: pathlib.Path) -> None: |
| 1151 | """Replacing a snapshot file with random bytes: all 100 rounds caught.""" |
| 1152 | repo = _repo(tmp_path) |
| 1153 | sid, path = _make_snapshot(repo) |
| 1154 | original = path.read_bytes() |
| 1155 | rng = random.Random(8) |
| 1156 | for _ in range(100): |
| 1157 | garbage = bytes(rng.randrange(256) for _ in range(len(original))) |
| 1158 | _corrupt_file(path, garbage) |
| 1159 | assert read_snapshot(repo, sid) is None |
| 1160 | _corrupt_file(path, original) |
| 1161 | |
| 1162 | |
| 1163 | # --------------------------------------------------------------------------- |
| 1164 | # 11. CRITICAL log emission on corruption detection |
| 1165 | # --------------------------------------------------------------------------- |
| 1166 | |
| 1167 | |
| 1168 | class TestCriticalLogged: |
| 1169 | """CRITICAL is emitted for every detected bit flip (both object + store).""" |
| 1170 | |
| 1171 | def test_object_bit_flip_emits_critical( |
| 1172 | self, tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture |
| 1173 | ) -> None: |
| 1174 | import logging |
| 1175 | repo = _repo(tmp_path) |
| 1176 | data = b"log test object" |
| 1177 | oid = _write(repo, data) |
| 1178 | p = _stored_path(repo, oid) |
| 1179 | original = p.read_bytes() |
| 1180 | _corrupt_file(p, _flip_bit(original, 0, 0)) |
| 1181 | with caplog.at_level(logging.CRITICAL): |
| 1182 | try: |
| 1183 | read_object(repo, oid) |
| 1184 | except OSError: |
| 1185 | pass |
| 1186 | _corrupt_file(p, original) |
| 1187 | assert any("integrity check" in r.message.lower() or "corrupt" in r.message.lower() |
| 1188 | for r in caplog.records) |
| 1189 | |
| 1190 | def test_commit_flip_emits_critical( |
| 1191 | self, tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture |
| 1192 | ) -> None: |
| 1193 | import logging |
| 1194 | repo = _repo(tmp_path) |
| 1195 | cid, path = _make_commit(repo) |
| 1196 | original = path.read_bytes() |
| 1197 | # Try flips until one hits the CRITICAL path (verify_commit_id) |
| 1198 | # Most flips will hit msgpack unpack error first; we want one that |
| 1199 | # produces valid msgpack but fails content-hash verification. |
| 1200 | import msgpack as _mp |
| 1201 | d = _mp.unpackb(original, raw=False) |
| 1202 | assert isinstance(d, dict) |
| 1203 | d["message"] = "tampered" |
| 1204 | _corrupt_file(path, _mp.packb(d, use_bin_type=True)) |
| 1205 | with caplog.at_level(logging.CRITICAL): |
| 1206 | read_commit(repo, cid) |
| 1207 | _corrupt_file(path, original) |
| 1208 | assert any("corrupt" in r.message.lower() for r in caplog.records) |
| 1209 | |
| 1210 | def test_snapshot_flip_emits_critical( |
| 1211 | self, tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture |
| 1212 | ) -> None: |
| 1213 | import logging |
| 1214 | repo = _repo(tmp_path) |
| 1215 | sid, path = _make_snapshot(repo) |
| 1216 | original = path.read_bytes() |
| 1217 | import msgpack as _mp |
| 1218 | d = _mp.unpackb(original, raw=False) |
| 1219 | assert isinstance(d, dict) |
| 1220 | assert isinstance(d["manifest"], dict) |
| 1221 | d["manifest"]["README.md"] = fake_id("oid-z") |
| 1222 | _corrupt_file(path, _mp.packb(d, use_bin_type=True)) |
| 1223 | with caplog.at_level(logging.CRITICAL): |
| 1224 | read_snapshot(repo, sid) |
| 1225 | _corrupt_file(path, original) |
| 1226 | assert any("corrupt" in r.message.lower() for r in caplog.records) |
| 1227 | |
| 1228 | |
| 1229 | # --------------------------------------------------------------------------- |
| 1230 | # 12. Round-trip integrity |
| 1231 | # --------------------------------------------------------------------------- |
| 1232 | |
| 1233 | |
| 1234 | class TestRoundTripIntegrity: |
| 1235 | """Clean writes always round-trip without error.""" |
| 1236 | |
| 1237 | def test_object_round_trip(self, tmp_path: pathlib.Path) -> None: |
| 1238 | repo = _repo(tmp_path) |
| 1239 | for size in (0, 1, 31, 32, 33, 4095, 4096, 65535, 65536, 65537): |
| 1240 | data = os.urandom(size) |
| 1241 | oid = _write(repo, data) |
| 1242 | assert read_object(repo, oid) == data |
| 1243 | |
| 1244 | def test_commit_round_trip(self, tmp_path: pathlib.Path) -> None: |
| 1245 | repo = _repo(tmp_path) |
| 1246 | cid, _ = _make_commit(repo, msg="clean commit", snap_id=fake_id("snap-3b")) |
| 1247 | result = read_commit(repo, cid) |
| 1248 | assert result is not None |
| 1249 | assert result.commit_id == cid |
| 1250 | assert result.message == "clean commit" |
| 1251 | |
| 1252 | def test_snapshot_round_trip(self, tmp_path: pathlib.Path) -> None: |
| 1253 | repo = _repo(tmp_path) |
| 1254 | manifest = {f"f{i}.py": fake_id(str(i)) for i in range(100)} |
| 1255 | sid, _ = _make_snapshot(repo, manifest) |
| 1256 | result = read_snapshot(repo, sid) |
| 1257 | assert result is not None |
| 1258 | assert result.snapshot_id == sid |
| 1259 | assert result.manifest == manifest |
| 1260 | |
| 1261 | def test_commit_with_parents_round_trip(self, tmp_path: pathlib.Path) -> None: |
| 1262 | repo = _repo(tmp_path) |
| 1263 | p1 = fake_id("parent-1") |
| 1264 | p2 = fake_id("parent-2") |
| 1265 | snap_id = fake_id("snap-3c") |
| 1266 | now = datetime.datetime.now(datetime.timezone.utc) |
| 1267 | cid = compute_commit_id(repo_id="r", parent_ids=[p1, p2], snapshot_id=snap_id, message="merge commit", committed_at_iso=now.isoformat()) |
| 1268 | rec = CommitRecord( |
| 1269 | commit_id=cid, repo_id="r", created_on_branch="main", |
| 1270 | snapshot_id=snap_id, message="merge commit", committed_at=now, |
| 1271 | parent_commit_id=p1, parent2_commit_id=p2, |
| 1272 | ) |
| 1273 | write_commit(repo, rec) |
| 1274 | result = read_commit(repo, cid) |
| 1275 | assert result is not None |
| 1276 | assert result.parent_commit_id == p1 |
| 1277 | assert result.parent2_commit_id == p2 |
File History
3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9
docs: expand cache plan with all seven testing tiers and do…
Sonnet 4.6
136 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c
docs: docstring sprint for-each-ref→hotspots — idiomatic ru…
Sonnet 4.6
patch
142 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9
fix(cursorignore): remove git-ism (.git/worktrees)
Human
minor
⚠
145 days ago