gabriel / muse public
test_write_snapshot_incoming_verify.py python
327 lines 13.5 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Tests for Bug 9: write_snapshot accepts incoming SnapshotRecord whose
2 snapshot_id doesn't match the hash of its manifest — creating a file that
3 read_snapshot always reports as corrupt (hash mismatch), permanently unreadable.
4
5 The symmetrical fix to Bug 8b (write_commit incoming verification): both
6 write_commit and write_snapshot must verify the incoming record hash before
7 touching disk.
8
9 Attack scenarios:
10 - apply_pack receives a bundle where snapshot_id is wrong (corruption/attack)
11 - A manually-constructed SnapshotRecord is passed with mismatched snapshot_id
12 - An adversary injects a snapshot that checksums fine for the wrong ID
13
14 Scope of tests
15 --------------
16 Unit (write_snapshot incoming hash verification):
17 - write_snapshot rejects incoming record with wrong snapshot_id (new file)
18 - write_snapshot rejects incoming record with wrong snapshot_id (existing good file)
19 - write_snapshot accepts valid incoming record (new file)
20 - write_snapshot is idempotent on valid record (second call skips)
21 - write_snapshot rejects incoming record with one wrong manifest entry
22 - write_snapshot rejects incoming record with extra injected manifest entry
23 - write_snapshot rejects incoming record with missing manifest entry
24 - write_snapshot rejects incoming record with wrong directories hash
25
26 Integration (apply_pack with corrupt snapshot_id):
27 - apply_pack skips snapshot with wrong snapshot_id
28 - apply_pack does not skip valid snapshots when one is corrupt
29 - apply_pack: written snapshot must be readable via read_snapshot
30 - apply_pack: corrupt snapshot_id in bundle cannot poison existing good snapshot
31
32 Security:
33 - A bundle cannot substitute a manifest that passes a different snapshot's hash
34 - Injected manifest entries are rejected before reaching disk
35
36 Stress:
37 - 100-snapshot bundle with one corrupt snapshot_id: 99 written, 1 skipped
38 """
39 from __future__ import annotations
40
41 import datetime
42 import pathlib
43
44 import pytest
45
46 from muse.core.pack import apply_pack, PackBundle
47 from muse.core.snapshot import compute_snapshot_id
48
49 from muse.core._types import Manifest
50 from muse.core.store import (
51 SnapshotRecord,
52 read_snapshot,
53 write_snapshot,
54 )
55
56 _TS = datetime.datetime(2024, 6, 15, 10, 0, 0, tzinfo=datetime.timezone.utc)
57
58
59 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
60 repo = tmp_path / "repo"
61 repo.mkdir()
62 (repo / ".muse").mkdir()
63 return repo
64
65
66 def _good_snap(manifest: Manifest | None = None) -> SnapshotRecord:
67 m = manifest or {"src/main.py": "a" * 64}
68 snap_id = compute_snapshot_id(m)
69 return SnapshotRecord(
70 snapshot_id=snap_id,
71 manifest=m,
72 directories=[],
73 created_at=_TS,
74 note="",
75 )
76
77
78 def _snap_with_wrong_id(manifest: Manifest | None = None) -> SnapshotRecord:
79 """SnapshotRecord whose snapshot_id doesn't match the hash of its manifest."""
80 m = manifest or {"src/main.py": "a" * 64}
81 return SnapshotRecord(
82 snapshot_id="f" * 64, # wrong — doesn't match manifest hash
83 manifest=m,
84 directories=[],
85 created_at=_TS,
86 note="",
87 )
88
89
90 # ──────────────────────────────────────────────────────────────────────────────
91 # Unit: write_snapshot incoming hash verification
92 # ──────────────────────────────────────────────────────────────────────────────
93
94 class TestWriteSnapshotIncomingVerification:
95
96 def test_rejects_wrong_snapshot_id_new_file(self, tmp_path: pathlib.Path) -> None:
97 """BUG: write_snapshot writes the bad record; read_snapshot returns None forever."""
98 repo = _make_repo(tmp_path)
99 bad = _snap_with_wrong_id()
100 with pytest.raises((ValueError, OSError)):
101 write_snapshot(repo, bad)
102
103 def test_rejects_wrong_snapshot_id_existing_good_file(self, tmp_path: pathlib.Path) -> None:
104 """write_snapshot must not write over an existing good file with a bad incoming record.
105 (The bad record has the same snapshot_id as good, but different manifest.)
106 """
107 repo = _make_repo(tmp_path)
108 good = _good_snap()
109 write_snapshot(repo, good)
110
111 # Construct bad record with SAME snapshot_id but different manifest
112 bad = SnapshotRecord(
113 snapshot_id=good.snapshot_id, # same ID
114 manifest={"other.py": "b" * 64}, # different content — hash won't match
115 directories=[],
116 created_at=_TS,
117 note="",
118 )
119 with pytest.raises((ValueError, OSError)):
120 write_snapshot(repo, bad)
121
122 # Good file must still be intact
123 stored = read_snapshot(repo, good.snapshot_id)
124 assert stored is not None
125 assert stored.manifest == good.manifest
126
127 def test_accepts_valid_incoming_record(self, tmp_path: pathlib.Path) -> None:
128 repo = _make_repo(tmp_path)
129 good = _good_snap()
130 write_snapshot(repo, good) # must not raise
131 stored = read_snapshot(repo, good.snapshot_id)
132 assert stored is not None
133 assert stored.snapshot_id == good.snapshot_id
134
135 def test_idempotent_on_valid_record(self, tmp_path: pathlib.Path) -> None:
136 repo = _make_repo(tmp_path)
137 good = _good_snap()
138 write_snapshot(repo, good)
139 write_snapshot(repo, good) # second call must not raise
140 assert read_snapshot(repo, good.snapshot_id) is not None
141
142 def test_rejects_incoming_with_wrong_object_id(self, tmp_path: pathlib.Path) -> None:
143 """Incoming snapshot with a tampered object ID must be rejected."""
144 repo = _make_repo(tmp_path)
145 good = _good_snap({"src/main.py": "a" * 64})
146
147 tampered = SnapshotRecord(
148 snapshot_id=good.snapshot_id, # original hash
149 manifest={"src/main.py": "b" * 64}, # different object ID
150 directories=[],
151 created_at=_TS,
152 note="",
153 )
154 with pytest.raises((ValueError, OSError)):
155 write_snapshot(repo, tampered)
156
157 def test_rejects_incoming_with_injected_manifest_entry(self, tmp_path: pathlib.Path) -> None:
158 """Incoming snapshot with an injected extra file must be rejected."""
159 repo = _make_repo(tmp_path)
160 good = _good_snap({"src/main.py": "a" * 64})
161
162 tampered = SnapshotRecord(
163 snapshot_id=good.snapshot_id,
164 manifest={"src/main.py": "a" * 64, "evil.sh": "e" * 64}, # injected
165 directories=[],
166 created_at=_TS,
167 note="",
168 )
169 with pytest.raises((ValueError, OSError)):
170 write_snapshot(repo, tampered)
171
172 def test_rejects_incoming_with_missing_manifest_entry(self, tmp_path: pathlib.Path) -> None:
173 """Incoming snapshot with a removed file entry must be rejected."""
174 repo = _make_repo(tmp_path)
175 good = _good_snap({"src/a.py": "a" * 64, "src/b.py": "b" * 64})
176
177 tampered = SnapshotRecord(
178 snapshot_id=good.snapshot_id,
179 manifest={"src/a.py": "a" * 64}, # missing src/b.py
180 directories=[],
181 created_at=_TS,
182 note="",
183 )
184 with pytest.raises((ValueError, OSError)):
185 write_snapshot(repo, tampered)
186
187 def test_rejects_incoming_with_wrong_directories_hash(self, tmp_path: pathlib.Path) -> None:
188 """Incoming snapshot with different directories list must be rejected."""
189 repo = _make_repo(tmp_path)
190 manifest = {"src/main.py": "a" * 64}
191 snap_id = compute_snapshot_id(manifest, ["src"])
192 good = SnapshotRecord(
193 snapshot_id=snap_id,
194 manifest=manifest,
195 directories=["src"],
196 created_at=_TS,
197 note="",
198 )
199 write_snapshot(repo, good) # good write
200
201 tampered = SnapshotRecord(
202 snapshot_id=snap_id,
203 manifest=manifest,
204 directories=["src", "evil"], # different directories
205 created_at=_TS,
206 note="",
207 )
208 with pytest.raises((ValueError, OSError)):
209 write_snapshot(repo, tampered)
210
211
212 # ──────────────────────────────────────────────────────────────────────────────
213 # Integration: apply_pack with corrupt snapshot_id
214 # ──────────────────────────────────────────────────────────────────────────────
215
216 def _bundle_with_snapshots(snapshots: list[dict]) -> PackBundle:
217 return PackBundle(objects=[], snapshots=snapshots, commits=[], tags=[])
218
219
220 class TestApplyPackCorruptSnapshotId:
221
222 def test_apply_pack_skips_snapshot_with_wrong_snapshot_id(self, tmp_path: pathlib.Path) -> None:
223 """apply_pack must not write a snapshot with mismatched snapshot_id."""
224 repo = _make_repo(tmp_path)
225 good = _good_snap()
226 wire = good.to_dict()
227 wire["snapshot_id"] = "f" * 64 # mismatch
228
229 bundle = _bundle_with_snapshots([wire])
230 apply_pack(repo, bundle)
231
232 # The corrupt entry must not be on disk, or if on disk must be unreadable
233 result = read_snapshot(repo, "f" * 64)
234 assert result is None, (
235 "SECURITY: apply_pack wrote a snapshot with mismatched snapshot_id; "
236 "the file is on disk but permanently unreadable."
237 )
238
239 def test_apply_pack_valid_snapshot_is_readable(self, tmp_path: pathlib.Path) -> None:
240 """Regression: valid snapshots must still be written and readable."""
241 repo = _make_repo(tmp_path)
242 good = _good_snap()
243 wire = good.to_dict()
244
245 bundle = _bundle_with_snapshots([wire])
246 result = apply_pack(repo, bundle)
247
248 assert result["snapshots_written"] == 1
249 stored = read_snapshot(repo, good.snapshot_id)
250 assert stored is not None
251 assert stored.manifest == good.manifest
252
253 def test_apply_pack_one_corrupt_does_not_block_valid_snapshots(self, tmp_path: pathlib.Path) -> None:
254 """One corrupt snapshot in a bundle must not block the valid ones."""
255 repo = _make_repo(tmp_path)
256 good1 = _good_snap({"a.py": "a" * 64})
257 good2 = _good_snap({"b.py": "b" * 64})
258
259 corrupt_wire = good1.to_dict()
260 corrupt_wire["snapshot_id"] = "0" * 64 # mismatch
261
262 bundle = _bundle_with_snapshots([
263 corrupt_wire,
264 good1.to_dict(),
265 good2.to_dict(),
266 ])
267 result = apply_pack(repo, bundle)
268
269 assert result["snapshots_written"] >= 2
270 assert read_snapshot(repo, good1.snapshot_id) is not None
271 assert read_snapshot(repo, good2.snapshot_id) is not None
272
273 def test_apply_pack_corrupt_bundle_cannot_poison_existing_good_snapshot(self, tmp_path: pathlib.Path) -> None:
274 """A malicious bundle must not be able to overwrite an existing valid snapshot."""
275 repo = _make_repo(tmp_path)
276 good = _good_snap({"src/main.py": "a" * 64})
277 write_snapshot(repo, good) # write the good snapshot first
278
279 # Bundle: same snapshot_id, different (injected) manifest
280 wire = good.to_dict()
281 wire["manifest"] = {"src/main.py": "b" * 64} # tampered
282 bundle = _bundle_with_snapshots([wire])
283 apply_pack(repo, bundle)
284
285 stored = read_snapshot(repo, good.snapshot_id)
286 assert stored is not None, "Good snapshot was destroyed by malicious bundle"
287 assert stored.manifest == good.manifest, (
288 f"SECURITY: manifest was overwritten by malicious bundle. "
289 f"Expected {good.manifest}, got {stored.manifest}"
290 )
291
292
293 # ──────────────────────────────────────────────────────────────────────────────
294 # Stress: large bundle with one corrupt snapshot_id
295 # ──────────────────────────────────────────────────────────────────────────────
296
297 class TestApplyPackSnapshotStress:
298
299 def test_100_snapshot_bundle_one_corrupt(self, tmp_path: pathlib.Path) -> None:
300 """100-snapshot bundle, one with wrong snapshot_id: 99 written, no crash."""
301 repo = _make_repo(tmp_path)
302 snaps = [_good_snap({f"src/f{i}.py": chr(97 + (i % 26)) * 64}) for i in range(100)]
303
304 wires = []
305 corrupt_id = None
306 for i, snap in enumerate(snaps):
307 wire = snap.to_dict()
308 if i == 50:
309 wire["snapshot_id"] = "9" * 64
310 corrupt_id = "9" * 64
311 wires.append((snap.snapshot_id, wire, i != 50))
312
313 bundle = _bundle_with_snapshots([w for _, w, _ in wires])
314 result = apply_pack(repo, bundle)
315
316 assert result["snapshots_written"] >= 0 # no crash
317
318 # corrupt entry must not be readable
319 if corrupt_id:
320 assert read_snapshot(repo, corrupt_id) is None
321
322 # spot-check first 5 valid snapshots
323 for snap_id, _, is_good in wires[:5]:
324 if is_good:
325 assert read_snapshot(repo, snap_id) is not None, (
326 f"Valid snapshot {snap_id[:8]} not readable after apply_pack"
327 )
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