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