gabriel / muse public
test_security_symlink.py python
611 lines 23.7 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 125 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_msgpack_atomic`` 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
30 import pytest
31
32 from muse.core.types import DEFAULT_HASH_ALGO, blob_id, fake_id, split_id
33 from muse.core.object_store import (
34 cleanup_stale_object_temps,
35 objects_algo_dir,
36 write_object,
37 write_object_from_path,
38 )
39 from muse.core.repo import _cleanup_muse_dir_temps, _verify_muse_dir_integrity, find_repo_root
40 from muse.core.store import CommitDict, _write_msgpack_atomic, write_text_atomic
41 from muse.core.validation import assert_not_symlink, assert_write_inside_repo
42 from muse.core.paths import commits_dir, config_toml_path, head_path, heads_dir, muse_dir, objects_dir
43 from tests.cli_test_helper import CliRunner
44
45
46 # ---------------------------------------------------------------------------
47 # Helpers
48 # ---------------------------------------------------------------------------
49
50
51 def _make_real_repo(tmp_path: pathlib.Path) -> pathlib.Path:
52 """Initialise a minimal real (non-symlinked) ``.muse/`` repo layout."""
53 repo = tmp_path / "repo"
54 repo.mkdir()
55 muse = muse_dir(repo)
56 for sub in ("objects", "commits", "snapshots", "refs", "refs/heads", "tags"):
57 (muse / sub).mkdir(parents=True)
58 (muse / "HEAD").write_text("ref: refs/heads/main\n")
59 (muse / "repo.json").write_text('{"repo_id": "test-repo"}')
60 return repo
61
62
63
64 # ---------------------------------------------------------------------------
65 # Unit tests — assert_not_symlink / assert_write_inside_repo
66 # ---------------------------------------------------------------------------
67
68
69 class TestAssertNotSymlink:
70 def test_real_dir_passes(self, tmp_path: pathlib.Path) -> None:
71 real = tmp_path / "real"
72 real.mkdir()
73 assert_not_symlink(real, "real dir") # should not raise
74
75 def test_real_file_passes(self, tmp_path: pathlib.Path) -> None:
76 f = tmp_path / "file.txt"
77 f.write_text("hello")
78 assert_not_symlink(f, "file") # should not raise
79
80 def test_nonexistent_passes(self, tmp_path: pathlib.Path) -> None:
81 # A path that does not yet exist is not a symlink.
82 assert_not_symlink(tmp_path / "no-such-path", "ghost")
83
84 def test_symlink_to_dir_raises(self, tmp_path: pathlib.Path) -> None:
85 target = tmp_path / "target"
86 target.mkdir()
87 link = tmp_path / "link"
88 link.symlink_to(target)
89 with pytest.raises(ValueError, match="symbolic link"):
90 assert_not_symlink(link, "test link")
91
92 def test_symlink_to_file_raises(self, tmp_path: pathlib.Path) -> None:
93 target = tmp_path / "target.txt"
94 target.write_text("data")
95 link = tmp_path / "link.txt"
96 link.symlink_to(target)
97 with pytest.raises(ValueError, match="symbolic link"):
98 assert_not_symlink(link)
99
100 def test_dangling_symlink_raises(self, tmp_path: pathlib.Path) -> None:
101 link = tmp_path / "dangling"
102 link.symlink_to(tmp_path / "nonexistent")
103 with pytest.raises(ValueError, match="symbolic link"):
104 assert_not_symlink(link, "dangling link")
105
106 def test_error_message_contains_label(self, tmp_path: pathlib.Path) -> None:
107 link = tmp_path / "malicious"
108 link.symlink_to(tmp_path)
109 with pytest.raises(ValueError, match="malicious-label"):
110 assert_not_symlink(link, "malicious-label")
111
112
113 class TestAssertWriteInsideRepo:
114 def test_path_inside_passes(self, tmp_path: pathlib.Path) -> None:
115 repo = tmp_path / "repo"
116 repo.mkdir()
117 target = commits_dir(repo) / "abc.msgpack"
118 assert_write_inside_repo(repo, target) # should not raise
119
120 def test_path_outside_raises(self, tmp_path: pathlib.Path) -> None:
121 repo = tmp_path / "repo"
122 repo.mkdir()
123 outside = tmp_path / "other" / "malicious.txt"
124 with pytest.raises(ValueError, match="outside the repository root"):
125 assert_write_inside_repo(repo, outside)
126
127 def test_symlink_escaping_raises(self, tmp_path: pathlib.Path) -> None:
128 """If dest resolves outside repo via symlink, the check catches it."""
129 repo = tmp_path / "repo"
130 repo.mkdir()
131 muse = muse_dir(repo)
132 muse.mkdir()
133 attacker = tmp_path / "attacker"
134 attacker.mkdir()
135 # Symlink .muse/objects → /tmp/attacker
136 malicious_link = muse / "objects"
137 malicious_link.symlink_to(attacker)
138 # The destination inside the objects dir resolves to attacker/...
139 # Parenthesise to avoid PosixPath * int precedence error.
140 dest = malicious_link / "ab" / ("cd" * 31)
141 with pytest.raises(ValueError, match="outside the repository root"):
142 assert_write_inside_repo(repo, dest)
143
144
145 # ---------------------------------------------------------------------------
146 # find_repo_root — symlinked .muse/ is rejected
147 # ---------------------------------------------------------------------------
148
149
150 class TestFindRepoRootSymlink:
151 def test_real_muse_dir_found(self, tmp_path: pathlib.Path) -> None:
152 repo = _make_real_repo(tmp_path)
153 found = find_repo_root(start=repo)
154 assert found == repo
155
156 def test_symlinked_muse_dir_not_found(self, tmp_path: pathlib.Path) -> None:
157 """If .muse/ is a symlink, find_repo_root must not return that directory."""
158 real_muse = tmp_path / "real_muse_dir"
159 real_muse.mkdir()
160 repo = tmp_path / "repo"
161 repo.mkdir()
162 muse_dir(repo).symlink_to(real_muse)
163 result = find_repo_root(start=repo)
164 assert result is None, (
165 f"find_repo_root should return None for symlinked .muse/, got {result}"
166 )
167
168 def test_dangling_symlink_muse_dir_not_found(self, tmp_path: pathlib.Path) -> None:
169 repo = tmp_path / "repo"
170 repo.mkdir()
171 muse_dir(repo).symlink_to(tmp_path / "nonexistent")
172 assert find_repo_root(start=repo) is None
173
174 def test_symlink_to_symlink_muse_dir_rejected(self, tmp_path: pathlib.Path) -> None:
175 real_muse = tmp_path / "real_muse"
176 real_muse.mkdir()
177 intermediate = tmp_path / "intermediate"
178 intermediate.symlink_to(real_muse)
179 repo = tmp_path / "repo"
180 repo.mkdir()
181 muse_dir(repo).symlink_to(intermediate)
182 assert find_repo_root(start=repo) is None
183
184 def test_env_override_still_requires_real_muse(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
185 """MUSE_REPO_ROOT override: returns None if .muse/ is a symlink."""
186 real_muse = tmp_path / "real_muse"
187 real_muse.mkdir()
188 repo = tmp_path / "repo"
189 repo.mkdir()
190 muse_dir(repo).symlink_to(real_muse)
191 monkeypatch.setenv("MUSE_REPO_ROOT", str(repo))
192 result = find_repo_root()
193 assert result is None
194
195
196 # ---------------------------------------------------------------------------
197 # _verify_muse_dir_integrity — critical subdirs must not be symlinks
198 # ---------------------------------------------------------------------------
199
200
201 class TestVerifyMuseDirIntegrity:
202 def test_clean_repo_passes(self, tmp_path: pathlib.Path) -> None:
203 repo = _make_real_repo(tmp_path)
204 _verify_muse_dir_integrity(muse_dir(repo)) # must not raise
205
206 @pytest.mark.parametrize("subdir", [
207 "objects",
208 "commits",
209 "snapshots",
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_msgpack_atomic — symlinked parent directory is rejected
359 # ---------------------------------------------------------------------------
360
361
362 class TestWriteMsgpackAtomicSymlink:
363 def _minimal_commit_dict(self) -> CommitDict:
364 return CommitDict(
365 commit_id="a" * 64,
366 repo_id=fake_id("repo"),
367 branch="main",
368 parent_commit_id=None,
369 parent2_commit_id=None,
370 snapshot_id="b" * 64,
371 message="test commit",
372 author="test",
373 committed_at="2026-01-01T00:00:00+00:00",
374 metadata={},
375 )
376
377 def test_normal_write_succeeds(self, tmp_path: pathlib.Path) -> None:
378 real_dir = tmp_path / "commits"
379 real_dir.mkdir()
380 target = real_dir / "abc.msgpack"
381 _write_msgpack_atomic(target, self._minimal_commit_dict())
382 assert target.exists()
383
384 def test_symlinked_parent_raises(self, tmp_path: pathlib.Path) -> None:
385 attacker = tmp_path / "attacker_commits"
386 attacker.mkdir()
387 link_dir = tmp_path / "commits_link"
388 link_dir.symlink_to(attacker)
389 target = link_dir / "abc.msgpack"
390
391 with pytest.raises(ValueError, match="symbolic link"):
392 _write_msgpack_atomic(target, self._minimal_commit_dict())
393 assert not any(attacker.iterdir()), "No data must reach symlink target"
394
395
396 # ---------------------------------------------------------------------------
397 # cleanup_stale_object_temps — symlinked shards are skipped
398 # ---------------------------------------------------------------------------
399
400
401 class TestCleanupSkipsSymlinks:
402 def test_symlinked_shard_not_entered(self, tmp_path: pathlib.Path) -> None:
403 """cleanup_stale_object_temps must skip symlinked shard directories."""
404 repo = _make_real_repo(tmp_path)
405 attacker = tmp_path / "attacker"
406 attacker.mkdir()
407 # Place a "stale temp" file inside the attacker directory
408 victim = attacker / ".obj-tmp-should-not-be-deleted"
409 victim.write_bytes(b"important attacker data")
410
411 # Replace a shard with a symlink → attacker
412 shard = objects_algo_dir(repo) / "ab"
413 shard.mkdir(parents=True, exist_ok=True)
414 import shutil
415 shutil.rmtree(shard)
416 shard.symlink_to(attacker)
417
418 removed = cleanup_stale_object_temps(repo)
419 assert removed == 0, "Symlinked shard must not be entered"
420 assert victim.exists(), "File in symlink target must not be deleted"
421
422 def test_real_shards_are_cleaned(self, tmp_path: pathlib.Path) -> None:
423 repo = _make_real_repo(tmp_path)
424 shard = objects_algo_dir(repo) / "cd"
425 shard.mkdir(parents=True)
426 stale = shard / ".obj-tmp-stale123"
427 stale.write_bytes(b"stale data")
428 # Backdate mtime so the 60-second age gate treats this file as stale.
429 os.utime(stale, (0, 0))
430 removed = cleanup_stale_object_temps(repo)
431 assert removed == 1
432 assert not stale.exists()
433
434
435 class TestCleanupMuseDirSkipsSymlinks:
436 def test_symlinked_subdir_not_entered(self, tmp_path: pathlib.Path) -> None:
437 """_cleanup_muse_dir_temps must skip symlinked subdirectories."""
438 repo = _make_real_repo(tmp_path)
439 attacker = tmp_path / "attacker_commits"
440 attacker.mkdir()
441 victim = attacker / ".muse-tmp-should-not-be-deleted"
442 victim.write_bytes(b"important data")
443
444 muse = muse_dir(repo)
445 import shutil
446 shutil.rmtree(muse / "commits")
447 (muse / "commits").symlink_to(attacker)
448
449 removed = _cleanup_muse_dir_temps(muse)
450 assert removed == 0, "Symlinked subdir must not be entered"
451 assert victim.exists(), "File in symlink target must not be deleted"
452
453
454 # ---------------------------------------------------------------------------
455 # Tracked-file symlinks — workdir walker skips them
456 # ---------------------------------------------------------------------------
457
458
459 class TestTrackedFileSymlinks:
460 def test_symlink_to_sensitive_file_not_staged(self, tmp_path: pathlib.Path) -> None:
461 """A tracked file that is a symlink is silently excluded from the manifest.
462
463 The workdir walker uses os.lstat + S_ISREG, so symlinks are never
464 hashed or stored — even if they point to /etc/passwd.
465 """
466 from muse.core.snapshot import build_snapshot_manifest
467
468 repo = _make_real_repo(tmp_path)
469 workdir = repo
470
471 # Create a real file (should be tracked)
472 real_file = workdir / "song.mid"
473 real_file.write_bytes(b"\x4d\x54\x68\x64" + b"\x00" * 10)
474
475 # Create a symlink to a sensitive target
476 sensitive = tmp_path / "sensitive.txt"
477 sensitive.write_text("secret data")
478 malicious_link = workdir / "malicious.txt"
479 malicious_link.symlink_to(sensitive)
480
481 manifest = build_snapshot_manifest(workdir)
482 assert "song.mid" in manifest, "real file must be tracked"
483 assert "malicious.txt" not in manifest, "symlink must NOT be in manifest"
484
485 def test_symlink_to_nonexistent_target_not_staged(self, tmp_path: pathlib.Path) -> None:
486 from muse.core.snapshot import build_snapshot_manifest
487
488 repo = _make_real_repo(tmp_path)
489 workdir = repo
490 dangling = workdir / "dangling.txt"
491 dangling.symlink_to(tmp_path / "nonexistent")
492
493 manifest = build_snapshot_manifest(workdir)
494 assert "dangling.txt" not in manifest
495
496
497 # ---------------------------------------------------------------------------
498 # Stress: concurrent symlink-swap during write_object
499 # ---------------------------------------------------------------------------
500
501
502 class TestConcurrentSymlinkSwapStress:
503 def test_concurrent_symlink_swap_does_not_corrupt(
504 self, tmp_path: pathlib.Path
505 ) -> None:
506 """50 concurrent symlink-swap threads racing against write_object.
507
508 write_object either succeeds (writes to the real location) or raises
509 ValueError (detects the symlink). It must never silently write to
510 the attacker-controlled location.
511 """
512 repo = _make_real_repo(tmp_path)
513 attacker = tmp_path / "attacker_stress"
514 attacker.mkdir()
515 obj_dir = objects_dir(repo)
516
517 content = b"stress test object symlink-check"
518 oid = blob_id(content)
519 shard_prefix = oid[:2]
520 shard_dir = obj_dir / shard_prefix
521
522 errors: list[str] = []
523 swap_active = threading.Event()
524 stop_swapping = threading.Event()
525
526 def swap_shard() -> None:
527 """Repeatedly swap shard dir between real and symlink."""
528 import shutil
529 while not stop_swapping.is_set():
530 swap_active.set()
531 # Replace real shard with symlink
532 try:
533 if shard_dir.exists() and not shard_dir.is_symlink():
534 shutil.rmtree(shard_dir)
535 shard_dir.symlink_to(attacker)
536 time.sleep(0.0005)
537 # Restore real shard
538 if shard_dir.is_symlink():
539 shard_dir.unlink()
540 shard_dir.mkdir(exist_ok=True)
541 except OSError:
542 pass
543
544 swapper = threading.Thread(target=swap_shard, daemon=True)
545 swapper.start()
546 swap_active.wait(timeout=1.0)
547
548 write_errors = 0
549 write_successes = 0
550 for _ in range(50):
551 try:
552 write_object(repo, oid, content)
553 write_successes += 1
554 except (ValueError, OSError, SystemExit):
555 write_errors += 1
556
557 stop_swapping.set()
558 swapper.join(timeout=2.0)
559
560 # The attacker directory must remain empty regardless of outcome.
561 attacker_files = list(attacker.rglob("*"))
562 if attacker_files:
563 errors.append(
564 f"Data leaked to attacker dir: {[str(f) for f in attacker_files]}"
565 )
566
567 assert not errors, "\n".join(errors)
568 # Sanity: at least some operations completed (either succeeded or were blocked).
569 assert write_successes + write_errors == 50
570
571
572 # ---------------------------------------------------------------------------
573 # Integration: end-to-end CLI commands with symlinked .muse/
574 # ---------------------------------------------------------------------------
575
576
577 class TestCLIWithSymlinkedMuse:
578 def test_muse_status_rejects_symlinked_muse(self, tmp_path: pathlib.Path) -> None:
579 """muse status must fail when .muse/ is a symlink."""
580 real_muse = tmp_path / "real_muse"
581 real_muse.mkdir()
582 for sub in ("objects", "commits", "snapshots", "refs/heads", "tags"):
583 (real_muse / sub).mkdir(parents=True)
584 (real_muse / "HEAD").write_text("ref: refs/heads/main\n")
585 (real_muse / "repo.json").write_text('{"repo_id": "test"}')
586
587 repo = tmp_path / "repo"
588 repo.mkdir()
589 muse_dir(repo).symlink_to(real_muse)
590
591 runner = CliRunner()
592 # find_repo_root won't find a real .muse/ → should exit non-zero
593 result = runner.invoke(None, ["status"], env={"MUSE_REPO_ROOT": str(repo)})
594 assert result.exit_code != 0
595
596 def test_muse_status_accepts_real_muse(self, tmp_path: pathlib.Path) -> None:
597 """muse status does not reject a real .muse/ directory as a symlink."""
598 repo = _make_real_repo(tmp_path)
599 (config_toml_path(repo)).write_text(
600 "[core]\nauthor = \"test\"\n"
601 )
602 (heads_dir(repo) / "main").write_text("")
603 (head_path(repo)).write_text("ref: refs/heads/main\n")
604
605 runner = CliRunner()
606 result = runner.invoke(
607 None, ["status"],
608 env={"MUSE_REPO_ROOT": str(repo)},
609 )
610 # Must not complain about symlinks on a real .muse/.
611 assert "symbolic link" not in result.output.lower()
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 125 days ago