fix(778): backslash is a metacharacter too, or the none arm's proof does not hold
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 18s
PR Gates / decisions lifecycle (pull_request) Successful in 17s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 14s
review-verdict/h10 Awaiting review verdict for cd1b286
Review verdict / Set review-verdict status (pull_request_target) Successful in 16s
PR Gates / Script tests (pytest) (pull_request) Successful in 4m12s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m56s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m18s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m49s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s

Self-found while stress-testing the superset claim I introduced one commit earlier —
the crux the new classifier's safety rests on.

`none` authorises a DENY on the stated grounds that nothing can possibly govern this
base, so its premise must hold unconditionally, not usually. The superset was "literal
prefix + .* + literal suffix", which is sound for every glob dialect EXCEPT one case:
gobwas/glob reads `\{` as a LITERAL brace, so a rule `a\{b` governs the base `a{b`,
while a superset treating `\` as an ordinary character builds `a\.*b`, misses, and
denies a base that is in fact protected.

Verified before and after: with `\` outside the metacharacter class the classifier
answered `none` for that pair; with it inside, `undecidable` -> ask. 18 adversarial
rule/base pairs (brace alternation, negated and ranged classes, `**`, leading and
trailing metacharacters, unicode, empty alternation) all answer `undecidable`, never
`none`, so no dialect-matching case falls through the deny arm.

Git ref rules make this nearly unreachable — a branch name may not contain `*`, `?`,
`[` or `\` — but `{` IS legal in one, and "nearly unreachable" is not the standard for
the arm that issues a deny. Checked with `git check-ref-format` rather than assumed.

refs #778

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-19 18:59:36 +02:00
co-authored by Claude Opus 5
parent 456042ee51
commit cd1b286378
2 changed files with 42 additions and 3 deletions
+11 -2
View File
@@ -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>[.+?^${}()|\\[\\]\\\\])"; "\\" + .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 |
@@ -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")