Split ctf_status.py into a tracker package, config from .env only
ctf_status.py is now just the command line; the work lives in tracker/. The rules that decide whether a challenge is believed are in tracker/checks.py, one function per rule, listed in README_CHECKS and REPO_CHECKS. Output is unchanged. Read the config from .env only (ctf_status.env.example -> .env.example), let single_run_lock() hold its own lock files, and rewrite the README as a short overview of what the repo does and how to run it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
"""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<key>[A-Za-z ]+?)\**\s*:\s*(?P<val>.*?)\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 = "⛔"
|
||||
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 checks.py
|
||||
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 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
|
||||
Reference in New Issue
Block a user