#!/usr/bin/env python3 """Convert the official Base64 GFWList AutoProxy list into an NG2 rule snapshot. Domain anchors become suffix matches. URL prefixes with an empty or root path and a literal domain host become exact matches, discarding the scheme and port. Other URL conditions are reported as losses rather than expanded to domains. `@@` exceptions shadowed by a broader proxy rule are kept out of the ACL and reported item by item; the broader proxy rules stay. Unknown syntax, corrupt input, or abnormal shrinkage block the update and leave the previous snapshot. """ from __future__ import annotations import argparse import base64 import binascii import datetime as dt import hashlib import ipaddress import json import re import sys from pathlib import Path from urllib.parse import urlsplit CONVERTER_VERSION = "2.0.0" SCHEMA_VERSION = 2 MINIMUM_RULE_COUNT = 100 MAXIMUM_RULE_COUNT = 50_000 MINIMUM_SCALE_RATIO = 0.5 MAXIMUM_SCALE_RATIO = 2.0 DOMAIN_HOST_RE = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$") class UnknownSyntax(Exception): def __init__(self, line: str) -> None: super().__init__(f"unknown AutoProxy syntax: {line}") self.line = line def looks_like_ip(text: str) -> bool: try: ipaddress.ip_address(text) return True except ValueError: pass parts = text.split(".") return len(parts) == 4 and all(p.isdigit() for p in parts) def is_valid_domain_host(host: str) -> bool: return bool(host) and "." in host and DOMAIN_HOST_RE.fullmatch(host) is not None def parse_line(raw_line: str) -> dict: """Classify one AutoProxy line. Raises UnknownSyntax on unrecognized forms.""" line = raw_line.strip() if not line: return {"kind": "blank"} if line.startswith("!"): return {"kind": "comment"} if line.startswith("[") and line.endswith("]"): return {"kind": "header"} is_exception = line.startswith("@@") pattern = line[2:] if is_exception else line if pattern.startswith("||"): return parse_domain_pattern(pattern[2:], line, is_exception) if pattern.startswith("|"): return parse_url_prefix(pattern[1:], line, is_exception) if len(pattern) >= 3 and pattern.startswith("/") and pattern.endswith("/"): return {"kind": "regexp", "original": line, "isException": is_exception} if "*" in pattern: return {"kind": "wildcard", "original": line, "isException": is_exception} if looks_like_ip(pattern): return {"kind": "ipLiteral", "original": line, "isException": is_exception} if "." in pattern and DOMAIN_HOST_RE.fullmatch(pattern): return {"kind": "plainText", "original": line, "isException": is_exception} raise UnknownSyntax(line) def parse_url_prefix(url: str, original: str, is_exception: bool) -> dict: skipped = {"kind": "urlPrefix", "original": original, "isException": is_exception} # urlsplit tolerates whitespace and bare hosts; neither is a literal URL rule. if any(char.isspace() for char in url) or not re.match( r"^[A-Za-z][A-Za-z0-9+.-]*://", url ): return skipped try: parsed = urlsplit(url) host = parsed.hostname or "" _ = parsed.port # Validate numeric syntax and range before dropping it. except ValueError: return skipped if parsed.path not in {"", "/"} or "?" in url or "#" in url or parsed.username is not None: return skipped if ( parsed.netloc.endswith(":") or not is_valid_domain_host(host) or looks_like_ip(host) ): return skipped labels = host.split(".") if ( len(host) > 253 or not all( re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?", label) for label in labels ) or not any(char.isalpha() for char in labels[-1]) ): return skipped kind = "exactDomainException" if is_exception else "exactDomainProxy" return {"kind": kind, "host": host.lower(), "original": original} def parse_domain_pattern(host_part: str, original: str, is_exception: bool) -> dict: if "/" in host_part: return {"kind": "urlPath", "original": original, "isException": is_exception} if "*" in host_part: return {"kind": "wildcard", "original": original, "isException": is_exception} if "$" in host_part: return {"kind": "filterOption", "original": original, "isException": is_exception} host = host_part if host.endswith("^"): host = host[:-1] if "^" in host: raise UnknownSyntax(original) if looks_like_ip(host): return {"kind": "ipLiteral", "original": original, "isException": is_exception} if "." not in host: return { "kind": "singleLabelPrefix", "original": original, "isException": is_exception, } if not is_valid_domain_host(host): raise UnknownSyntax(original) kind = "domainException" if is_exception else "domainProxy" return {"kind": kind, "host": host.lower(), "original": original} def domain_suffix_covers(broad: str, narrow: str) -> bool: broad_host = broad.lower() narrow_host = narrow.lower() return narrow_host == broad_host or narrow_host.endswith("." + broad_host) def split_shadowed( proxy_rules: list[dict], exception_rules: list[dict] ) -> tuple[list[dict], list[dict], list[tuple[dict, dict]]]: """Return (kept_proxy, kept_exceptions, shadowed_pairs). A suffix proxy covers exact or suffix exceptions on that domain or subdomains. An exact proxy only covers an exact exception on the same domain. sslocal matches domain proxy_list before bypass_list, so a covered exception would be an ineffective ACL entry. """ kept_exceptions: list[dict] = [] shadowed: list[tuple[dict, dict]] = [] for exception in exception_rules: blocker = None for proxy in proxy_rules: broad, narrow = proxy["match"], exception["match"] if ( broad["kind"] == "domainSuffix" and domain_suffix_covers(broad["value"], narrow["value"]) ) or (broad == narrow): blocker = proxy break if blocker is None: kept_exceptions.append(exception) else: shadowed.append((exception, blocker)) return proxy_rules, kept_exceptions, shadowed def make_rule(action: str, match: dict) -> dict: return { "action": action, "match": match, } def convert_document(document: str) -> tuple[list[dict], dict]: """Return (rules, lossReport).""" skipped: dict[str, int] = {} proxy_rules: list[dict] = [] exception_rules: list[dict] = [] saw_code_line = False originals: dict[tuple[str, str, str], str] = {} def rule_key(rule: dict) -> tuple[str, str, str]: return (rule["action"], rule["match"]["kind"], rule["match"]["value"]) def bump(category: str) -> None: skipped[category] = skipped.get(category, 0) + 1 for raw_line in document.splitlines(): entry = parse_line(raw_line) kind = entry["kind"] if kind == "blank": bump("blank") elif kind == "comment": bump("comment") elif kind == "header": bump("header") elif kind in {"domainProxy", "domainException", "exactDomainProxy", "exactDomainException"}: saw_code_line = True action = "direct" if kind.endswith("Exception") else "proxy" match_kind = "domainExact" if kind.startswith("exact") else "domainSuffix" rule = make_rule( action, {"kind": match_kind, "value": entry["host"]} ) originals.setdefault(rule_key(rule), entry["original"]) (exception_rules if action == "direct" else proxy_rules).append(rule) elif kind in { "urlPrefix", "urlPath", "filterOption", "wildcard", "regexp", "plainText", "singleLabelPrefix", "ipLiteral", }: saw_code_line = True bump(kind) print(f"unexpressible-{kind}: {entry['original']}") else: raise UnknownSyntax(entry.get("original", raw_line)) if not saw_code_line: raise SystemExit("empty input") kept_proxy, kept_exceptions, shadowed = split_shadowed(proxy_rules, exception_rules) if shadowed: for exception, blocker in shadowed: print( "shadowed-exception: " f"{originals[rule_key(exception)]} " f"shadowed-by {originals[rule_key(blocker)]}" ) rules = kept_proxy + kept_exceptions # Deduplicate by (action, match.kind, match.value), keeping first. seen: set[tuple[str, str, str]] = set() deduped: list[dict] = [] for rule in rules: key = (rule["action"], rule["match"]["kind"], rule["match"]["value"]) if key in seen: continue seen.add(key) deduped.append(rule) rules = deduped report = { "convertedCount": len(rules), "absorbedCount": len(shadowed), "skipped": skipped, "rejected": {}, } return rules, report def build_snapshot( document: str, source: dict, upstream_reference: str, license_name: str, attribution: str, previous_rule_count: int | None, ) -> dict: rules, report = convert_document(document) rule_count = len(rules) if rule_count < MINIMUM_RULE_COUNT: raise SystemExit(f"abnormal rule count {rule_count} (min {MINIMUM_RULE_COUNT})") if rule_count > MAXIMUM_RULE_COUNT: raise SystemExit(f"abnormal rule count {rule_count} (max {MAXIMUM_RULE_COUNT})") if previous_rule_count is not None and previous_rule_count > 0: lower = int(previous_rule_count * MINIMUM_SCALE_RATIO) upper = int(previous_rule_count * MAXIMUM_SCALE_RATIO) if not (lower <= rule_count <= upper): raise SystemExit( f"abnormal scale change: {rule_count} vs previous {previous_rule_count} " f"(allowed {lower}..{upper})" ) return { "schemaVersion": SCHEMA_VERSION, "metadata": { "source": source, "upstreamReference": upstream_reference, "inputDigest": hashlib.sha256(document.encode("utf-8")).hexdigest(), "fetchedAt": dt.datetime.now(dt.timezone.utc) .replace(microsecond=0) .isoformat() .replace("+00:00", "Z"), "converterVersion": CONVERTER_VERSION, "license": license_name, "attribution": attribution, }, "rules": rules, "lossReport": report, } def decode_autoproxy(text: str) -> str: compact = "".join(text.split()) try: raw = base64.b64decode(compact, validate=False) except (binascii.Error, ValueError) as exc: raise SystemExit(f"corrupt Base64 input: {exc}") from exc try: return raw.decode("utf-8") except UnicodeDecodeError as exc: raise SystemExit(f"corrupt AutoProxy payload: {exc}") from exc def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("input", type=Path, help="path to official Base64 gfwlist.txt") parser.add_argument("--upstream", required=True, help="pinned upstream commit") parser.add_argument( "--upstream-reference", required=True, help="upstream repo/product identifier including the pinned commit or URL", ) parser.add_argument("--license", required=True) parser.add_argument("--attribution", required=True) parser.add_argument("--out", type=Path, required=True, help="output snapshot.json") parser.add_argument( "--previous-count", type=int, default=None, help="previous snapshot rule count for scale-change detection", ) args = parser.parse_args() base64_text = args.input.read_text(encoding="utf-8") document = decode_autoproxy(base64_text) source = { "kind": "gfwlist", "upstreamVersion": args.upstream, "label": "GFWList", } try: snapshot = build_snapshot( document, source, args.upstream_reference, args.license, args.attribution, args.previous_count, ) except UnknownSyntax as exc: raise SystemExit(str(exc)) from exc args.out.parent.mkdir(parents=True, exist_ok=True) tmp = args.out.with_suffix(".json.tmp") tmp.write_text(json.dumps(snapshot, separators=(",", ":"), sort_keys=True) + "\n", encoding="utf-8") tmp.replace(args.out) loss = snapshot["lossReport"] print( f"wrote {args.out}: {loss['convertedCount']} rules, " f"skipped={loss['skipped']}, " f"shadowed={loss['absorbedCount']}" ) return 0 if __name__ == "__main__": raise SystemExit(main())