gabriel / muse public
test_integrity_I10_bit_flip.py python
1,277 lines 54.5 KB
Raw
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