gabriel / muse public
test_cmd_fetch_hardening.py python
843 lines 38.2 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 121 days ago
1 """Comprehensive hardening tests for ``muse fetch``.
2
3 Coverage
4 --------
5 Unit
6 - _stale_ref_names: no-dir, all-live, stale detected, nested branches, symlink skip
7 - _prune_stale_refs: dry-run, live delete, empty-parent cleanup, return values
8 - negotiate_have (in transport): empty list, single-round ready, fallback, large-stress
9
10 Integration (mocked transport)
11 - _fetch_one: up-to-date, fetched, dry-run writes nothing, unknown remote, transport
12 error, branch missing without prune, branch missing with prune, negotiate called
13 before fetch_pack, negotiate fallback, set_remote_head after apply_mpack
14
15 Security
16 - ANSI injection in remote name stripped in stderr
17 - ANSI injection in branch name stripped in stderr
18 - available-branches list sanitized before output
19 - symlink traversal blocked in _stale_ref_names
20 - all diagnostics go to stderr, not stdout
21
22 E2E (via CliRunner)
23 - basic fetch exits 0
24 - already-up-to-date exits 0
25 - --json output schema correct
26 - --format json equivalent to --json
27 - --dry-run exits 0
28 - --dry-run --json status = "dry_run"
29 - --branch flag
30 - --branch --json carries correct branch
31 - unknown remote exits non-zero
32 - --prune flag
33 - --prune --json includes pruned list
34 - --all fetches every remote
35 - --all --json has N results
36 - --all + --branch fetches named branch from every remote
37 - --all with no remotes exits non-zero
38
39 Performance
40 - negotiate_have result used as have, not raw all_local
41 - 10 000-commit negotiation converges in 3 rounds
42
43 Stress
44 - 8 concurrent prune scans on isolated repos
45 - 8 concurrent negotiate_have calls
46 """
47
48 from __future__ import annotations
49
50 import contextlib
51 import json
52 import pathlib
53 import threading
54 from collections.abc import Callable
55 from typing import TYPE_CHECKING
56 from unittest.mock import MagicMock, patch
57
58 import pytest
59
60 from tests.cli_test_helper import CliRunner, InvokeResult
61
62 if TYPE_CHECKING:
63 from muse.cli.commands.fetch import _FetchJson, _RemoteResultJson
64 from muse.core.pack import ApplyResult, MPackBundle
65 from muse.core.transport import MuseTransport, NegotiateResponse
66
67 cli = None
68 runner = CliRunner()
69
70 REMOTE_ID = "a" * 64
71 OLD_REMOTE_ID = "b" * 64
72
73 from muse.core.types import Manifest, blob_id
74 from muse.core.paths import muse_dir, remotes_dir
75
76 type _RemoteInfoMap = dict[str, str | dict[str, str]]
77
78
79 # ── typed helpers ─────────────────────────────────────────────────────────────
80
81 def _make_apply_result(
82 commits_written: int = 3,
83 objects_written: int = 7,
84 ) -> "ApplyResult":
85 from muse.core.pack import ApplyResult
86 return ApplyResult(
87 commits_written=commits_written,
88 snapshots_written=commits_written,
89 objects_written=objects_written,
90 objects_skipped=0,
91 )
92
93
94 def _make_bundle() -> "MPackBundle":
95 from muse.core.pack import MPackBundle
96 return MPackBundle(commits=[], snapshots=[], objects=[])
97
98
99 def _make_fetch_stream_result(
100 commits_count: int = 0,
101 ) -> Mapping[str, object]:
102 """Return a FetchStreamResult-compatible dict for mocking fetch_stream."""
103 from muse.core.transport import FetchStreamResult
104 return FetchStreamResult(
105 repo_id="test-repo-id",
106 domain="code",
107 default_branch="main",
108 branch_heads={"main": REMOTE_ID},
109 commits=[],
110 snapshots=[],
111 objects_received=commits_count,
112 )
113
114
115 def _make_remote_info(
116 branch_heads: Manifest | None = None,
117 ) -> _RemoteInfoMap:
118 return {
119 "repo_id": "test-repo-id",
120 "domain": "code",
121 "default_branch": "main",
122 "branch_heads": branch_heads or {"main": REMOTE_ID},
123 }
124
125
126 def _make_negotiate_response(
127 ack: list[str] | None = None,
128 ready: bool = True,
129 ) -> "NegotiateResponse":
130 return {"ack": ack or [], "common_base": None, "ready": ready}
131
132
133 def _make_transport_mock(
134 branch_heads: Manifest | None = None,
135 objects_count: int = 7,
136 ) -> MagicMock:
137 t = MagicMock()
138 t.fetch_remote_info.return_value = _make_remote_info(branch_heads)
139
140 def _fetch_stream(
141 url: str, token: str | None, want: list[str], have: list[str],
142 on_object: "Callable[..., None] | None" = None, **kwargs: str,
143 ) -> Mapping[str, object]:
144 if callable(on_object):
145 for i in range(objects_count):
146 # Content-addressed: OID matches actual content so integrity check passes.
147 content = f"fake-blob-{i}".encode()
148 oid = blob_id(content)
149 on_object({"object_id": oid, "content": content, "path": f"f{i}.txt"})
150 return _make_fetch_stream_result()
151
152 t.fetch_stream.side_effect = _fetch_stream
153 t.negotiate.return_value = _make_negotiate_response(ready=True)
154 return t
155
156
157 def _json_line(result: InvokeResult) -> "_FetchJson":
158 """Extract the JSON object from cli_test_helper's combined output.
159
160 The test helper mixes stderr into result.output, so we scan for the first
161 line beginning with '{'.
162 """
163 for line in result.output.splitlines():
164 stripped = line.strip()
165 if stripped.startswith("{"):
166 parsed: _FetchJson = json.loads(stripped)
167 return parsed
168 raise ValueError(f"No JSON line in output:\n{result.output!r}")
169
170
171 def _init_repo(tmp_path: pathlib.Path) -> None:
172 dot_muse = muse_dir(tmp_path)
173 for sub in ("objects", "commits", "snapshots", "remotes", "refs/heads", "branches"):
174 (dot_muse / sub).mkdir(parents=True, exist_ok=True)
175 (dot_muse / "HEAD").write_text("ref: refs/heads/main\n")
176 (dot_muse / "refs" / "heads" / "main").write_text("")
177 (dot_muse / "config.toml").write_text(
178 '[remotes.origin]\nurl = "http://localhost:19999"\n'
179 )
180 (dot_muse / "repo.json").write_text('{"id": "test-repo-id"}')
181
182
183 def _write_remote_ref(
184 tmp_path: pathlib.Path, remote: str, branch: str, commit_id: str
185 ) -> None:
186 ref_file = remotes_dir(tmp_path) / remote / branch
187 ref_file.parent.mkdir(parents=True, exist_ok=True)
188 ref_file.write_text(commit_id)
189
190
191 # ── Unit: _stale_ref_names ────────────────────────────────────────────────────
192
193 class TestStaleRefNames:
194 def test_no_refs_dir_returns_empty(self, tmp_path: pathlib.Path) -> None:
195 from muse.cli.commands.fetch import _stale_ref_names
196 assert _stale_ref_names(tmp_path, "origin", {"main": REMOTE_ID}) == []
197
198 def test_all_live_returns_empty(self, tmp_path: pathlib.Path) -> None:
199 from muse.cli.commands.fetch import _stale_ref_names
200 _init_repo(tmp_path)
201 _write_remote_ref(tmp_path, "origin", "main", REMOTE_ID)
202 assert _stale_ref_names(tmp_path, "origin", {"main": REMOTE_ID}) == []
203
204 def test_stale_branch_detected(self, tmp_path: pathlib.Path) -> None:
205 from muse.cli.commands.fetch import _stale_ref_names
206 _init_repo(tmp_path)
207 _write_remote_ref(tmp_path, "origin", "main", REMOTE_ID)
208 _write_remote_ref(tmp_path, "origin", "feat/old", OLD_REMOTE_ID)
209 stale = _stale_ref_names(tmp_path, "origin", {"main": REMOTE_ID})
210 assert stale == ["feat/old"]
211
212 def test_nested_branch_name_preserved(self, tmp_path: pathlib.Path) -> None:
213 """Slashes in branch names stored as nested files must round-trip correctly."""
214 from muse.cli.commands.fetch import _stale_ref_names
215 _init_repo(tmp_path)
216 _write_remote_ref(tmp_path, "origin", "feat/ui/redesign", REMOTE_ID)
217 stale = _stale_ref_names(tmp_path, "origin", {})
218 assert "feat/ui/redesign" in stale
219
220 def test_symlinks_skipped(self, tmp_path: pathlib.Path) -> None:
221 """Symlinks inside the refs dir must not be followed (path-traversal guard)."""
222 from muse.cli.commands.fetch import _stale_ref_names
223 _init_repo(tmp_path)
224 refs_dir = remotes_dir(tmp_path) / "origin"
225 refs_dir.mkdir(parents=True, exist_ok=True)
226 target = tmp_path / "outside.txt"
227 target.write_text("sensitive")
228 (refs_dir / "malicious-link").symlink_to(target)
229 stale = _stale_ref_names(tmp_path, "origin", {})
230 assert "malicious-link" not in stale
231
232
233 # ── Unit: _prune_stale_refs ───────────────────────────────────────────────────
234
235 class TestPruneStaleRefs:
236 def test_dry_run_does_not_delete(
237 self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]
238 ) -> None:
239 from muse.cli.commands.fetch import _prune_stale_refs
240 _init_repo(tmp_path)
241 _write_remote_ref(tmp_path, "origin", "dead-branch", OLD_REMOTE_ID)
242 pruned = _prune_stale_refs(tmp_path, "origin", {}, dry_run=True)
243 assert pruned == ["origin/dead-branch"]
244 assert (remotes_dir(tmp_path) / "origin" / "dead-branch").exists()
245 assert "Would prune" in capsys.readouterr().err
246
247 def test_live_delete_removes_file(
248 self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]
249 ) -> None:
250 from muse.cli.commands.fetch import _prune_stale_refs
251 _init_repo(tmp_path)
252 _write_remote_ref(tmp_path, "origin", "dead-branch", OLD_REMOTE_ID)
253 pruned = _prune_stale_refs(tmp_path, "origin", {}, dry_run=False)
254 assert pruned == ["origin/dead-branch"]
255 assert not (remotes_dir(tmp_path) / "origin" / "dead-branch").exists()
256 assert "[deleted]" in capsys.readouterr().err
257
258 def test_empty_parent_dirs_removed(self, tmp_path: pathlib.Path) -> None:
259 from muse.cli.commands.fetch import _prune_stale_refs
260 _init_repo(tmp_path)
261 _write_remote_ref(tmp_path, "origin", "feat/old-thing", OLD_REMOTE_ID)
262 _prune_stale_refs(tmp_path, "origin", {}, dry_run=False)
263 assert not (remotes_dir(tmp_path) / "origin" / "feat").exists()
264
265 def test_returns_qualified_remote_branch_names(self, tmp_path: pathlib.Path) -> None:
266 from muse.cli.commands.fetch import _prune_stale_refs
267 _init_repo(tmp_path)
268 _write_remote_ref(tmp_path, "origin", "stale-a", OLD_REMOTE_ID)
269 _write_remote_ref(tmp_path, "origin", "stale-b", OLD_REMOTE_ID)
270 pruned = _prune_stale_refs(tmp_path, "origin", {}, dry_run=False)
271 assert "origin/stale-a" in pruned
272 assert "origin/stale-b" in pruned
273
274 def test_no_refs_dir_is_noop(self, tmp_path: pathlib.Path) -> None:
275 from muse.cli.commands.fetch import _prune_stale_refs
276 _init_repo(tmp_path)
277 assert _prune_stale_refs(tmp_path, "no-remote", {}, dry_run=False) == []
278
279 def test_output_goes_to_stderr_not_stdout(
280 self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]
281 ) -> None:
282 from muse.cli.commands.fetch import _prune_stale_refs
283 _init_repo(tmp_path)
284 _write_remote_ref(tmp_path, "origin", "dead", OLD_REMOTE_ID)
285 _prune_stale_refs(tmp_path, "origin", {}, dry_run=False)
286 assert capsys.readouterr().out == ""
287
288
289 # ── Unit: negotiate_have ──────────────────────────────────────────────────────
290
291 class TestNegotiateHave:
292 def _make_transport(
293 self,
294 ready_after: int = 1,
295 ack_ids: list[str] | None = None,
296 ) -> MagicMock:
297 call_count = 0
298
299 def negotiate(
300 url: str, token: str | None, want: list[str], have: list[str]
301 ) -> "NegotiateResponse":
302 nonlocal call_count
303 call_count += 1
304 return {"ack": ack_ids or have, "common_base": None, "ready": call_count >= ready_after}
305
306 t = MagicMock()
307 t.negotiate.side_effect = negotiate
308 return t
309
310 def test_empty_local_returns_empty_no_network(self) -> None:
311 from muse.core.transport import negotiate_have
312 transport = self._make_transport()
313 assert negotiate_have(transport, "http://x", None, ["want"], []) == []
314 transport.negotiate.assert_not_called()
315
316 def test_single_round_ready_returns_ack(self) -> None:
317 from muse.core.transport import negotiate_have
318 transport = self._make_transport(ready_after=1, ack_ids=["common"])
319 result = negotiate_have(transport, "http://x", None, ["want"], ["c1", "c2"])
320 assert result == ["common"]
321
322 def test_falls_back_to_full_list_when_never_ready(self) -> None:
323 from muse.core.transport import negotiate_have, NEGOTIATE_DEPTH
324 transport = self._make_transport(ready_after=999)
325 all_local = [f"c{i}" for i in range(NEGOTIATE_DEPTH + 5)]
326 result = negotiate_have(transport, "http://x", None, ["want"], all_local)
327 assert result == all_local
328
329 def test_stress_10k_commits_3_rounds(self) -> None:
330 """10 000-commit history must converge in exactly 3 rounds."""
331 from muse.core.transport import negotiate_have
332 transport = self._make_transport(ready_after=3)
333 all_local = [f"c{i}" for i in range(10_000)]
334 result = negotiate_have(transport, "http://x", None, ["want"], all_local)
335 assert len(result) > 0
336 assert transport.negotiate.call_count == 3
337
338
339 # ── Integration: _fetch_one ───────────────────────────────────────────────────
340
341 class TestFetchOne:
342 def _patches(
343 self,
344 already_known: str | None = None,
345 branch_heads: Manifest | None = None,
346 apply_result: "ApplyResult | None" = None,
347 objects_count: int = 7,
348 ) -> contextlib.ExitStack:
349 stack = contextlib.ExitStack()
350 transport = _make_transport_mock(branch_heads or {"main": REMOTE_ID}, objects_count=objects_count)
351 stack.enter_context(patch("muse.cli.commands.fetch.get_remote", return_value="http://localhost:19999"))
352 stack.enter_context(patch("muse.cli.commands.fetch.get_signing_identity", return_value=None))
353 stack.enter_context(patch("muse.cli.commands.fetch.make_transport", return_value=transport))
354 stack.enter_context(patch("muse.cli.commands.fetch.get_remote_head", return_value=already_known))
355 stack.enter_context(patch("muse.cli.commands.fetch.set_remote_head"))
356 stack.enter_context(patch("muse.cli.commands.fetch.apply_mpack", return_value=apply_result or _make_apply_result()))
357 stack.enter_context(patch("muse.cli.commands.fetch.get_all_commits", return_value=[]))
358 stack.enter_context(patch("muse.cli.commands.fetch.negotiate_have", return_value=[]))
359 # write_object returns True when the object is new (written), enabling objects_written counting
360 stack.enter_context(patch("muse.cli.commands.fetch.write_object", return_value=True))
361 return stack
362
363 def test_up_to_date_status(self, tmp_path: pathlib.Path) -> None:
364 from muse.cli.commands.fetch import _fetch_one
365 with self._patches(already_known=REMOTE_ID):
366 result = _fetch_one(tmp_path, "origin", "main", prune=False, dry_run=False)
367 assert result["status"] == "up_to_date"
368 assert result["commits_received"] == 0
369
370 def test_fetched_status(self, tmp_path: pathlib.Path) -> None:
371 from muse.cli.commands.fetch import _fetch_one
372 with self._patches():
373 result = _fetch_one(tmp_path, "origin", "main", prune=False, dry_run=False)
374 assert result["status"] == "fetched"
375 assert result["commits_received"] == 3
376 assert result["objects_written"] == 7
377
378 def test_commits_received_from_apply_result_not_bundle(self, tmp_path: pathlib.Path) -> None:
379 """Regression: use apply_result['commits_written'], not len(bundle['commits'])."""
380 from muse.cli.commands.fetch import _fetch_one
381 with self._patches(apply_result=_make_apply_result(commits_written=5, objects_written=12), objects_count=12):
382 result = _fetch_one(tmp_path, "origin", "main", prune=False, dry_run=False)
383 assert result["commits_received"] == 5
384 assert result["objects_written"] == 12
385
386 def test_dry_run_does_not_write(self, tmp_path: pathlib.Path) -> None:
387 from muse.cli.commands.fetch import _fetch_one
388 set_mock = MagicMock()
389 with self._patches() as stack:
390 stack.enter_context(patch("muse.cli.commands.fetch.set_remote_head", set_mock))
391 result = _fetch_one(tmp_path, "origin", "main", prune=False, dry_run=True)
392 assert result["status"] == "dry_run"
393
394 def test_unknown_remote_exits_user_error(self, tmp_path: pathlib.Path) -> None:
395 from muse.cli.commands.fetch import _fetch_one
396 from muse.core.errors import ExitCode
397 with patch("muse.cli.commands.fetch.get_remote", return_value=None):
398 with pytest.raises(SystemExit) as exc:
399 _fetch_one(tmp_path, "no-such", "main", prune=False, dry_run=False)
400 assert exc.value.code == ExitCode.USER_ERROR
401
402 def test_branch_missing_without_prune_exits(self, tmp_path: pathlib.Path) -> None:
403 from muse.cli.commands.fetch import _fetch_one
404 with self._patches(branch_heads={"dev": REMOTE_ID}):
405 with pytest.raises(SystemExit):
406 _fetch_one(tmp_path, "origin", "main", prune=False, dry_run=False)
407
408 def test_branch_missing_with_prune_returns_branch_missing(self, tmp_path: pathlib.Path) -> None:
409 from muse.cli.commands.fetch import _fetch_one
410 with self._patches(branch_heads={"dev": REMOTE_ID}):
411 result = _fetch_one(tmp_path, "origin", "main", prune=True, dry_run=False)
412 assert result["status"] == "branch_missing"
413
414 def test_negotiate_called_before_fetch_stream(self, tmp_path: pathlib.Path) -> None:
415 """MWP negotiation must precede fetch_stream to minimise wire transfer."""
416 from muse.cli.commands.fetch import _fetch_one
417 call_order: list[str] = []
418
419 def _neg(
420 _t: "MuseTransport", _url: str, _token: str | None,
421 _want: list[str], _all: list[str],
422 ) -> list[str]:
423 call_order.append("negotiate_have")
424 return ["common"]
425
426 transport = MagicMock()
427 transport.fetch_remote_info.return_value = _make_remote_info({"main": REMOTE_ID})
428
429 def _fs(
430 url: str, token: str | None, want: list[str], have: list[str], **kwargs: str,
431 ) -> Mapping[str, object]:
432 call_order.append("fetch_stream")
433 return _make_fetch_stream_result()
434
435 transport.fetch_stream.side_effect = _fs
436
437 with (
438 patch("muse.cli.commands.fetch.get_remote", return_value="http://x"),
439 patch("muse.cli.commands.fetch.get_signing_identity", return_value=None),
440 patch("muse.cli.commands.fetch.make_transport", return_value=transport),
441 patch("muse.cli.commands.fetch.get_remote_head", return_value=None),
442 patch("muse.cli.commands.fetch.set_remote_head"),
443 patch("muse.cli.commands.fetch.apply_mpack", return_value=_make_apply_result()),
444 patch("muse.cli.commands.fetch.get_all_commits", return_value=[]),
445 patch("muse.cli.commands.fetch.negotiate_have", _neg),
446 ):
447 _fetch_one(tmp_path, "origin", "main", prune=False, dry_run=False)
448
449 assert call_order.index("negotiate_have") < call_order.index("fetch_stream")
450
451 def test_negotiate_failure_falls_back_to_full_have(self, tmp_path: pathlib.Path) -> None:
452 """If negotiate_have raises TransportError the full local list is used."""
453 from muse.cli.commands.fetch import _fetch_one
454 from muse.core.transport import TransportError
455
456 local_commits = [MagicMock(commit_id=f"c{i}") for i in range(5)]
457 captured_have: list[list[str]] = []
458 transport = MagicMock()
459 transport.fetch_remote_info.return_value = _make_remote_info({"main": REMOTE_ID})
460
461 def _fs(url: str, token: str | None, want: list[str], have: list[str], **kwargs: str) -> Mapping[str, object]:
462 captured_have.append(have)
463 return _make_fetch_stream_result()
464
465 transport.fetch_stream.side_effect = _fs
466
467 def _neg_raise(
468 _t: "MuseTransport", _url: str, _token: str | None,
469 _want: list[str], _all: list[str],
470 ) -> list[str]:
471 raise TransportError("negotiate not supported", 501)
472
473 with (
474 patch("muse.cli.commands.fetch.get_remote", return_value="http://x"),
475 patch("muse.cli.commands.fetch.get_signing_identity", return_value=None),
476 patch("muse.cli.commands.fetch.make_transport", return_value=transport),
477 patch("muse.cli.commands.fetch.get_remote_head", return_value=None),
478 patch("muse.cli.commands.fetch.set_remote_head"),
479 patch("muse.cli.commands.fetch.apply_mpack", return_value=_make_apply_result()),
480 patch("muse.cli.commands.fetch.get_all_commits", return_value=local_commits),
481 patch("muse.cli.commands.fetch.negotiate_have", _neg_raise),
482 ):
483 _fetch_one(tmp_path, "origin", "main", prune=False, dry_run=False)
484
485 assert captured_have[0] == [f"c{i}" for i in range(5)]
486
487 def test_set_remote_head_called_after_apply_pack(self, tmp_path: pathlib.Path) -> None:
488 """Remote tracking pointer must only advance after apply_mpack succeeds."""
489 from muse.cli.commands.fetch import _fetch_one
490 call_order: list[str] = []
491
492 def _apply(_root: pathlib.Path, _bundle: "MPackBundle") -> "ApplyResult":
493 call_order.append("apply_mpack")
494 return _make_apply_result()
495
496 def _set_head(
497 remote_name: str, branch: str, commit_id: str,
498 repo_root: pathlib.Path | None = None,
499 ) -> None:
500 call_order.append("set_remote_head")
501
502 with (
503 patch("muse.cli.commands.fetch.get_remote", return_value="http://x"),
504 patch("muse.cli.commands.fetch.get_signing_identity", return_value=None),
505 patch("muse.cli.commands.fetch.make_transport", return_value=_make_transport_mock()),
506 patch("muse.cli.commands.fetch.get_remote_head", return_value=None),
507 patch("muse.cli.commands.fetch.apply_mpack", _apply),
508 patch("muse.cli.commands.fetch.set_remote_head", _set_head),
509 patch("muse.cli.commands.fetch.get_all_commits", return_value=[]),
510 patch("muse.cli.commands.fetch.negotiate_have", return_value=[]),
511 patch("muse.cli.commands.fetch.write_object", return_value=True),
512 ):
513 _fetch_one(tmp_path, "origin", "main", prune=False, dry_run=False)
514
515 assert call_order.index("apply_mpack") < call_order.index("set_remote_head")
516
517
518 # ── Security ──────────────────────────────────────────────────────────────────
519
520 class TestSecurity:
521 def test_ansi_in_remote_name_stripped(
522 self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]
523 ) -> None:
524 from muse.cli.commands.fetch import _fetch_one
525 malicious = "\x1b[31mEVIL\x1b[0m"
526 with patch("muse.cli.commands.fetch.get_remote", return_value=None):
527 with pytest.raises(SystemExit):
528 _fetch_one(tmp_path, malicious, "main", prune=False, dry_run=False)
529 assert "\x1b[" not in capsys.readouterr().err
530
531 def test_ansi_in_branch_name_stripped(
532 self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]
533 ) -> None:
534 from muse.cli.commands.fetch import _fetch_one
535 malicious_branch = "\x1b[31mHACKED\x1b[0m"
536 with (
537 patch("muse.cli.commands.fetch.get_remote", return_value="http://x"),
538 patch("muse.cli.commands.fetch.get_signing_identity", return_value=None),
539 patch("muse.cli.commands.fetch.make_transport", return_value=_make_transport_mock({"main": REMOTE_ID})),
540 ):
541 with pytest.raises(SystemExit):
542 _fetch_one(tmp_path, "origin", malicious_branch, prune=False, dry_run=False)
543 assert "\x1b[" not in capsys.readouterr().err
544
545 def test_available_branches_sanitized_in_error(
546 self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]
547 ) -> None:
548 """Branch names returned by the remote must be sanitized before printing."""
549 from muse.cli.commands.fetch import _fetch_one
550 malicious_branch = "\x1b[32mhijacked\x1b[0m"
551 with (
552 patch("muse.cli.commands.fetch.get_remote", return_value="http://x"),
553 patch("muse.cli.commands.fetch.get_signing_identity", return_value=None),
554 patch("muse.cli.commands.fetch.make_transport", return_value=_make_transport_mock({malicious_branch: REMOTE_ID})),
555 ):
556 with pytest.raises(SystemExit):
557 _fetch_one(tmp_path, "origin", "no-such", prune=False, dry_run=False)
558 assert "\x1b[" not in capsys.readouterr().err
559
560 def test_symlink_traversal_blocked_in_stale_ref_names(
561 self, tmp_path: pathlib.Path
562 ) -> None:
563 from muse.cli.commands.fetch import _stale_ref_names
564 _init_repo(tmp_path)
565 refs_dir = remotes_dir(tmp_path) / "origin"
566 refs_dir.mkdir(parents=True, exist_ok=True)
567 (tmp_path / "secret.txt").write_text("top-secret")
568 (refs_dir / "malicious").symlink_to(tmp_path / "secret.txt")
569 assert "malicious" not in _stale_ref_names(tmp_path, "origin", {})
570
571 def test_all_diagnostics_go_to_stderr_not_stdout(
572 self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]
573 ) -> None:
574 from muse.cli.commands.fetch import _fetch_one
575 with (
576 patch("muse.cli.commands.fetch.get_remote", return_value="http://x"),
577 patch("muse.cli.commands.fetch.get_signing_identity", return_value=None),
578 patch("muse.cli.commands.fetch.make_transport", return_value=_make_transport_mock()),
579 patch("muse.cli.commands.fetch.get_remote_head", return_value=None),
580 patch("muse.cli.commands.fetch.set_remote_head"),
581 patch("muse.cli.commands.fetch.apply_mpack", return_value=_make_apply_result()),
582 patch("muse.cli.commands.fetch.get_all_commits", return_value=[]),
583 patch("muse.cli.commands.fetch.negotiate_have", return_value=[]),
584 patch("muse.cli.commands.fetch.write_object", return_value=True),
585 ):
586 _fetch_one(tmp_path, "origin", "main", prune=False, dry_run=False)
587 assert capsys.readouterr().out == ""
588
589
590 # ── E2E: CLI via CliRunner ────────────────────────────────────────────────────
591
592 def _invoke(*args: str, branch_heads: Manifest | None = None) -> InvokeResult:
593 """Invoke ``muse fetch`` with all transport-layer functions mocked."""
594 transport = _make_transport_mock(branch_heads)
595 with (
596 patch("muse.cli.commands.fetch.require_repo", return_value=pathlib.Path("/fake")),
597 patch("muse.cli.commands.fetch.read_current_branch", return_value="main"),
598 patch("muse.cli.commands.fetch.get_remote", return_value="http://localhost:19999"),
599 patch("muse.cli.commands.fetch.get_signing_identity", return_value=None),
600 patch("muse.cli.commands.fetch.make_transport", return_value=transport),
601 patch("muse.cli.commands.fetch.get_remote_head", return_value=None),
602 patch("muse.cli.commands.fetch.set_remote_head"),
603 patch("muse.cli.commands.fetch.apply_mpack", return_value=_make_apply_result()),
604 patch("muse.cli.commands.fetch.get_all_commits", return_value=[]),
605 patch("muse.cli.commands.fetch.negotiate_have", return_value=[]),
606 patch("muse.cli.commands.fetch.write_object", return_value=True),
607 ):
608 return runner.invoke(cli, ["fetch", *args])
609
610
611 class TestCLIFetch:
612 def test_basic_fetch_exits_zero(self) -> None:
613 assert _invoke().exit_code == 0
614
615 def test_already_up_to_date_exits_zero(self) -> None:
616 with (
617 patch("muse.cli.commands.fetch.require_repo", return_value=pathlib.Path("/fake")),
618 patch("muse.cli.commands.fetch.read_current_branch", return_value="main"),
619 patch("muse.cli.commands.fetch.get_remote", return_value="http://localhost:19999"),
620 patch("muse.cli.commands.fetch.get_signing_identity", return_value=None),
621 patch("muse.cli.commands.fetch.make_transport", return_value=_make_transport_mock()),
622 patch("muse.cli.commands.fetch.get_remote_head", return_value=REMOTE_ID),
623 patch("muse.cli.commands.fetch.set_remote_head"),
624 patch("muse.cli.commands.fetch.apply_mpack", return_value=_make_apply_result()),
625 patch("muse.cli.commands.fetch.get_all_commits", return_value=[]),
626 patch("muse.cli.commands.fetch.negotiate_have", return_value=[]),
627 ):
628 result = runner.invoke(cli, ["fetch"])
629 assert result.exit_code == 0
630
631 def test_json_schema_complete(self) -> None:
632 result = _invoke("--json")
633 assert result.exit_code == 0
634 data = _json_line(result)
635 assert "results" in data
636 assert "dry_run" in data
637 r = data["results"][0]
638 for key in ("remote", "branch", "status", "commits_received", "objects_written", "head", "pruned", "dry_run"):
639 assert key in r, f"Missing key: {key}"
640 assert r["status"] in {"fetched", "up_to_date", "dry_run", "branch_missing"}
641
642 def test_json_flag_produces_valid_json(self) -> None:
643 data = _json_line(_invoke("--json"))
644 assert "exit_code" in data
645
646 def test_dry_run_exits_zero(self) -> None:
647 assert _invoke("--dry-run").exit_code == 0
648
649 def test_dry_run_json_status(self) -> None:
650 result = _invoke("--dry-run", "--json")
651 assert result.exit_code == 0
652 data = _json_line(result)
653 assert data["dry_run"] is True
654 assert data["results"][0]["status"] == "dry_run"
655
656 def test_branch_flag(self) -> None:
657 assert _invoke("--branch", "dev", branch_heads={"dev": REMOTE_ID}).exit_code == 0
658
659 def test_branch_flag_json_carries_branch(self) -> None:
660 result = _invoke("--branch", "dev", "--json", branch_heads={"dev": REMOTE_ID})
661 assert result.exit_code == 0
662 assert _json_line(result)["results"][0]["branch"] == "dev"
663
664 def test_unknown_remote_exits_nonzero(self) -> None:
665 with (
666 patch("muse.cli.commands.fetch.require_repo", return_value=pathlib.Path("/fake")),
667 patch("muse.cli.commands.fetch.read_current_branch", return_value="main"),
668 patch("muse.cli.commands.fetch.get_remote", return_value=None),
669 ):
670 result = runner.invoke(cli, ["fetch", "no-such-remote"])
671 assert result.exit_code != 0
672
673 def test_prune_flag_succeeds(self) -> None:
674 assert _invoke("--prune").exit_code == 0
675
676 def test_prune_json_has_pruned_list(self) -> None:
677 result = _invoke("--prune", "--json")
678 assert result.exit_code == 0
679 assert isinstance(_json_line(result)["results"][0]["pruned"], list)
680
681 def test_json_on_stdout_parseable(self) -> None:
682 result = _invoke("--json")
683 assert result.exit_code == 0
684 data = _json_line(result)
685 assert "results" in data
686
687
688 class TestCLIFetchAll:
689 def _invoke_all(self, *extra: str, branch_heads: Manifest | None = None) -> InvokeResult:
690 remotes = [
691 {"name": "origin", "url": "http://origin"},
692 {"name": "upstream", "url": "http://upstream"},
693 ]
694 transport = _make_transport_mock(branch_heads or {"main": REMOTE_ID})
695 with (
696 patch("muse.cli.commands.fetch.require_repo", return_value=pathlib.Path("/fake")),
697 patch("muse.cli.commands.fetch.read_current_branch", return_value="main"),
698 patch("muse.cli.commands.fetch.list_remotes", return_value=remotes),
699 patch("muse.cli.commands.fetch.get_remote", return_value="http://localhost:19999"),
700 patch("muse.cli.commands.fetch.get_signing_identity", return_value=None),
701 patch("muse.cli.commands.fetch.make_transport", return_value=transport),
702 patch("muse.cli.commands.fetch.get_remote_head", return_value=None),
703 patch("muse.cli.commands.fetch.set_remote_head"),
704 patch("muse.cli.commands.fetch.apply_mpack", return_value=_make_apply_result()),
705 patch("muse.cli.commands.fetch.get_all_commits", return_value=[]),
706 patch("muse.cli.commands.fetch.negotiate_have", return_value=[]),
707 patch("muse.cli.commands.fetch.write_object", return_value=True),
708 ):
709 return runner.invoke(cli, ["fetch", "--all", *extra])
710
711 def test_all_exits_zero(self) -> None:
712 assert self._invoke_all().exit_code == 0
713
714 def test_all_json_has_result_per_remote(self) -> None:
715 result = self._invoke_all("--json")
716 assert result.exit_code == 0
717 data = _json_line(result)
718 assert len(data["results"]) == 2
719 remotes_seen = {r["remote"] for r in data["results"]}
720 assert "origin" in remotes_seen
721 assert "upstream" in remotes_seen
722
723 def test_all_plus_branch_uses_named_branch(self) -> None:
724 """--all --branch dev must fetch 'dev' from every remote."""
725 result = self._invoke_all("--branch", "dev", "--json", branch_heads={"dev": REMOTE_ID})
726 assert result.exit_code == 0
727 data = _json_line(result)
728 for r in data["results"]:
729 assert r["branch"] == "dev"
730
731 def test_all_no_remotes_exits_nonzero(self) -> None:
732 with (
733 patch("muse.cli.commands.fetch.require_repo", return_value=pathlib.Path("/fake")),
734 patch("muse.cli.commands.fetch.read_current_branch", return_value="main"),
735 patch("muse.cli.commands.fetch.list_remotes", return_value=[]),
736 ):
737 result = runner.invoke(cli, ["fetch", "--all"])
738 assert result.exit_code != 0
739
740
741 # ── Performance ───────────────────────────────────────────────────────────────
742
743 class TestPerformance:
744 def test_negotiate_result_used_as_have_not_all_local(self) -> None:
745 """fetch_stream must receive negotiate_have output, not the raw all_local list."""
746 minimal = ["common-base-only"]
747 captured_have: list[list[str]] = []
748 transport = MagicMock()
749 transport.fetch_remote_info.return_value = _make_remote_info()
750
751 def _fs(url: str, token: str | None, want: list[str], have: list[str], **kwargs: str) -> Mapping[str, object]:
752 captured_have.append(have)
753 return _make_fetch_stream_result()
754
755 transport.fetch_stream.side_effect = _fs
756
757 with (
758 patch("muse.cli.commands.fetch.get_remote", return_value="http://x"),
759 patch("muse.cli.commands.fetch.get_signing_identity", return_value=None),
760 patch("muse.cli.commands.fetch.make_transport", return_value=transport),
761 patch("muse.cli.commands.fetch.get_remote_head", return_value=None),
762 patch("muse.cli.commands.fetch.set_remote_head"),
763 patch("muse.cli.commands.fetch.apply_mpack", return_value=_make_apply_result()),
764 patch(
765 "muse.cli.commands.fetch.get_all_commits",
766 return_value=[MagicMock(commit_id=f"c{i}") for i in range(1_000)],
767 ),
768 patch("muse.cli.commands.fetch.negotiate_have", return_value=minimal),
769 ):
770 from muse.cli.commands.fetch import _fetch_one
771 _fetch_one(pathlib.Path("/fake"), "origin", "main", prune=False, dry_run=False)
772
773 assert captured_have[0] == minimal
774
775 def test_large_negotiation_converges_in_3_rounds(self) -> None:
776 from muse.core.transport import negotiate_have
777 transport = MagicMock()
778 rounds: list[int] = [0]
779
780 def _neg(url: str, token: str | None, want: list[str], have: list[str]) -> "NegotiateResponse":
781 rounds[0] += 1
782 return {"ack": have[:1], "common_base": None, "ready": rounds[0] >= 3}
783
784 transport.negotiate.side_effect = _neg
785 result = negotiate_have(
786 transport, "http://x", None, ["want"], [f"c{i}" for i in range(10_000)]
787 )
788 assert len(result) > 0
789 assert rounds[0] == 3
790
791
792 # ── Stress: concurrent filesystem and negotiation ────────────────────────────
793
794 class TestStressConcurrent:
795 def test_8_concurrent_prune_scans_isolated_repos(self, tmp_path: pathlib.Path) -> None:
796 """_prune_stale_refs on isolated repos must not interfere across threads."""
797 from muse.cli.commands.fetch import _prune_stale_refs
798 errors: list[str] = []
799
800 def _do(idx: int) -> None:
801 try:
802 repo = tmp_path / f"repo{idx}"
803 repo.mkdir()
804 _init_repo(repo)
805 _write_remote_ref(repo, "origin", "stale-branch", OLD_REMOTE_ID)
806 pruned = _prune_stale_refs(repo, "origin", {}, dry_run=False)
807 assert pruned == ["origin/stale-branch"]
808 assert not (remotes_dir(repo) / "origin" / "stale-branch").exists()
809 except Exception as exc:
810 errors.append(f"Thread {idx}: {exc}")
811
812 threads = [threading.Thread(target=_do, args=(i,)) for i in range(8)]
813 for t in threads:
814 t.start()
815 for t in threads:
816 t.join()
817 assert errors == [], f"Concurrent prune failures: {errors}"
818
819 def test_8_concurrent_negotiate_have_calls(self) -> None:
820 """negotiate_have is stateless — 8 concurrent calls must not interfere."""
821 from muse.core.transport import negotiate_have
822 errors: list[str] = []
823
824 def _do(idx: int) -> None:
825 try:
826 transport = MagicMock()
827 transport.negotiate.return_value = _make_negotiate_response(
828 ack=[f"common-{idx}"], ready=True
829 )
830 result = negotiate_have(
831 transport, "http://x", None, [f"want-{idx}"],
832 [f"c{i}" for i in range(100)]
833 )
834 assert result == [f"common-{idx}"]
835 except Exception as exc:
836 errors.append(f"Thread {idx}: {exc}")
837
838 threads = [threading.Thread(target=_do, args=(i,)) for i in range(8)]
839 for t in threads:
840 t.start()
841 for t in threads:
842 t.join()
843 assert errors == [], f"Concurrent negotiate_have failures: {errors}"
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 121 days ago