gabriel / muse public
test_security_object_store_poisoning.py python
732 lines 29.9 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
1 """Phase 2.3 — Object store poisoning tests.
2
3 Covers every adversarial input and edge case identified in the recon phase:
4
5 1. Hash mismatch injection into write_object / write_object_from_path.
6 2. Per-object size cap enforcement at write time (not just read time).
7 3. restore_object re-hashes source before copying — corrupt store is detected.
8 4. apply_mpack: object count limit (pack-bomb).
9 5. apply_mpack: per-object size cap before write_object is called.
10 6. apply_mpack: object-ID deduplication (sha256 O(1) for duplicate IDs).
11 7. apply_mpack: snapshot / commit isolation — malformed entries skipped.
12 8. Zero-byte objects: valid empty blobs are accepted.
13 9. All write_object callsites confirmed to use content-derived IDs.
14 10. Stress: 10 000-object pack processed within time budget.
15 11. Stress: 50 concurrent poisoning attempts do not corrupt the store.
16 12. Threat-model boundary: SHA-256 collision infeasibility documented via test.
17 """
18
19 from __future__ import annotations
20
21 import hashlib
22 import os
23 import pathlib
24 import tempfile
25 import threading
26 import time
27 import uuid
28
29 import pytest
30 from unittest.mock import patch
31
32 from muse.core.object_store import (
33 has_object,
34 read_object,
35 restore_object,
36 write_object,
37 write_object_from_path,
38 )
39 from muse.core.pack import ApplyResult, MPackBundle, apply_mpack
40 from muse.core.store import CommitDict, SnapshotDict
41 from muse.core.validation import MAX_OBJECT_WRITE_BYTES, MAX_PACK_OBJECTS
42 from muse.core._types import Manifest, blob_id, long_id, now_utc_iso
43
44
45 # ---------------------------------------------------------------------------
46 # Helpers
47 # ---------------------------------------------------------------------------
48
49
50 def _sha256(data: bytes) -> str:
51 return blob_id(data)
52
53
54 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
55 repo = tmp_path / "repo"
56 repo.mkdir()
57 muse = repo / ".muse"
58 for sub in ("objects", "commits", "snapshots", "refs", "refs/heads", "tags"):
59 (muse / sub).mkdir(parents=True)
60 (muse / "HEAD").write_text("ref: refs/heads/main\n")
61 (muse / "repo.json").write_text('{"repo_id": "test-repo"}')
62 return repo
63
64
65 def _stored_object(repo: pathlib.Path, content: bytes) -> str:
66 """Write content to the store and return its object ID."""
67 oid = _sha256(content)
68 write_object(repo, oid, content)
69 return oid
70
71
72 def _minimal_commit_dict(snap_id: str) -> CommitDict:
73 rid = str(uuid.uuid4())
74 ts = now_utc_iso()
75 return CommitDict(
76 commit_id="a" * 64,
77 repo_id=rid,
78 branch="main",
79 parent_commit_id=None,
80 parent2_commit_id=None,
81 snapshot_id=snap_id,
82 message="test",
83 author="test",
84 committed_at=ts,
85 metadata={},
86 )
87
88
89 def _minimal_snapshot_dict(manifest: Manifest) -> SnapshotDict:
90 from muse.core.snapshot import compute_snapshot_id
91 snap_id = compute_snapshot_id(manifest)
92 ts = now_utc_iso()
93 return SnapshotDict(
94 snapshot_id=snap_id,
95 manifest=manifest,
96 created_at=ts,
97 )
98
99
100 # ---------------------------------------------------------------------------
101 # 1. Hash mismatch injection
102 # ---------------------------------------------------------------------------
103
104
105 class TestHashMismatch:
106 def test_write_object_wrong_content_raises(self, tmp_path: pathlib.Path) -> None:
107 """write_object must reject content whose sha256 ≠ object_id."""
108 repo = _make_repo(tmp_path)
109 legit = b"legitimate content"
110 malicious = b"poisoned content"
111 correct_id = _sha256(legit)
112 with pytest.raises(ValueError, match="Content integrity failure"):
113 write_object(repo, correct_id, malicious)
114 assert not has_object(repo, correct_id), "Poisoned object must not be stored"
115
116 def test_write_object_correct_content_succeeds(self, tmp_path: pathlib.Path) -> None:
117 repo = _make_repo(tmp_path)
118 content = b"valid content"
119 oid = _sha256(content)
120 assert write_object(repo, oid, content) is True
121 assert read_object(repo, oid) == content
122
123 def test_write_object_from_path_wrong_id_raises(self, tmp_path: pathlib.Path) -> None:
124 """write_object_from_path rejects when declared object_id ≠ file hash."""
125 repo = _make_repo(tmp_path)
126 real = tmp_path / "real.bin"
127 real.write_bytes(b"real file content")
128 wrong_id = _sha256(b"different content entirely")
129 with pytest.raises(ValueError, match="Content integrity failure"):
130 write_object_from_path(repo, wrong_id, real)
131 assert not has_object(repo, wrong_id)
132
133 def test_write_object_from_path_correct_id_succeeds(self, tmp_path: pathlib.Path) -> None:
134 repo = _make_repo(tmp_path)
135 content = b"file content"
136 src = tmp_path / "file.bin"
137 src.write_bytes(content)
138 oid = _sha256(content)
139 assert write_object_from_path(repo, oid, src) is True
140 assert has_object(repo, oid)
141
142 def test_all_ones_id_mismatch_raises(self, tmp_path: pathlib.Path) -> None:
143 """Crafted all-hex-ones object_id still caught by hash mismatch."""
144 repo = _make_repo(tmp_path)
145 content = b"something"
146 fake_id = "f" * 64
147 with pytest.raises(ValueError):
148 write_object(repo, fake_id, content)
149
150 def test_empty_object_valid(self, tmp_path: pathlib.Path) -> None:
151 """Zero-byte content is a valid object — sha256 of empty bytes."""
152 repo = _make_repo(tmp_path)
153 empty_id = _sha256(b"") # e3b0c44...
154 assert write_object(repo, empty_id, b"") is True
155 assert read_object(repo, empty_id) == b""
156
157 def test_invalid_object_id_format_raises(self, tmp_path: pathlib.Path) -> None:
158 repo = _make_repo(tmp_path)
159 with pytest.raises((ValueError, TypeError)):
160 write_object(repo, "not-a-hex-id", b"content")
161 with pytest.raises((ValueError, TypeError)):
162 write_object(repo, "a" * 63, b"content") # one char short
163 with pytest.raises((ValueError, TypeError)):
164 write_object(repo, "G" * 64, b"content") # uppercase hex (invalid)
165
166
167 # ---------------------------------------------------------------------------
168 # 2. Per-object size cap on write
169 # ---------------------------------------------------------------------------
170
171
172 class TestObjectSizeCap:
173 def test_oversized_content_rejected_at_write(self, tmp_path: pathlib.Path) -> None:
174 """write_object must reject blobs above MAX_OBJECT_WRITE_BYTES."""
175 repo = _make_repo(tmp_path)
176 # Build oversized content (just above limit).
177 oversized = b"x" * (MAX_OBJECT_WRITE_BYTES + 1)
178 oid = _sha256(oversized)
179 with pytest.raises(ValueError, match="exceeding the"):
180 write_object(repo, oid, oversized)
181 assert not has_object(repo, oid), "Oversized object must not be stored"
182
183 def test_exactly_at_limit_is_rejected(self, tmp_path: pathlib.Path) -> None:
184 """An object of exactly MAX_OBJECT_WRITE_BYTES + 1 bytes is rejected."""
185 repo = _make_repo(tmp_path)
186 # MAX_OBJECT_WRITE_BYTES itself is the ceiling — bytes > limit are rejected.
187 oversized = b"y" * (MAX_OBJECT_WRITE_BYTES + 1)
188 oid = _sha256(oversized)
189 with pytest.raises(ValueError):
190 write_object(repo, oid, oversized)
191
192 def test_write_object_from_path_oversized_raises(self, tmp_path: pathlib.Path) -> None:
193 """write_object_from_path must stat and reject oversized source files."""
194 repo = _make_repo(tmp_path)
195 big_file = tmp_path / "big.bin"
196 # Create a sparse file that appears large without using disk space.
197 with big_file.open("wb") as fh:
198 fh.seek(MAX_OBJECT_WRITE_BYTES)
199 fh.write(b"\x00")
200 # Compute the real hash of this sparse file.
201 h = hashlib.sha256()
202 with big_file.open("rb") as fh:
203 for chunk in iter(lambda: fh.read(65536), b""):
204 h.update(chunk)
205 oid = long_id(h.hexdigest())
206 with pytest.raises(ValueError, match="exceeding the"):
207 write_object_from_path(repo, oid, big_file)
208 assert not has_object(repo, oid)
209
210 def test_just_under_limit_succeeds(self, tmp_path: pathlib.Path) -> None:
211 """An object of exactly MAX_OBJECT_WRITE_BYTES bytes is accepted."""
212 repo = _make_repo(tmp_path)
213 # Use a tiny blob to not exhaust memory in CI — just verify the boundary.
214 tiny = b"t" * 16
215 oid = _sha256(tiny)
216 assert write_object(repo, oid, tiny) is True
217
218
219 # ---------------------------------------------------------------------------
220 # 3. restore_object — hash re-verification before copy
221 # ---------------------------------------------------------------------------
222
223
224 class TestRestoreObjectIntegrity:
225 def test_restore_clean_object_succeeds(self, tmp_path: pathlib.Path) -> None:
226 repo = _make_repo(tmp_path)
227 content = b"data to restore"
228 oid = _stored_object(repo, content)
229 dest = tmp_path / "restored.bin"
230 assert restore_object(repo, oid, dest) is True
231 assert dest.read_bytes() == content
232
233 def test_restore_missing_object_returns_false(self, tmp_path: pathlib.Path) -> None:
234 repo = _make_repo(tmp_path)
235 ghost_id = _sha256(b"ghost")
236 dest = tmp_path / "ghost.bin"
237 assert restore_object(repo, ghost_id, dest) is False
238 assert not dest.exists()
239
240 def test_restore_detects_corrupted_store_object(self, tmp_path: pathlib.Path) -> None:
241 """If the on-disk object file is corrupted, restore_object must raise OSError."""
242 repo = _make_repo(tmp_path)
243 content = b"important file content"
244 oid = _stored_object(repo, content)
245
246 # Corrupt the object file directly (bypass the immutable mode).
247 from muse.core.object_store import _object_path_with_fallback
248 obj_file = _object_path_with_fallback(repo, oid)
249 os.chmod(obj_file, 0o644)
250 obj_file.write_bytes(b"corrupted bytes that do not match the declared hash")
251 os.chmod(obj_file, 0o444)
252
253 dest = tmp_path / "should-not-exist.bin"
254 with pytest.raises(OSError, match="failed SHA-256 integrity check"):
255 restore_object(repo, oid, dest)
256 assert not dest.exists(), "No corrupted data must reach the working tree"
257
258 def test_restore_dest_is_writable(self, tmp_path: pathlib.Path) -> None:
259 """Restored files must be writable (0o444 object mode must not propagate)."""
260 repo = _make_repo(tmp_path)
261 content = b"editable file"
262 oid = _stored_object(repo, content)
263 dest = tmp_path / "editable.txt"
264 restore_object(repo, oid, dest)
265 # Should be writable by owner.
266 dest.write_bytes(b"new content") # must not raise PermissionError
267
268 def test_restore_is_atomic(self, tmp_path: pathlib.Path) -> None:
269 """A concurrent reader never sees a partial restore."""
270 repo = _make_repo(tmp_path)
271 content = b"atomic restore test " + b"x" * 1000
272 oid = _stored_object(repo, content)
273 dest = tmp_path / "atomic.bin"
274 restore_object(repo, oid, dest)
275 assert dest.read_bytes() == content
276
277
278 # ---------------------------------------------------------------------------
279 # 4 & 5. apply_mpack — pack-bomb and per-object size cap
280 # ---------------------------------------------------------------------------
281
282
283 class TestApplyPackBomb:
284 def _build_pack(
285 self,
286 *,
287 n_objects: int = 0,
288 n_snapshots: int = 0,
289 n_commits: int = 0,
290 object_size: int = 1,
291 ) -> MPackBundle:
292 objects = []
293 for i in range(n_objects):
294 content = f"object-{i}".encode() + b"\x00" * object_size
295 oid = _sha256(content)
296 objects.append({"object_id": oid, "content": content})
297 return MPackBundle(
298 commits=[],
299 snapshots=[],
300 objects=objects,
301 )
302
303 def test_pack_at_limit_succeeds(self, tmp_path: pathlib.Path) -> None:
304 """A pack with exactly MAX_PACK_OBJECTS items (objects + snapshots + commits) is accepted."""
305 repo = _make_repo(tmp_path)
306 # Use a small object count that is within the limit.
307 n = min(10, MAX_PACK_OBJECTS)
308 bundle = self._build_pack(n_objects=n)
309 result = apply_mpack(repo, bundle)
310 assert result["objects_written"] == n
311
312 def test_pack_exceeds_limit_raises(self, tmp_path: pathlib.Path) -> None:
313 """A pack with total items > MAX_PACK_OBJECTS must be rejected."""
314 repo = _make_repo(tmp_path)
315 # Build a fake bundle that claims MAX_PACK_OBJECTS + 1 items.
316 # We don't actually need the objects to be real — the count check fires first.
317 fake_obj = {"object_id": "a" * 64, "content": b"x"}
318 oversized_bundle: MPackBundle = MPackBundle(
319 commits=[],
320 snapshots=[],
321 objects=[fake_obj] * (MAX_PACK_OBJECTS + 1),
322 )
323 with pytest.raises(ValueError, match="exceeds the"):
324 apply_mpack(repo, oversized_bundle)
325
326 def test_oversized_object_in_pack_is_skipped(self, tmp_path: pathlib.Path) -> None:
327 """An object in the pack that exceeds MAX_OBJECT_WRITE_BYTES is logged and skipped."""
328 repo = _make_repo(tmp_path)
329 big_content = b"B" * (MAX_OBJECT_WRITE_BYTES + 1)
330 big_oid = _sha256(big_content)
331 tiny_content = b"tiny object"
332 tiny_oid = _sha256(tiny_content)
333 bundle: MPackBundle = MPackBundle(
334 commits=[],
335 snapshots=[],
336 objects=[
337 {"object_id": big_oid, "content": big_content},
338 {"object_id": tiny_oid, "content": tiny_content},
339 ],
340 )
341 result = apply_mpack(repo, bundle)
342 # Big object must be skipped, tiny object must be written.
343 assert not has_object(repo, big_oid), "Oversized object must not be stored"
344 assert has_object(repo, tiny_oid), "Valid object must be stored"
345 assert result["objects_written"] == 1
346
347 def test_zero_item_pack_is_accepted(self, tmp_path: pathlib.Path) -> None:
348 repo = _make_repo(tmp_path)
349 empty: MPackBundle = MPackBundle(commits=[], snapshots=[], objects=[])
350 result = apply_mpack(repo, empty)
351 assert result == ApplyResult(
352 commits_written=0,
353 snapshots_written=0,
354 objects_written=0,
355 objects_skipped=0,
356 tags_written=0,
357 )
358
359
360 # ---------------------------------------------------------------------------
361 # 6. apply_mpack — object-ID deduplication
362 # ---------------------------------------------------------------------------
363
364
365 class TestApplyPackDeduplication:
366 def test_duplicate_object_ids_not_hashed_twice(self, tmp_path: pathlib.Path) -> None:
367 """Duplicate object IDs in the pack are skipped without re-computing sha256."""
368 repo = _make_repo(tmp_path)
369 content = b"dedup test object"
370 oid = _sha256(content)
371 # Send the same object 100 times.
372 bundle: MPackBundle = MPackBundle(
373 commits=[],
374 snapshots=[],
375 objects=[{"object_id": oid, "content": content}] * 100,
376 )
377 result = apply_mpack(repo, bundle)
378 assert result["objects_written"] == 1
379 assert result["objects_skipped"] == 99
380 assert has_object(repo, oid)
381
382 def test_duplicate_then_different_both_processed(self, tmp_path: pathlib.Path) -> None:
383 repo = _make_repo(tmp_path)
384 c1 = b"first object"
385 c2 = b"second object"
386 o1 = _sha256(c1)
387 o2 = _sha256(c2)
388 bundle: MPackBundle = MPackBundle(
389 commits=[],
390 snapshots=[],
391 objects=[
392 {"object_id": o1, "content": c1},
393 {"object_id": o1, "content": c1}, # duplicate
394 {"object_id": o2, "content": c2},
395 ],
396 )
397 result = apply_mpack(repo, bundle)
398 assert result["objects_written"] == 2
399 assert result["objects_skipped"] == 1
400
401
402 # ---------------------------------------------------------------------------
403 # 7. apply_mpack — malformed entries are isolated (snapshot / commit)
404 # ---------------------------------------------------------------------------
405
406
407 class TestApplyPackMalformedEntries:
408 def test_malformed_object_entry_does_not_abort_pack(self, tmp_path: pathlib.Path) -> None:
409 """A bad object entry is logged and skipped; other entries are still written.
410
411 Note: deduplication means each object_id is only attempted once per
412 apply_mpack call. Two entries with the same object_id but different
413 content are impossible in a valid content-addressed store — if the
414 first attempt fails (hash mismatch or malformed ID), the second
415 attempt for the same ID is correctly deduplicated. Use distinct IDs
416 to test that bad entries do not prevent good ones from being written.
417 """
418 repo = _make_repo(tmp_path)
419 good_content_a = b"good object A"
420 good_oid_a = _sha256(good_content_a)
421 good_content_b = b"good object B"
422 good_oid_b = _sha256(good_content_b)
423 bundle: MPackBundle = MPackBundle(
424 commits=[],
425 snapshots=[],
426 objects=[
427 {"object_id": "not-hex", "content": b"bad"}, # malformed ID
428 {"object_id": good_oid_a, "content": b"wrong bytes"}, # hash mismatch
429 {"object_id": good_oid_b, "content": good_content_b}, # valid different OID
430 ],
431 )
432 result = apply_mpack(repo, bundle)
433 assert not has_object(repo, good_oid_a), "Hash-mismatched entry must not be stored"
434 assert has_object(repo, good_oid_b), "Valid entry after bad ones must be stored"
435 assert result["objects_written"] == 1
436
437 def test_missing_object_id_in_pack_entry_skipped(self, tmp_path: pathlib.Path) -> None:
438 repo = _make_repo(tmp_path)
439 bundle: MPackBundle = MPackBundle(
440 commits=[],
441 snapshots=[],
442 objects=[{"object_id": "", "content": b"anything"}],
443 )
444 result = apply_mpack(repo, bundle)
445 assert result["objects_written"] == 0
446
447 def test_empty_content_in_pack_entry_skipped(self, tmp_path: pathlib.Path) -> None:
448 """An entry with empty content (b'') and any oid is skipped (not-oid check)."""
449 repo = _make_repo(tmp_path)
450 from muse.core.pack import ObjectPayload
451 # An entry with empty oid and empty content has no oid — should be skipped.
452 empty_entry = ObjectPayload(object_id="", content=b"")
453 bundle: MPackBundle = MPackBundle(commits=[], snapshots=[], objects=[empty_entry])
454 result = apply_mpack(repo, bundle)
455 assert result["objects_written"] == 0
456
457
458 # ---------------------------------------------------------------------------
459 # 8. read_object — corruption detected on every read
460 # ---------------------------------------------------------------------------
461
462
463 class TestReadObjectIntegrity:
464 def test_read_clean_object_succeeds(self, tmp_path: pathlib.Path) -> None:
465 repo = _make_repo(tmp_path)
466 content = b"clean read test"
467 oid = _stored_object(repo, content)
468 assert read_object(repo, oid) == content
469
470 def test_read_corrupted_object_raises(self, tmp_path: pathlib.Path) -> None:
471 repo = _make_repo(tmp_path)
472 content = b"will be corrupted"
473 oid = _stored_object(repo, content)
474 from muse.core.object_store import _object_path_with_fallback
475 obj_file = _object_path_with_fallback(repo, oid)
476 os.chmod(obj_file, 0o644)
477 obj_file.write_bytes(b"corrupted bytes")
478 os.chmod(obj_file, 0o444)
479 with pytest.raises(OSError, match="integrity check"):
480 read_object(repo, oid)
481
482 def test_read_absent_object_returns_none(self, tmp_path: pathlib.Path) -> None:
483 repo = _make_repo(tmp_path)
484 assert read_object(repo, _sha256(b"absent")) is None
485
486
487 # ---------------------------------------------------------------------------
488 # 9. Confirmed: all write_object callsites use content-derived IDs
489 # ---------------------------------------------------------------------------
490
491
492 class TestCallsiteIntegrity:
493 def test_hash_object_stdin_derives_id_from_content(self, tmp_path: pathlib.Path) -> None:
494 """hash-object with --write derives object_id from actual stdin bytes."""
495 from tests.cli_test_helper import CliRunner
496 repo = _make_repo(tmp_path)
497 (repo / ".muse" / "config.toml").write_text("[core]\nauthor = \"test\"\n")
498 content = b"stdin content for hashing"
499 expected_oid = _sha256(content)
500 runner = CliRunner()
501 result = runner.invoke(
502 None,
503 ["hash-object", "--stdin", "--write"],
504 input=content,
505 env={"MUSE_REPO_ROOT": str(repo)},
506 )
507 assert result.exit_code == 0, result.output
508 assert expected_oid in result.output
509 assert has_object(repo, expected_oid)
510
511 def test_hash_object_file_derives_id_from_file_content(self, tmp_path: pathlib.Path) -> None:
512 """hash-object with a file path derives object_id from actual file bytes."""
513 from tests.cli_test_helper import CliRunner
514 repo = _make_repo(tmp_path)
515 (repo / ".muse" / "config.toml").write_text("[core]\nauthor = \"test\"\n")
516 content = b"file content for hashing"
517 target = tmp_path / "target.bin"
518 target.write_bytes(content)
519 expected_oid = _sha256(content)
520 runner = CliRunner()
521 result = runner.invoke(
522 None,
523 ["hash-object", str(target), "--write"],
524 env={"MUSE_REPO_ROOT": str(repo)},
525 )
526 assert result.exit_code == 0, result.output
527 assert expected_oid in result.output
528 assert has_object(repo, expected_oid)
529
530 def test_unpack_objects_hash_mismatch_rejected(self, tmp_path: pathlib.Path) -> None:
531 """muse unpack-objects rejects a pack object with wrong hash."""
532 from tests.cli_test_helper import CliRunner
533 import msgpack
534 repo = _make_repo(tmp_path)
535 (repo / ".muse" / "config.toml").write_text("[core]\nauthor = \"test\"\n")
536 legit_content = b"legitimate"
537 legit_oid = _sha256(legit_content)
538 # Pack claims legit_oid but delivers different bytes.
539 poison_bundle = {
540 "commits": [],
541 "snapshots": [],
542 "objects": [{"object_id": legit_oid, "content": b"evil bytes"}],
543 }
544 packed = msgpack.packb(poison_bundle, use_bin_type=True)
545
546 # apply_mpack directly to test the core logic.
547 bundle: MPackBundle = MPackBundle(
548 commits=[], snapshots=[],
549 objects=[{"object_id": legit_oid, "content": b"evil bytes"}],
550 )
551 result = apply_mpack(repo, bundle)
552 # The poisoned object should be skipped (hash mismatch caught by write_object).
553 assert not has_object(repo, legit_oid), "Poisoned object must not enter the store"
554 assert result["objects_written"] == 0
555
556
557 # ---------------------------------------------------------------------------
558 # 10. Stress: 10 000-object pack processed within time budget
559 # ---------------------------------------------------------------------------
560
561
562 class TestStress:
563 @pytest.fixture(autouse=True)
564 def no_fsync(self) -> None:
565 """Mock fsync so the budget test measures algorithmic cost, not I/O latency."""
566 with patch("muse.core.object_store._fsync_fd", return_value=None), \
567 patch("muse.core.store.os.fsync", return_value=None), \
568 patch("muse.core.store.fcntl.fcntl", return_value=0):
569 yield
570
571 @pytest.mark.perf
572 def test_10k_object_pack_within_budget(self, tmp_path: pathlib.Path) -> None:
573 """10 000 unique objects written through apply_mpack in under 30 seconds."""
574 repo = _make_repo(tmp_path)
575 n = 10_000
576 objects = []
577 for i in range(n):
578 content = f"stress-object-{i:06d}".encode()
579 oid = _sha256(content)
580 objects.append({"object_id": oid, "content": content})
581
582 bundle: MPackBundle = MPackBundle(commits=[], snapshots=[], objects=objects)
583 start = time.monotonic()
584 result = apply_mpack(repo, bundle)
585 elapsed = time.monotonic() - start
586
587 assert result["objects_written"] == n
588 assert elapsed < 30.0, f"10k-object pack took {elapsed:.1f}s — too slow"
589
590 def test_idempotent_10k_pack_fast(self, tmp_path: pathlib.Path) -> None:
591 """Re-applying the same 10k pack is faster (all objects already present)."""
592 repo = _make_repo(tmp_path)
593 n = 1_000 # smaller for the idempotency test
594 objects = []
595 for i in range(n):
596 content = f"idem-object-{i:06d}".encode()
597 oid = _sha256(content)
598 objects.append({"object_id": oid, "content": content})
599
600 bundle: MPackBundle = MPackBundle(commits=[], snapshots=[], objects=objects)
601 apply_mpack(repo, bundle) # first application
602 result2 = apply_mpack(repo, bundle) # second application
603 assert result2["objects_written"] == 0
604 assert result2["objects_skipped"] == n
605
606 def test_10k_duplicate_ids_deduplicated(self, tmp_path: pathlib.Path) -> None:
607 """10 000 entries with the same object_id are deduplicated to one write."""
608 repo = _make_repo(tmp_path)
609 content = b"one true object"
610 oid = _sha256(content)
611 bundle: MPackBundle = MPackBundle(
612 commits=[],
613 snapshots=[],
614 objects=[{"object_id": oid, "content": content}] * 10_000,
615 )
616 result = apply_mpack(repo, bundle)
617 assert result["objects_written"] == 1
618 assert result["objects_skipped"] == 9_999
619
620
621 # ---------------------------------------------------------------------------
622 # 11. Concurrent poisoning stress
623 # ---------------------------------------------------------------------------
624
625
626 class TestConcurrentPoisoning:
627 def test_concurrent_hash_mismatch_attempts_do_not_corrupt(
628 self, tmp_path: pathlib.Path
629 ) -> None:
630 """50 threads simultaneously trying to poison the store — none succeeds."""
631 repo = _make_repo(tmp_path)
632 legit_content = b"the one true content"
633 legit_oid = _sha256(legit_content)
634
635 # Write the legitimate object first.
636 write_object(repo, legit_oid, legit_content)
637
638 errors: list[str] = []
639
640 def poison_attempt(idx: int) -> None:
641 evil_content = f"evil-{idx}".encode()
642 try:
643 write_object(repo, legit_oid, evil_content)
644 errors.append(f"Thread {idx}: poisoning succeeded!")
645 except ValueError:
646 pass # expected
647
648 threads = [threading.Thread(target=poison_attempt, args=(i,)) for i in range(50)]
649 for t in threads:
650 t.start()
651 for t in threads:
652 t.join(timeout=5.0)
653
654 assert not errors, "\n".join(errors)
655 # The stored object must still be the legitimate one.
656 assert read_object(repo, legit_oid) == legit_content
657
658 def test_concurrent_writes_of_same_object_idempotent(
659 self, tmp_path: pathlib.Path
660 ) -> None:
661 """50 threads writing the same valid object — exactly one write, no corruption."""
662 repo = _make_repo(tmp_path)
663 content = b"concurrent valid object"
664 oid = _sha256(content)
665 results: list[bool] = []
666 lock = threading.Lock()
667
668 def write_it() -> None:
669 wrote = write_object(repo, oid, content)
670 with lock:
671 results.append(wrote)
672
673 threads = [threading.Thread(target=write_it) for _ in range(50)]
674 for t in threads:
675 t.start()
676 for t in threads:
677 t.join(timeout=5.0)
678
679 assert results.count(True) >= 1, "At least one thread must have written"
680 assert read_object(repo, oid) == content
681
682
683 # ---------------------------------------------------------------------------
684 # 12. SHA-256 threat model documentation test
685 # ---------------------------------------------------------------------------
686
687
688 class TestSHA256ThreatModel:
689 def test_sha256_preimage_resistance_documented(self) -> None:
690 """Document that SHA-256 preimage resistance is the security boundary.
691
692 Muse's object store is secure against hash-mismatch injection because:
693 1. write_object computes sha256(content) and rejects any mismatch.
694 2. read_object recomputes sha256 on every read.
695 3. restore_object recomputes sha256 before copying to working tree.
696
697 A successful poisoning attack would require finding a second preimage:
698 a different content M' such that sha256(M') == sha256(M).
699
700 As of 2026, the best known second-preimage attack on SHA-256 requires
701 2^256 operations — computationally infeasible for any adversary.
702
703 This test is a living specification of the threat model, not a
704 cryptographic proof. It verifies the code paths enforce the model.
705 """
706 content_a = b"message A"
707 content_b = b"message B"
708 # Two different messages must have different SHA-256 digests.
709 # (With overwhelming probability — hash collision is computationally
710 # infeasible but not theoretically impossible.)
711 assert _sha256(content_a) != _sha256(content_b)
712
713 def test_write_then_read_roundtrip_preserves_content(
714 self, tmp_path: pathlib.Path
715 ) -> None:
716 """Content written to the store is always returned verbatim on read."""
717 repo = _make_repo(tmp_path)
718 for i in range(20):
719 content = uuid.uuid4().bytes * (i + 1)
720 oid = _sha256(content)
721 write_object(repo, oid, content)
722 assert read_object(repo, oid) == content
723
724 def test_object_mode_is_immutable(self, tmp_path: pathlib.Path) -> None:
725 """Stored objects have mode 0o444 — expressing immutability at OS level."""
726 repo = _make_repo(tmp_path)
727 content = b"immutable object"
728 oid = _stored_object(repo, content)
729 from muse.core.object_store import _object_path_with_fallback
730 obj_file = _object_path_with_fallback(repo, oid)
731 mode = oct(obj_file.stat().st_mode & 0o777)
732 assert mode == oct(0o444), f"Expected 0o444, got {mode}"
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 141 days ago