gabriel / muse public
test_security_symlink.py python
613 lines 23.7 KB
Raw
sha256:ddd0be8a557fdecec391ed3c1a250b58596b4989d7a12ae6dadd8c0624b44dba fix: align read --json schema and drop outdated tests Sonnet 4.6 minor ⚠ breaking 24 days ago
1 """Phase 2.2 — Symlink attack tests.
2
3 Covers every identified attack vector:
4
5 1. ``.muse/`` itself replaced by a symlink → ``find_repo_root`` rejects it.
6 2. Critical subdirectories (``.muse/objects/``, ``.muse/commits/``, etc.)
7 replaced by symlinks → ``require_repo`` detects and exits.
8 3. ``write_object`` / ``write_object_from_path`` detect a symlinked shard dir
9 or objects directory and raise before writing.
10 4. ``write_text_atomic`` / ``write_shelf_entry`` detect a symlinked parent
11 directory and raise before writing.
12 5. ``cleanup_stale_object_temps`` skips symlinked shard directories safely.
13 6. ``_cleanup_muse_dir_temps`` skips symlinked subdirectories safely.
14 7. Tracked-file symlinks are silently skipped by the workdir walker
15 (``os.lstat`` + ``S_ISREG`` filter).
16 8. Stress: 50 concurrent symlink-swap attempts during an object write do not
17 corrupt or redirect any data.
18
19 Each test creates its own isolated temporary directory — no shared state.
20 """
21
22 from __future__ import annotations
23
24 import os
25 import pathlib
26 import tempfile
27 import threading
28 import time
29 from typing import TypedDict
30
31 import pytest
32
33 from muse.core.types import DEFAULT_HASH_ALGO, blob_id, fake_id, split_id
34 from muse.core.object_store import (
35 cleanup_stale_object_temps,
36 objects_algo_dir,
37 write_object,
38 write_object_from_path,
39 )
40 from muse.core.repo import _cleanup_muse_dir_temps, _verify_muse_dir_integrity, find_repo_root
41 from muse.core.io import write_text_atomic
42 from muse.core.shelf import write_shelf_entry
43 from muse.core.validation import assert_not_symlink, assert_write_inside_repo
44 from muse.core.paths import commits_dir, config_toml_path, head_path, heads_dir, muse_dir, objects_dir
45 from tests.cli_test_helper import CliRunner
46
47
48 # ---------------------------------------------------------------------------
49 # Helpers
50 # ---------------------------------------------------------------------------
51
52
53 def _make_real_repo(tmp_path: pathlib.Path) -> pathlib.Path:
54 """Initialise a minimal real (non-symlinked) ``.muse/`` repo layout."""
55 repo = tmp_path / "repo"
56 repo.mkdir()
57 muse = muse_dir(repo)
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
66 # ---------------------------------------------------------------------------
67 # Unit tests — assert_not_symlink / assert_write_inside_repo
68 # ---------------------------------------------------------------------------
69
70
71 class TestAssertNotSymlink:
72 def test_real_dir_passes(self, tmp_path: pathlib.Path) -> None:
73 real = tmp_path / "real"
74 real.mkdir()
75 assert_not_symlink(real, "real dir") # should not raise
76
77 def test_real_file_passes(self, tmp_path: pathlib.Path) -> None:
78 f = tmp_path / "file.txt"
79 f.write_text("hello")
80 assert_not_symlink(f, "file") # should not raise
81
82 def test_nonexistent_passes(self, tmp_path: pathlib.Path) -> None:
83 # A path that does not yet exist is not a symlink.
84 assert_not_symlink(tmp_path / "no-such-path", "ghost")
85
86 def test_symlink_to_dir_raises(self, tmp_path: pathlib.Path) -> None:
87 target = tmp_path / "target"
88 target.mkdir()
89 link = tmp_path / "link"
90 link.symlink_to(target)
91 with pytest.raises(ValueError, match="symbolic link"):
92 assert_not_symlink(link, "test link")
93
94 def test_symlink_to_file_raises(self, tmp_path: pathlib.Path) -> None:
95 target = tmp_path / "target.txt"
96 target.write_text("data")
97 link = tmp_path / "link.txt"
98 link.symlink_to(target)
99 with pytest.raises(ValueError, match="symbolic link"):
100 assert_not_symlink(link)
101
102 def test_dangling_symlink_raises(self, tmp_path: pathlib.Path) -> None:
103 link = tmp_path / "dangling"
104 link.symlink_to(tmp_path / "nonexistent")
105 with pytest.raises(ValueError, match="symbolic link"):
106 assert_not_symlink(link, "dangling link")
107
108 def test_error_message_contains_label(self, tmp_path: pathlib.Path) -> None:
109 link = tmp_path / "malicious"
110 link.symlink_to(tmp_path)
111 with pytest.raises(ValueError, match="malicious-label"):
112 assert_not_symlink(link, "malicious-label")
113
114
115 class TestAssertWriteInsideRepo:
116 def test_path_inside_passes(self, tmp_path: pathlib.Path) -> None:
117 repo = tmp_path / "repo"
118 repo.mkdir()
119 target = commits_dir(repo) / "abc.msgpack"
120 assert_write_inside_repo(repo, target) # should not raise
121
122 def test_path_outside_raises(self, tmp_path: pathlib.Path) -> None:
123 repo = tmp_path / "repo"
124 repo.mkdir()
125 outside = tmp_path / "other" / "malicious.txt"
126 with pytest.raises(ValueError, match="outside the repository root"):
127 assert_write_inside_repo(repo, outside)
128
129 def test_symlink_escaping_raises(self, tmp_path: pathlib.Path) -> None:
130 """If dest resolves outside repo via symlink, the check catches it."""
131 repo = tmp_path / "repo"
132 repo.mkdir()
133 muse = muse_dir(repo)
134 muse.mkdir()
135 attacker = tmp_path / "attacker"
136 attacker.mkdir()
137 # Symlink .muse/objects → /tmp/attacker
138 malicious_link = muse / "objects"
139 malicious_link.symlink_to(attacker)
140 # The destination inside the objects dir resolves to attacker/...
141 # Parenthesise to avoid PosixPath * int precedence error.
142 dest = malicious_link / "ab" / ("cd" * 31)
143 with pytest.raises(ValueError, match="outside the repository root"):
144 assert_write_inside_repo(repo, dest)
145
146
147 # ---------------------------------------------------------------------------
148 # find_repo_root — symlinked .muse/ is rejected
149 # ---------------------------------------------------------------------------
150
151
152 class TestFindRepoRootSymlink:
153 def test_real_muse_dir_found(self, tmp_path: pathlib.Path) -> None:
154 repo = _make_real_repo(tmp_path)
155 found = find_repo_root(start=repo)
156 assert found == repo
157
158 def test_symlinked_muse_dir_not_found(self, tmp_path: pathlib.Path) -> None:
159 """If .muse/ is a symlink, find_repo_root must not return that directory."""
160 real_muse = tmp_path / "real_muse_dir"
161 real_muse.mkdir()
162 repo = tmp_path / "repo"
163 repo.mkdir()
164 muse_dir(repo).symlink_to(real_muse)
165 result = find_repo_root(start=repo)
166 assert result is None, (
167 f"find_repo_root should return None for symlinked .muse/, got {result}"
168 )
169
170 def test_dangling_symlink_muse_dir_not_found(self, tmp_path: pathlib.Path) -> None:
171 repo = tmp_path / "repo"
172 repo.mkdir()
173 muse_dir(repo).symlink_to(tmp_path / "nonexistent")
174 assert find_repo_root(start=repo) is None
175
176 def test_symlink_to_symlink_muse_dir_rejected(self, tmp_path: pathlib.Path) -> None:
177 real_muse = tmp_path / "real_muse"
178 real_muse.mkdir()
179 intermediate = tmp_path / "intermediate"
180 intermediate.symlink_to(real_muse)
181 repo = tmp_path / "repo"
182 repo.mkdir()
183 muse_dir(repo).symlink_to(intermediate)
184 assert find_repo_root(start=repo) is None
185
186 def test_env_override_still_requires_real_muse(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
187 """MUSE_REPO_ROOT override: returns None if .muse/ is a symlink."""
188 real_muse = tmp_path / "real_muse"
189 real_muse.mkdir()
190 repo = tmp_path / "repo"
191 repo.mkdir()
192 muse_dir(repo).symlink_to(real_muse)
193 monkeypatch.setenv("MUSE_REPO_ROOT", str(repo))
194 result = find_repo_root()
195 assert result is None
196
197
198 # ---------------------------------------------------------------------------
199 # _verify_muse_dir_integrity — critical subdirs must not be symlinks
200 # ---------------------------------------------------------------------------
201
202
203 class TestVerifyMuseDirIntegrity:
204 def test_clean_repo_passes(self, tmp_path: pathlib.Path) -> None:
205 repo = _make_real_repo(tmp_path)
206 _verify_muse_dir_integrity(muse_dir(repo)) # must not raise
207
208 @pytest.mark.parametrize("subdir", [
209 "objects",
210 "refs",
211 "refs/heads",
212 "tags",
213 ])
214 def test_symlinked_subdir_causes_exit(
215 self, tmp_path: pathlib.Path, subdir: str
216 ) -> None:
217 import shutil
218 repo = _make_real_repo(tmp_path)
219 muse = muse_dir(repo)
220 attacker = tmp_path / "attacker"
221 attacker.mkdir(parents=True, exist_ok=True)
222 target = muse / subdir
223 # Remove the real directory tree (may be non-empty, e.g. refs/).
224 if target.exists() and not target.is_symlink():
225 shutil.rmtree(target)
226 target.symlink_to(attacker)
227 with pytest.raises(SystemExit):
228 _verify_muse_dir_integrity(muse)
229
230 def test_missing_subdirs_pass(self, tmp_path: pathlib.Path) -> None:
231 """Newly-initialised repos may not have all dirs yet — that's fine."""
232 repo = tmp_path / "fresh"
233 repo.mkdir()
234 muse = muse_dir(repo)
235 muse.mkdir()
236 _verify_muse_dir_integrity(muse) # no dirs present yet — must not raise
237
238
239 # ---------------------------------------------------------------------------
240 # write_object — symlinked shard directory is rejected
241 # ---------------------------------------------------------------------------
242
243
244 class TestWriteObjectSymlink:
245 def test_normal_write_succeeds(self, tmp_path: pathlib.Path) -> None:
246 repo = _make_real_repo(tmp_path)
247 content = b"hello world"
248 oid = blob_id(content)
249 result = write_object(repo, oid, content)
250 assert result is True
251
252 def test_symlinked_objects_dir_raises(self, tmp_path: pathlib.Path) -> None:
253 """If .muse/objects/ is a symlink, write_object must raise ValueError."""
254 repo = _make_real_repo(tmp_path)
255 attacker = tmp_path / "attacker"
256 attacker.mkdir()
257 import shutil
258 shutil.rmtree(objects_dir(repo))
259 (objects_dir(repo)).symlink_to(attacker)
260
261 content = b"malicious payload"
262 oid = blob_id(content)
263 # write_object creates the shard dir, then checks it
264 with pytest.raises((ValueError, SystemExit)):
265 write_object(repo, oid, content)
266 # Verify nothing was written to the attacker dir
267 assert not any(attacker.rglob("*")), "Data must not be written to symlink target"
268
269 def test_symlinked_shard_dir_raises(self, tmp_path: pathlib.Path) -> None:
270 """A symlinked shard dir (e.g. objects/ab/ → /tmp/malicious/) is rejected."""
271 repo = _make_real_repo(tmp_path)
272 content = b"shard attack"
273 oid = blob_id(content)
274 prefix = split_id(oid)[1][:2]
275 attacker = tmp_path / "attacker_shard"
276 attacker.mkdir()
277 shard = objects_algo_dir(repo) / prefix
278 shard.mkdir(parents=True, exist_ok=True)
279 # Replace real shard dir with symlink
280 import shutil
281 shutil.rmtree(shard)
282 shard.symlink_to(attacker)
283
284 with pytest.raises((ValueError, SystemExit)):
285 write_object(repo, oid, content)
286 assert not any(attacker.rglob("*")), "No data must reach symlink target"
287
288 def test_write_object_from_path_symlinked_objects_dir_raises(
289 self, tmp_path: pathlib.Path
290 ) -> None:
291 repo = _make_real_repo(tmp_path)
292 attacker = tmp_path / "attacker"
293 attacker.mkdir()
294 import shutil
295 shutil.rmtree(objects_dir(repo))
296 (objects_dir(repo)).symlink_to(attacker)
297
298 src = tmp_path / "source.bin"
299 src.write_bytes(b"from path content")
300 oid = blob_id(src.read_bytes())
301 with pytest.raises((ValueError, SystemExit)):
302 write_object_from_path(repo, oid, src)
303 assert not any(attacker.rglob("*")), "Data must not be written to symlink target"
304
305
306 # ---------------------------------------------------------------------------
307 # write_text_atomic — symlinked parent directory is rejected
308 # ---------------------------------------------------------------------------
309
310
311 class TestWriteTextAtomicSymlink:
312 def test_normal_write_succeeds(self, tmp_path: pathlib.Path) -> None:
313 target = tmp_path / "HEAD"
314 write_text_atomic(target, "ref: refs/heads/main\n")
315 assert target.read_text() == "ref: refs/heads/main\n"
316
317 def test_symlinked_parent_raises(self, tmp_path: pathlib.Path) -> None:
318 """If the parent directory is a symlink, write_text_atomic must raise."""
319 real_dir = tmp_path / "real"
320 real_dir.mkdir()
321 attacker = tmp_path / "attacker"
322 attacker.mkdir()
323 link_dir = tmp_path / "link_dir"
324 link_dir.symlink_to(attacker)
325 target = link_dir / "HEAD"
326
327 with pytest.raises(ValueError, match="symbolic link"):
328 write_text_atomic(target, "ref: refs/heads/main\n")
329 # Verify attacker dir untouched
330 assert not any(attacker.iterdir()), "No data must reach symlink target"
331
332 def test_symlink_at_destination_is_replaced(self, tmp_path: pathlib.Path) -> None:
333 """POSIX os.replace on a symlink replaces the symlink entry itself.
334
335 This is the SAFE case: writing HEAD when HEAD is a symlink replaces
336 the symlink with a real file — data goes to .muse/HEAD, not to the
337 symlink target. This test documents that behaviour is preserved.
338 """
339 real_parent = tmp_path / "muse_dir"
340 real_parent.mkdir()
341 elsewhere = tmp_path / "elsewhere.txt"
342 elsewhere.write_text("original")
343
344 head = real_parent / "HEAD"
345 head.symlink_to(elsewhere)
346 assert head.is_symlink()
347
348 write_text_atomic(head, "new content\n")
349
350 # The symlink should be gone — HEAD is now a real file
351 assert not head.is_symlink(), "symlink at destination must be replaced by real file"
352 assert head.read_text() == "new content\n"
353 # The symlink target is untouched
354 assert elsewhere.read_text() == "original"
355
356
357 # ---------------------------------------------------------------------------
358 # write_shelf_entry — symlinked .muse/shelf/ is rejected
359 # ---------------------------------------------------------------------------
360
361
362 class _MinimalShelfEntry(TypedDict):
363 id: str
364 snapshot: dict[str, str]
365 branch: str
366 created_at: str
367
368
369 class TestWriteShelfEntrySymlink:
370 def _minimal_entry(self) -> _MinimalShelfEntry:
371 return {
372 "id": f"sha256:{'a' * 64}",
373 "snapshot": {},
374 "branch": "main",
375 "created_at": "2026-01-01T00:00:00+00:00",
376 }
377
378 def test_normal_write_succeeds(self, tmp_path: pathlib.Path) -> None:
379 repo = tmp_path / "repo"
380 (repo / ".muse").mkdir(parents=True)
381 write_shelf_entry(repo, self._minimal_entry())
382 shelf_file = repo / ".muse" / "shelf" / "sha256" / ("a" * 64)
383 assert shelf_file.exists()
384
385 def test_symlinked_shelf_dir_raises(self, tmp_path: pathlib.Path) -> None:
386 repo = tmp_path / "repo"
387 (repo / ".muse").mkdir(parents=True)
388 attacker = tmp_path / "attacker_shelf"
389 attacker.mkdir()
390 shelf = repo / ".muse" / "shelf"
391 shelf.symlink_to(attacker)
392
393 with pytest.raises(ValueError, match="symlink"):
394 write_shelf_entry(repo, self._minimal_entry())
395 assert not any(attacker.iterdir()), "No data must reach symlink target"
396
397
398 # ---------------------------------------------------------------------------
399 # cleanup_stale_object_temps — symlinked shards are skipped
400 # ---------------------------------------------------------------------------
401
402
403 class TestCleanupSkipsSymlinks:
404 def test_symlinked_shard_not_entered(self, tmp_path: pathlib.Path) -> None:
405 """cleanup_stale_object_temps must skip symlinked shard directories."""
406 repo = _make_real_repo(tmp_path)
407 attacker = tmp_path / "attacker"
408 attacker.mkdir()
409 # Place a "stale temp" file inside the attacker directory
410 victim = attacker / ".obj-tmp-should-not-be-deleted"
411 victim.write_bytes(b"important attacker data")
412
413 # Replace a shard with a symlink → attacker
414 shard = objects_algo_dir(repo) / "ab"
415 shard.mkdir(parents=True, exist_ok=True)
416 import shutil
417 shutil.rmtree(shard)
418 shard.symlink_to(attacker)
419
420 removed = cleanup_stale_object_temps(repo)
421 assert removed == 0, "Symlinked shard must not be entered"
422 assert victim.exists(), "File in symlink target must not be deleted"
423
424 def test_real_shards_are_cleaned(self, tmp_path: pathlib.Path) -> None:
425 repo = _make_real_repo(tmp_path)
426 shard = objects_algo_dir(repo) / "cd"
427 shard.mkdir(parents=True)
428 stale = shard / ".obj-tmp-stale123"
429 stale.write_bytes(b"stale data")
430 # Backdate mtime so the 60-second age gate treats this file as stale.
431 os.utime(stale, (0, 0))
432 removed = cleanup_stale_object_temps(repo)
433 assert removed == 1
434 assert not stale.exists()
435
436
437 class TestCleanupMuseDirSkipsSymlinks:
438 def test_symlinked_subdir_not_entered(self, tmp_path: pathlib.Path) -> None:
439 """_cleanup_muse_dir_temps must skip symlinked subdirectories."""
440 repo = _make_real_repo(tmp_path)
441 attacker = tmp_path / "attacker_commits"
442 attacker.mkdir()
443 victim = attacker / ".muse-tmp-should-not-be-deleted"
444 victim.write_bytes(b"important data")
445
446 muse = muse_dir(repo)
447 import shutil
448 shutil.rmtree(muse / "commits")
449 (muse / "commits").symlink_to(attacker)
450
451 removed = _cleanup_muse_dir_temps(muse)
452 assert removed == 0, "Symlinked subdir must not be entered"
453 assert victim.exists(), "File in symlink target must not be deleted"
454
455
456 # ---------------------------------------------------------------------------
457 # Tracked-file symlinks — workdir walker skips them
458 # ---------------------------------------------------------------------------
459
460
461 class TestTrackedFileSymlinks:
462 def test_symlink_to_sensitive_file_not_staged(self, tmp_path: pathlib.Path) -> None:
463 """A tracked file that is a symlink is silently excluded from the manifest.
464
465 The workdir walker uses os.lstat + S_ISREG, so symlinks are never
466 hashed or stored — even if they point to /etc/passwd.
467 """
468 from muse.core.snapshot import build_snapshot_manifest
469
470 repo = _make_real_repo(tmp_path)
471 workdir = repo
472
473 # Create a real file (should be tracked)
474 real_file = workdir / "song.mid"
475 real_file.write_bytes(b"\x4d\x54\x68\x64" + b"\x00" * 10)
476
477 # Create a symlink to a sensitive target
478 sensitive = tmp_path / "sensitive.txt"
479 sensitive.write_text("secret data")
480 malicious_link = workdir / "malicious.txt"
481 malicious_link.symlink_to(sensitive)
482
483 manifest = build_snapshot_manifest(workdir)
484 assert "song.mid" in manifest, "real file must be tracked"
485 assert "malicious.txt" not in manifest, "symlink must NOT be in manifest"
486
487 def test_symlink_to_nonexistent_target_not_staged(self, tmp_path: pathlib.Path) -> None:
488 from muse.core.snapshot import build_snapshot_manifest
489
490 repo = _make_real_repo(tmp_path)
491 workdir = repo
492 dangling = workdir / "dangling.txt"
493 dangling.symlink_to(tmp_path / "nonexistent")
494
495 manifest = build_snapshot_manifest(workdir)
496 assert "dangling.txt" not in manifest
497
498
499 # ---------------------------------------------------------------------------
500 # Stress: concurrent symlink-swap during write_object
501 # ---------------------------------------------------------------------------
502
503
504 class TestConcurrentSymlinkSwapStress:
505 def test_concurrent_symlink_swap_does_not_corrupt(
506 self, tmp_path: pathlib.Path
507 ) -> None:
508 """50 concurrent symlink-swap threads racing against write_object.
509
510 write_object either succeeds (writes to the real location) or raises
511 ValueError (detects the symlink). It must never silently write to
512 the attacker-controlled location.
513 """
514 repo = _make_real_repo(tmp_path)
515 attacker = tmp_path / "attacker_stress"
516 attacker.mkdir()
517 obj_dir = objects_dir(repo)
518
519 content = b"stress test object symlink-check"
520 oid = blob_id(content)
521 shard_prefix = oid[:2]
522 shard_dir = obj_dir / shard_prefix
523
524 errors: list[str] = []
525 swap_active = threading.Event()
526 stop_swapping = threading.Event()
527
528 def swap_shard() -> None:
529 """Repeatedly swap shard dir between real and symlink."""
530 import shutil
531 while not stop_swapping.is_set():
532 swap_active.set()
533 # Replace real shard with symlink
534 try:
535 if shard_dir.exists() and not shard_dir.is_symlink():
536 shutil.rmtree(shard_dir)
537 shard_dir.symlink_to(attacker)
538 time.sleep(0.0005)
539 # Restore real shard
540 if shard_dir.is_symlink():
541 shard_dir.unlink()
542 shard_dir.mkdir(exist_ok=True)
543 except OSError:
544 pass
545
546 swapper = threading.Thread(target=swap_shard, daemon=True)
547 swapper.start()
548 swap_active.wait(timeout=1.0)
549
550 write_errors = 0
551 write_successes = 0
552 for _ in range(50):
553 try:
554 write_object(repo, oid, content)
555 write_successes += 1
556 except (ValueError, OSError, SystemExit):
557 write_errors += 1
558
559 stop_swapping.set()
560 swapper.join(timeout=2.0)
561
562 # The attacker directory must remain empty regardless of outcome.
563 attacker_files = list(attacker.rglob("*"))
564 if attacker_files:
565 errors.append(
566 f"Data leaked to attacker dir: {[str(f) for f in attacker_files]}"
567 )
568
569 assert not errors, "\n".join(errors)
570 # Sanity: at least some operations completed (either succeeded or were blocked).
571 assert write_successes + write_errors == 50
572
573
574 # ---------------------------------------------------------------------------
575 # Integration: end-to-end CLI commands with symlinked .muse/
576 # ---------------------------------------------------------------------------
577
578
579 class TestCLIWithSymlinkedMuse:
580 def test_muse_status_rejects_symlinked_muse(self, tmp_path: pathlib.Path) -> None:
581 """muse status must fail when .muse/ is a symlink."""
582 real_muse = tmp_path / "real_muse"
583 real_muse.mkdir()
584 for sub in ("objects", "commits", "snapshots", "refs/heads", "tags"):
585 (real_muse / sub).mkdir(parents=True)
586 (real_muse / "HEAD").write_text("ref: refs/heads/main\n")
587 (real_muse / "repo.json").write_text('{"repo_id": "test"}')
588
589 repo = tmp_path / "repo"
590 repo.mkdir()
591 muse_dir(repo).symlink_to(real_muse)
592
593 runner = CliRunner()
594 # find_repo_root won't find a real .muse/ → should exit non-zero
595 result = runner.invoke(None, ["status"], env={"MUSE_REPO_ROOT": str(repo)})
596 assert result.exit_code != 0
597
598 def test_muse_status_accepts_real_muse(self, tmp_path: pathlib.Path) -> None:
599 """muse status does not reject a real .muse/ directory as a symlink."""
600 repo = _make_real_repo(tmp_path)
601 (config_toml_path(repo)).write_text(
602 "[core]\nauthor = \"test\"\n"
603 )
604 (heads_dir(repo) / "main").write_text("")
605 (head_path(repo)).write_text("ref: refs/heads/main\n")
606
607 runner = CliRunner()
608 result = runner.invoke(
609 None, ["status"],
610 env={"MUSE_REPO_ROOT": str(repo)},
611 )
612 # Must not complain about symlinks on a real .muse/.
613 assert "symbolic link" not in result.output.lower()
File History 5 commits
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 23 days ago
sha256:7781e508756c81b7ddb0b08b408fd2b99bad87798cefa596773373efc360952c chore: typing audit — zero violations, zero untyped defs Sonnet 4.6 patch 24 days ago
sha256:09656d1b0772ea4c96f8911d7bf8042b33eb0596992c6546dfab3d21e9dee330 fix: align muse read --json schema and test contracts Sonnet 4.6 minor 24 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 29 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 30 days ago