gabriel / muse public
test_security_branch_ref_injection.py python
750 lines 27.4 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Phase 2.6 — Branch and ref injection security tests.
2
3 Attack surface
4 --------------
5 Branch names are user-controlled strings that become filesystem paths:
6 .muse/refs/heads/<branch>
7
8 A permissive validator allows an attacker to:
9 1. Escape the ref store via path traversal (../../etc/cron.d/pwned).
10 2. Inject terminal-escape sequences into for-each-ref text output via ESC or
11 other C0 control characters in the branch name.
12 3. Create phantom branch aliases: ``feat/./sub`` resolves to the same inode
13 as ``feat/sub`` on every POSIX filesystem, so two names share one file.
14 4. Produce .lock-suffixed files that look like stale atomic-write temp files
15 to any tooling scanning the ref directory.
16 5. Inject git reflog notation (``@{``) into pipeline outputs, confusing
17 downstream parsers.
18 6. Smuggle glob metacharacters that expand unexpectedly if branch names are
19 ever used in a glob pattern.
20
21 Fixes
22 -----
23 ``_BRANCH_FORBIDDEN_RE`` in ``muse.core.validation`` was extended to block:
24 - All C0 control chars (0x00–0x1F), space (0x20), DEL (0x7F).
25 - Git-banned punctuation: ``~``, ``^``, ``:``, ``?``, ``*``, ``[``.
26 - Single-dot path component (``/./``).
27 - Any path component ending in ``.lock``.
28 - The ``@{`` sequence and the bare ``@`` string.
29
30 All ref-writing plumbing commands (``update-ref``, ``symbolic-ref``,
31 ``branch``) call ``validate_branch_name`` before any filesystem operation,
32 so these fixes propagate automatically to every write path.
33 """
34
35 from __future__ import annotations
36
37 import json
38 import os
39 import pathlib
40 from typing import TypedDict
41
42 import pytest
43
44 from muse.core.validation import validate_branch_name
45 from muse.core.store import write_branch_ref, write_head_branch
46 from tests.cli_test_helper import CliRunner
47
48
49 class _CheckRefFormatResult(TypedDict, total=False):
50 """Shape of muse plumbing check-ref-format --json output."""
51 all_valid: bool
52 valid_count: int
53 invalid_count: int
54 results: list[dict[str, str | bool | None]]
55 max_length: int
56 forbidden_chars: list[str]
57 forbidden_patterns: list[str]
58 notes: str
59
60 cli = None # argparse migration — CliRunner ignores this
61 runner = CliRunner()
62
63
64 # ---------------------------------------------------------------------------
65 # Helpers
66 # ---------------------------------------------------------------------------
67
68 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
69 """Create a minimal .muse repo skeleton for integration tests."""
70 (tmp_path / ".muse" / "refs" / "heads").mkdir(parents=True)
71 (tmp_path / ".muse" / "commits").mkdir(parents=True)
72 (tmp_path / ".muse" / "snapshots").mkdir(parents=True)
73 (tmp_path / ".muse" / "objects").mkdir(parents=True)
74 (tmp_path / ".muse" / "HEAD").write_text("ref: refs/heads/main\n")
75 (tmp_path / ".muse" / "repo.json").write_text(
76 '{"repo_id": "test-repo", "name": "test"}'
77 )
78 return tmp_path
79
80
81 def _invoke_in_repo(tmp_path: pathlib.Path, args: list[str]) -> tuple[int, str]:
82 """Invoke the muse CLI inside *tmp_path* (which must contain a .muse dir)."""
83 old_cwd = os.getcwd()
84 try:
85 os.chdir(tmp_path)
86 result = runner.invoke(cli, args)
87 return result.exit_code, result.output
88 finally:
89 os.chdir(old_cwd)
90
91
92 _ZERO_OID = "0" * 64
93
94
95 # ===========================================================================
96 # Unit tests — validate_branch_name
97 # ===========================================================================
98
99
100 class TestValidBranchNames:
101 """Names that must be accepted."""
102
103 @pytest.mark.parametrize("name", [
104 "main",
105 "dev",
106 "feature/my-branch",
107 "fix/auth-token-exposure",
108 "feat/v2/core",
109 "release/1.2.0",
110 "bugfix/PROJ-42",
111 "hotfix/auth",
112 "branch-123_test",
113 "a",
114 "A",
115 "Z9",
116 "a" * 255,
117 "-branch", # leading dash: allowed (Git allows it; no shell interpolation)
118 "branch-", # trailing dash: allowed
119 "feat/--desc", # double dash in namespace: allowed
120 ])
121 def test_accepted(self, name: str) -> None:
122 assert validate_branch_name(name) == name
123
124
125 # ---------------------------------------------------------------------------
126 # C0/C1 control character injection
127 # ---------------------------------------------------------------------------
128
129
130 class TestControlCharInjection:
131 """All C0 control chars must be rejected to prevent terminal injection.
132
133 ESC (0x1b) is the highest-risk char: a branch named ``main\x1b[31m``
134 would inject ANSI colour sequences into ``for-each-ref --format text``
135 output, potentially hiding output, changing terminal colours, or
136 triggering OSC 8 hyperlinks in compliant terminal emulators.
137 """
138
139 @pytest.mark.parametrize("char,description", [
140 ("\x00", "NUL"),
141 ("\x01", "SOH"),
142 ("\x02", "STX"),
143 ("\x03", "ETX"),
144 ("\x04", "EOT"),
145 ("\x05", "ENQ"),
146 ("\x06", "ACK"),
147 ("\x07", "BEL"),
148 ("\x08", "BS"),
149 ("\x09", "HT (tab)"),
150 ("\x0a", "LF"),
151 ("\x0b", "VT"),
152 ("\x0c", "FF"),
153 ("\x0d", "CR"),
154 ("\x0e", "SO"),
155 ("\x0f", "SI"),
156 ("\x10", "DLE"),
157 ("\x11", "DC1"),
158 ("\x12", "DC2"),
159 ("\x13", "DC3"),
160 ("\x14", "DC4"),
161 ("\x15", "NAK"),
162 ("\x16", "SYN"),
163 ("\x17", "ETB"),
164 ("\x18", "CAN"),
165 ("\x19", "EM"),
166 ("\x1a", "SUB"),
167 ("\x1b", "ESC — highest risk, ANSI sequence introducer"),
168 ("\x1c", "FS"),
169 ("\x1d", "GS"),
170 ("\x1e", "RS"),
171 ("\x1f", "US"),
172 ("\x20", "space (0x20) — shell interpolation / log-parsing hazard"),
173 ("\x7f", "DEL"),
174 ])
175 def test_control_char_rejected(self, char: str, description: str) -> None:
176 with pytest.raises((ValueError, TypeError)):
177 validate_branch_name(f"main{char}evil")
178
179 def test_esc_at_start(self) -> None:
180 with pytest.raises(ValueError):
181 validate_branch_name("\x1bmain")
182
183 def test_esc_at_end(self) -> None:
184 with pytest.raises(ValueError):
185 validate_branch_name("main\x1b")
186
187 def test_multiple_control_chars(self) -> None:
188 """Payloads combining multiple control chars are still rejected."""
189 with pytest.raises(ValueError):
190 validate_branch_name("feat\x1b[31m/\x07sub")
191
192 def test_space_only(self) -> None:
193 with pytest.raises(ValueError):
194 validate_branch_name(" ")
195
196 def test_space_in_namespace(self) -> None:
197 with pytest.raises(ValueError):
198 validate_branch_name("feat/my branch")
199
200
201 # ---------------------------------------------------------------------------
202 # Git-banned punctuation
203 # ---------------------------------------------------------------------------
204
205
206 class TestGitBannedPunctuation:
207 """Characters forbidden by git-check-ref-format that Muse now also rejects."""
208
209 @pytest.mark.parametrize("char,description", [
210 ("~", "tilde — git ancestry operator"),
211 ("^", "caret — git ancestry operator"),
212 (":", "colon — refspec separator"),
213 ("?", "question mark — glob wildcard"),
214 ("*", "asterisk — glob wildcard"),
215 ("[", "open bracket — character class in glob"),
216 ])
217 def test_git_banned_char_in_name(self, char: str, description: str) -> None:
218 with pytest.raises(ValueError):
219 validate_branch_name(f"feat{char}evil")
220
221 def test_tilde_suffix(self) -> None:
222 """feat~1 looks like a git ancestry ref; must be rejected."""
223 with pytest.raises(ValueError):
224 validate_branch_name("feat~1")
225
226 def test_colon_refspec(self) -> None:
227 """feat:main is a refspec; must be rejected."""
228 with pytest.raises(ValueError):
229 validate_branch_name("feat:main")
230
231 def test_glob_expansion_star(self) -> None:
232 with pytest.raises(ValueError):
233 validate_branch_name("feat/*")
234
235 def test_glob_expansion_question(self) -> None:
236 with pytest.raises(ValueError):
237 validate_branch_name("feat/fo?")
238
239 def test_glob_char_class(self) -> None:
240 with pytest.raises(ValueError):
241 validate_branch_name("feat/[abc]")
242
243
244 # ---------------------------------------------------------------------------
245 # Single-dot path component (inode aliasing)
246 # ---------------------------------------------------------------------------
247
248
249 class TestSingleDotPathComponent:
250 """``feat/./sub`` and ``feat/sub`` resolve to the same inode on disk.
251
252 If both were valid branch names, writing to the first would silently
253 overwrite the second's ref file. This is a subtle data-corruption vector
254 that requires no privilege escalation.
255 """
256
257 def test_dot_slash_dot_slash(self) -> None:
258 """feat/./sub — single dot in the middle."""
259 with pytest.raises(ValueError):
260 validate_branch_name("feat/./sub")
261
262 def test_dot_slash_at_end(self) -> None:
263 """feat/. — trailing slash-dot."""
264 with pytest.raises(ValueError):
265 validate_branch_name("feat/.")
266
267 def test_deep_dot_path(self) -> None:
268 """a/b/./c/d — dot buried deep in a hierarchy."""
269 with pytest.raises(ValueError):
270 validate_branch_name("a/b/./c/d")
271
272 def test_multiple_dots(self) -> None:
273 """Two single-dot components in a row."""
274 with pytest.raises(ValueError):
275 validate_branch_name("a/././b")
276
277 def test_dot_as_entire_name(self) -> None:
278 """Bare dot is already rejected by the leading-dot rule."""
279 with pytest.raises(ValueError):
280 validate_branch_name(".")
281
282 def test_inode_aliasing_proven(self, tmp_path: pathlib.Path) -> None:
283 """Demonstrate the attack: /tmp/x/feat/./sub IS the same file as /tmp/x/feat/sub."""
284 import os
285 (tmp_path / "feat").mkdir()
286 (tmp_path / "feat" / "sub").write_text("ORIGINAL")
287 alias = tmp_path / "feat" / "." / "sub"
288 assert alias.exists(), "alias should exist via filesystem normalisation"
289 assert os.stat(tmp_path / "feat" / "sub").st_ino == os.stat(alias).st_ino
290 alias.write_text("OVERWRITTEN")
291 assert (tmp_path / "feat" / "sub").read_text() == "OVERWRITTEN"
292
293
294 # ---------------------------------------------------------------------------
295 # .lock suffix
296 # ---------------------------------------------------------------------------
297
298
299 class TestLockSuffix:
300 """Names ending in .lock on any path component must be rejected.
301
302 The VCS convention reserves ``.lock`` for exclusive-lock files. Allowing
303 ``main.lock`` would create ``.muse/refs/heads/main.lock`` — a file that
304 tooling scanning the ref directory could mistake for a stale lock or a
305 failed atomic write.
306 """
307
308 def test_top_level_lock(self) -> None:
309 with pytest.raises(ValueError):
310 validate_branch_name("main.lock")
311
312 def test_namespaced_lock(self) -> None:
313 with pytest.raises(ValueError):
314 validate_branch_name("feat/my-branch.lock")
315
316 def test_lock_as_midpath_component(self) -> None:
317 with pytest.raises(ValueError):
318 validate_branch_name("feat/foo.lock/sub")
319
320 def test_lock_prefix_only_is_allowed(self) -> None:
321 """A branch named 'lockdown' does not end in .lock; must be allowed."""
322 assert validate_branch_name("lockdown") == "lockdown"
323
324 def test_lock_substring_allowed(self) -> None:
325 """'lockfix' does not end in .lock; must be allowed."""
326 assert validate_branch_name("lockfix") == "lockfix"
327
328 def test_dotlock_exact_name(self) -> None:
329 """.lock alone is rejected by the leading-dot rule first."""
330 with pytest.raises(ValueError):
331 validate_branch_name(".lock")
332
333
334 # ---------------------------------------------------------------------------
335 # @{ sequence and bare @
336 # ---------------------------------------------------------------------------
337
338
339 class TestAtBraceSequence:
340 """The @{ sequence is git reflog notation; it must be rejected.
341
342 A branch named ``feat/@{0}`` would confuse any tool that parses
343 ``<branch>@{<n>}`` as a reflog reference — including Muse's own future
344 reflog implementation.
345 """
346
347 def test_at_brace_top_level(self) -> None:
348 with pytest.raises(ValueError):
349 validate_branch_name("@{upstream}")
350
351 def test_at_brace_in_namespace(self) -> None:
352 with pytest.raises(ValueError):
353 validate_branch_name("feat/@{0}")
354
355 def test_at_brace_suffix(self) -> None:
356 with pytest.raises(ValueError):
357 validate_branch_name("feat@{0}")
358
359 def test_bare_at(self) -> None:
360 """Bare @ is git HEAD shorthand; rejected for the same reason."""
361 with pytest.raises(ValueError):
362 validate_branch_name("@")
363
364 def test_at_in_normal_name_allowed(self) -> None:
365 """@ followed by anything other than { is not the forbidden sequence."""
366 # e.g. "feat@42" is unusual but not the @{ reflog pattern
367 # validate_branch_name should allow it (@ is ASCII printable, not
368 # in the C0 or punctuation block).
369 result = validate_branch_name("feat@42")
370 assert result == "feat@42"
371
372
373 # ---------------------------------------------------------------------------
374 # Existing rules (regression: they must still work after the regex change)
375 # ---------------------------------------------------------------------------
376
377
378 class TestExistingRulesRegression:
379 """Ensure the new regex does not break pre-existing rejections."""
380
381 def test_backslash(self) -> None:
382 with pytest.raises(ValueError):
383 validate_branch_name("evil\\branch")
384
385 def test_null_byte(self) -> None:
386 with pytest.raises(ValueError):
387 validate_branch_name("branch\x00name")
388
389 def test_carriage_return(self) -> None:
390 with pytest.raises(ValueError):
391 validate_branch_name("branch\rname")
392
393 def test_linefeed(self) -> None:
394 with pytest.raises(ValueError):
395 validate_branch_name("branch\nname")
396
397 def test_tab(self) -> None:
398 with pytest.raises(ValueError):
399 validate_branch_name("branch\tname")
400
401 def test_leading_dot(self) -> None:
402 with pytest.raises(ValueError):
403 validate_branch_name(".hidden")
404
405 def test_trailing_dot(self) -> None:
406 with pytest.raises(ValueError):
407 validate_branch_name("branch.")
408
409 def test_consecutive_dots(self) -> None:
410 with pytest.raises(ValueError):
411 validate_branch_name("branch..name")
412
413 def test_double_slash(self) -> None:
414 with pytest.raises(ValueError):
415 validate_branch_name("feat//branch")
416
417 def test_leading_slash(self) -> None:
418 with pytest.raises(ValueError):
419 validate_branch_name("/branch")
420
421 def test_trailing_slash(self) -> None:
422 with pytest.raises(ValueError):
423 validate_branch_name("branch/")
424
425 def test_empty_string(self) -> None:
426 with pytest.raises(ValueError):
427 validate_branch_name("")
428
429 def test_too_long(self) -> None:
430 with pytest.raises(ValueError):
431 validate_branch_name("a" * 256)
432
433 def test_dotdot_traversal(self) -> None:
434 with pytest.raises(ValueError):
435 validate_branch_name("../../etc/passwd")
436
437 def test_dotdot_in_namespace(self) -> None:
438 with pytest.raises(ValueError):
439 validate_branch_name("feat/../main")
440
441
442 # ===========================================================================
443 # Integration tests — store-level gatekeeping
444 # ===========================================================================
445
446
447 class TestWriteBranchRefGatekeeping:
448 """write_branch_ref validates the branch name before writing any file."""
449
450 def test_traversal_rejected_before_write(self, tmp_path: pathlib.Path) -> None:
451 repo = _make_repo(tmp_path)
452 with pytest.raises(ValueError):
453 write_branch_ref(repo, "../../etc/passwd", _ZERO_OID)
454 assert not (tmp_path / "etc" / "passwd").exists()
455
456 def test_esc_injection_rejected_before_write(self, tmp_path: pathlib.Path) -> None:
457 repo = _make_repo(tmp_path)
458 with pytest.raises(ValueError):
459 write_branch_ref(repo, "main\x1b[31m", _ZERO_OID)
460
461 def test_single_dot_component_rejected(self, tmp_path: pathlib.Path) -> None:
462 repo = _make_repo(tmp_path)
463 with pytest.raises(ValueError):
464 write_branch_ref(repo, "feat/./sub", _ZERO_OID)
465
466 def test_lock_suffix_rejected(self, tmp_path: pathlib.Path) -> None:
467 repo = _make_repo(tmp_path)
468 with pytest.raises(ValueError):
469 write_branch_ref(repo, "main.lock", _ZERO_OID)
470
471 def test_at_brace_rejected(self, tmp_path: pathlib.Path) -> None:
472 repo = _make_repo(tmp_path)
473 with pytest.raises(ValueError):
474 write_branch_ref(repo, "feat/@{0}", _ZERO_OID)
475
476 def test_space_in_name_rejected(self, tmp_path: pathlib.Path) -> None:
477 repo = _make_repo(tmp_path)
478 with pytest.raises(ValueError):
479 write_branch_ref(repo, "feat branch", _ZERO_OID)
480
481 def test_valid_name_writes_file(self, tmp_path: pathlib.Path) -> None:
482 repo = _make_repo(tmp_path)
483 write_branch_ref(repo, "feat/ok", _ZERO_OID)
484 ref_path = repo / ".muse" / "refs" / "heads" / "feat" / "ok"
485 assert ref_path.read_text().strip() == _ZERO_OID
486
487 def test_valid_name_no_file_escape(self, tmp_path: pathlib.Path) -> None:
488 """A valid name must not write outside .muse/refs/heads/."""
489 repo = _make_repo(tmp_path)
490 write_branch_ref(repo, "main", _ZERO_OID)
491 ref_path = repo / ".muse" / "refs" / "heads" / "main"
492 assert ref_path.exists()
493 assert not (repo / "main").exists()
494
495
496 class TestWriteHeadBranchGatekeeping:
497 """write_head_branch validates the branch name before writing HEAD."""
498
499 def test_esc_injection_rejected(self, tmp_path: pathlib.Path) -> None:
500 repo = _make_repo(tmp_path)
501 with pytest.raises(ValueError):
502 write_head_branch(repo, "main\x1b[31m")
503
504 def test_dotdot_traversal_rejected(self, tmp_path: pathlib.Path) -> None:
505 repo = _make_repo(tmp_path)
506 with pytest.raises(ValueError):
507 write_head_branch(repo, "../../etc/passwd")
508
509 def test_valid_name_writes_head(self, tmp_path: pathlib.Path) -> None:
510 repo = _make_repo(tmp_path)
511 write_head_branch(repo, "feat/ok")
512 head = (repo / ".muse" / "HEAD").read_text()
513 assert "feat/ok" in head
514 assert "../../" not in head
515
516
517 # ===========================================================================
518 # Integration tests — CLI commands via CliRunner
519 # ===========================================================================
520
521
522 class TestUpdateRefCLIGatekeeping:
523 """muse plumbing update-ref rejects injection branch names at the CLI level."""
524
525 def test_dotdot_traversal(self, tmp_path: pathlib.Path) -> None:
526 _make_repo(tmp_path)
527 code, out = _invoke_in_repo(tmp_path, ["update-ref", "../../etc/passwd", _ZERO_OID])
528 assert code != 0
529 assert "Invalid branch name" in out or "forbidden" in out.lower() or "error" in out.lower()
530
531 def test_esc_injection(self, tmp_path: pathlib.Path) -> None:
532 _make_repo(tmp_path)
533 code, out = _invoke_in_repo(tmp_path, ["update-ref", "main\x1b[31m", _ZERO_OID])
534 assert code != 0
535
536 def test_lock_suffix(self, tmp_path: pathlib.Path) -> None:
537 _make_repo(tmp_path)
538 code, out = _invoke_in_repo(tmp_path, ["update-ref", "main.lock", _ZERO_OID])
539 assert code != 0
540 assert not (tmp_path / ".muse" / "refs" / "heads" / "main.lock").exists()
541
542 def test_single_dot_component(self, tmp_path: pathlib.Path) -> None:
543 _make_repo(tmp_path)
544 code, out = _invoke_in_repo(tmp_path, ["update-ref", "feat/./sub", _ZERO_OID])
545 assert code != 0
546 # The alias must not have silently created feat/sub
547 assert not (tmp_path / ".muse" / "refs" / "heads" / "feat" / "sub").exists()
548
549 def test_at_brace(self, tmp_path: pathlib.Path) -> None:
550 _make_repo(tmp_path)
551 code, out = _invoke_in_repo(tmp_path, ["update-ref", "feat/@{0}", _ZERO_OID])
552 assert code != 0
553
554 def test_space_in_name(self, tmp_path: pathlib.Path) -> None:
555 _make_repo(tmp_path)
556 code, out = _invoke_in_repo(tmp_path, ["update-ref", "feat branch", _ZERO_OID])
557 assert code != 0
558
559 def test_tilde(self, tmp_path: pathlib.Path) -> None:
560 _make_repo(tmp_path)
561 code, out = _invoke_in_repo(tmp_path, ["update-ref", "feat~1", _ZERO_OID])
562 assert code != 0
563
564
565 class TestSymbolicRefCLIGatekeeping:
566 """muse plumbing symbolic-ref --set rejects injection branch names."""
567
568 def test_dotdot_traversal(self, tmp_path: pathlib.Path) -> None:
569 _make_repo(tmp_path)
570 code, out = _invoke_in_repo(tmp_path, [
571 "symbolic-ref", "HEAD",
572 "--set", "../../etc/passwd", "--create-branch",
573 ])
574 assert code != 0
575
576 def test_esc_injection(self, tmp_path: pathlib.Path) -> None:
577 _make_repo(tmp_path)
578 code, out = _invoke_in_repo(tmp_path, [
579 "symbolic-ref", "HEAD",
580 "--set", "main\x1b[31m", "--create-branch",
581 ])
582 assert code != 0
583
584 def test_lock_suffix(self, tmp_path: pathlib.Path) -> None:
585 _make_repo(tmp_path)
586 code, out = _invoke_in_repo(tmp_path, [
587 "symbolic-ref", "HEAD",
588 "--set", "main.lock", "--create-branch",
589 ])
590 assert code != 0
591
592 def test_at_brace(self, tmp_path: pathlib.Path) -> None:
593 _make_repo(tmp_path)
594 code, out = _invoke_in_repo(tmp_path, [
595 "symbolic-ref", "HEAD",
596 "--set", "@{0}", "--create-branch",
597 ])
598 assert code != 0
599
600
601 class TestCheckRefFormatCLI:
602 """muse plumbing check-ref-format reflects the full rule set."""
603
604 def _run_check(self, tmp_path: pathlib.Path, name: str) -> tuple[int, _CheckRefFormatResult]:
605 _make_repo(tmp_path)
606 code, out = _invoke_in_repo(tmp_path, ["check-ref-format", name, "--json"])
607 raw = out.strip()
608 data: _CheckRefFormatResult = json.loads(raw) if raw else {}
609 return code, data
610
611 def test_valid_name_passes(self, tmp_path: pathlib.Path) -> None:
612 code, data = self._run_check(tmp_path, "feat/ok")
613 assert code == 0
614 assert data["all_valid"] is True
615
616 def test_dotdot_traversal_fails(self, tmp_path: pathlib.Path) -> None:
617 code, data = self._run_check(tmp_path, "../../etc/passwd")
618 assert code != 0
619 assert data.get("all_valid") is False
620
621 def test_esc_injection_fails(self, tmp_path: pathlib.Path) -> None:
622 code, data = self._run_check(tmp_path, "main\x1b[31m")
623 assert code != 0
624 assert data.get("all_valid") is False
625
626 def test_lock_suffix_fails(self, tmp_path: pathlib.Path) -> None:
627 code, data = self._run_check(tmp_path, "main.lock")
628 assert code != 0
629 assert data.get("all_valid") is False
630
631 def test_single_dot_component_fails(self, tmp_path: pathlib.Path) -> None:
632 code, data = self._run_check(tmp_path, "feat/./sub")
633 assert code != 0
634 assert data.get("all_valid") is False
635
636 def test_at_brace_fails(self, tmp_path: pathlib.Path) -> None:
637 code, data = self._run_check(tmp_path, "@{upstream}")
638 assert code != 0
639 assert data.get("all_valid") is False
640
641 def test_space_fails(self, tmp_path: pathlib.Path) -> None:
642 code, data = self._run_check(tmp_path, "feat branch")
643 assert code != 0
644 assert data.get("all_valid") is False
645
646 def test_tilde_fails(self, tmp_path: pathlib.Path) -> None:
647 code, data = self._run_check(tmp_path, "feat~1")
648 assert code != 0
649 assert data.get("all_valid") is False
650
651 def test_rules_endpoint_lists_new_patterns(self, tmp_path: pathlib.Path) -> None:
652 """--rules must mention the new forbidden patterns."""
653 _make_repo(tmp_path)
654 code, out = _invoke_in_repo(tmp_path, ["check-ref-format", "--rules", "--json"])
655 rules = json.loads(out.strip())
656 patterns = rules.get("forbidden_patterns", [])
657 assert any("lock" in p for p in patterns), "missing .lock rule"
658 assert any("dot" in p.lower() and "/" in p for p in patterns), "missing /./rule"
659 assert any("@{" in p for p in patterns), "missing @{ rule"
660
661
662 # ===========================================================================
663 # Concurrency / race — validate blocks before any write
664 # ===========================================================================
665
666
667 class TestConcurrentWriteWithInjectionName:
668 """Two threads racing to write a traversal branch name: both must fail."""
669
670 def test_concurrent_traversal_both_fail(self, tmp_path: pathlib.Path) -> None:
671 import threading
672
673 repo = _make_repo(tmp_path)
674 errors: list[str] = []
675 successes: list[str] = []
676
677 def try_write(name: str) -> None:
678 try:
679 write_branch_ref(repo, name, _ZERO_OID)
680 successes.append(name)
681 except (ValueError, TypeError) as exc:
682 errors.append(str(exc))
683
684 threads = [
685 threading.Thread(target=try_write, args=("../../etc/passwd",)),
686 threading.Thread(target=try_write, args=("feat\x1b[31m",)),
687 threading.Thread(target=try_write, args=("main.lock",)),
688 threading.Thread(target=try_write, args=("feat/./sub",)),
689 ]
690 for t in threads:
691 t.start()
692 for t in threads:
693 t.join()
694
695 assert successes == [], f"Expected all writes to fail; successes: {successes}"
696 assert len(errors) == 4
697
698 def test_concurrent_valid_writes_succeed(self, tmp_path: pathlib.Path) -> None:
699 """Ensure the fix does not regress valid concurrent writes."""
700 import threading
701
702 repo = _make_repo(tmp_path)
703 errors: list[str] = []
704
705 def try_write(name: str) -> None:
706 try:
707 write_branch_ref(repo, name, _ZERO_OID)
708 except Exception as exc:
709 errors.append(f"{name}: {exc}")
710
711 threads = [
712 threading.Thread(target=try_write, args=(f"feat/branch-{i}",))
713 for i in range(8)
714 ]
715 for t in threads:
716 t.start()
717 for t in threads:
718 t.join()
719
720 assert errors == [], f"Valid writes unexpectedly failed: {errors}"
721
722
723 # ===========================================================================
724 # Fuzzing — randomised injection payloads
725 # ===========================================================================
726
727
728 class TestFuzzedBranchNames:
729 """Randomised payloads: any name containing a forbidden char must be rejected."""
730
731 @pytest.mark.parametrize("seed", range(20))
732 def test_random_control_char_payload(self, seed: int) -> None:
733 import random
734 rng = random.Random(seed)
735 # Build a name with a random C0 or DEL char embedded
736 forbidden = [chr(c) for c in range(0x00, 0x21)] + ["\x7f"]
737 char = rng.choice(forbidden)
738 name = f"feat/{rng.randbytes(4).hex()}{char}suffix"
739 with pytest.raises((ValueError, TypeError)):
740 validate_branch_name(name)
741
742 @pytest.mark.parametrize("seed", range(10))
743 def test_random_git_punct_payload(self, seed: int) -> None:
744 import random
745 rng = random.Random(seed + 100)
746 git_banned = list("~^:?*[")
747 char = rng.choice(git_banned)
748 name = f"branch{char}{rng.randbytes(3).hex()}"
749 with pytest.raises((ValueError, TypeError)):
750 validate_branch_name(name)
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago