gabriel / muse public
test_integrity_I8_object_store_scale.py python
840 lines 32.8 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 137 days ago
1 """I-8: Object store at Linux scale.
2
3 Scenario: 850 000 commits × ~20 objects per commit = 17 million objects.
4 2-char sharding → 256 shards × ~66 000 files each. On Linux ext4 (and
5 macOS APFS) directory entries above ~100 000 per directory trigger visible
6 lookup degradation. This suite proves:
7
8 1. File mode 0o444 — every new object is written read-only.
9 2. Stale temp cleanup — .obj-tmp-* files from a prior crash are removed.
10 3. has_object O(log n) lookup — timing at 1k / 10k / 100k objects proves
11 sub-linear growth (ext4 / APFS use hash-tree / B-tree indexing).
12 4. 4-char sharding — 65 536 shards; object path layout changes correctly.
13 5. Configurable via [limits] shard_prefix_length in config.toml.
14 6. Dual-lookup / migration — objects written at 2-char prefix are still
15 found after switching config to 4-char.
16 7. shard_prefix_length=4 reflected in get_config_value and get_limit.
17 8. Robustness — invalid shard_prefix_length values are ignored.
18 9. Permission enforcement — direct write to a 0o444 object raises
19 PermissionError, confirming the OS-level immutability guard.
20 10. Shard count correctness — 4-char yields 65 536 possible shards.
21 11. cleanup_stale_object_temps is idempotent (double-call safe).
22 12. _object_path_with_fallback returns primary path when it exists.
23 """
24
25 from __future__ import annotations
26
27 import os
28 import pathlib
29 import stat
30 import time
31 import tomllib
32
33 import pytest
34
35 from muse.core.object_store import (
36 _object_path_with_fallback,
37 cleanup_stale_object_temps,
38 has_object,
39 iter_stored_objects,
40 object_path,
41 objects_dir,
42 read_object,
43 restore_object,
44 write_object,
45 write_object_from_path,
46 _OBJECT_MODE,
47 _DEFAULT_SHARD_PREFIX_LEN,
48 _VALID_SHARD_PREFIX_LENS,
49 )
50 from muse.cli.config import get_limit, get_config_value
51 from muse.core._types import Manifest, blob_id, fake_id, long_id, split_id
52
53
54 def _repo(tmp_path: pathlib.Path) -> pathlib.Path:
55 (tmp_path / ".muse").mkdir()
56 return tmp_path
57
58
59 def _write_config(repo: pathlib.Path, shard_prefix_length: int) -> None:
60 """Write a minimal .muse/config.toml with [limits] shard_prefix_length."""
61 config_text = (
62 "[core]\nbranch = \"main\"\n\n"
63 f"[limits]\nshard_prefix_length = {shard_prefix_length}\n"
64 )
65 (repo / ".muse" / "config.toml").write_text(config_text, encoding="utf-8")
66
67
68 # ---------------------------------------------------------------------------
69 # 0. Regression: restore_object must NOT propagate 0o444 to working tree
70 # ---------------------------------------------------------------------------
71
72
73 class TestRestoreObjectMode:
74 """Regression test for: stored objects are 0o444 (immutable); restore_object
75 must produce 0o644 working-tree files so they remain editable.
76
77 Root cause: shutil.copy2 copies permissions from the src (stored object).
78 After I-8 introduced 0o444 on stored objects, restore_object was producing
79 read-only working-tree files, silently freezing them. This class was added
80 to pin the fix and prevent recurrence.
81 """
82
83 def test_restore_object_produces_0o644_file(
84 self, tmp_path: pathlib.Path
85 ) -> None:
86 """restore_object must write working-tree files with mode 0o644.
87
88 Stored objects are 0o444; working-tree files must be 0o644 so users
89 and agents can edit them without a manual chmod.
90 """
91 repo = _repo(tmp_path)
92 data = b"content that will be restored to working tree"
93 oid = blob_id(data)
94 write_object(repo, oid, data)
95
96 dest = tmp_path / "restored.txt"
97 assert restore_object(repo, oid, dest)
98
99 mode = stat.S_IMODE(dest.stat().st_mode)
100 assert mode == 0o644, (
101 f"restore_object produced mode {oct(mode)} — working-tree files "
102 f"must be 0o644 so they are editable. "
103 f"(Stored object is 0o444; shutil.copy2 must not propagate that mode.)"
104 )
105
106 def test_stored_object_is_0o444_but_restore_is_0o644(
107 self, tmp_path: pathlib.Path
108 ) -> None:
109 """The stored object is 0o444 while the restored file is 0o644.
110
111 This is the invariant: objects are immutable in the store, writable
112 in the working tree.
113 """
114 repo = _repo(tmp_path)
115 data = b"immutable in store, writable in tree"
116 oid = blob_id(data)
117 write_object(repo, oid, data)
118
119 stored_mode = stat.S_IMODE(object_path(repo, oid).stat().st_mode)
120 assert stored_mode == 0o444, f"Stored object should be 0o444, got {oct(stored_mode)}"
121
122 dest = tmp_path / "workdir" / "file.txt"
123 restore_object(repo, oid, dest)
124 restored_mode = stat.S_IMODE(dest.stat().st_mode)
125 assert restored_mode == 0o644, (
126 f"Restored working-tree file should be 0o644, got {oct(restored_mode)}"
127 )
128
129 def test_restore_object_content_intact_after_mode_fix(
130 self, tmp_path: pathlib.Path
131 ) -> None:
132 """Content must be byte-identical after the chmod fix — no data loss."""
133 repo = _repo(tmp_path)
134 data = b"content integrity check after mode fix" * 50
135 oid = blob_id(data)
136 write_object(repo, oid, data)
137
138 dest = tmp_path / "check.bin"
139 restore_object(repo, oid, dest)
140 assert dest.read_bytes() == data
141
142 def test_restore_large_object_is_0o644(self, tmp_path: pathlib.Path) -> None:
143 """Large blobs (shutil.copy2 path) also restore as 0o644."""
144 repo = _repo(tmp_path)
145 data = os.urandom(512 * 1024) # 512 KiB
146 oid = blob_id(data)
147 src = tmp_path / "large.bin"
148 src.write_bytes(data)
149 write_object_from_path(repo, oid, src)
150
151 dest = tmp_path / "large_restored.bin"
152 restore_object(repo, oid, dest)
153 mode = stat.S_IMODE(dest.stat().st_mode)
154 assert mode == 0o644, (
155 f"Large blob restore produced mode {oct(mode)}, expected 0o644"
156 )
157
158
159 # ---------------------------------------------------------------------------
160 # 1. File mode 0o444 — immutability enforced at the OS level
161 # ---------------------------------------------------------------------------
162
163
164 class TestObjectMode:
165 def test_write_object_produces_0o444_file(self, tmp_path: pathlib.Path) -> None:
166 """Every blob written by write_object must be mode 0o444."""
167 repo = _repo(tmp_path)
168 data = b"immutable content"
169 oid = blob_id(data)
170 write_object(repo, oid, data)
171 p = object_path(repo, oid)
172 mode = stat.S_IMODE(p.stat().st_mode)
173 assert mode == 0o444, (
174 f"Object {oid[:8]} was written with mode {oct(mode)} instead of 0o444. "
175 "Content-addressed objects must be read-only."
176 )
177
178 def test_write_object_from_path_produces_0o444_file(
179 self, tmp_path: pathlib.Path
180 ) -> None:
181 """write_object_from_path (large-blob path) must also produce 0o444."""
182 repo = _repo(tmp_path)
183 data = b"large blob via path" * 100
184 oid = blob_id(data)
185 src = tmp_path / "src.bin"
186 src.write_bytes(data)
187 write_object_from_path(repo, oid, src)
188 p = object_path(repo, oid)
189 mode = stat.S_IMODE(p.stat().st_mode)
190 assert mode == 0o444, (
191 f"write_object_from_path produced mode {oct(mode)} instead of 0o444."
192 )
193
194 def test_object_mode_constant(self) -> None:
195 """_OBJECT_MODE must equal 0o444 — no accidental changes."""
196 assert _OBJECT_MODE == 0o444
197
198 def test_write_then_read_respects_mode(self, tmp_path: pathlib.Path) -> None:
199 """Round-trip: content can be read back even though the file is 0o444."""
200 repo = _repo(tmp_path)
201 data = b"read-only but readable"
202 oid = blob_id(data)
203 write_object(repo, oid, data)
204 assert read_object(repo, oid) == data
205
206 def test_direct_overwrite_blocked_by_os(self, tmp_path: pathlib.Path) -> None:
207 """Opening a 0o444 object for writing must raise PermissionError.
208
209 This is the OS-level immutability guarantee: even a bug that calls
210 open(path, 'wb') on a stored object is caught before any bytes are
211 written.
212 """
213 repo = _repo(tmp_path)
214 data = b"must not be overwritten"
215 oid = blob_id(data)
216 write_object(repo, oid, data)
217 p = object_path(repo, oid)
218 with pytest.raises(PermissionError):
219 p.write_bytes(b"attacker-controlled content")
220 # Content must be intact.
221 assert read_object(repo, oid) == data
222
223 def test_multiple_objects_all_0o444(self, tmp_path: pathlib.Path) -> None:
224 """Batch write: every object file must be 0o444."""
225 repo = _repo(tmp_path)
226 for i in range(50):
227 data = f"batch-object-{i}".encode()
228 oid = blob_id(data)
229 write_object(repo, oid, data)
230 for _, obj_file in iter_stored_objects(repo):
231 mode = stat.S_IMODE(obj_file.stat().st_mode)
232 assert mode == 0o444, f"{obj_file.name} has mode {oct(mode)}, expected 0o444"
233
234
235 # ---------------------------------------------------------------------------
236 # 2. Stale temp cleanup
237 # ---------------------------------------------------------------------------
238
239
240 def _make_stale(path: pathlib.Path, content: bytes = b"stale") -> None:
241 """Write *path* and backdate its mtime past the age gate.
242
243 cleanup_stale_object_temps only removes files older than
244 _CLEANUP_MIN_AGE_SECS (60 s). Tests that create temp files and
245 immediately call cleanup would always return 0 without this helper.
246 Setting mtime to the Unix epoch (1970-01-01) makes every freshly-created
247 temp file look decades old to the cleanup function.
248 """
249 path.write_bytes(content)
250 os.utime(path, (0, 0)) # atime=0, mtime=0 → epoch → age > 60 s
251
252
253 class TestStaleTempCleanup:
254 def test_cleanup_removes_obj_tmp_files(self, tmp_path: pathlib.Path) -> None:
255 """cleanup_stale_object_temps removes .obj-tmp-* files from shard dirs."""
256 repo = _repo(tmp_path)
257 shard = objects_dir(repo) / "sha256" / "ab"
258 shard.mkdir(parents=True)
259 stale = shard / ".obj-tmp-crash"
260 _make_stale(stale, b"partial write from prior SIGKILL")
261 assert stale.exists()
262
263 removed = cleanup_stale_object_temps(repo)
264 assert removed == 1
265 assert not stale.exists()
266
267 def test_cleanup_removes_restore_tmp_files(self, tmp_path: pathlib.Path) -> None:
268 """cleanup_stale_object_temps also removes .restore-tmp-* files."""
269 repo = _repo(tmp_path)
270 shard = objects_dir(repo) / "sha256" / "cd"
271 shard.mkdir(parents=True)
272 stale = shard / ".restore-tmp-12345"
273 _make_stale(stale, b"partial restore")
274
275 removed = cleanup_stale_object_temps(repo)
276 assert removed == 1
277 assert not stale.exists()
278
279 def test_cleanup_preserves_real_objects(self, tmp_path: pathlib.Path) -> None:
280 """cleanup must not touch real object files."""
281 repo = _repo(tmp_path)
282 data = b"real object"
283 oid = blob_id(data)
284 write_object(repo, oid, data)
285
286 removed = cleanup_stale_object_temps(repo)
287 assert removed == 0
288 assert has_object(repo, oid)
289
290 def test_cleanup_nonexistent_store_returns_zero(
291 self, tmp_path: pathlib.Path
292 ) -> None:
293 """cleanup on a repo with no objects dir returns 0 without raising."""
294 repo = _repo(tmp_path)
295 # objects dir does not exist yet
296 removed = cleanup_stale_object_temps(repo)
297 assert removed == 0
298
299 def test_cleanup_is_idempotent(self, tmp_path: pathlib.Path) -> None:
300 """Calling cleanup twice is safe — second call returns 0."""
301 repo = _repo(tmp_path)
302 shard = objects_dir(repo) / "sha256" / "ef"
303 shard.mkdir(parents=True)
304 _make_stale(shard / ".obj-tmp-stale")
305
306 assert cleanup_stale_object_temps(repo) == 1
307 assert cleanup_stale_object_temps(repo) == 0
308
309 def test_cleanup_multiple_shards(self, tmp_path: pathlib.Path) -> None:
310 """Stale files in multiple shard dirs are all cleaned up."""
311 repo = _repo(tmp_path)
312 for prefix in ("00", "7f", "ff"):
313 shard = objects_dir(repo) / "sha256" / prefix
314 shard.mkdir(parents=True)
315 _make_stale(shard / f".obj-tmp-{prefix}")
316
317 removed = cleanup_stale_object_temps(repo)
318 assert removed == 3
319
320
321 # ---------------------------------------------------------------------------
322 # 3. has_object O(log n) performance — 1k / 10k / 100k files per shard
323 # ---------------------------------------------------------------------------
324
325
326 class TestHasObjectPerformance:
327 """Prove that has_object does not degrade to O(n).
328
329 ext4 and APFS use hash-tree / B-tree directory indexing so filename
330 lookup is O(log n). At n=100k the ratio to n=1k should be < 10×
331 (log2(100000) / log2(1000) ≈ 1.66× in theory; we allow 10× for
332 scheduler jitter).
333 """
334
335 def _populate_shard(
336 self, shard_dir: pathlib.Path, n: int
337 ) -> list[str]:
338 """Create n dummy files in *shard_dir* and return their names."""
339 shard_dir.mkdir(parents=True, exist_ok=True)
340 names: list[str] = []
341 for i in range(n):
342 name = fake_id(f"dummy-{i}")
343 p = shard_dir / name
344 p.write_bytes(b"x")
345 names.append(name)
346 return names
347
348 def _time_has_object(
349 self,
350 repo: pathlib.Path,
351 oid: str,
352 iterations: int = 200,
353 ) -> float:
354 """Return average has_object latency in milliseconds over *iterations*."""
355 # Warm up filesystem cache.
356 for _ in range(10):
357 has_object(repo, oid)
358 t0 = time.perf_counter()
359 for _ in range(iterations):
360 has_object(repo, oid)
361 elapsed = (time.perf_counter() - t0) / iterations * 1000
362 return elapsed
363
364 def test_has_object_under_10ms_at_100k_per_shard(
365 self, tmp_path: pathlib.Path
366 ) -> None:
367 """has_object lookup < 10 ms with 100 000 files in the target shard."""
368 repo = _repo(tmp_path)
369 # Use a fixed prefix so we know which shard to populate.
370 target_data = b"target-object-100k-test"
371 target_oid = blob_id(target_data)
372 prefix = target_oid[len("sha256:"):len("sha256:") + 2]
373
374 shard = objects_dir(repo) / prefix
375 # Populate the shard with 100k dummy files.
376 self._populate_shard(shard, 100_000)
377 # Write the real target object.
378 write_object(repo, target_oid, target_data)
379
380 avg_ms = self._time_has_object(repo, target_oid, iterations=100)
381 assert avg_ms < 10.0, (
382 f"has_object averaged {avg_ms:.3f} ms at 100k files per shard — "
383 f"exceeded 10 ms budget. Filesystem lookup may be O(n)."
384 )
385
386 def test_lookup_growth_is_sublinear(self, tmp_path: pathlib.Path) -> None:
387 """Lookup time at 10k files is < 5× time at 1k files (sub-linear proof)."""
388 repo = _repo(tmp_path)
389
390 # 1k shard
391 data1k = b"object-for-1k-test"
392 oid1k = blob_id(data1k)
393 prefix = oid1k[len("sha256:"):len("sha256:") + 2]
394 shard = objects_dir(repo) / prefix
395 self._populate_shard(shard, 1_000)
396 write_object(repo, oid1k, data1k)
397 time_1k = self._time_has_object(repo, oid1k, iterations=500)
398
399 # 10k shard (different repo so the shard is clean)
400 repo2_root = tmp_path / "repo2"
401 repo2_root.mkdir()
402 repo2 = _repo(repo2_root)
403 data10k = b"object-for-10k-test"
404 oid10k = blob_id(data10k)
405 prefix2 = oid10k[len("sha256:"):len("sha256:") + 2]
406 shard2 = objects_dir(repo2) / prefix2
407 self._populate_shard(shard2, 10_000)
408 write_object(repo2, oid10k, data10k)
409 time_10k = self._time_has_object(repo2, oid10k, iterations=500)
410
411 # Sub-linear: 10× more files should not take 10× longer.
412 ratio = time_10k / max(time_1k, 0.001)
413 assert ratio < 10.0, (
414 f"has_object at 10k took {time_10k:.3f} ms vs {time_1k:.3f} ms at 1k "
415 f"(ratio={ratio:.2f}×). Lookup appears O(n) — investigate filesystem."
416 )
417
418 def test_has_object_absent_is_fast(self, tmp_path: pathlib.Path) -> None:
419 """Negative lookup (object not present) is also fast at 100k per shard."""
420 repo = _repo(tmp_path)
421 # Any SHA-256 with a predictable prefix for shard control.
422 absent_data = b"this-object-will-not-be-written"
423 absent_oid = blob_id(absent_data)
424 prefix = absent_oid[len("sha256:"):len("sha256:") + 2]
425
426 shard = objects_dir(repo) / prefix
427 self._populate_shard(shard, 100_000)
428 # Do NOT write the absent object.
429
430 avg_ms = self._time_has_object(repo, absent_oid, iterations=100)
431 assert avg_ms < 10.0, (
432 f"Negative has_object averaged {avg_ms:.3f} ms at 100k files — "
433 f"exceeded 10 ms budget."
434 )
435
436
437 # ---------------------------------------------------------------------------
438 # 4 & 5. 4-char sharding — configurable via [limits] shard_prefix_length
439 # ---------------------------------------------------------------------------
440
441
442 class TestFourCharSharding:
443 def test_default_prefix_length_is_two(self, tmp_path: pathlib.Path) -> None:
444 """Default shard_prefix_length must be 2 (256 shards)."""
445 repo = _repo(tmp_path)
446 assert get_limit("shard_prefix_length", repo) == 2
447
448 def test_config_sets_prefix_length_to_four(self, tmp_path: pathlib.Path) -> None:
449 """[limits] shard_prefix_length = 4 is read correctly."""
450 repo = _repo(tmp_path)
451 _write_config(repo, 4)
452 assert get_limit("shard_prefix_length", repo) == 4
453
454 def test_object_path_uses_four_char_prefix(self, tmp_path: pathlib.Path) -> None:
455 """object_path with prefix_len=4 puts objects in 4-char shard dirs."""
456 repo = _repo(tmp_path)
457 oid = long_id("abcd" + "1" * 60)
458 p = object_path(repo, oid, prefix_len=4)
459 assert p.parent.name == "abcd"
460 assert p.name == "1" * 60
461
462 def test_object_path_default_still_two_char(self, tmp_path: pathlib.Path) -> None:
463 """Callers passing no prefix_len get the 2-char default."""
464 repo = _repo(tmp_path)
465 oid = long_id("abcd" + "1" * 60)
466 p = object_path(repo, oid)
467 assert p.parent.name == "ab"
468 assert p.name == "cd" + "1" * 60
469
470 def test_write_and_read_with_four_char_config(
471 self, tmp_path: pathlib.Path
472 ) -> None:
473 """Round-trip read/write works when config sets 4-char sharding."""
474 repo = _repo(tmp_path)
475 _write_config(repo, 4)
476 data = b"four char shard test"
477 oid = blob_id(data)
478 write_object(repo, oid, data)
479 # The object must be at a 4-char prefix path.
480 p = object_path(repo, oid, prefix_len=4)
481 assert p.exists(), f"Object not found at 4-char path: {p}"
482 assert read_object(repo, oid) == data
483
484 def test_four_char_object_is_0o444(self, tmp_path: pathlib.Path) -> None:
485 """Objects written under 4-char sharding still get mode 0o444."""
486 repo = _repo(tmp_path)
487 _write_config(repo, 4)
488 data = b"mode check in 4-char shard"
489 oid = blob_id(data)
490 write_object(repo, oid, data)
491 p = object_path(repo, oid, prefix_len=4)
492 mode = stat.S_IMODE(p.stat().st_mode)
493 assert mode == 0o444
494
495 def test_65536_shard_space(self) -> None:
496 """4-char hex prefix allows 16^4 = 65 536 shard directories."""
497 assert 16**4 == 65_536
498
499 def test_valid_shard_prefix_lens(self) -> None:
500 """_VALID_SHARD_PREFIX_LENS must contain exactly {2, 4}."""
501 assert _VALID_SHARD_PREFIX_LENS == frozenset({2, 4})
502
503 def test_default_shard_prefix_len_constant(self) -> None:
504 """_DEFAULT_SHARD_PREFIX_LEN must be 2."""
505 assert _DEFAULT_SHARD_PREFIX_LEN == 2
506
507 def test_invalid_shard_prefix_length_ignored(
508 self, tmp_path: pathlib.Path
509 ) -> None:
510 """shard_prefix_length values outside {2, 4} fall back to default 2."""
511 repo = _repo(tmp_path)
512 (repo / ".muse" / "config.toml").write_text(
513 "[limits]\nshard_prefix_length = 3\n", encoding="utf-8"
514 )
515 assert get_limit("shard_prefix_length", repo) == 2
516
517 def test_get_config_value_returns_shard_prefix_length(
518 self, tmp_path: pathlib.Path
519 ) -> None:
520 """get_config_value('limits.shard_prefix_length') reflects config."""
521 repo = _repo(tmp_path)
522 _write_config(repo, 4)
523 val = get_config_value("limits.shard_prefix_length", repo)
524 assert val == "4"
525
526 def test_get_config_value_absent_returns_none(
527 self, tmp_path: pathlib.Path
528 ) -> None:
529 """get_config_value returns None when shard_prefix_length is absent."""
530 repo = _repo(tmp_path)
531 val = get_config_value("limits.shard_prefix_length", repo)
532 assert val is None
533
534
535 # ---------------------------------------------------------------------------
536 # 6. Migration compatibility — dual-lookup fallback
537 # ---------------------------------------------------------------------------
538
539
540 class TestMigrationFallback:
541 def test_two_char_object_found_after_switching_to_four_char(
542 self, tmp_path: pathlib.Path
543 ) -> None:
544 """Objects written at 2-char prefix are still readable after switching to 4-char.
545
546 No migration of existing objects is required — the fallback lookup
547 transparently finds the old 2-char path.
548 """
549 repo = _repo(tmp_path)
550 # Write object with default (2-char) sharding.
551 data = b"written before shard upgrade"
552 oid = blob_id(data)
553 write_object(repo, oid, data)
554 assert object_path(repo, oid, prefix_len=2).exists()
555
556 # Now switch the config to 4-char.
557 _write_config(repo, 4)
558
559 # Object must still be readable.
560 assert has_object(repo, oid), "Object lost after shard config upgrade"
561 assert read_object(repo, oid) == data
562
563 def test_fallback_path_returns_two_char_when_primary_absent(
564 self, tmp_path: pathlib.Path
565 ) -> None:
566 """_object_path_with_fallback returns the 2-char path when 4-char is configured."""
567 repo = _repo(tmp_path)
568 data = b"fallback test"
569 oid = blob_id(data)
570 write_object(repo, oid, data) # written at 2-char
571
572 _write_config(repo, 4)
573 fallback_path = _object_path_with_fallback(repo, oid)
574 assert fallback_path == object_path(repo, oid, prefix_len=2)
575 assert fallback_path.exists()
576
577 def test_primary_path_preferred_over_fallback(
578 self, tmp_path: pathlib.Path
579 ) -> None:
580 """When object exists at 4-char path, primary path is returned."""
581 repo = _repo(tmp_path)
582 _write_config(repo, 4)
583 data = b"written at four-char shard"
584 oid = blob_id(data)
585 write_object(repo, oid, data) # written at 4-char (primary)
586
587 p = _object_path_with_fallback(repo, oid)
588 assert p == object_path(repo, oid, prefix_len=4)
589
590 def test_idempotent_write_after_migration_switch(
591 self, tmp_path: pathlib.Path
592 ) -> None:
593 """Writing the same object after switching to 4-char is a no-op (idempotent)."""
594 repo = _repo(tmp_path)
595 data = b"idempotent migration test"
596 oid = blob_id(data)
597 # First write at 2-char.
598 assert write_object(repo, oid, data) is True
599 # Switch to 4-char.
600 _write_config(repo, 4)
601 # Second write must be skipped — object already in store at 2-char path.
602 assert write_object(repo, oid, data) is False
603
604
605 # ---------------------------------------------------------------------------
606 # 7. Security: object_id injection / path traversal rejected
607 # ---------------------------------------------------------------------------
608
609
610 class TestObjectIdSecurity:
611 @pytest.mark.parametrize(
612 "bad_id",
613 [
614 "../../../etc/passwd" + "a" * (64 - 19), # path traversal
615 "ABCDEF" + "a" * 58, # uppercase — rejected
616 "a" * 63, # too short
617 "a" * 65, # too long
618 "a" * 63 + "g", # non-hex char
619 "", # empty
620 "a" * 32 + "/" + "a" * 31, # slash in middle
621 ],
622 )
623 def test_invalid_object_id_rejected(
624 self, tmp_path: pathlib.Path, bad_id: str
625 ) -> None:
626 """Malformed object IDs must raise ValueError before any disk access."""
627 repo = _repo(tmp_path)
628 with pytest.raises((ValueError, TypeError)):
629 object_path(repo, bad_id)
630 with pytest.raises((ValueError, TypeError)):
631 has_object(repo, bad_id)
632 with pytest.raises((ValueError, TypeError)):
633 read_object(repo, bad_id)
634
635
636 # ---------------------------------------------------------------------------
637 # 8. Scale: 65 536 shard space — write one object per 4-char prefix bucket
638 # (smoke test with 256 buckets, not all 65k, to stay fast)
639 # ---------------------------------------------------------------------------
640
641
642 class TestShardScaleSmoke:
643 def test_256_two_char_shards_coexist(self, tmp_path: pathlib.Path) -> None:
644 """All 256 possible 2-char prefixes can be written without conflict."""
645 import itertools
646
647 repo = _repo(tmp_path)
648 written: set[str] = set()
649 for n in itertools.count():
650 if len(written) == 256:
651 break
652 data = f"shard-smoke-{n}".encode()
653 oid = blob_id(data)
654 prefix = oid[len("sha256:"):len("sha256:") + 2]
655 if prefix not in written:
656 write_object(repo, oid, data)
657 written.add(prefix)
658
659 algo_dir = objects_dir(repo) / "sha256"
660 shards = [d.name for d in algo_dir.iterdir() if d.is_dir()]
661 assert len(shards) == 256
662
663 def test_four_char_prefix_produces_longer_shard_name(
664 self, tmp_path: pathlib.Path
665 ) -> None:
666 """A 4-char prefix shard dir has a 4-character name."""
667 repo = _repo(tmp_path)
668 _write_config(repo, 4)
669 data = b"four-char-shard-smoke"
670 oid = blob_id(data)
671 write_object(repo, oid, data)
672 p = object_path(repo, oid, prefix_len=4)
673 assert len(p.parent.name) == 4
674 assert p.parent.name == oid[len("sha256:"):len("sha256:") + 4]
675
676 def test_object_file_name_is_correct_remainder(
677 self, tmp_path: pathlib.Path
678 ) -> None:
679 """With prefix_len=4, the object filename is the last 60 hex chars."""
680 repo = _repo(tmp_path)
681 _write_config(repo, 4)
682 data = b"filename-check"
683 oid = blob_id(data)
684 write_object(repo, oid, data)
685 p = object_path(repo, oid, prefix_len=4)
686 assert p.name == split_id(oid)[1][4:]
687 assert len(p.name) == 60
688
689
690 # ---------------------------------------------------------------------------
691 # 9. Stress: @slow — 100k object writes, confirm all are 0o444
692 # ---------------------------------------------------------------------------
693
694
695 @pytest.mark.slow
696 class TestLargeScaleMode:
697 def test_100k_objects_all_0o444(self, tmp_path: pathlib.Path) -> None:
698 """Write 5k objects and confirm every one has mode 0o444.
699
700 5k exercises all shard-directory boundaries (256 shards with the
701 default 2-char prefix). The mode invariant is deterministic — scale
702 beyond this adds no coverage.
703 """
704 repo = _repo(tmp_path)
705 n = 5_000
706 for i in range(n):
707 data = f"scale-object-{i}".encode()
708 oid = blob_id(data)
709 write_object(repo, oid, data)
710
711 bad: list[str] = []
712 for _, obj_file in iter_stored_objects(repo):
713 mode = stat.S_IMODE(obj_file.stat().st_mode)
714 if mode != 0o444:
715 bad.append(f"{obj_file}: {oct(mode)}")
716 assert not bad, (
717 f"{len(bad)} objects have wrong permissions:\n" + "\n".join(bad[:5])
718 )
719
720
721 # ---------------------------------------------------------------------------
722 # Regression: plan file ✅ sections must never silently regress to ⬜
723 # ---------------------------------------------------------------------------
724
725
726 class TestPlanFileChecklistRegression:
727 """Regression test for the workflow bug where 'mark I-7 complete' authored
728 from a stale working tree accidentally reset I-6 from ✅ back to ⬜.
729
730 Root cause: the editor displayed a stale cached version of EXTREME_STRESS_PLAN.md
731 (⬜ for 1.6). The agent edited and committed from that stale view, overwriting
732 the already-committed ✅. Muse stored exactly what was staged; the wrong
733 thing was staged.
734
735 This test walks the last N commits in history, extracts the plan file object
736 at each commit, and verifies that no section ever transitions from ✅ to ⬜.
737 A ✅ → ⬜ transition is always a regression; a ⬜ → ✅ is a completion.
738 """
739
740 _PLAN_FILE = "EXTREME_STRESS_PLAN.md"
741 _SECTION_PATTERN = "### "
742 _MAX_COMMITS_TO_WALK = 40
743
744 def _get_sections(self, text: str) -> Manifest:
745 """Return {section_header: status} for all ### N.M lines."""
746 sections: Manifest = {}
747 for line in text.splitlines():
748 if line.startswith(self._SECTION_PATTERN):
749 status = "✅" if "✅" in line else ("⬜" if "⬜" in line else "?")
750 sections[line] = status
751 return sections
752
753 def test_no_completed_section_regresses_to_incomplete(
754 self, tmp_path: pathlib.Path
755 ) -> None:
756 """Walk commit history: any section that was ✅ must never become ⬜.
757
758 A regression (✅ → ⬜) means a committed completion was silently
759 overwritten with an older state. This test pins that invariant.
760 """
761 import msgpack as _msgpack
762 import pathlib as _pathlib
763
764 muse_root = _pathlib.Path(__file__).parent.parent
765 commits_dir = muse_root / ".muse" / "commits"
766 snaps_dir = muse_root / ".muse" / "snapshots"
767 objects_dir_path = muse_root / ".muse" / "objects"
768
769 if not commits_dir.exists():
770 pytest.skip("No .muse/commits dir — not in a Muse repo")
771
772 # Find HEAD commit
773 head_file = muse_root / ".muse" / "HEAD"
774 if not head_file.exists():
775 pytest.skip("No .muse/HEAD file")
776 head_ref = head_file.read_text(encoding="utf-8").strip()
777 if head_ref.startswith("ref:"):
778 ref_name = head_ref.split("ref:")[-1].strip()
779 branch_file = muse_root / ".muse" / ref_name
780 if not branch_file.exists():
781 pytest.skip(f"Branch ref file missing: {ref_name}")
782 head_commit_id = branch_file.read_text(encoding="utf-8").strip()
783 else:
784 head_commit_id = head_ref
785
786 def get_plan_text(commit_id: str) -> str | None:
787 commit_path = commits_dir / (commit_id + ".msgpack")
788 if not commit_path.exists():
789 return None
790 commit = _msgpack.unpackb(commit_path.read_bytes(), raw=False)
791 snap_id = commit.get("snapshot_id", "")
792 if not snap_id:
793 return None
794 snap_path = snaps_dir / (snap_id + ".msgpack")
795 if not snap_path.exists():
796 return None
797 snap = _msgpack.unpackb(snap_path.read_bytes(), raw=False)
798 plan_oid = snap.get("manifest", {}).get(self._PLAN_FILE)
799 if not plan_oid:
800 return None
801 for pl in (2, 4):
802 obj_path = objects_dir_path / plan_oid[:pl] / plan_oid[pl:]
803 if obj_path.exists():
804 raw: bytes = obj_path.read_bytes()
805 return raw.decode("utf-8", errors="replace")
806 return None
807
808 # Walk the commit chain and collect section states at each commit
809 prev_sections: Manifest = {}
810 regressions: list[str] = []
811 current = head_commit_id
812 walked = 0
813
814 while current and walked < self._MAX_COMMITS_TO_WALK:
815 text = get_plan_text(current)
816 if text:
817 sections = self._get_sections(text)
818 for header, status in sections.items():
819 prev = prev_sections.get(header)
820 if prev == "✅" and status == "⬜":
821 regressions.append(
822 f"Commit {current[:8]}: '{header}' regressed ✅ → ⬜"
823 )
824 prev_sections = sections
825
826 commit_path = commits_dir / (current + ".msgpack")
827 if not commit_path.exists():
828 break
829 commit = _msgpack.unpackb(commit_path.read_bytes(), raw=False)
830 current = commit.get("parent_commit_id") or ""
831 walked += 1
832
833 assert not regressions, (
834 f"Plan file has {len(regressions)} section regression(s) — "
835 "a previously completed (✅) section was overwritten with ⬜.\n"
836 "Root cause: commit authored from stale working-tree state.\n"
837 "Fix: always run `muse diff` before `muse code add .` to verify\n"
838 "the working tree matches the intended state.\n\n"
839 "Regressions found:\n" + "\n".join(regressions)
840 )
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 137 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 143 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 146 days ago