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