"""Reading a challenge's README header. Only the template's header block is parsed: ### Challenge Name - Category: crypto - Difficulty: medium - Status: Testing Ready ... People fill it in loosely, so the values are normalised here. Whether they are good enough is decided in checks.py. """ from __future__ import annotations import re from dataclasses import dataclass, field from .gitlab import Repo FIELDS = ("Category", "Difficulty", "Event", "Author", "Tester", "Status") HEADING_RE = re.compile(r"^\s{0,3}#{1,4}\s+(.+?)\s*$", re.M) FIELD_RE = re.compile(r"^\s*[-*]\s*\**(?P[A-Za-z ]+?)\**\s*:\s*(?P.*?)\s*$", re.M) # a value the author never touched: "[crypto, pwn, ...]" or "[Author Name]" PLACEHOLDER_RE = re.compile(r"^\[.*\]$") NO_TESTER_RE = re.compile(r"^(\?+|none|tester name|not found\b.*|tbd)(\s.*)?$", re.I) STATUS_ORDER = ["Planning", "PoC Draft", "Testing Ready", "Tested", "Ready"] STATUS_RE = re.compile(r"testing\s*ready|tested|ready|poc(?:\s*draft)?|planning", re.I) STATUS_ICON = {"Planning": "⚪", "PoC Draft": "🟠", "Testing Ready": "🟡", "Tested": "🔵", "Ready": "🟢", None: "❔"} BLOCKED_ICON = "⛔" # what challenges.yml can say about a repo, and how the reports call it SELECTIONS = {"included": "included", "abandoned": "abandoned", "postponed": "left for another CTF"} CATEGORIES = ["crypto", "pwn", "web", "rev", "misc", "osint", "forensic", "hardware"] DIFFICULTIES = ["intro", "easy", "medium", "hard"] DIFF_RE = re.compile(r"intro|easy|medium|hard", re.I) DIFF_RANGE_RE = re.compile(r"(intro|easy|medium|hard)\s*(?:-|–|/|\bto\b)\s*(intro|easy|medium|hard)", re.I) CATEGORY_SPLIT_RE = re.compile(r"[,/&+]| and ") def clean(v: str) -> str: v = re.sub(r"\\+([\[\]_*])", r"\1", v) # markdown escapes, sometimes doubled return v.replace("**", "").strip() def is_placeholder(v: str) -> bool: v = clean(v) return not v or bool(PLACEHOLDER_RE.match(v)) def stage_of(raw: str) -> tuple[str | None, bool]: """Map a free-text status onto STATUS_ORDER -> (stage, clear). "Tested with h4tum-chall-format" is clearly Tested. "Planning to PoC Draft" or a half-deleted option list is not; we take the least advanced stage it names, so nothing looks further along than it is, and call it unclear.""" if is_placeholder(raw): return None, False found = set() for m in STATUS_RE.finditer(raw): w = m.group(0).lower() found.add("Testing Ready" if w.startswith("testing") else "PoC Draft" if w.startswith("poc") else w.title()) if not found: return None, False return min(found, key=STATUS_ORDER.index), len(found) == 1 def difficulty_of(raw: str) -> str | None: """"medium/hard", "medium-hard", "easy to medium" -> a range "medium-hard".""" if is_placeholder(raw): return None m = DIFF_RANGE_RE.search(raw) if m: ends = sorted({m.group(1).lower(), m.group(2).lower()}, key=DIFFICULTIES.index) return "-".join(ends) if len(ends) > 1 else ends[0] # prose like "currently medium, maybe also an easy and a hard version": the first one m = DIFF_RE.search(raw) return m.group(0).lower() if m else None def difficulty_rank(d: str | None) -> float: if not d: return 99 ends = d.split("-") return sum(DIFFICULTIES.index(e) for e in ends) / len(ends) @dataclass class Challenge: repo: Repo name: str = "" fields: dict = field(default_factory=dict) # filled in by overview.collect(), see selection.py and checks.py selection: str | None = None # None: not in challenges.yml skip_checks: list = field(default_factory=list) problems: list = field(default_factory=list) # readme_problems() blockers: list = field(default_factory=list) # repo_blockers() def get(self, key: str, dash: str = "—") -> str: v = clean(self.fields.get(key, "")) return dash if is_placeholder(v) else v # -- derived ----------------------------------------------------------- @property def named(self) -> bool: return bool(self.name) and not is_placeholder(self.name) @property def title(self) -> str: return self.name if self.named else self.repo.path @property def touched(self) -> bool: """Did anyone put *any* real content in the README template?""" return self.named or any(not is_placeholder(self.fields.get(f, "")) for f in FIELDS) @property def status_raw(self) -> str: return self.get("Status") @property def stage(self) -> str | None: return stage_of(self.fields.get("Status", ""))[0] @property def difficulty(self) -> str | None: return difficulty_of(self.fields.get("Difficulty", "")) @property def category(self) -> str | None: """The first category named; "rev, misc" counts as rev.""" raw = self.get("Category", "") first = CATEGORY_SPLIT_RE.split(raw)[0].strip().lower() return first or None @property def included(self) -> bool: return self.selection == "included" @property def claims_testable(self) -> bool: """The README says Testing Ready or beyond; checks.is_testable() also asks the repo.""" return bool(self.stage) and STATUS_ORDER.index(self.stage) >= STATUS_ORDER.index("Testing Ready") @property def icon(self) -> str: return BLOCKED_ICON if self.blockers else STATUS_ICON[self.stage] @property def has_tester(self) -> bool: t = self.get("Tester", "") return bool(t) and not NO_TESTER_RE.match(t) def parse_challenge(repo: Repo) -> Challenge: c = Challenge(repo=repo) text = repo.readme or "" m = HEADING_RE.search(text) if m: c.name = clean(m.group(1)) for fm in FIELD_RE.finditer(text): key = fm.group("key").strip().title() if key in FIELDS and key not in c.fields: c.fields[key] = fm.group("val") return c