diff --git a/.claude/hooks/pretooluse-merge-consent.sh b/.claude/hooks/pretooluse-merge-consent.sh index e01f7c3a9..04d7b7ffe 100755 --- a/.claude/hooks/pretooluse-merge-consent.sh +++ b/.claude/hooks/pretooluse-merge-consent.sh @@ -437,7 +437,16 @@ else # case-insensitively (matching Gitea's EqualFold). Decidable; inspect the rule. # undecidable — some glob rule COULD govern this base. Tested with a provable SUPERSET of any # glob dialect: literal prefix before the first metacharacter, `.*`, literal - # suffix after the last. If even that does not match, no dialect can. + # suffix after the last. If even that does not match, no dialect can, because + # every dialect requires the literal head and tail to match literally. + # + # BACKSLASH counts as a metacharacter for that purpose, and it is the one case that breaks the + # superset proof if it does not. gobwas/glob reads `\{` as a LITERAL brace, so a rule `a\{b` + # governs the base `a{b` — while a superset that treated `\` as literal would build `a\.*b`, + # fail to match, and answer `none`, i.e. deny a base that IS protected. Git ref rules make this + # nearly unreachable (a branch name may not contain `*`, `?`, `[` or `\`, though it MAY contain + # `{`), but `none` is the arm that authorises a DENY on the stated grounds "nothing can govern + # this base", so its premise has to hold unconditionally rather than usually. # none — nothing can possibly govern the base, so it is genuinely unprotected. # # `undecidable` asks rather than granting or denying. Over-matching would auto-grant on a base @@ -447,7 +456,7 @@ else # directions, and it is rare: the repo's rule today is the plain name `main`. bp_verdict=$(printf '%s' "$bp_list" | jq --arg b "$base_ref" -c ' def esc: gsub("(?[.+?^${}()|\\[\\]\\\\])"; "\\" + .c); - def offs: [match("[*?\\[\\]{}]"; "g").offset]; + def offs: [match("[*?\\[\\]{}\\\\]"; "g").offset]; def superset: . as $n | (offs) as $o | ($n[0:$o[0]] | esc) + ".*" + ($n[($o[-1]+1):] | esc); . as $rules | $b as $base | diff --git a/scripts/tests/test_merge_consent_required_check.py b/scripts/tests/test_merge_consent_required_check.py index 3b1ed8440..4152a4cf0 100644 --- a/scripts/tests/test_merge_consent_required_check.py +++ b/scripts/tests/test_merge_consent_required_check.py @@ -65,6 +65,12 @@ if url.rstrip("/").endswith("/branch_protections"): # The LIST endpoint, consulted only after a 404 on the by-name lookup. Gitea keys the by-name # endpoint on the RULE name, so a base covered by a glob rule 404s there while being protected. mode = (state / "bp").read_text().strip() + if mode == "ESCAPED-META-RULE": + # gobwas/glob reads `\\{` as a LITERAL brace, so this rule governs the base `a{b`. If the + # superset treats `\\` as literal it builds `a\\.*b`, misses, and answers `none` — denying a + # base that IS protected. + respond(json.dumps([{"branch_name": "a\\{b", "enable_status_check": True, + "status_check_contexts": ["review-verdict/h10"]}])) if mode == "CASEFOLD-RULE": # Gitea compares a plain (non-glob) rule name with EqualFold, so a rule named `MAIN` # governs base `main`. A case-SENSITIVE comparison here would miss it and deny. @@ -107,7 +113,8 @@ if "/branch_protections/" in url: # curl itself fails: no body, and `-w` prints 000. respond("", "000") if mode in ("NOT-FOUND", "GLOB-RULE", "LIST-UNREADABLE", "REGEX-META-RULE", - "GLOB-WITH-DOT-RULE", "CHARCLASS-RULE", "CASEFOLD-RULE"): + "GLOB-WITH-DOT-RULE", "CHARCLASS-RULE", "CASEFOLD-RULE", + "ESCAPED-META-RULE"): respond('{"message":"not found"}', "404") if mode == "FORBIDDEN": respond('{"message":"token does not have at least one of required scope(s)"}', "403") @@ -654,3 +661,26 @@ def test_a_plain_rule_name_is_matched_CASE_INSENSITIVELY(hook): assert "NO branch protection" not in reason, ( "a rule named 'MAIN' governs base 'main' in Gitea but was missed by a case-sensitive compare") assert "deny" not in reason + + +def test_a_BACKSLASH_ESCAPED_metacharacter_in_a_rule_name_is_undecidable_not_absent(hook): + """The one case that breaks the superset proof the `none` arm rests on. + + `none` authorises a DENY on the stated grounds that nothing can possibly govern this base, so + its premise must hold unconditionally. gobwas/glob reads `\\{` as a LITERAL brace, so the rule + `a\\{b` governs the base `a{b`; a superset that treated `\\` as an ordinary character would build + `a\\.*b`, fail to match, and deny a base that is in fact protected. Treating backslash as a + metacharacter restores the property. + + Git ref rules make this nearly unreachable — a branch name may not contain `*`, `?`, `[` or `\\` + — but `{` IS legal in a branch name, and "nearly unreachable" is not the standard for the arm + that issues a deny. + """ + hook.set_base("a{b") + hook.drop_recorded_base() + hook.set_branch_protection("ESCAPED-META-RULE") + reason = hook.reason() + assert "NO branch protection" not in reason, ( + "a rule whose escaped brace governs this base was reported as unable to govern it") + assert "ask" in reason, ( + "an escaped-metacharacter rule is undecidable here and must ask")