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