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