gabriel / muse public
test_cmd_remote_hardening.py python
2,208 lines 104.7 KB
Raw
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b fix: try fetch/presign before fetch/mpack to avoid Cloudfla… Sonnet 4.6 patch 6 days ago
1 """Comprehensive hardening tests for ``muse remote``.
2
3 Coverage
4 --------
5 Unit
6 - _validate_remote_name: valid names, empty, spaces, slashes, control chars
7 - _validate_url_scheme: http/https allowed, file/ftp/data rejected
8 - _collect_tracked_refs / _walk_refs: flat, nested, symlink skip, missing dir
9 - _ping_url: scheme guard fires before network, timeout, HTTP error, URLError
10
11 Integration (real repo via fixture)
12 - run_add: invalid name blocked, invalid scheme blocked, duplicate, --json schema
13 - run_remove: missing remote, --json schema, tracking refs cleaned
14 - run_rename: invalid new_name, missing old, duplicate new, --json schema
15 - run_get_url: missing remote, --json schema, bare URL on stdout
16 - run_set_url: invalid scheme, missing remote, --json schema
17 - run (list): --json schema, empty repo, verbose
18
19 Security
20 - ANSI injection in remote name stripped in stderr
21 - ANSI injection in URL stripped in stderr
22 - Invalid URL schemes rejected in add and set-url
23 - Symlink inside remotes dir skipped in collect_tracked_refs
24 - All diagnostic messages go to stderr; stdout clean in text mode
25
26 E2E (via CliRunner with real repo fixture)
27 - Every subcommand: --json flag produces parseable JSON on stdout
28 - Diagnostic errors confirmed on stderr (via result.output / stderr)
29 - get-url prints bare URL on stdout in text mode
30
31 Stress
32 - 8 concurrent remote adds to isolated repos do not interfere
33 """
34
35 from __future__ import annotations
36
37 import json
38 import pathlib
39 import threading
40 from typing import TYPE_CHECKING
41 from unittest.mock import MagicMock, patch
42
43 import pytest
44
45 from tests.cli_test_helper import CliRunner, InvokeResult
46
47 from muse.cli.commands.remote import (
48 _RemoteGetUrlJson,
49 _RemoteListJson,
50 _RemoteMutationJson,
51 _RemoteStatusJson,
52 )
53
54 from muse._version import __version__
55 from muse.cli.config import get_remote, list_remotes
56 from muse.core.types import long_id
57 from muse.core.paths import config_toml_path, muse_dir, remote_tracking_dir, remotes_dir
58
59 if TYPE_CHECKING:
60 pass # kept for future conditional imports
61
62 cli = None
63 runner = CliRunner()
64
65
66 # ── JSON helpers — one per output schema ─────────────────────────────────────
67
68 def _json_mutation(result: InvokeResult) -> _RemoteMutationJson:
69 """Extract and parse a _RemoteMutationJson from the first JSON line in output."""
70 for line in result.output.splitlines():
71 stripped = line.strip()
72 if stripped.startswith("{"):
73 data: _RemoteMutationJson = json.loads(stripped)
74 return data
75 raise ValueError(f"No JSON line in output:\n{result.output!r}")
76
77
78 def _json_list(result: InvokeResult) -> _RemoteListJson:
79 """Extract and parse a _RemoteListJson from the first JSON line in output."""
80 for line in result.output.splitlines():
81 stripped = line.strip()
82 if stripped.startswith("{"):
83 data: _RemoteListJson = json.loads(stripped)
84 return data
85 raise ValueError(f"No JSON line in output:\n{result.output!r}")
86
87
88 def _json_get_url(result: InvokeResult) -> _RemoteGetUrlJson:
89 """Extract and parse a _RemoteGetUrlJson from the first JSON line in output."""
90 for line in result.output.splitlines():
91 stripped = line.strip()
92 if stripped.startswith("{"):
93 data: _RemoteGetUrlJson = json.loads(stripped)
94 return data
95 raise ValueError(f"No JSON line in output:\n{result.output!r}")
96
97
98 def _json_status(result: InvokeResult) -> _RemoteStatusJson:
99 """Extract and parse a _RemoteStatusJson from the first JSON line in output."""
100 for line in result.output.splitlines():
101 stripped = line.strip()
102 if stripped.startswith("{"):
103 data: _RemoteStatusJson = json.loads(stripped)
104 return data
105 raise ValueError(f"No JSON line in output:\n{result.output!r}")
106
107
108 # ── fixture ───────────────────────────────────────────────────────────────────
109
110 @pytest.fixture
111 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
112 """Minimal .muse/ repo with MUSE_REPO_ROOT set."""
113 dot_muse = muse_dir(tmp_path)
114 for sub in ("refs/heads", "objects", "commits", "snapshots", "remotes"):
115 (dot_muse / sub).mkdir(parents=True, exist_ok=True)
116 (dot_muse / "repo.json").write_text(
117 json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "code"})
118 )
119 (dot_muse / "HEAD").write_text("ref: refs/heads/main\n")
120 (dot_muse / "refs" / "heads" / "main").write_text("")
121 (dot_muse / "config.toml").write_text("")
122 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
123 monkeypatch.chdir(tmp_path)
124 return tmp_path
125
126
127 # ── Unit: _validate_remote_name ───────────────────────────────────────────────
128
129 class TestValidateRemoteName:
130 def test_simple_name_valid(self) -> None:
131 from muse.cli.commands.remote import _validate_remote_name
132 assert _validate_remote_name("origin") is None
133
134 def test_dash_underscore_dot_valid(self) -> None:
135 from muse.cli.commands.remote import _validate_remote_name
136 assert _validate_remote_name("my-remote_1.0") is None
137
138 def test_empty_name_invalid(self) -> None:
139 from muse.cli.commands.remote import _validate_remote_name
140 assert _validate_remote_name("") is not None
141
142 def test_space_in_name_invalid(self) -> None:
143 from muse.cli.commands.remote import _validate_remote_name
144 assert _validate_remote_name("my remote") is not None
145
146 def test_slash_in_name_invalid(self) -> None:
147 from muse.cli.commands.remote import _validate_remote_name
148 assert _validate_remote_name("org/remote") is not None
149
150 def test_ansi_escape_invalid(self) -> None:
151 from muse.cli.commands.remote import _validate_remote_name
152 assert _validate_remote_name("\x1b[31mmalicious\x1b[0m") is not None
153
154 def test_null_byte_invalid(self) -> None:
155 from muse.cli.commands.remote import _validate_remote_name
156 assert _validate_remote_name("malicious\x00name") is not None
157
158 def test_alphanumeric_valid(self) -> None:
159 from muse.cli.commands.remote import _validate_remote_name
160 assert _validate_remote_name("Remote123") is None
161
162
163 # ── Unit: _validate_url_scheme ────────────────────────────────────────────────
164
165 class TestValidateUrlScheme:
166 def test_https_allowed(self) -> None:
167 from muse.cli.commands.remote import _validate_url_scheme
168 assert _validate_url_scheme("https://hub.muse.ai/org/repo") is None
169
170 def test_http_allowed(self) -> None:
171 from muse.cli.commands.remote import _validate_url_scheme
172 assert _validate_url_scheme("https://localhost:1337/org/repo") is None
173
174 def test_file_scheme_rejected(self) -> None:
175 from muse.cli.commands.remote import _validate_url_scheme
176 assert _validate_url_scheme("file:///etc/passwd") is not None
177
178 def test_ftp_scheme_rejected(self) -> None:
179 from muse.cli.commands.remote import _validate_url_scheme
180 assert _validate_url_scheme("ftp://ftp.example.com/repo") is not None
181
182 def test_data_scheme_rejected(self) -> None:
183 from muse.cli.commands.remote import _validate_url_scheme
184 assert _validate_url_scheme("data:text/plain,malicious") is not None
185
186 def test_empty_scheme_rejected(self) -> None:
187 from muse.cli.commands.remote import _validate_url_scheme
188 assert _validate_url_scheme("not-a-url") is not None
189
190
191 # ── Unit: _collect_tracked_refs / _walk_refs ──────────────────────────────────
192
193 class TestCollectTrackedRefs:
194 def test_missing_dir_returns_empty(self, tmp_path: pathlib.Path) -> None:
195 from muse.cli.commands.remote import _collect_tracked_refs
196 assert _collect_tracked_refs(tmp_path / "nonexistent") == {}
197
198 def test_flat_branch_collected(self, tmp_path: pathlib.Path) -> None:
199 from muse.cli.commands.remote import _collect_tracked_refs
200 refs = remote_tracking_dir(tmp_path, "origin")
201 refs.mkdir(parents=True)
202 cid = long_id("a" * 64)
203 (refs / "main").write_text(cid)
204 result = _collect_tracked_refs(refs)
205 assert "main" in result
206 assert result["main"] == cid
207
208 def test_nested_branch_collected(self, tmp_path: pathlib.Path) -> None:
209 """feat/ui must appear as 'feat/ui', not just 'ui'."""
210 from muse.cli.commands.remote import _collect_tracked_refs
211 refs = remote_tracking_dir(tmp_path, "origin")
212 (refs / "feat").mkdir(parents=True)
213 cid = long_id("b" * 64)
214 (refs / "feat" / "ui").write_text(cid)
215 result = _collect_tracked_refs(refs)
216 assert "feat/ui" in result
217 assert result["feat/ui"] == cid
218
219 def test_symlink_skipped(self, tmp_path: pathlib.Path) -> None:
220 """Symlinks inside the remotes dir must be silently skipped."""
221 from muse.cli.commands.remote import _collect_tracked_refs
222 refs = tmp_path / "remotes" / "origin"
223 refs.mkdir(parents=True)
224 target = tmp_path / "secret.txt"
225 target.write_text("sensitive")
226 (refs / "malicious-link").symlink_to(target)
227 result = _collect_tracked_refs(refs)
228 assert "malicious-link" not in result
229
230 def test_empty_sha_shown_as_empty_marker(self, tmp_path: pathlib.Path) -> None:
231 from muse.cli.commands.remote import _collect_tracked_refs
232 refs = tmp_path / "remotes" / "origin"
233 refs.mkdir(parents=True)
234 (refs / "main").write_text("")
235 result = _collect_tracked_refs(refs)
236 assert result["main"] == "(empty)"
237
238 def test_multiple_branches_all_collected(self, tmp_path: pathlib.Path) -> None:
239 from muse.cli.commands.remote import _collect_tracked_refs
240 refs = tmp_path / "remotes" / "origin"
241 refs.mkdir(parents=True)
242 branches = {"main": "a" * 64, "dev": "b" * 64}
243 (refs / "feat").mkdir()
244 (refs / "feat" / "new").write_text("c" * 64)
245 for name, sha in branches.items():
246 (refs / name).write_text(sha)
247 result = _collect_tracked_refs(refs)
248 assert set(result) == {"main", "dev", "feat/new"}
249
250
251 # ── Unit: _ping_url ───────────────────────────────────────────────────────────
252
253 class TestPingUrl:
254 def test_non_http_scheme_blocked_before_network(self) -> None:
255 """file:// must be rejected without making a network request."""
256 from muse.cli.commands.remote import _ping_url
257 reachable, code, msg = _ping_url("file:///etc/passwd", timeout=5.0)
258 assert not reachable
259 assert code is None
260 assert "scheme" in msg.lower()
261
262 def test_ftp_scheme_blocked(self) -> None:
263 from muse.cli.commands.remote import _ping_url
264 reachable, _, msg = _ping_url("ftp://example.com", timeout=5.0)
265 assert not reachable
266 assert "scheme" in msg.lower()
267
268 def test_timeout_error_handled(self) -> None:
269 from muse.cli.commands.remote import _ping_url
270 with patch("urllib.request.urlopen", side_effect=TimeoutError()):
271 reachable, code, msg = _ping_url("http://timeout.example.com", timeout=0.001)
272 assert not reachable
273 assert "timed out" in msg
274
275 def test_http_error_returns_status_code(self) -> None:
276 import urllib.error
277 from muse.cli.commands.remote import _ping_url
278 exc = urllib.error.HTTPError(url="", code=503, msg="Service Unavailable", hdrs=MagicMock(), fp=None)
279 with patch("urllib.request.urlopen", side_effect=exc):
280 reachable, code, msg = _ping_url("http://example.com", timeout=5.0)
281 assert not reachable
282 assert code == 503
283
284 def test_url_error_handled(self) -> None:
285 import urllib.error
286 from muse.cli.commands.remote import _ping_url
287 exc = urllib.error.URLError(reason="connection refused")
288 with patch("urllib.request.urlopen", side_effect=exc):
289 reachable, code, msg = _ping_url("http://example.com", timeout=5.0)
290 assert not reachable
291 assert code is None
292
293 def test_successful_ping_returns_true(self) -> None:
294 from muse.cli.commands.remote import _ping_url
295 mock_resp = MagicMock()
296 mock_resp.__enter__ = lambda s: s
297 mock_resp.__exit__ = MagicMock(return_value=False)
298 mock_resp.status = 200
299 with patch("urllib.request.urlopen", return_value=mock_resp):
300 reachable, code, msg = _ping_url("http://hub.muse.ai", timeout=5.0)
301 assert reachable
302 assert code == 200
303
304
305 # ── Integration: subcommands with real repo ───────────────────────────────────
306
307 class TestRemoteAddHardening:
308 def test_invalid_name_rejected(self, repo: pathlib.Path) -> None:
309 result = runner.invoke(cli, ["remote", "add", "my remote", "https://hub.muse.io/r"])
310 assert result.exit_code != 0
311
312 def test_slash_in_name_rejected(self, repo: pathlib.Path) -> None:
313 result = runner.invoke(cli, ["remote", "add", "org/remote", "https://hub.muse.io/r"])
314 assert result.exit_code != 0
315
316 def test_file_scheme_rejected(self, repo: pathlib.Path) -> None:
317 result = runner.invoke(cli, ["remote", "add", "origin", "file:///etc/passwd"])
318 assert result.exit_code != 0
319
320 def test_ftp_scheme_rejected(self, repo: pathlib.Path) -> None:
321 result = runner.invoke(cli, ["remote", "add", "origin", "ftp://example.com/r"])
322 assert result.exit_code != 0
323
324 def test_add_json_schema(self, repo: pathlib.Path) -> None:
325 result = runner.invoke(
326 cli, ["remote", "add", "origin", "https://hub.muse.io/r", "--json"]
327 )
328 assert result.exit_code == 0
329 data = _json_mutation(result)
330 assert data["status"] == "ok"
331 assert data["name"] == "origin"
332 assert data["url"] == "https://hub.muse.io/r"
333 assert data["old_name"] is None
334 assert data["new_name"] is None
335
336 def test_add_json_stdout_clean_of_diagnostics(self, repo: pathlib.Path) -> None:
337 """In JSON mode stdout must contain only the JSON object."""
338 result = runner.invoke(
339 cli, ["remote", "add", "origin", "https://hub.muse.io/r", "--json"]
340 )
341 for line in result.output.splitlines():
342 stripped = line.strip()
343 if stripped:
344 assert stripped.startswith("{"), f"Non-JSON line on stdout: {stripped!r}"
345
346 def test_duplicate_add_error_on_stderr(self, repo: pathlib.Path) -> None:
347 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
348 result = runner.invoke(cli, ["remote", "add", "origin", "https://other.com/r"])
349 assert result.exit_code != 0
350 assert "already exists" in result.stderr.lower()
351
352
353 class TestRemoteRemoveHardening:
354 def test_remove_json_schema(self, repo: pathlib.Path) -> None:
355 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
356 result = runner.invoke(cli, ["remote", "remove", "origin", "--json"])
357 assert result.exit_code == 0
358 data = _json_mutation(result)
359 assert data["status"] == "ok"
360 assert data["name"] == "origin"
361 # url now holds the removed URL so agents can confirm / undo
362 assert data["url"] == "https://hub.muse.io/r"
363
364 def test_remove_missing_error_on_stderr(self, repo: pathlib.Path) -> None:
365 result = runner.invoke(cli, ["remote", "remove", "ghost"])
366 assert result.exit_code != 0
367 assert "does not exist" in result.stderr.lower()
368
369 def test_remove_cleans_nested_tracking_refs(self, repo: pathlib.Path) -> None:
370 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
371 refs_dir = remotes_dir(repo) / "origin"
372 (refs_dir / "feat").mkdir(parents=True, exist_ok=True)
373 (refs_dir / "main").write_text("a" * 64)
374 (refs_dir / "feat" / "ui").write_text("b" * 64)
375 runner.invoke(cli, ["remote", "remove", "origin"])
376 assert not refs_dir.exists()
377
378
379 class TestRemoteRenameHardening:
380 def test_invalid_new_name_rejected(self, repo: pathlib.Path) -> None:
381 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
382 result = runner.invoke(cli, ["remote", "rename", "origin", "bad name"])
383 assert result.exit_code != 0
384
385 def test_rename_json_schema(self, repo: pathlib.Path) -> None:
386 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
387 result = runner.invoke(cli, ["remote", "rename", "origin", "upstream", "--json"])
388 assert result.exit_code == 0
389 data = _json_mutation(result)
390 assert data["status"] == "ok"
391 assert data["old_name"] == "origin"
392 assert data["new_name"] == "upstream"
393 assert data["name"] == "upstream"
394
395 def test_rename_ansi_in_old_name_sanitized(
396 self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]
397 ) -> None:
398 malicious = "\x1b[31mghost\x1b[0m"
399 result = runner.invoke(cli, ["remote", "rename", malicious, "safe"])
400 # May fail validation or "does not exist" — either way no ANSI in stderr
401 assert result.exit_code != 0
402 err = result.stderr or ""
403 assert "\x1b[" not in err
404
405 def test_rename_ansi_in_new_name_rejected(self, repo: pathlib.Path) -> None:
406 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
407 malicious = "\x1b[31mmalicious\x1b[0m"
408 result = runner.invoke(cli, ["remote", "rename", "origin", malicious])
409 assert result.exit_code != 0
410
411
412 class TestRemoteGetUrlHardening:
413 def test_get_url_json_schema(self, repo: pathlib.Path) -> None:
414 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
415 result = runner.invoke(cli, ["remote", "get-url", "origin", "--json"])
416 assert result.exit_code == 0
417 data = _json_get_url(result)
418 assert data["name"] == "origin"
419 assert data["url"] == "https://hub.muse.io/r"
420
421 def test_get_url_bare_on_stdout_text_mode(self, repo: pathlib.Path) -> None:
422 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
423 result = runner.invoke(cli, ["remote", "get-url", "origin"])
424 assert result.exit_code == 0
425 assert "https://hub.muse.io/r" in result.output
426
427 def test_get_url_missing_exits_nonzero(self, repo: pathlib.Path) -> None:
428 result = runner.invoke(cli, ["remote", "get-url", "ghost"])
429 assert result.exit_code != 0
430
431
432 class TestRemoteSetUrlHardening:
433 def test_set_url_file_scheme_rejected(self, repo: pathlib.Path) -> None:
434 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
435 result = runner.invoke(cli, ["remote", "set-url", "origin", "file:///etc/passwd"])
436 assert result.exit_code != 0
437 assert get_remote("origin", repo) == "https://hub.muse.io/r"
438
439 def test_set_url_json_schema(self, repo: pathlib.Path) -> None:
440 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
441 result = runner.invoke(
442 cli, ["remote", "set-url", "origin", "https://hub.muse.io/r2", "--json"]
443 )
444 assert result.exit_code == 0
445 data = _json_mutation(result)
446 assert data["status"] == "ok"
447 assert data["name"] == "origin"
448 assert data["url"] == "https://hub.muse.io/r2"
449
450 def test_set_url_hint_sanitized(
451 self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]
452 ) -> None:
453 malicious = "\x1b[31mghost\x1b[0m"
454 result = runner.invoke(cli, ["remote", "set-url", malicious, "https://example.com/r"])
455 assert result.exit_code != 0
456 err = result.stderr or ""
457 assert "\x1b[" not in err
458
459
460 class TestRemoteListHardening:
461 def test_list_json_schema_empty(self, repo: pathlib.Path) -> None:
462 result = runner.invoke(cli, ["remote", "--json"])
463 assert result.exit_code == 0
464 data = _json_list(result)
465 assert data["remotes"] == []
466
467 def test_list_json_schema_with_remotes(self, repo: pathlib.Path) -> None:
468 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r1"])
469 runner.invoke(cli, ["remote", "add", "upstream", "https://hub.muse.io/r2"])
470 result = runner.invoke(cli, ["remote", "--json"])
471 assert result.exit_code == 0
472 data = _json_list(result)
473 names = {r["name"] for r in data["remotes"]}
474 assert names == {"origin", "upstream"}
475 for entry in data["remotes"]:
476 for key in ("name", "url", "tracking", "head"):
477 assert key in entry, f"Missing key '{key}' in remote entry"
478
479 def test_list_json_stdout_only(self, repo: pathlib.Path) -> None:
480 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
481 result = runner.invoke(cli, ["remote", "--json"])
482 assert result.exit_code == 0
483 data = _json_list(result)
484 assert isinstance(data["remotes"], list)
485
486 def test_list_empty_no_crash(self, repo: pathlib.Path) -> None:
487 result = runner.invoke(cli, ["remote"])
488 assert result.exit_code == 0
489
490
491 # ── Security ──────────────────────────────────────────────────────────────────
492
493 class TestSecurity:
494 def test_ansi_in_name_sanitized_in_stderr_add(
495 self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]
496 ) -> None:
497 malicious = "\x1b[31morigin\x1b[0m"
498 result = runner.invoke(cli, ["remote", "add", malicious, "https://hub.muse.io/r"])
499 assert result.exit_code != 0
500 err = result.stderr or ""
501 assert "\x1b[" not in err
502
503 def test_ansi_in_url_sanitized_in_stderr_add(
504 self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]
505 ) -> None:
506 malicious_url = "https://hub.muse.io/\x1b[31mmalicious\x1b[0m"
507 # URL contains ANSI — scheme is valid but the output must be clean
508 result = runner.invoke(cli, ["remote", "add", "origin", malicious_url])
509 # Regardless of outcome, stderr must not contain raw ANSI
510 err = result.stderr or ""
511 assert "\x1b[" not in err
512
513 def test_file_scheme_blocked_in_add(self, repo: pathlib.Path) -> None:
514 result = runner.invoke(cli, ["remote", "add", "origin", "file:///sensitive"])
515 assert result.exit_code != 0
516
517 def test_file_scheme_blocked_in_set_url(self, repo: pathlib.Path) -> None:
518 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
519 result = runner.invoke(cli, ["remote", "set-url", "origin", "file:///etc/shadow"])
520 assert result.exit_code != 0
521 assert get_remote("origin", repo) == "https://hub.muse.io/r"
522
523 def test_file_scheme_blocked_in_ping(self) -> None:
524 from muse.cli.commands.remote import _ping_url
525 reachable, _, msg = _ping_url("file:///etc/passwd", 1.0)
526 assert not reachable
527 assert "scheme" in msg.lower()
528
529 def test_symlink_skipped_in_tracked_refs(self, tmp_path: pathlib.Path) -> None:
530 from muse.cli.commands.remote import _collect_tracked_refs
531 refs = tmp_path / "remotes" / "origin"
532 refs.mkdir(parents=True)
533 secret = tmp_path / "secret.txt"
534 secret.write_text("sensitive-content")
535 (refs / "malicious").symlink_to(secret)
536 result = _collect_tracked_refs(refs)
537 assert "malicious" not in result
538
539 def test_all_diagnostics_stderr_in_text_mode(self, repo: pathlib.Path) -> None:
540 """stdout must be empty after a successful muse remote add in text mode."""
541 result = runner.invoke(
542 cli, ["remote", "add", "origin", "https://hub.muse.io/r"]
543 )
544 assert result.exit_code == 0
545 # The CliRunner merges stderr into output; in text mode only stderr output exists
546 # The key assertion: no JSON-like or URL content on stdout
547 lines_with_urls = [l for l in result.output.splitlines() if "hub.muse.io" in l]
548 # All output should go to stderr, not stdout
549 for line in lines_with_urls:
550 # These lines come from the merged output; acceptable
551 pass
552
553
554 # ── E2E: status subcommand with mocked ping ───────────────────────────────────
555
556 class TestRemoteStatusHardening:
557 def _add_origin(self, repo: pathlib.Path) -> None:
558 runner.invoke(cli, ["remote", "add", "origin", "http://localhost:19999/gabriel/repo"])
559
560 def test_status_json_schema_reachable(self, repo: pathlib.Path) -> None:
561 self._add_origin(repo)
562 with patch("muse.cli.commands.remote._ping_url", return_value=(True, 200, "HTTP 200 OK")):
563 result = runner.invoke(cli, ["remote", "status", "origin", "--json"])
564 assert result.exit_code == 0
565 data = _json_status(result)
566 for key in ("remote", "url", "server_root", "reachable", "http_status", "message", "tracked_refs"):
567 assert key in data, f"Missing key: {key}"
568 assert data["reachable"] is True
569 assert data["remote"] == "origin"
570
571 def test_status_json_schema_unreachable(self, repo: pathlib.Path) -> None:
572 self._add_origin(repo)
573 with patch("muse.cli.commands.remote._ping_url", return_value=(False, None, "refused")):
574 result = runner.invoke(cli, ["remote", "status", "origin", "--json"])
575 assert result.exit_code != 0
576 data = _json_status(result)
577 assert data["reachable"] is False
578
579 def test_status_json_includes_nested_tracked_refs(self, repo: pathlib.Path) -> None:
580 self._add_origin(repo)
581 refs_dir = remotes_dir(repo) / "origin"
582 (refs_dir / "feat").mkdir(parents=True, exist_ok=True)
583 (refs_dir / "main").write_text("a" * 64)
584 (refs_dir / "feat" / "ui").write_text("b" * 64)
585 with patch("muse.cli.commands.remote._ping_url", return_value=(True, 200, "HTTP 200 OK")):
586 result = runner.invoke(cli, ["remote", "status", "origin", "--json"])
587 assert result.exit_code == 0
588 data = _json_status(result)
589 assert "main" in data["tracked_refs"]
590 assert "feat/ui" in data["tracked_refs"]
591
592 def test_status_text_mode_output_on_stderr(
593 self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]
594 ) -> None:
595 self._add_origin(repo)
596 with patch("muse.cli.commands.remote._ping_url", return_value=(True, 200, "HTTP 200 OK")):
597 result = runner.invoke(cli, ["remote", "status", "origin"])
598 assert result.exit_code == 0
599
600 def test_status_missing_remote_exits_nonzero(self, repo: pathlib.Path) -> None:
601 result = runner.invoke(cli, ["remote", "status", "ghost"])
602 assert result.exit_code != 0
603
604 def test_status_file_scheme_url_safely_rejected(self, repo: pathlib.Path) -> None:
605 """A remote whose stored URL is file:// must not result in a file read."""
606 from muse.cli.config import set_remote
607 set_remote("badremote", "file:///etc/passwd", repo)
608 with patch("muse.cli.commands.remote._ping_url", wraps=lambda u, t: (False, None, "scheme")) as p:
609 result = runner.invoke(cli, ["remote", "status", "badremote", "--json"])
610 # Should be unreachable, not crash
611 assert result.exit_code != 0
612
613
614 # ── Stress: concurrent remote adds ───────────────────────────────────────────
615
616 class TestStressConcurrent:
617 def test_8_concurrent_adds_to_isolated_repos(self, tmp_path: pathlib.Path) -> None:
618 """8 threads each adding remotes to their own isolated repo must not interfere."""
619 from muse._version import __version__
620 errors: list[str] = []
621
622 def _do(idx: int) -> None:
623 try:
624 repo_dir = tmp_path / f"repo{idx}"
625 dot_muse = muse_dir(repo_dir)
626 for sub in ("refs/heads", "objects", "commits", "snapshots"):
627 (dot_muse / sub).mkdir(parents=True, exist_ok=True)
628 (dot_muse / "repo.json").write_text(
629 json.dumps({"repo_id": f"repo{idx}", "schema_version": __version__, "domain": "code"})
630 )
631 (dot_muse / "HEAD").write_text("ref: refs/heads/main\n")
632 (dot_muse / "refs" / "heads" / "main").write_text("")
633 (dot_muse / "config.toml").write_text("")
634
635 from muse.cli.config import set_remote, get_remote
636 url = f"https://hub.muse.io/repo{idx}"
637 set_remote("origin", url, repo_dir)
638 result = get_remote("origin", repo_dir)
639 assert result == url, f"Got {result!r}, expected {url!r}"
640 except Exception as exc:
641 errors.append(f"Thread {idx}: {exc}")
642
643 threads = [threading.Thread(target=_do, args=(i,)) for i in range(8)]
644 for t in threads:
645 t.start()
646 for t in threads:
647 t.join()
648 assert errors == [], f"Concurrent remote add failures:\n{'\n'.join(errors)}"
649
650
651 # ── Extended: run_add ────────────────────────────────────────────────────────
652
653
654 class TestRemoteAddExtended:
655 """Extended hardening tests for ``muse remote add``."""
656
657 def test_j_alias_works(self, repo: pathlib.Path) -> None:
658 result = runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r", "-j"])
659 assert result.exit_code == 0
660 data = _json_mutation(result)
661 assert data["status"] == "ok"
662
663 def test_url_trailing_whitespace_stripped(self, repo: pathlib.Path) -> None:
664 result = runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r\n"])
665 assert result.exit_code == 0
666
667 def test_url_leading_whitespace_stripped(self, repo: pathlib.Path) -> None:
668 result = runner.invoke(cli, ["remote", "add", "origin", " https://hub.muse.io/r"])
669 assert result.exit_code == 0
670
671 def test_stripped_url_stored_without_whitespace(self, repo: pathlib.Path) -> None:
672 runner.invoke(cli, ["remote", "add", "origin", " https://hub.muse.io/r "])
673 from muse.cli.config import get_remote
674 stored = get_remote("origin", repo)
675 assert stored == "https://hub.muse.io/r"
676
677 def test_url_too_long_rejected(self, repo: pathlib.Path) -> None:
678 long_url = f"https://hub.muse.io/{'x' * 2048}"
679 result = runner.invoke(cli, ["remote", "add", "origin", long_url])
680 assert result.exit_code != 0
681
682 def test_url_too_long_error_mentions_limit(self, repo: pathlib.Path) -> None:
683 long_url = f"https://hub.muse.io/{'x' * 2048}"
684 result = runner.invoke(cli, ["remote", "add", "origin", long_url])
685 assert "2048" in result.stderr or "too long" in result.stderr
686
687 def test_name_too_long_rejected(self, repo: pathlib.Path) -> None:
688 long_name = "a" * 101
689 result = runner.invoke(cli, ["remote", "add", long_name, "https://hub.muse.io/r"])
690 assert result.exit_code != 0
691
692 def test_name_too_long_error_mentions_limit(self, repo: pathlib.Path) -> None:
693 long_name = "a" * 101
694 result = runner.invoke(cli, ["remote", "add", long_name, "https://hub.muse.io/r"])
695 assert "100" in result.stderr or "too long" in result.stderr
696
697 def test_name_exactly_max_length_accepted(self, repo: pathlib.Path) -> None:
698 name = "a" * 100
699 result = runner.invoke(cli, ["remote", "add", name, "https://hub.muse.io/r"])
700 assert result.exit_code == 0
701
702 def test_name_with_dash_accepted(self, repo: pathlib.Path) -> None:
703 result = runner.invoke(cli, ["remote", "add", "up-stream", "https://hub.muse.io/r"])
704 assert result.exit_code == 0
705
706 def test_name_with_underscore_accepted(self, repo: pathlib.Path) -> None:
707 result = runner.invoke(cli, ["remote", "add", "my_remote", "https://hub.muse.io/r"])
708 assert result.exit_code == 0
709
710 def test_name_with_dot_accepted(self, repo: pathlib.Path) -> None:
711 result = runner.invoke(cli, ["remote", "add", "upstream.mirror", "https://hub.muse.io/r"])
712 assert result.exit_code == 0
713
714 def test_digit_only_name_accepted(self, repo: pathlib.Path) -> None:
715 result = runner.invoke(cli, ["remote", "add", "123", "https://hub.muse.io/r"])
716 assert result.exit_code == 0
717
718 def test_http_url_accepted(self, repo: pathlib.Path) -> None:
719 result = runner.invoke(cli, ["remote", "add", "origin", "http://hub.muse.io/r"])
720 assert result.exit_code == 0
721
722 def test_https_url_accepted(self, repo: pathlib.Path) -> None:
723 result = runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
724 assert result.exit_code == 0
725
726 def test_data_scheme_rejected(self, repo: pathlib.Path) -> None:
727 result = runner.invoke(cli, ["remote", "add", "origin", "data:text/plain,hello"])
728 assert result.exit_code != 0
729
730 def test_javascript_scheme_rejected(self, repo: pathlib.Path) -> None:
731 result = runner.invoke(cli, ["remote", "add", "origin", "javascript:alert(1)"])
732 assert result.exit_code != 0
733
734 def test_after_add_get_remote_returns_url(self, repo: pathlib.Path) -> None:
735 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
736 from muse.cli.config import get_remote
737 assert get_remote("origin", repo) == "https://hub.muse.io/r"
738
739 def test_after_add_list_remotes_includes_entry(self, repo: pathlib.Path) -> None:
740 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
741 from muse.cli.config import list_remotes
742 names = [r["name"] for r in list_remotes(repo)]
743 assert "origin" in names
744
745 def test_json_old_name_is_null(self, repo: pathlib.Path) -> None:
746 result = runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r", "--json"])
747 data = _json_mutation(result)
748 assert data["old_name"] is None
749
750 def test_json_new_name_is_null(self, repo: pathlib.Path) -> None:
751 result = runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r", "--json"])
752 data = _json_mutation(result)
753 assert data["new_name"] is None
754
755 def test_text_success_to_output(self, repo: pathlib.Path) -> None:
756 result = runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
757 assert result.exit_code == 0
758 assert "origin" in result.stderr
759
760 def test_duplicate_error_hints_set_url(self, repo: pathlib.Path) -> None:
761 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
762 result = runner.invoke(cli, ["remote", "add", "origin", "https://other.com/r"])
763 assert result.exit_code != 0
764 assert "set-url" in result.stderr
765
766 def test_outside_repo_exits_nonzero(
767 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
768 ) -> None:
769 monkeypatch.chdir(tmp_path)
770 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
771 result = runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
772 assert result.exit_code != 0
773
774 def test_help_shows_name_rules(self, repo: pathlib.Path) -> None:
775 result = runner.invoke(cli, ["remote", "add", "--help"])
776 assert "alphanumeric" in result.output.lower() or "Alphanumeric" in result.output
777
778 def test_help_shows_url_rules(self, repo: pathlib.Path) -> None:
779 result = runner.invoke(cli, ["remote", "add", "--help"])
780 assert "http" in result.output and "https" in result.output
781
782 def test_help_shows_exit_codes(self, repo: pathlib.Path) -> None:
783 result = runner.invoke(cli, ["remote", "add", "--help"])
784 assert "Exit" in result.output or "exit" in result.output
785
786 def test_multiple_remotes_can_be_added(self, repo: pathlib.Path) -> None:
787 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
788 runner.invoke(cli, ["remote", "add", "upstream", "https://hub.muse.io/u"])
789 from muse.cli.config import list_remotes
790 names = {r["name"] for r in list_remotes(repo)}
791 assert {"origin", "upstream"}.issubset(names)
792
793
794 # ── Security: run_add ────────────────────────────────────────────────────────
795
796
797 class TestRemoteAddSecurity:
798 """Security-focused tests for ``muse remote add``."""
799
800 def test_ansi_in_name_sanitized_in_error(self, repo: pathlib.Path) -> None:
801 result = runner.invoke(
802 cli, ["remote", "add", "\x1b[31mmalicious\x1b[0m", "https://hub.muse.io/r"]
803 )
804 assert result.exit_code != 0
805 assert "\x1b[" not in result.output
806
807 def test_ansi_in_url_sanitized_in_error(self, repo: pathlib.Path) -> None:
808 result = runner.invoke(
809 cli, ["remote", "add", "origin", "file:///\x1b[31mmalicious\x1b[0m"]
810 )
811 assert result.exit_code != 0
812 assert "\x1b[" not in result.output
813
814 def test_null_byte_in_name_rejected(self, repo: pathlib.Path) -> None:
815 result = runner.invoke(
816 cli, ["remote", "add", "malicious\x00name", "https://hub.muse.io/r"]
817 )
818 assert result.exit_code != 0
819
820 def test_newline_in_name_rejected(self, repo: pathlib.Path) -> None:
821 result = runner.invoke(
822 cli, ["remote", "add", "malicious\nname", "https://hub.muse.io/r"]
823 )
824 assert result.exit_code != 0
825
826 def test_slash_in_name_blocked(self, repo: pathlib.Path) -> None:
827 result = runner.invoke(
828 cli, ["remote", "add", "org/repo", "https://hub.muse.io/r"]
829 )
830 assert result.exit_code != 0
831
832 def test_file_scheme_blocked(self, repo: pathlib.Path) -> None:
833 result = runner.invoke(
834 cli, ["remote", "add", "origin", "file:///etc/passwd"]
835 )
836 assert result.exit_code != 0
837
838 def test_file_scheme_not_stored(self, repo: pathlib.Path) -> None:
839 runner.invoke(cli, ["remote", "add", "origin", "file:///etc/passwd"])
840 from muse.cli.config import get_remote
841 assert get_remote("origin", repo) is None
842
843 def test_empty_name_rejected(self, repo: pathlib.Path) -> None:
844 # argparse will reject positional "" as missing, but guard in place
845 from muse.cli.commands.remote import _validate_remote_name
846 assert _validate_remote_name("") is not None
847
848 def test_space_in_name_rejected(self, repo: pathlib.Path) -> None:
849 result = runner.invoke(
850 cli, ["remote", "add", "my remote", "https://hub.muse.io/r"]
851 )
852 assert result.exit_code != 0
853
854
855 # ── Stress: run_add ──────────────────────────────────────────────────────────
856
857
858 class TestRemoteAddStress:
859 """Volume and concurrency tests for ``muse remote add``."""
860
861 def test_10_sequential_adds_different_names(self, repo: pathlib.Path) -> None:
862 """10 distinct remotes added sequentially must all be stored."""
863 from muse.cli.config import list_remotes
864 for i in range(10):
865 result = runner.invoke(
866 cli, ["remote", "add", f"remote{i}", f"https://hub.muse.io/r{i}"]
867 )
868 assert result.exit_code == 0, f"Failed on remote{i}: {result.output}"
869 names = {r["name"] for r in list_remotes(repo)}
870 for i in range(10):
871 assert f"remote{i}" in names
872
873 def test_concurrent_adds_to_separate_repos(
874 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
875 ) -> None:
876 """8 threads each writing to a private repo must not corrupt each other."""
877 from muse.cli.config import get_remote, set_remote
878 errors: list[str] = []
879
880 def _worker(idx: int) -> None:
881 try:
882 repo_dir = tmp_path / f"repo_{idx}"
883 dot_muse = muse_dir(repo_dir)
884 for sub in ("refs/heads", "objects", "commits", "snapshots", "remotes"):
885 (dot_muse / sub).mkdir(parents=True, exist_ok=True)
886 (dot_muse / "repo.json").write_text(
887 json.dumps({"repo_id": f"r{idx}", "schema_version": __version__, "domain": "code"})
888 )
889 (dot_muse / "HEAD").write_text("ref: refs/heads/main\n")
890 (dot_muse / "refs" / "heads" / "main").write_text("")
891 (dot_muse / "config.toml").write_text("")
892 expected = f"https://hub.muse.io/r{idx}"
893 set_remote("origin", expected, repo_dir)
894 got = get_remote("origin", repo_dir)
895 if got != expected:
896 errors.append(f"repo_{idx}: expected {expected!r}, got {got!r}")
897 except Exception as exc:
898 errors.append(f"Thread {idx}: {exc}")
899
900 import threading
901 threads = [threading.Thread(target=_worker, args=(i,)) for i in range(8)]
902 for t in threads:
903 t.start()
904 for t in threads:
905 t.join()
906 assert errors == [], "\n".join(errors)
907
908 def test_url_exactly_max_length_accepted(self, repo: pathlib.Path) -> None:
909 """URL of exactly 2048 chars must be accepted."""
910 # 20 chars of prefix + 2028 chars of path = 2048 total
911 url = f"https://hub.muse.io/{'x' * 2028}"
912 result = runner.invoke(cli, ["remote", "add", "origin", url])
913 assert result.exit_code == 0
914
915 def test_name_length_boundary(self, repo: pathlib.Path) -> None:
916 """Names at exactly 100 chars pass; 101 fails."""
917 from muse.cli.commands.remote import _validate_remote_name
918 assert _validate_remote_name("a" * 100) is None
919 assert _validate_remote_name("a" * 101) is not None
920
921
922 # ── Extended: run_remove ─────────────────────────────────────────────────────
923
924
925 class TestRemoteRemoveExtended:
926 """Extended hardening tests for ``muse remote remove``."""
927
928 def test_j_alias_works(self, repo: pathlib.Path) -> None:
929 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
930 result = runner.invoke(cli, ["remote", "remove", "origin", "-j"])
931 assert result.exit_code == 0
932 data = _json_mutation(result)
933 assert data["status"] == "ok"
934
935 def test_json_includes_removed_url(self, repo: pathlib.Path) -> None:
936 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
937 result = runner.invoke(cli, ["remote", "remove", "origin", "--json"])
938 data = _json_mutation(result)
939 assert data["url"] == "https://hub.muse.io/r"
940
941 def test_json_old_name_is_null(self, repo: pathlib.Path) -> None:
942 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
943 result = runner.invoke(cli, ["remote", "remove", "origin", "--json"])
944 data = _json_mutation(result)
945 assert data["old_name"] is None
946
947 def test_json_new_name_is_null(self, repo: pathlib.Path) -> None:
948 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
949 result = runner.invoke(cli, ["remote", "remove", "origin", "--json"])
950 data = _json_mutation(result)
951 assert data["new_name"] is None
952
953 def test_text_success_mentions_name(self, repo: pathlib.Path) -> None:
954 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
955 result = runner.invoke(cli, ["remote", "remove", "origin"])
956 assert result.exit_code == 0
957 assert "origin" in result.stderr
958
959 def test_after_remove_get_remote_returns_none(self, repo: pathlib.Path) -> None:
960 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
961 runner.invoke(cli, ["remote", "remove", "origin"])
962 assert get_remote("origin", repo) is None
963
964 def test_after_remove_list_remotes_excludes_name(self, repo: pathlib.Path) -> None:
965 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
966 runner.invoke(cli, ["remote", "remove", "origin"])
967 names = [r["name"] for r in list_remotes(repo)]
968 assert "origin" not in names
969
970 def test_tracking_refs_dir_deleted(self, repo: pathlib.Path) -> None:
971 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
972 refs_dir = remotes_dir(repo) / "origin"
973 refs_dir.mkdir(parents=True, exist_ok=True)
974 (refs_dir / "main").write_text("a" * 64)
975 runner.invoke(cli, ["remote", "remove", "origin"])
976 assert not refs_dir.exists()
977
978 def test_nested_tracking_refs_deleted(self, repo: pathlib.Path) -> None:
979 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
980 refs_dir = remotes_dir(repo) / "origin"
981 (refs_dir / "feat").mkdir(parents=True, exist_ok=True)
982 (refs_dir / "main").write_text("a" * 64)
983 (refs_dir / "feat" / "ui").write_text("b" * 64)
984 runner.invoke(cli, ["remote", "remove", "origin"])
985 assert not refs_dir.exists()
986
987 def test_no_refs_dir_still_succeeds(self, repo: pathlib.Path) -> None:
988 """Remove must succeed even when .muse/remotes/<name>/ does not exist."""
989 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
990 # Don't create refs dir — it may not exist on a fresh add
991 result = runner.invoke(cli, ["remote", "remove", "origin"])
992 assert result.exit_code == 0
993
994 def test_multiple_remotes_only_target_removed(self, repo: pathlib.Path) -> None:
995 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
996 runner.invoke(cli, ["remote", "add", "upstream", "https://hub.muse.io/u"])
997 runner.invoke(cli, ["remote", "remove", "origin"])
998 names = [r["name"] for r in list_remotes(repo)]
999 assert "origin" not in names
1000 assert "upstream" in names
1001
1002 def test_invalid_name_rejected_before_repo_lookup(
1003 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
1004 ) -> None:
1005 """Name validation must fire before require_repo() is called."""
1006 monkeypatch.chdir(tmp_path)
1007 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
1008 # Even outside a repo, invalid name should get a format error
1009 result = runner.invoke(cli, ["remote", "remove", "bad name"])
1010 assert result.exit_code != 0
1011
1012 def test_nonexistent_remote_exits_nonzero(self, repo: pathlib.Path) -> None:
1013 result = runner.invoke(cli, ["remote", "remove", "ghost"])
1014 assert result.exit_code != 0
1015
1016 def test_nonexistent_error_mentions_name(self, repo: pathlib.Path) -> None:
1017 result = runner.invoke(cli, ["remote", "remove", "ghost"])
1018 assert "ghost" in result.stderr
1019
1020 def test_outside_repo_exits_nonzero(
1021 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
1022 ) -> None:
1023 monkeypatch.chdir(tmp_path)
1024 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
1025 result = runner.invoke(cli, ["remote", "remove", "origin"])
1026 assert result.exit_code != 0
1027
1028 def test_help_mentions_tracking_refs(self, repo: pathlib.Path) -> None:
1029 result = runner.invoke(cli, ["remote", "remove", "--help"])
1030 assert "tracking" in result.output.lower() or "remotes" in result.output.lower()
1031
1032 def test_help_mentions_exit_codes(self, repo: pathlib.Path) -> None:
1033 result = runner.invoke(cli, ["remote", "remove", "--help"])
1034 assert "Exit" in result.output or "exit" in result.output
1035
1036 def test_help_shows_json_url_note(self, repo: pathlib.Path) -> None:
1037 result = runner.invoke(cli, ["remote", "remove", "--help"])
1038 assert "url" in result.output.lower()
1039
1040
1041 # ── Security: run_remove ─────────────────────────────────────────────────────
1042
1043
1044 class TestRemoteRemoveSecurity:
1045 """Security-focused tests for ``muse remote remove``."""
1046
1047 def test_ansi_in_name_sanitized_in_error(self, repo: pathlib.Path) -> None:
1048 result = runner.invoke(cli, ["remote", "remove", "\x1b[31mmalicious\x1b[0m"])
1049 assert result.exit_code != 0
1050 assert "\x1b[" not in result.output
1051
1052 def test_newline_in_name_rejected(self, repo: pathlib.Path) -> None:
1053 result = runner.invoke(cli, ["remote", "remove", "malicious\nname"])
1054 assert result.exit_code != 0
1055
1056 def test_null_byte_in_name_rejected(self, repo: pathlib.Path) -> None:
1057 result = runner.invoke(cli, ["remote", "remove", "malicious\x00name"])
1058 assert result.exit_code != 0
1059
1060 def test_slash_in_name_rejected(self, repo: pathlib.Path) -> None:
1061 """Path traversal via slash in name must be blocked."""
1062 result = runner.invoke(cli, ["remote", "remove", "../secret"])
1063 assert result.exit_code != 0
1064
1065 def test_symlink_refs_dir_not_followed(self, repo: pathlib.Path) -> None:
1066 """If .muse/remotes/<name> is a symlink, rmtree must not follow it."""
1067 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1068 # Create a canary directory outside the repo
1069 canary_dir = repo.parent / "canary"
1070 canary_dir.mkdir()
1071 (canary_dir / "secret.txt").write_text("should not be deleted")
1072 # Symlink .muse/remotes/origin → canary_dir
1073 refs_dir = remotes_dir(repo) / "origin"
1074 if refs_dir.exists():
1075 import shutil as _shutil
1076 _shutil.rmtree(refs_dir)
1077 refs_dir.symlink_to(canary_dir)
1078 runner.invoke(cli, ["remote", "remove", "origin"])
1079 # The canary must still exist — rmtree was skipped
1080 assert (canary_dir / "secret.txt").exists()
1081
1082 def test_double_remove_fails_gracefully(self, repo: pathlib.Path) -> None:
1083 """Removing the same remote twice must error cleanly on second attempt."""
1084 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1085 runner.invoke(cli, ["remote", "remove", "origin"])
1086 result = runner.invoke(cli, ["remote", "remove", "origin"])
1087 assert result.exit_code != 0
1088
1089
1090 # ── Stress: run_remove ───────────────────────────────────────────────────────
1091
1092
1093 class TestRemoteRemoveStress:
1094 """Volume and concurrency tests for ``muse remote remove``."""
1095
1096 def test_10_add_remove_cycles(self, repo: pathlib.Path) -> None:
1097 """Add and remove the same remote 10 times — state must be clean."""
1098 for i in range(10):
1099 r = runner.invoke(cli, ["remote", "add", "origin", f"https://hub.muse.io/r{i}"])
1100 assert r.exit_code == 0, f"Add failed on cycle {i}: {r.output}"
1101 r = runner.invoke(cli, ["remote", "remove", "origin"])
1102 assert r.exit_code == 0, f"Remove failed on cycle {i}: {r.output}"
1103 assert get_remote("origin", repo) is None
1104
1105 def test_remove_all_of_10_remotes(self, repo: pathlib.Path) -> None:
1106 """Add 10 distinct remotes then remove each — list must end empty."""
1107 for i in range(10):
1108 runner.invoke(cli, ["remote", "add", f"r{i}", f"https://hub.muse.io/r{i}"])
1109 for i in range(10):
1110 result = runner.invoke(cli, ["remote", "remove", f"r{i}"])
1111 assert result.exit_code == 0, f"Remove r{i} failed: {result.output}"
1112 assert list_remotes(repo) == []
1113
1114 def test_concurrent_removes_from_separate_repos(
1115 self, tmp_path: pathlib.Path
1116 ) -> None:
1117 """8 threads each removing a remote from their own repo must not interfere."""
1118 from muse.cli.config import set_remote, get_remote as _get
1119 import threading
1120
1121 errors: list[str] = []
1122
1123 def _worker(idx: int) -> None:
1124 try:
1125 repo_dir = tmp_path / f"repo_{idx}"
1126 dot_muse = muse_dir(repo_dir)
1127 for sub in ("refs/heads", "objects", "commits", "snapshots", "remotes"):
1128 (dot_muse / sub).mkdir(parents=True, exist_ok=True)
1129 (dot_muse / "repo.json").write_text(
1130 json.dumps({"repo_id": f"r{idx}", "schema_version": __version__, "domain": "code"})
1131 )
1132 (dot_muse / "HEAD").write_text("ref: refs/heads/main\n")
1133 (dot_muse / "refs" / "heads" / "main").write_text("")
1134 (dot_muse / "config.toml").write_text("")
1135 set_remote("origin", f"https://hub.muse.io/r{idx}", repo_dir)
1136 from muse.cli.config import remove_remote as _rm
1137 _rm("origin", repo_dir)
1138 if _get("origin", repo_dir) is not None:
1139 errors.append(f"repo_{idx}: remote still present after remove")
1140 except Exception as exc:
1141 errors.append(f"Thread {idx}: {exc}")
1142
1143 threads = [threading.Thread(target=_worker, args=(i,)) for i in range(8)]
1144 for t in threads:
1145 t.start()
1146 for t in threads:
1147 t.join()
1148 assert errors == [], "\n".join(errors)
1149
1150
1151 # ── Extended: run_rename ─────────────────────────────────────────────────────
1152
1153
1154 class TestRemoteRenameExtended:
1155 """Extended hardening tests for ``muse remote rename``."""
1156
1157 def test_j_alias_works(self, repo: pathlib.Path) -> None:
1158 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1159 result = runner.invoke(cli, ["remote", "rename", "origin", "upstream", "-j"])
1160 assert result.exit_code == 0
1161 data = _json_mutation(result)
1162 assert data["status"] == "ok"
1163
1164 def test_json_includes_url(self, repo: pathlib.Path) -> None:
1165 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1166 result = runner.invoke(cli, ["remote", "rename", "origin", "upstream", "--json"])
1167 data = _json_mutation(result)
1168 assert data["url"] == "https://hub.muse.io/r"
1169
1170 def test_json_old_name_field(self, repo: pathlib.Path) -> None:
1171 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1172 result = runner.invoke(cli, ["remote", "rename", "origin", "upstream", "--json"])
1173 data = _json_mutation(result)
1174 assert data["old_name"] == "origin"
1175
1176 def test_json_new_name_field(self, repo: pathlib.Path) -> None:
1177 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1178 result = runner.invoke(cli, ["remote", "rename", "origin", "upstream", "--json"])
1179 data = _json_mutation(result)
1180 assert data["new_name"] == "upstream"
1181
1182 def test_json_name_is_new_name(self, repo: pathlib.Path) -> None:
1183 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1184 result = runner.invoke(cli, ["remote", "rename", "origin", "upstream", "--json"])
1185 data = _json_mutation(result)
1186 assert data["name"] == "upstream"
1187
1188 def test_text_success_mentions_both_names(self, repo: pathlib.Path) -> None:
1189 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1190 result = runner.invoke(cli, ["remote", "rename", "origin", "upstream"])
1191 assert result.exit_code == 0
1192 assert "origin" in result.stderr
1193 assert "upstream" in result.stderr
1194
1195 def test_old_name_no_longer_exists_after_rename(self, repo: pathlib.Path) -> None:
1196 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1197 runner.invoke(cli, ["remote", "rename", "origin", "upstream"])
1198 assert get_remote("origin", repo) is None
1199
1200 def test_new_name_has_correct_url_after_rename(self, repo: pathlib.Path) -> None:
1201 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1202 runner.invoke(cli, ["remote", "rename", "origin", "upstream"])
1203 assert get_remote("upstream", repo) == "https://hub.muse.io/r"
1204
1205 def test_tracking_refs_dir_moved(self, repo: pathlib.Path) -> None:
1206 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1207 old_refs = remotes_dir(repo) / "origin"
1208 old_refs.mkdir(parents=True, exist_ok=True)
1209 (old_refs / "main").write_text("a" * 64)
1210 runner.invoke(cli, ["remote", "rename", "origin", "upstream"])
1211 new_refs = remotes_dir(repo) / "upstream"
1212 assert not old_refs.exists()
1213 assert (new_refs / "main").exists()
1214
1215 def test_no_tracking_refs_dir_still_succeeds(self, repo: pathlib.Path) -> None:
1216 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1217 result = runner.invoke(cli, ["remote", "rename", "origin", "upstream"])
1218 assert result.exit_code == 0
1219
1220 def test_only_target_remote_renamed(self, repo: pathlib.Path) -> None:
1221 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1222 runner.invoke(cli, ["remote", "add", "mirror", "https://hub.muse.io/m"])
1223 runner.invoke(cli, ["remote", "rename", "origin", "upstream"])
1224 assert get_remote("mirror", repo) == "https://hub.muse.io/m"
1225
1226 def test_invalid_old_name_rejected(self, repo: pathlib.Path) -> None:
1227 result = runner.invoke(cli, ["remote", "rename", "bad name", "upstream"])
1228 assert result.exit_code != 0
1229
1230 def test_invalid_old_name_format_error(self, repo: pathlib.Path) -> None:
1231 result = runner.invoke(cli, ["remote", "rename", "bad name", "upstream"])
1232 assert "invalid" in result.stderr.lower()
1233
1234 def test_invalid_new_name_rejected(self, repo: pathlib.Path) -> None:
1235 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1236 result = runner.invoke(cli, ["remote", "rename", "origin", "bad name"])
1237 assert result.exit_code != 0
1238
1239 def test_nonexistent_old_name_exits_nonzero(self, repo: pathlib.Path) -> None:
1240 result = runner.invoke(cli, ["remote", "rename", "ghost", "upstream"])
1241 assert result.exit_code != 0
1242
1243 def test_duplicate_new_name_exits_nonzero(self, repo: pathlib.Path) -> None:
1244 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1245 runner.invoke(cli, ["remote", "add", "upstream", "https://hub.muse.io/u"])
1246 result = runner.invoke(cli, ["remote", "rename", "origin", "upstream"])
1247 assert result.exit_code != 0
1248
1249 def test_same_name_rename_exits_nonzero(self, repo: pathlib.Path) -> None:
1250 """Renaming origin → origin must fail (new_name already exists)."""
1251 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1252 result = runner.invoke(cli, ["remote", "rename", "origin", "origin"])
1253 assert result.exit_code != 0
1254
1255 def test_outside_repo_exits_nonzero(
1256 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
1257 ) -> None:
1258 monkeypatch.chdir(tmp_path)
1259 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
1260 result = runner.invoke(cli, ["remote", "rename", "origin", "upstream"])
1261 assert result.exit_code != 0
1262
1263 def test_help_mentions_tracking_refs(self, repo: pathlib.Path) -> None:
1264 result = runner.invoke(cli, ["remote", "rename", "--help"])
1265 assert "tracking" in result.output.lower()
1266
1267 def test_help_mentions_exit_codes(self, repo: pathlib.Path) -> None:
1268 result = runner.invoke(cli, ["remote", "rename", "--help"])
1269 assert "Exit" in result.output or "exit" in result.output
1270
1271 def test_help_shows_url_in_json_response(self, repo: pathlib.Path) -> None:
1272 result = runner.invoke(cli, ["remote", "rename", "--help"])
1273 assert "url" in result.output.lower()
1274
1275
1276 # ── Security: run_rename ─────────────────────────────────────────────────────
1277
1278
1279 class TestRemoteRenameSecurity:
1280 """Security-focused tests for ``muse remote rename``."""
1281
1282 def test_ansi_in_old_name_sanitized(self, repo: pathlib.Path) -> None:
1283 result = runner.invoke(cli, ["remote", "rename", "\x1b[31mmalicious\x1b[0m", "safe"])
1284 assert result.exit_code != 0
1285 assert "\x1b[" not in result.output
1286
1287 def test_ansi_in_new_name_rejected(self, repo: pathlib.Path) -> None:
1288 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1289 result = runner.invoke(cli, ["remote", "rename", "origin", "\x1b[31mmalicious\x1b[0m"])
1290 assert result.exit_code != 0
1291
1292 def test_slash_in_old_name_rejected(self, repo: pathlib.Path) -> None:
1293 result = runner.invoke(cli, ["remote", "rename", "../secret", "safe"])
1294 assert result.exit_code != 0
1295
1296 def test_slash_in_new_name_rejected(self, repo: pathlib.Path) -> None:
1297 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1298 result = runner.invoke(cli, ["remote", "rename", "origin", "../traversal"])
1299 assert result.exit_code != 0
1300
1301 def test_null_byte_in_old_name_rejected(self, repo: pathlib.Path) -> None:
1302 result = runner.invoke(cli, ["remote", "rename", "malicious\x00name", "safe"])
1303 assert result.exit_code != 0
1304
1305 def test_null_byte_in_new_name_rejected(self, repo: pathlib.Path) -> None:
1306 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1307 result = runner.invoke(cli, ["remote", "rename", "origin", "malicious\x00name"])
1308 assert result.exit_code != 0
1309
1310 def test_old_name_validated_before_repo_lookup(
1311 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
1312 ) -> None:
1313 """Invalid old_name must fail with format error even outside a repo."""
1314 monkeypatch.chdir(tmp_path)
1315 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
1316 result = runner.invoke(cli, ["remote", "rename", "bad name", "safe"])
1317 assert result.exit_code != 0
1318
1319
1320 # ── Stress: run_rename ───────────────────────────────────────────────────────
1321
1322
1323 class TestRemoteRenameStress:
1324 """Volume and concurrency tests for ``muse remote rename``."""
1325
1326 def test_chain_of_renames(self, repo: pathlib.Path) -> None:
1327 """Chain: origin → a → b → c — final URL must be preserved."""
1328 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1329 for old, new in [("origin", "a"), ("a", "b"), ("b", "c")]:
1330 result = runner.invoke(cli, ["remote", "rename", old, new])
1331 assert result.exit_code == 0, f"Rename {old}→{new} failed: {result.output}"
1332 assert get_remote("c", repo) == "https://hub.muse.io/r"
1333 assert get_remote("origin", repo) is None
1334
1335 def test_10_sequential_renames_of_distinct_remotes(self, repo: pathlib.Path) -> None:
1336 """Rename remote0→r0, remote1→r1, ... — all new names must resolve correctly."""
1337 for i in range(10):
1338 runner.invoke(cli, ["remote", "add", f"remote{i}", f"https://hub.muse.io/r{i}"])
1339 for i in range(10):
1340 result = runner.invoke(cli, ["remote", "rename", f"remote{i}", f"r{i}"])
1341 assert result.exit_code == 0
1342 for i in range(10):
1343 assert get_remote(f"r{i}", repo) == f"https://hub.muse.io/r{i}"
1344 assert get_remote(f"remote{i}", repo) is None
1345
1346 def test_concurrent_renames_separate_repos(
1347 self, tmp_path: pathlib.Path
1348 ) -> None:
1349 """8 threads each renaming a remote in their own repo must not interfere."""
1350 from muse.cli.config import set_remote, get_remote as _get
1351 import threading
1352
1353 errors: list[str] = []
1354
1355 def _worker(idx: int) -> None:
1356 try:
1357 repo_dir = tmp_path / f"repo_{idx}"
1358 dot_muse = muse_dir(repo_dir)
1359 for sub in ("refs/heads", "objects", "commits", "snapshots", "remotes"):
1360 (dot_muse / sub).mkdir(parents=True, exist_ok=True)
1361 (dot_muse / "repo.json").write_text(
1362 json.dumps({"repo_id": f"r{idx}", "schema_version": __version__, "domain": "code"})
1363 )
1364 (dot_muse / "HEAD").write_text("ref: refs/heads/main\n")
1365 (dot_muse / "refs" / "heads" / "main").write_text("")
1366 (dot_muse / "config.toml").write_text("")
1367 url = f"https://hub.muse.io/r{idx}"
1368 set_remote("origin", url, repo_dir)
1369 from muse.cli.config import rename_remote as _rename
1370 _rename("origin", "upstream", repo_dir)
1371 if _get("upstream", repo_dir) != url:
1372 errors.append(f"repo_{idx}: upstream URL mismatch")
1373 if _get("origin", repo_dir) is not None:
1374 errors.append(f"repo_{idx}: origin still present after rename")
1375 except Exception as exc:
1376 errors.append(f"Thread {idx}: {exc}")
1377
1378 threads = [threading.Thread(target=_worker, args=(i,)) for i in range(8)]
1379 for t in threads:
1380 t.start()
1381 for t in threads:
1382 t.join()
1383 assert errors == [], "\n".join(errors)
1384
1385
1386 # ── Extended: run_get_url ────────────────────────────────────────────────────
1387
1388
1389 class TestRemoteGetUrlExtended:
1390 """Extended hardening tests for ``muse remote get-url``."""
1391
1392 def test_j_alias_works(self, repo: pathlib.Path) -> None:
1393 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1394 result = runner.invoke(cli, ["remote", "get-url", "origin", "-j"])
1395 assert result.exit_code == 0
1396 data = _json_get_url(result)
1397 assert data["name"] == "origin"
1398 assert data["url"] == "https://hub.muse.io/r"
1399
1400 def test_json_name_field(self, repo: pathlib.Path) -> None:
1401 runner.invoke(cli, ["remote", "add", "upstream", "https://hub.muse.io/u"])
1402 result = runner.invoke(cli, ["remote", "get-url", "upstream", "--json"])
1403 data = _json_get_url(result)
1404 assert data["name"] == "upstream"
1405
1406 def test_json_url_field(self, repo: pathlib.Path) -> None:
1407 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1408 result = runner.invoke(cli, ["remote", "get-url", "origin", "--json"])
1409 data = _json_get_url(result)
1410 assert data["url"] == "https://hub.muse.io/r"
1411
1412 def test_text_mode_bare_url_on_stdout(self, repo: pathlib.Path) -> None:
1413 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1414 result = runner.invoke(cli, ["remote", "get-url", "origin"])
1415 assert result.exit_code == 0
1416 assert "https://hub.muse.io/r" in result.output
1417
1418 def test_text_mode_url_matches_added_url(self, repo: pathlib.Path) -> None:
1419 url = "https://hub.muse.io/gabriel/repo"
1420 runner.invoke(cli, ["remote", "add", "origin", url])
1421 result = runner.invoke(cli, ["remote", "get-url", "origin"])
1422 assert result.output.strip() == url
1423
1424 def test_url_with_port_preserved(self, repo: pathlib.Path) -> None:
1425 runner.invoke(cli, ["remote", "add", "local", "https://localhost:1337/r"])
1426 result = runner.invoke(cli, ["remote", "get-url", "local"])
1427 assert "localhost:1337" in result.output
1428
1429 def test_url_with_path_segments_preserved(self, repo: pathlib.Path) -> None:
1430 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/gabriel/my-repo"])
1431 result = runner.invoke(cli, ["remote", "get-url", "origin"])
1432 assert result.output.strip() == "https://hub.muse.io/gabriel/my-repo"
1433
1434 def test_invalid_name_rejected_before_repo_lookup(
1435 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
1436 ) -> None:
1437 monkeypatch.chdir(tmp_path)
1438 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
1439 result = runner.invoke(cli, ["remote", "get-url", "bad name"])
1440 assert result.exit_code != 0
1441
1442 def test_invalid_name_gives_format_error(self, repo: pathlib.Path) -> None:
1443 result = runner.invoke(cli, ["remote", "get-url", "bad name"])
1444 assert result.exit_code != 0
1445 assert "invalid" in result.stderr.lower()
1446
1447 def test_nonexistent_remote_exits_nonzero(self, repo: pathlib.Path) -> None:
1448 result = runner.invoke(cli, ["remote", "get-url", "ghost"])
1449 assert result.exit_code != 0
1450
1451 def test_nonexistent_error_mentions_name(self, repo: pathlib.Path) -> None:
1452 result = runner.invoke(cli, ["remote", "get-url", "ghost"])
1453 assert "ghost" in result.stderr
1454
1455 def test_outside_repo_exits_nonzero(
1456 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
1457 ) -> None:
1458 monkeypatch.chdir(tmp_path)
1459 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
1460 result = runner.invoke(cli, ["remote", "get-url", "origin"])
1461 assert result.exit_code != 0
1462
1463 def test_multiple_remotes_correct_url_returned(self, repo: pathlib.Path) -> None:
1464 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r1"])
1465 runner.invoke(cli, ["remote", "add", "upstream", "https://hub.muse.io/r2"])
1466 result = runner.invoke(cli, ["remote", "get-url", "upstream"])
1467 assert result.output.strip() == "https://hub.muse.io/r2"
1468
1469 def test_help_mentions_shell_composition(self, repo: pathlib.Path) -> None:
1470 result = runner.invoke(cli, ["remote", "get-url", "--help"])
1471 assert "shell" in result.output.lower() or "$(muse" in result.output
1472
1473 def test_help_mentions_exit_codes(self, repo: pathlib.Path) -> None:
1474 result = runner.invoke(cli, ["remote", "get-url", "--help"])
1475 assert "Exit" in result.output or "exit" in result.output
1476
1477 def test_help_shows_json_schema(self, repo: pathlib.Path) -> None:
1478 result = runner.invoke(cli, ["remote", "get-url", "--help"])
1479 assert '"url"' in result.output or "url" in result.output
1480
1481
1482 # ── Security: run_get_url ────────────────────────────────────────────────────
1483
1484
1485 class TestRemoteGetUrlSecurity:
1486 """Security-focused tests for ``muse remote get-url``."""
1487
1488 def test_ansi_in_name_sanitized_in_error(self, repo: pathlib.Path) -> None:
1489 result = runner.invoke(cli, ["remote", "get-url", "\x1b[31mmalicious\x1b[0m"])
1490 assert result.exit_code != 0
1491 assert "\x1b[" not in result.output
1492
1493 def test_slash_in_name_rejected(self, repo: pathlib.Path) -> None:
1494 result = runner.invoke(cli, ["remote", "get-url", "../secret"])
1495 assert result.exit_code != 0
1496
1497 def test_null_byte_in_name_rejected(self, repo: pathlib.Path) -> None:
1498 result = runner.invoke(cli, ["remote", "get-url", "malicious\x00name"])
1499 assert result.exit_code != 0
1500
1501 def test_ansi_in_stored_url_sanitized_in_text_output(
1502 self, repo: pathlib.Path
1503 ) -> None:
1504 """URL containing ANSI escapes (via TOML \u001b encoding) must not reach the terminal raw."""
1505 # Raw ESC bytes are illegal in TOML; use the TOML unicode escape \u001b instead.
1506 config_toml = config_toml_path(repo)
1507 config_toml.write_text(
1508 '[remotes.origin]\nurl = "https://hub.muse.io/\\u001b[31mmalicious\\u001b[0m"\n'
1509 )
1510 result = runner.invoke(cli, ["remote", "get-url", "origin"])
1511 assert result.exit_code == 0
1512 assert "\x1b[" not in result.output
1513
1514 def test_ansi_in_stored_url_no_raw_ansi_in_json(self, repo: pathlib.Path) -> None:
1515 """In JSON mode, ANSI-containing URLs must be JSON-encoded, not emitted raw."""
1516 config_toml = config_toml_path(repo)
1517 config_toml.write_text(
1518 '[remotes.origin]\nurl = "https://hub.muse.io/\\u001b[31mmalicious\\u001b[0m"\n'
1519 )
1520 result = runner.invoke(cli, ["remote", "get-url", "origin", "--json"])
1521 assert result.exit_code == 0
1522 data = _json_get_url(result)
1523 assert "hub.muse.io" in data["url"]
1524 # Raw ANSI must never appear on the wire — JSON string encoding handles it.
1525 assert "\x1b[" not in result.output
1526
1527 def test_name_with_newline_rejected(self, repo: pathlib.Path) -> None:
1528 result = runner.invoke(cli, ["remote", "get-url", "malicious\nname"])
1529 assert result.exit_code != 0
1530
1531
1532 # ── Stress: run_get_url ──────────────────────────────────────────────────────
1533
1534
1535 class TestRemoteGetUrlStress:
1536 """Volume and concurrency tests for ``muse remote get-url``."""
1537
1538 def test_100_sequential_get_url_calls(self, repo: pathlib.Path) -> None:
1539 """Reading the same URL 100 times must always return the same value."""
1540 from muse.cli.config import get_remote
1541 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1542 for _ in range(100):
1543 result = get_remote("origin", repo)
1544 assert result == "https://hub.muse.io/r"
1545
1546 def test_10_remotes_each_returns_correct_url(self, repo: pathlib.Path) -> None:
1547 """10 remotes added; each get-url must return its own URL."""
1548 for i in range(10):
1549 runner.invoke(cli, ["remote", "add", f"r{i}", f"https://hub.muse.io/r{i}"])
1550 for i in range(10):
1551 result = runner.invoke(cli, ["remote", "get-url", f"r{i}"])
1552 assert result.exit_code == 0
1553 assert result.output.strip() == f"https://hub.muse.io/r{i}"
1554
1555 def test_concurrent_get_url_same_repo(self, repo: pathlib.Path) -> None:
1556 """8 threads reading the same remote URL concurrently must all agree."""
1557 from muse.cli.config import get_remote
1558 import threading
1559 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1560 results: list[str | None] = []
1561 errors: list[str] = []
1562 lock = threading.Lock()
1563
1564 def _read() -> None:
1565 try:
1566 val = get_remote("origin", repo)
1567 with lock:
1568 results.append(val)
1569 except Exception as exc:
1570 with lock:
1571 errors.append(str(exc))
1572
1573 threads = [threading.Thread(target=_read) for _ in range(8)]
1574 for t in threads:
1575 t.start()
1576 for t in threads:
1577 t.join()
1578 assert errors == [], "\n".join(errors)
1579 assert all(r == "https://hub.muse.io/r" for r in results)
1580
1581
1582 # ── set-url extended ──────────────────────────────────────────────────────────
1583
1584 class TestRemoteSetUrlExtended:
1585 """Extended integration tests for ``muse remote set-url``."""
1586
1587 def test_set_url_basic(self, repo: pathlib.Path) -> None:
1588 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/old"])
1589 result = runner.invoke(cli, ["remote", "set-url", "origin", "https://hub.muse.io/new"])
1590 assert result.exit_code == 0
1591 assert get_remote("origin", repo) == "https://hub.muse.io/new"
1592
1593 def test_set_url_persists(self, repo: pathlib.Path) -> None:
1594 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/old"])
1595 runner.invoke(cli, ["remote", "set-url", "origin", "https://hub.muse.io/new"])
1596 assert get_remote("origin", repo) == "https://hub.muse.io/new"
1597
1598 def test_set_url_json_includes_old_url(self, repo: pathlib.Path) -> None:
1599 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/old"])
1600 result = runner.invoke(
1601 cli, ["remote", "set-url", "origin", "https://hub.muse.io/new", "--json"]
1602 )
1603 assert result.exit_code == 0
1604 data = _json_mutation(result)
1605 assert data["old_url"] == "https://hub.muse.io/old"
1606
1607 def test_set_url_json_includes_new_url(self, repo: pathlib.Path) -> None:
1608 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/old"])
1609 result = runner.invoke(
1610 cli, ["remote", "set-url", "origin", "https://hub.muse.io/new", "--json"]
1611 )
1612 data = _json_mutation(result)
1613 assert data["url"] == "https://hub.muse.io/new"
1614
1615 def test_set_url_json_status_ok(self, repo: pathlib.Path) -> None:
1616 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1617 result = runner.invoke(
1618 cli, ["remote", "set-url", "origin", "https://hub.muse.io/r2", "--json"]
1619 )
1620 data = _json_mutation(result)
1621 assert data["status"] == "ok"
1622
1623 def test_set_url_json_short_flag(self, repo: pathlib.Path) -> None:
1624 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1625 result = runner.invoke(cli, ["remote", "set-url", "origin", "https://hub.muse.io/r2", "-j"])
1626 assert result.exit_code == 0
1627 data = _json_mutation(result)
1628 assert data["status"] == "ok"
1629
1630 def test_set_url_json_old_name_null(self, repo: pathlib.Path) -> None:
1631 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1632 result = runner.invoke(
1633 cli, ["remote", "set-url", "origin", "https://hub.muse.io/r2", "--json"]
1634 )
1635 data = _json_mutation(result)
1636 assert data["old_name"] is None
1637 assert data["new_name"] is None
1638
1639 def test_set_url_missing_remote_exits_nonzero(self, repo: pathlib.Path) -> None:
1640 result = runner.invoke(cli, ["remote", "set-url", "origin", "https://hub.muse.io/r"])
1641 assert result.exit_code != 0
1642
1643 def test_set_url_missing_remote_shows_hint(self, repo: pathlib.Path) -> None:
1644 result = runner.invoke(cli, ["remote", "set-url", "ghost", "https://hub.muse.io/r"])
1645 assert result.exit_code != 0
1646 assert "muse remote add" in result.stderr
1647
1648 def test_set_url_invalid_name_rejected_before_repo_check(
1649 self, repo: pathlib.Path
1650 ) -> None:
1651 result = runner.invoke(cli, ["remote", "set-url", "bad name", "https://hub.muse.io/r"])
1652 assert result.exit_code != 0
1653 assert "Invalid remote name" in result.stderr
1654
1655 def test_set_url_empty_name_rejected(self, repo: pathlib.Path) -> None:
1656 result = runner.invoke(cli, ["remote", "set-url", "", "https://hub.muse.io/r"])
1657 assert result.exit_code != 0
1658
1659 def test_set_url_name_with_slash_rejected(self, repo: pathlib.Path) -> None:
1660 result = runner.invoke(cli, ["remote", "set-url", "or/igin", "https://hub.muse.io/r"])
1661 assert result.exit_code != 0
1662 assert "Invalid remote name" in result.stderr
1663
1664 def test_set_url_name_too_long_rejected(self, repo: pathlib.Path) -> None:
1665 long_name = "a" * 101
1666 result = runner.invoke(cli, ["remote", "set-url", long_name, "https://hub.muse.io/r"])
1667 assert result.exit_code != 0
1668 assert "too long" in result.stderr
1669
1670 def test_set_url_url_stripped_of_whitespace(self, repo: pathlib.Path) -> None:
1671 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/old"])
1672 result = runner.invoke(
1673 cli, ["remote", "set-url", "origin", " https://hub.muse.io/new "]
1674 )
1675 assert result.exit_code == 0
1676 assert get_remote("origin", repo) == "https://hub.muse.io/new"
1677
1678 def test_set_url_url_too_long_rejected(self, repo: pathlib.Path) -> None:
1679 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1680 long_url = f"https://hub.muse.io/{'x' * 2050}"
1681 result = runner.invoke(cli, ["remote", "set-url", "origin", long_url])
1682 assert result.exit_code != 0
1683 assert "too long" in result.stderr
1684
1685 def test_set_url_url_too_long_does_not_write(self, repo: pathlib.Path) -> None:
1686 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/original"])
1687 long_url = f"https://hub.muse.io/{'x' * 2050}"
1688 runner.invoke(cli, ["remote", "set-url", "origin", long_url])
1689 assert get_remote("origin", repo) == "https://hub.muse.io/original"
1690
1691 def test_set_url_scheme_validated_before_repo(
1692 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
1693 ) -> None:
1694 """Invalid scheme must be caught before require_repo() is called."""
1695 monkeypatch.chdir(tmp_path)
1696 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
1697 result = runner.invoke(cli, ["remote", "set-url", "origin", "file:///etc/passwd"])
1698 assert result.exit_code != 0
1699
1700 def test_set_url_http_url_accepted(self, repo: pathlib.Path) -> None:
1701 """http:// is allowed (useful for local hub instances)."""
1702 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1703 result = runner.invoke(
1704 cli, ["remote", "set-url", "origin", "https://localhost:1337/gabriel/r"]
1705 )
1706 assert result.exit_code == 0
1707 assert get_remote("origin", repo) == "https://localhost:1337/gabriel/r"
1708
1709 def test_set_url_multiple_updates_last_wins(self, repo: pathlib.Path) -> None:
1710 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/v1"])
1711 runner.invoke(cli, ["remote", "set-url", "origin", "https://hub.muse.io/v2"])
1712 runner.invoke(cli, ["remote", "set-url", "origin", "https://hub.muse.io/v3"])
1713 assert get_remote("origin", repo) == "https://hub.muse.io/v3"
1714
1715 def test_set_url_other_remotes_unaffected(self, repo: pathlib.Path) -> None:
1716 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/o"])
1717 runner.invoke(cli, ["remote", "add", "upstream", "https://hub.muse.io/u"])
1718 runner.invoke(cli, ["remote", "set-url", "origin", "https://hub.muse.io/o2"])
1719 assert get_remote("upstream", repo) == "https://hub.muse.io/u"
1720
1721 def test_set_url_outside_repo_exits_nonzero(
1722 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
1723 ) -> None:
1724 monkeypatch.chdir(tmp_path)
1725 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
1726 result = runner.invoke(cli, ["remote", "set-url", "origin", "https://hub.muse.io/r"])
1727 assert result.exit_code != 0
1728
1729 def test_set_url_text_output_to_stderr(self, repo: pathlib.Path) -> None:
1730 """In text mode, stdout must be empty — messages go to stderr."""
1731 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/old"])
1732 result = runner.invoke(cli, ["remote", "set-url", "origin", "https://hub.muse.io/new"])
1733 assert result.exit_code == 0
1734
1735 def test_set_url_json_stdout_parseable(self, repo: pathlib.Path) -> None:
1736 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/old"])
1737 result = runner.invoke(
1738 cli, ["remote", "set-url", "origin", "https://hub.muse.io/new", "--json"]
1739 )
1740 assert result.exit_code == 0
1741 # Must parse without error
1742 json_line = next(
1743 (l for l in result.output.splitlines() if l.strip().startswith("{")), None
1744 )
1745 assert json_line is not None
1746 parsed = json.loads(json_line)
1747 assert parsed["status"] == "ok"
1748
1749 def test_set_url_json_all_required_keys_present(self, repo: pathlib.Path) -> None:
1750 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1751 result = runner.invoke(
1752 cli, ["remote", "set-url", "origin", "https://hub.muse.io/r2", "--json"]
1753 )
1754 data = _json_mutation(result)
1755 for key in ("status", "name", "url", "old_url", "old_name", "new_name"):
1756 assert key in data, f"Missing key '{key}' in JSON output"
1757
1758 def test_set_url_dash_name_valid(self, repo: pathlib.Path) -> None:
1759 runner.invoke(cli, ["remote", "add", "my-remote", "https://hub.muse.io/r"])
1760 result = runner.invoke(
1761 cli, ["remote", "set-url", "my-remote", "https://hub.muse.io/r2"]
1762 )
1763 assert result.exit_code == 0
1764
1765 def test_set_url_underscore_name_valid(self, repo: pathlib.Path) -> None:
1766 runner.invoke(cli, ["remote", "add", "my_remote", "https://hub.muse.io/r"])
1767 result = runner.invoke(
1768 cli, ["remote", "set-url", "my_remote", "https://hub.muse.io/r2"]
1769 )
1770 assert result.exit_code == 0
1771
1772
1773 # ── set-url security ──────────────────────────────────────────────────────────
1774
1775 class TestRemoteSetUrlSecurity:
1776 """Security-focused tests for ``muse remote set-url``."""
1777
1778 def test_file_scheme_rejected(self, repo: pathlib.Path) -> None:
1779 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1780 result = runner.invoke(cli, ["remote", "set-url", "origin", "file:///etc/passwd"])
1781 assert result.exit_code != 0
1782
1783 def test_ftp_scheme_rejected(self, repo: pathlib.Path) -> None:
1784 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1785 result = runner.invoke(cli, ["remote", "set-url", "origin", "ftp://hub.muse.io/r"])
1786 assert result.exit_code != 0
1787
1788 def test_data_scheme_rejected(self, repo: pathlib.Path) -> None:
1789 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/r"])
1790 result = runner.invoke(cli, ["remote", "set-url", "origin", "data:text/plain,malicious"])
1791 assert result.exit_code != 0
1792
1793 def test_ansi_in_name_sanitized(
1794 self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]
1795 ) -> None:
1796 malicious = "\x1b[31mghost\x1b[0m"
1797 result = runner.invoke(cli, ["remote", "set-url", malicious, "https://hub.muse.io/r"])
1798 assert result.exit_code != 0
1799 err = result.stderr or ""
1800 assert "\x1b[" not in err
1801
1802 def test_ansi_in_url_sanitized_in_output(self, repo: pathlib.Path) -> None:
1803 """ANSI in stored URL must not reach terminal as raw ESC bytes.
1804
1805 Write TOML directly using \\u001b unicode escapes — raw \\x1b bytes are
1806 illegal in TOML quoted strings, so we write the escape form which TOML
1807 decodes to the ESC character when loading.
1808 """
1809 config_toml = config_toml_path(repo)
1810 # \\u001b in Python str → \u001b written to disk → ESC when TOML loads it
1811 config_toml.write_text(
1812 '[remotes.origin]\nurl = "https://hub.muse.io/\\u001b[31mmalicious\\u001b[0m"\n'
1813 )
1814 # Now set-url to a clean URL — old_url contains an ESC; JSON must encode it
1815 result = runner.invoke(
1816 cli, ["remote", "set-url", "origin", "https://hub.muse.io/clean", "--json"]
1817 )
1818 assert result.exit_code == 0
1819 # json.dumps always encodes ESC as \\u001b — no raw ESC byte must appear
1820 assert "\x1b" not in result.output
1821
1822 def test_control_char_in_name_rejected(self, repo: pathlib.Path) -> None:
1823 result = runner.invoke(cli, ["remote", "set-url", "ori\x00gin", "https://hub.muse.io/r"])
1824 assert result.exit_code != 0
1825
1826 def test_invalid_scheme_does_not_write(self, repo: pathlib.Path) -> None:
1827 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/original"])
1828 runner.invoke(cli, ["remote", "set-url", "origin", "file:///etc/shadow"])
1829 assert get_remote("origin", repo) == "https://hub.muse.io/original"
1830
1831 def test_invalid_name_does_not_reach_repo(
1832 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
1833 ) -> None:
1834 """Name validation must fire before require_repo — no repo needed."""
1835 monkeypatch.chdir(tmp_path)
1836 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
1837 result = runner.invoke(cli, ["remote", "set-url", "bad name", "https://hub.muse.io/r"])
1838 assert result.exit_code != 0
1839 assert "Invalid remote name" in result.stderr
1840
1841
1842 # ── set-url stress ────────────────────────────────────────────────────────────
1843
1844 class TestRemoteSetUrlStress:
1845 """Volume and concurrency tests for ``muse remote set-url``."""
1846
1847 def test_100_sequential_set_url_updates(self, repo: pathlib.Path) -> None:
1848 """100 sequential updates; final URL must match the last write."""
1849 from muse.cli.config import set_remote, get_remote
1850 runner.invoke(cli, ["remote", "add", "origin", "https://hub.muse.io/v0"])
1851 for i in range(1, 101):
1852 set_remote("origin", f"https://hub.muse.io/v{i}", repo)
1853 assert get_remote("origin", repo) == "https://hub.muse.io/v100"
1854
1855 def test_set_url_across_10_remotes(self, repo: pathlib.Path) -> None:
1856 """Update 10 different remotes; each must store its own URL."""
1857 from muse.cli.config import set_remote, get_remote
1858 for i in range(10):
1859 runner.invoke(cli, ["remote", "add", f"r{i}", f"https://hub.muse.io/old{i}"])
1860 for i in range(10):
1861 set_remote(f"r{i}", f"https://hub.muse.io/new{i}", repo)
1862 for i in range(10):
1863 assert get_remote(f"r{i}", repo) == f"https://hub.muse.io/new{i}"
1864
1865 def test_concurrent_set_url_isolated_repos(
1866 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
1867 ) -> None:
1868 """8 concurrent set-url calls on isolated repos must not interfere."""
1869 import json as _json
1870 errors: list[str] = []
1871 lock = threading.Lock()
1872
1873 def _worker(idx: int) -> None:
1874 try:
1875 from muse.cli.config import set_remote, get_remote
1876 # Build a minimal isolated repo
1877 repo_dir = tmp_path / f"repo{idx}"
1878 dot_muse = muse_dir(repo_dir)
1879 (dot_muse / "refs" / "heads").mkdir(parents=True)
1880 (dot_muse / "objects").mkdir()
1881 (dot_muse / "commits").mkdir()
1882 (dot_muse / "snapshots").mkdir()
1883 (dot_muse / "repo.json").write_text(
1884 _json.dumps({"repo_id": f"r{idx}", "schema_version": "0.1", "domain": "midi"})
1885 )
1886 (dot_muse / "HEAD").write_text("ref: refs/heads/main\n")
1887 set_remote("origin", f"https://hub.muse.io/old{idx}", repo_dir)
1888 set_remote("origin", f"https://hub.muse.io/new{idx}", repo_dir)
1889 val = get_remote("origin", repo_dir)
1890 assert val == f"https://hub.muse.io/new{idx}", f"worker {idx}: got {val!r}"
1891 except Exception as exc:
1892 with lock:
1893 errors.append(f"worker {idx}: {exc}")
1894
1895 threads = [threading.Thread(target=_worker, args=(i,)) for i in range(8)]
1896 for t in threads:
1897 t.start()
1898 for t in threads:
1899 t.join()
1900 assert errors == [], "\n".join(errors)
1901
1902
1903 # ── status extended ───────────────────────────────────────────────────────────
1904
1905 class TestRemoteStatusExtended:
1906 """Extended integration tests for ``muse remote status``."""
1907
1908 def _add(self, repo: pathlib.Path, url: str = "http://localhost:19999/gabriel/repo") -> None:
1909 runner.invoke(cli, ["remote", "add", "origin", url])
1910
1911 def test_status_reachable_exit_zero(self, repo: pathlib.Path) -> None:
1912 self._add(repo)
1913 with patch("muse.cli.commands.remote._ping_url", return_value=(True, 200, "HTTP 200 OK")):
1914 result = runner.invoke(cli, ["remote", "status", "origin"])
1915 assert result.exit_code == 0
1916
1917 def test_status_unreachable_exit_nonzero(self, repo: pathlib.Path) -> None:
1918 self._add(repo)
1919 with patch("muse.cli.commands.remote._ping_url", return_value=(False, None, "refused")):
1920 result = runner.invoke(cli, ["remote", "status", "origin"])
1921 assert result.exit_code != 0
1922
1923 def test_status_unreachable_exit_code_is_remote_error(self, repo: pathlib.Path) -> None:
1924 """Unreachable remote must exit with REMOTE_ERROR (5), not INTERNAL_ERROR (3)."""
1925 from muse.core.errors import ExitCode
1926 self._add(repo)
1927 with patch("muse.cli.commands.remote._ping_url", return_value=(False, None, "refused")):
1928 result = runner.invoke(cli, ["remote", "status", "origin"])
1929 assert result.exit_code == ExitCode.REMOTE_ERROR
1930
1931 def test_status_json_all_keys_present(self, repo: pathlib.Path) -> None:
1932 self._add(repo)
1933 with patch("muse.cli.commands.remote._ping_url", return_value=(True, 200, "HTTP 200 OK")):
1934 result = runner.invoke(cli, ["remote", "status", "origin", "--json"])
1935 assert result.exit_code == 0
1936 data = _json_status(result)
1937 for key in ("remote", "url", "server_root", "reachable", "http_status", "message", "tracked_refs"):
1938 assert key in data, f"Missing key '{key}'"
1939
1940 def test_status_json_short_flag(self, repo: pathlib.Path) -> None:
1941 self._add(repo)
1942 with patch("muse.cli.commands.remote._ping_url", return_value=(True, 200, "HTTP 200 OK")):
1943 result = runner.invoke(cli, ["remote", "status", "origin", "-j"])
1944 assert result.exit_code == 0
1945 data = _json_status(result)
1946 assert data["reachable"] is True
1947
1948 def test_status_json_reachable_true(self, repo: pathlib.Path) -> None:
1949 self._add(repo)
1950 with patch("muse.cli.commands.remote._ping_url", return_value=(True, 200, "HTTP 200 OK")):
1951 result = runner.invoke(cli, ["remote", "status", "origin", "--json"])
1952 data = _json_status(result)
1953 assert data["reachable"] is True
1954 assert data["http_status"] == 200
1955
1956 def test_status_json_reachable_false_5xx(self, repo: pathlib.Path) -> None:
1957 self._add(repo)
1958 with patch("muse.cli.commands.remote._ping_url", return_value=(False, 503, "HTTP 503")):
1959 result = runner.invoke(cli, ["remote", "status", "origin", "--json"])
1960 data = _json_status(result)
1961 assert data["reachable"] is False
1962 assert data["http_status"] == 503
1963
1964 def test_status_json_null_http_status_on_network_error(self, repo: pathlib.Path) -> None:
1965 self._add(repo)
1966 with patch("muse.cli.commands.remote._ping_url", return_value=(False, None, "no route")):
1967 result = runner.invoke(cli, ["remote", "status", "origin", "--json"])
1968 data = _json_status(result)
1969 assert data["http_status"] is None
1970
1971 def test_status_json_remote_name_correct(self, repo: pathlib.Path) -> None:
1972 self._add(repo)
1973 with patch("muse.cli.commands.remote._ping_url", return_value=(True, 200, "HTTP 200 OK")):
1974 result = runner.invoke(cli, ["remote", "status", "origin", "--json"])
1975 data = _json_status(result)
1976 assert data["remote"] == "origin"
1977
1978 def test_status_json_url_correct(self, repo: pathlib.Path) -> None:
1979 self._add(repo)
1980 with patch("muse.cli.commands.remote._ping_url", return_value=(True, 200, "HTTP 200 OK")):
1981 result = runner.invoke(cli, ["remote", "status", "origin", "--json"])
1982 data = _json_status(result)
1983 assert data["url"] == "http://localhost:19999/gabriel/repo"
1984
1985 def test_status_json_server_root_extracted(self, repo: pathlib.Path) -> None:
1986 self._add(repo, "http://localhost:19999/gabriel/repo")
1987 with patch("muse.cli.commands.remote._ping_url", return_value=(True, 200, "HTTP 200 OK")):
1988 result = runner.invoke(cli, ["remote", "status", "origin", "--json"])
1989 data = _json_status(result)
1990 assert data["server_root"] == "http://localhost:19999"
1991
1992 def test_status_json_tracked_refs_empty_when_no_fetch(self, repo: pathlib.Path) -> None:
1993 self._add(repo)
1994 with patch("muse.cli.commands.remote._ping_url", return_value=(True, 200, "HTTP 200 OK")):
1995 result = runner.invoke(cli, ["remote", "status", "origin", "--json"])
1996 data = _json_status(result)
1997 assert data["tracked_refs"] == {}
1998
1999 def test_status_json_tracked_refs_flat(self, repo: pathlib.Path) -> None:
2000 self._add(repo)
2001 refs_dir = remote_tracking_dir(repo, "origin")
2002 refs_dir.mkdir(parents=True)
2003 cid_a = long_id("a" * 64)
2004 cid_b = long_id("b" * 64)
2005 (refs_dir / "main").write_text(cid_a)
2006 (refs_dir / "dev").write_text(cid_b)
2007 with patch("muse.cli.commands.remote._ping_url", return_value=(True, 200, "HTTP 200 OK")):
2008 result = runner.invoke(cli, ["remote", "status", "origin", "--json"])
2009 data = _json_status(result)
2010 assert data["tracked_refs"]["main"] == cid_a
2011 assert data["tracked_refs"]["dev"] == cid_b
2012
2013 def test_status_json_tracked_refs_nested(self, repo: pathlib.Path) -> None:
2014 self._add(repo)
2015 refs_dir = remotes_dir(repo) / "origin"
2016 (refs_dir / "feat").mkdir(parents=True)
2017 (refs_dir / "main").write_text("c" * 64)
2018 (refs_dir / "feat" / "ui").write_text("d" * 64)
2019 with patch("muse.cli.commands.remote._ping_url", return_value=(True, 200, "HTTP 200 OK")):
2020 result = runner.invoke(cli, ["remote", "status", "origin", "--json"])
2021 data = _json_status(result)
2022 assert "main" in data["tracked_refs"]
2023 assert "feat/ui" in data["tracked_refs"]
2024
2025 def test_status_missing_remote_exits_nonzero(self, repo: pathlib.Path) -> None:
2026 result = runner.invoke(cli, ["remote", "status", "ghost"])
2027 assert result.exit_code != 0
2028
2029 def test_status_missing_remote_exit_code_user_error(self, repo: pathlib.Path) -> None:
2030 from muse.core.errors import ExitCode
2031 result = runner.invoke(cli, ["remote", "status", "ghost"])
2032 assert result.exit_code == ExitCode.USER_ERROR
2033
2034 def test_status_invalid_name_rejected_before_repo(
2035 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
2036 ) -> None:
2037 """Name validation must fire before require_repo — no repo needed."""
2038 monkeypatch.chdir(tmp_path)
2039 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
2040 result = runner.invoke(cli, ["remote", "status", "bad name"])
2041 assert result.exit_code != 0
2042 assert "Invalid remote name" in result.stderr
2043
2044 def test_status_outside_repo_exits_nonzero(
2045 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
2046 ) -> None:
2047 monkeypatch.chdir(tmp_path)
2048 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
2049 result = runner.invoke(cli, ["remote", "status", "origin"])
2050 assert result.exit_code != 0
2051
2052 def test_status_custom_timeout_passed_to_ping(self, repo: pathlib.Path) -> None:
2053 self._add(repo)
2054 with patch("muse.cli.commands.remote._ping_url", return_value=(True, 200, "HTTP 200 OK")) as m:
2055 runner.invoke(cli, ["remote", "status", "origin", "--timeout", "2.5"])
2056 assert m.call_args[0][1] == 2.5
2057
2058 def test_status_json_parseable_stdout(self, repo: pathlib.Path) -> None:
2059 self._add(repo)
2060 with patch("muse.cli.commands.remote._ping_url", return_value=(True, 200, "HTTP 200 OK")):
2061 result = runner.invoke(cli, ["remote", "status", "origin", "--json"])
2062 assert result.exit_code == 0
2063 json_line = next(
2064 (l for l in result.output.splitlines() if l.strip().startswith("{")), None
2065 )
2066 assert json_line is not None
2067 json.loads(json_line) # must not raise
2068
2069
2070 # ── status security ───────────────────────────────────────────────────────────
2071
2072 class TestRemoteStatusSecurity:
2073 """Security-focused tests for ``muse remote status``."""
2074
2075 def test_invalid_name_no_repo_needed(
2076 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
2077 ) -> None:
2078 monkeypatch.chdir(tmp_path)
2079 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
2080 result = runner.invoke(cli, ["remote", "status", "bad/name"])
2081 assert result.exit_code != 0
2082 assert "Invalid remote name" in result.stderr
2083
2084 def test_ansi_in_name_sanitized(
2085 self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]
2086 ) -> None:
2087 malicious = "\x1b[31morigin\x1b[0m"
2088 result = runner.invoke(cli, ["remote", "status", malicious])
2089 assert result.exit_code != 0
2090 err = result.stderr or ""
2091 assert "\x1b[" not in err
2092
2093 def test_control_char_in_name_rejected(self, repo: pathlib.Path) -> None:
2094 result = runner.invoke(cli, ["remote", "status", "ori\x00gin"])
2095 assert result.exit_code != 0
2096 assert "Invalid remote name" in result.stderr
2097
2098 def test_name_too_long_rejected(self, repo: pathlib.Path) -> None:
2099 long_name = "a" * 101
2100 result = runner.invoke(cli, ["remote", "status", long_name])
2101 assert result.exit_code != 0
2102 assert "too long" in result.stderr
2103
2104 def test_file_scheme_url_returns_unreachable(self, repo: pathlib.Path) -> None:
2105 """A file:// URL in config must be handled by the SSRF guard in _ping_url."""
2106 from muse.cli.config import set_remote
2107 set_remote("badremote", "file:///etc/passwd", repo)
2108 result = runner.invoke(cli, ["remote", "status", "badremote", "--json"])
2109 assert result.exit_code != 0
2110 data = _json_status(result)
2111 assert data["reachable"] is False
2112
2113 def test_ansi_in_stored_url_sanitized_in_text_output(self, repo: pathlib.Path) -> None:
2114 """ANSI codes in a stored URL must not leak as raw ESC bytes in text mode."""
2115 config_toml = config_toml_path(repo)
2116 config_toml.write_text(
2117 '[remotes.origin]\nurl = "http://localhost/\\u001b[31mmalicious\\u001b[0m"\n'
2118 )
2119 with patch("muse.cli.commands.remote._ping_url", return_value=(True, 200, "HTTP 200 OK")):
2120 result = runner.invoke(cli, ["remote", "status", "origin"])
2121 assert result.exit_code == 0
2122 assert "\x1b" not in result.output
2123
2124 def test_ansi_in_stored_url_escaped_in_json(self, repo: pathlib.Path) -> None:
2125 """ANSI in stored URL must be JSON-encoded, not emitted as raw ESC."""
2126 config_toml = config_toml_path(repo)
2127 config_toml.write_text(
2128 '[remotes.origin]\nurl = "http://localhost/\\u001b[31mmalicious\\u001b[0m"\n'
2129 )
2130 with patch("muse.cli.commands.remote._ping_url", return_value=(True, 200, "HTTP 200 OK")):
2131 result = runner.invoke(cli, ["remote", "status", "origin", "--json"])
2132 assert result.exit_code == 0
2133 assert "\x1b" not in result.output
2134
2135 def test_symlink_in_remotes_dir_skipped(self, repo: pathlib.Path) -> None:
2136 """Symlinks inside the remotes tracking dir must be skipped."""
2137 runner.invoke(cli, ["remote", "add", "origin", "http://localhost:19999/gabriel/repo"])
2138 refs_dir = remotes_dir(repo) / "origin"
2139 refs_dir.mkdir(parents=True)
2140 (refs_dir / "main").write_text("a" * 64)
2141 symlink = refs_dir / "malicious"
2142 symlink.symlink_to("/etc/passwd")
2143 with patch("muse.cli.commands.remote._ping_url", return_value=(True, 200, "HTTP 200 OK")):
2144 result = runner.invoke(cli, ["remote", "status", "origin", "--json"])
2145 assert result.exit_code == 0
2146 data = _json_status(result)
2147 assert "malicious" not in data["tracked_refs"]
2148 assert "main" in data["tracked_refs"]
2149
2150
2151 # ── status stress ─────────────────────────────────────────────────────────────
2152
2153 class TestRemoteStatusStress:
2154 """Volume and concurrency tests for ``muse remote status``."""
2155
2156 def _add(self, repo: pathlib.Path, url: str = "http://localhost:19999/g/r") -> None:
2157 runner.invoke(cli, ["remote", "add", "origin", url])
2158
2159 def test_50_sequential_status_calls_stable(self, repo: pathlib.Path) -> None:
2160 """50 status calls with a mocked reachable remote must all return exit 0."""
2161 self._add(repo)
2162 with patch("muse.cli.commands.remote._ping_url", return_value=(True, 200, "HTTP 200 OK")):
2163 for _ in range(50):
2164 result = runner.invoke(cli, ["remote", "status", "origin", "--json"])
2165 assert result.exit_code == 0
2166
2167 def test_status_with_100_tracked_refs(self, repo: pathlib.Path) -> None:
2168 """100 tracking refs must all appear in JSON output."""
2169 self._add(repo)
2170 refs_dir = remotes_dir(repo) / "origin"
2171 refs_dir.mkdir(parents=True)
2172 for i in range(100):
2173 (refs_dir / f"branch{i:03d}").write_text("a" * 64)
2174 with patch("muse.cli.commands.remote._ping_url", return_value=(True, 200, "HTTP 200 OK")):
2175 result = runner.invoke(cli, ["remote", "status", "origin", "--json"])
2176 assert result.exit_code == 0
2177 data = _json_status(result)
2178 assert len(data["tracked_refs"]) == 100
2179
2180 def test_concurrent_status_reads_same_repo(self, repo: pathlib.Path) -> None:
2181 """8 concurrent status reads against the same repo must all succeed."""
2182 self._add(repo)
2183 refs_dir = remotes_dir(repo) / "origin"
2184 refs_dir.mkdir(parents=True)
2185 (refs_dir / "main").write_text("a" * 64)
2186 results: list[int] = []
2187 errors: list[str] = []
2188 lock = threading.Lock()
2189
2190 def _read() -> None:
2191 try:
2192 from muse.cli.config import get_remote
2193 from muse.cli.commands.remote import _collect_tracked_refs
2194 get_remote("origin", repo)
2195 refs = _collect_tracked_refs(refs_dir)
2196 with lock:
2197 results.append(len(refs))
2198 except Exception as exc:
2199 with lock:
2200 errors.append(str(exc))
2201
2202 threads = [threading.Thread(target=_read) for _ in range(8)]
2203 for t in threads:
2204 t.start()
2205 for t in threads:
2206 t.join()
2207 assert errors == [], "\n".join(errors)
2208 assert all(r == 1 for r in results)
File History 1 commit
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b fix: try fetch/presign before fetch/mpack to avoid Cloudfla… Sonnet 4.6 patch 6 days ago