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