gitea: require literal branch protection rules

Replace the reimplemented gobwas/glob matcher with exact-literal rule
matching that fails closed on any special or malformed pattern, accepts
legacy zero priorities, and rejects duplicate primary-branch rules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jenkins 2026-08-17 15:15:35 -03:00
parent 8c6e3acdac
commit 2019b08276
3 changed files with 98 additions and 169 deletions

View File

@ -7,7 +7,6 @@ import argparse
import datetime
import json
import os
import re
import stat
from pathlib import Path
@ -35,90 +34,25 @@ def _read_bounded(path: Path) -> bytes:
return value
def _glob_regex(pattern: str, position: int = 0, terminators: str = "") -> tuple[str, int]:
"""Compile the gobwas/glob syntax Gitea v1.23.8 uses."""
pieces: list[str] = []
while position < len(pattern):
character = pattern[position]
if character in terminators:
break
if character == "\\":
position += 1
if position >= len(pattern):
raise ValueError("trailing escape")
pieces.append(re.escape(pattern[position]))
elif character == "*":
if position + 1 < len(pattern) and pattern[position + 1] == "*":
pieces.append(".*")
position += 1
else:
pieces.append("[^/]*")
elif character == "?":
pieces.append("[^/]")
elif character == "[":
end = pattern.find("]", position + 1)
if end < 0:
raise ValueError("unterminated range")
value = pattern[position + 1 : end]
if not value:
raise ValueError("empty range")
negate = value.startswith("!")
value = value[1:] if negate else value
if not value or "[" in value or "\\" in value:
raise ValueError("invalid range")
if "-" in value:
if value.count("-") != 1 or value.startswith("-") or value.endswith("-"):
raise ValueError("invalid range")
low, high = value.split("-")
if len(low) != 1 or len(high) != 1 or ord(high) < ord(low):
raise ValueError("invalid range")
content = re.escape(low) + "-" + re.escape(high)
else:
content = re.escape(value)
pieces.append("[" + ("^" if negate else "") + content + "]")
position = end
elif character == "{":
alternatives: list[str] = []
position += 1
while True:
alternative, position = _glob_regex(pattern, position, ",}")
alternatives.append(alternative)
if position >= len(pattern):
raise ValueError("unterminated alternatives")
if pattern[position] == "}":
break
position += 1
if len(alternatives) < 2 or any(not item for item in alternatives):
raise ValueError("invalid alternatives")
pieces.append("(?:" + "|".join(alternatives) + ")")
elif character in "]}":
# A closing delimiter outside its grammar context is literal in
# gobwas/glob's lexer.
pieces.append(re.escape(character))
else:
pieces.append(re.escape(character))
position += 1
return "".join(pieces), position
def _is_plain(pattern: str) -> bool:
return not any(character in SPECIAL for character in pattern)
def _matches(pattern: str, branch: str) -> bool:
def _literal_rule(pattern: str) -> str:
"""Return one exact rule name or reject Gitea glob interpretation.
Gitea 1.23.8 delegates matching to gobwas/glob. Reimplementing that
grammar and its error quoting rules here would create a second policy
engine. The reconciler instead accepts only plain ASCII literals. This
deliberately rejects braces, ranges, escapes, malformed globs, and even
special rules that appear unrelated: an operator must remove ambiguity
before this job may attest the primary-branch boundary.
"""
if not pattern.isascii() or not 1 <= len(pattern) <= MAX_RULE_NAME:
raise PolicyError("branch protection rule name is invalid")
if _is_plain(pattern):
return pattern.casefold() == branch.casefold()
try:
expression, position = _glob_regex(pattern)
if position != len(pattern):
raise ValueError("incomplete glob")
except (re.error, ValueError):
# Gitea quotes an invalid special pattern and matches it literally.
# A literal containing a special byte cannot equal main or master.
return False
return re.fullmatch(expression, branch) is not None
if not _is_plain(pattern):
raise PolicyError("glob branch protection rules require human review")
return pattern
def _created(value: object) -> datetime.datetime:
@ -159,7 +93,7 @@ def evaluate(value: bytes, branch: str, reviewer: str) -> str:
data = json.loads(value)
if not isinstance(data, list) or len(data) > MAX_RULES:
raise PolicyError("branch protection response is invalid")
ordered: list[tuple[int, bool, datetime.datetime, int, dict[str, object]]] = []
ordered: list[tuple[int, datetime.datetime, int, dict[str, object]]] = []
for index, raw in enumerate(data):
if not isinstance(raw, dict):
raise PolicyError("branch protection entry is invalid")
@ -168,18 +102,23 @@ def evaluate(value: bytes, branch: str, reviewer: str) -> str:
if (
not isinstance(priority, int)
or isinstance(priority, bool)
or not 1 <= priority <= 1_000_000
or not 0 <= priority <= 1_000_000
or not isinstance(rule_name, str)
):
raise PolicyError("branch protection priority is ambiguous")
created = _created(raw.get("created_at"))
if _matches(rule_name, branch):
# Gitea v1.23.8 sorts by priority, then puts plain names before
# globs, then uses creation time. The API exposes every component.
ordered.append((priority, not _is_plain(rule_name), created, index, raw))
literal = _literal_rule(rule_name)
if literal == branch:
# With every rule constrained to a plain literal, Gitea's glob
# comparator cannot affect this result. Preserve API ordering:
# priority first (including legacy zero), creation time, then the
# stable response order for an otherwise exact tie.
ordered.append((priority, created, index, raw))
if not ordered:
return "ABSENT"
effective = min(ordered)[4]
if len(ordered) != 1:
raise PolicyError("duplicate primary-branch protections are ambiguous")
effective = ordered[0][3]
for key, expected in _required(reviewer).items():
if effective.get(key) != expected:
raise PolicyError(

View File

@ -83,27 +83,16 @@ def test_read_bounded_rejects_unsafe_or_changed_input(
monkeypatch.setattr(module.os, "read", real_read)
@pytest.mark.parametrize(
("pattern", "branch", "expected"),
[
("m?in", "main", True),
("m[ai]in", "main", True),
("m[!z]in", "main", True),
("m]ain", "m]ain", True),
("m}ain", "m}ain", True),
("m[a-z]in", "main", True),
("m{ain,aster}", "main", True),
("m\\ain", "main", True),
],
)
def test_glob_grammar_accepts_supported_constructs(pattern, branch, expected):
module = _module("branch_glob_supported")
assert module._matches(pattern, branch) is expected
@pytest.mark.parametrize(
"pattern",
[
"{main}",
"{main,master}",
"m?in",
"m[ai]in",
"m[!z]in",
"m[a-z]in",
"m\\ain",
"m\\",
"m[",
"m[]",
@ -120,16 +109,26 @@ def test_glob_grammar_accepts_supported_constructs(pattern, branch, expected):
"m{ain,aster",
],
)
def test_invalid_globs_are_literal_nonmatches(pattern):
def test_every_special_or_malformed_glob_fails_closed(pattern):
module = _module("branch_glob_invalid")
assert module._matches(pattern, "main") is False
with pytest.raises(module.PolicyError, match="glob branch protection"):
module._literal_rule(pattern)
@pytest.mark.parametrize("pattern", ["", "x" * 256, "máin"])
def test_rule_names_are_ascii_and_bounded(pattern):
module = _module("branch_name_bounds")
with pytest.raises(module.PolicyError, match="rule name"):
module._matches(pattern, "main")
module._literal_rule(pattern)
def test_plain_rule_matching_is_exact_and_case_sensitive():
module = _module("branch_literal_case")
assert module._literal_rule("main") == "main"
assert module._literal_rule("MAIN") == "MAIN"
assert module.evaluate(
json.dumps([_rule(module, name="MAIN")]).encode(), "main", "bstein"
) == "ABSENT"
@pytest.mark.parametrize("value", [None, "x" * 65, "invalid", "2026-01-01T00:00:00"])
@ -157,7 +156,7 @@ def test_evaluate_rejects_unknown_branch_and_excess_rules():
module.evaluate(json.dumps([{}] * 101).encode(), "main", "bstein")
@pytest.mark.parametrize("priority", [True, 0, 1_000_001, "1"])
@pytest.mark.parametrize("priority", [True, -1, 1_000_001, "1"])
def test_evaluate_rejects_ambiguous_priority_types(priority):
module = _module("branch_priority_bounds")
rule = _rule(module, priority=priority)
@ -165,6 +164,33 @@ def test_evaluate_rejects_ambiguous_priority_types(priority):
module.evaluate(json.dumps([rule]).encode(), "main", "bstein")
def test_legacy_zero_priority_is_accepted_but_duplicate_ties_fail_closed():
module = _module("branch_priority_zero")
assert module.evaluate(
json.dumps([_rule(module, priority=0)]).encode(), "main", "bstein"
) == "PRESENT"
first = _rule(module, priority=0, created_at="2026-01-01T00:00:00Z")
second = _rule(
module,
priority=0,
created_at="2026-01-01T00:00:00Z",
enable_force_push=True,
)
with pytest.raises(module.PolicyError, match="duplicate primary-branch"):
module.evaluate(json.dumps([first, second]).encode(), "main", "bstein")
def test_any_special_rule_blocks_absent_create_and_present_readback():
module = _module("branch_special_readback")
for rules in (
[_rule(module, name="feature/*")],
[_rule(module), _rule(module, name="{main}", priority=0)],
):
with pytest.raises(module.PolicyError, match="human review"):
module.evaluate(json.dumps(rules).encode(), "main", "bstein")
def test_main_reports_success_and_failures(tmp_path: Path, monkeypatch, capsys):
module = _module("branch_main_paths")
source = tmp_path / "rules.json"

View File

@ -216,83 +216,47 @@ def _protection(rule_name: str, priority: int, helper, **overrides):
return value
def test_branch_protection_uses_first_effective_glob_by_priority():
def test_branch_protection_requires_exact_literal_rules_only():
helper = _load_path(
"branch_protection_test",
ROOT / "services/gitea/scripts/gitea_branch_protection_check.py",
)
earlier_drift = _protection("m*", 1, helper, required_approvals=0)
later_exact = _protection("main", 2, helper)
exact = _protection("main", 2, helper)
assert (
helper.evaluate(json.dumps([exact]).encode(), "main", "bstein") == "PRESENT"
)
drifted = _protection("main", 2, helper, required_approvals=0)
with pytest.raises(helper.PolicyError, match="effective main protection differs"):
helper.evaluate(json.dumps([later_exact, earlier_drift]).encode(), "main", "bstein")
helper.evaluate(json.dumps([drifted]).encode(), "main", "bstein")
earlier_drift["required_approvals"] = 1
assert (
helper.evaluate(json.dumps([later_exact, earlier_drift]).encode(), "main", "bstein")
== "PRESENT"
)
brace_drift = _protection("m{ain,aster}", 1, helper, required_approvals=0)
with pytest.raises(helper.PolicyError, match="effective master protection differs"):
helper.evaluate(json.dumps([brace_drift]).encode(), "master", "bstein")
upper_glob = _protection("M*", 1, helper, required_approvals=0)
assert (
helper.evaluate(json.dumps([upper_glob, later_exact]).encode(), "main", "bstein")
== "PRESENT"
)
def test_branch_protection_matches_gitea_v1238_tie_order():
helper = _load_path(
"branch_protection_order_test",
ROOT / "services/gitea/scripts/gitea_branch_protection_check.py",
)
older_glob = _protection(
"m*", 1, helper, created_at="2025-01-01T00:00:00Z", required_approvals=0
)
newer_plain = _protection(
"MAIN", 1, helper, created_at="2026-01-01T00:00:00Z"
)
assert (
helper.evaluate(json.dumps([older_glob, newer_plain]).encode(), "main", "bstein")
== "PRESENT"
)
older_glob["created_at"] = "2026-01-02T00:00:00Z"
earlier_glob = _protection(
"m{ain,aster}",
1,
helper,
created_at="2026-01-01T00:00:00Z",
required_approvals=0,
)
with pytest.raises(helper.PolicyError, match="effective main protection differs"):
helper.evaluate(json.dumps([older_glob, earlier_glob]).encode(), "main", "bstein")
duplicate = _protection("main", 1, helper)
with pytest.raises(helper.PolicyError, match="duplicate primary-branch"):
helper.evaluate(json.dumps([exact, duplicate]).encode(), "main", "bstein")
@pytest.mark.parametrize(
("rule_name", "branch", "expected"),
"rule_name",
[
("release/*", "release/v1.17", True),
("release/**/v1.17", "release/test/1/v1.17", True),
("release/*/v1.17", "release/test/1/v1.17", False),
("*", "release/v1.16", False),
("**", "release/v1.16", True),
("MAIN", "main", True),
("M*", "main", False),
("m{ain,aster}", "master", True),
(r"m\ain", "main", True),
"release/*",
"release/**/v1.17",
"*",
"**",
"M*",
"m{ain,aster}",
r"m\ain",
"m?in",
"m[ai]in",
],
)
def test_branch_glob_matches_gitea_v1238_source_corpus(
rule_name: str, branch: str, expected: bool
):
def test_branch_glob_rules_fail_closed_pending_human_review(rule_name: str):
helper = _load_path(
"branch_protection_glob_parity_test",
ROOT / "services/gitea/scripts/gitea_branch_protection_check.py",
)
assert helper._matches(rule_name, branch) is expected
rules = [_protection(rule_name, 1, helper)]
with pytest.raises(helper.PolicyError, match="human review"):
helper.evaluate(json.dumps(rules).encode(), "main", "bstein")
@pytest.mark.parametrize(
@ -317,5 +281,5 @@ def test_branch_protection_reports_absent_only_when_no_rule_matches():
"branch_protection_absent_test",
ROOT / "services/gitea/scripts/gitea_branch_protection_check.py",
)
rules = [_protection("release/*", 1, helper)]
rules = [_protection("develop", 1, helper)]
assert helper.evaluate(json.dumps(rules).encode(), "master", "bstein") == "ABSENT"