#!/usr/bin/env python3 """Fail when a source tree contains files unsuitable for public release.""" from __future__ import annotations import argparse import ast import json import re import sys from pathlib import Path TEXT_SUFFIXES = { ".css", ".env", ".html", ".ini", ".js", ".json", ".md", ".py", ".sh", ".toml", ".txt", ".yaml", ".yml", ".ps1", ".cmd", ".bat", } SKIP_DIRS = {".git", ".venv", "venv", "__pycache__", "dist", "THIRD_PARTY_LICENSES"} ALLOWED_RUNTIME_MARKER = ".gitkeep" FORBIDDEN_FILES = { ".env", "data/config/config.json", "data/config/APPINFO.json", "app/main_old.py", "PRUEFEN-0.5.5.5.sh", } FORBIDDEN_RUNTIME_DIRS = { "data/postgres", "data/uploads", "data/logs", "data/backups", "data/backup", "data/scripts", "app/static/uploads", } # Comparison language is blocked independently of a specific competitor name. # Real integrations should be described factually instead of "similar to ...". COMPARISON_PATTERNS = { "similar-to comparison": re.compile(r"\b(?:ähnlich(?:e|er|es|en|em)?|similar to)\b", re.IGNORECASE), "modeled-after comparison": re.compile(r"\b(?:nach (?:dem )?Vorbild|modelled after|modeled after)\b", re.IGNORECASE), "inspired-by comparison": re.compile(r"\b(?:inspiriert von|inspired by)\b", re.IGNORECASE), "like-in comparison": re.compile(r"\b(?:wie bei|as in)\b", re.IGNORECASE), "based-on comparison": re.compile(r"\b(?:angelehnt an|based on the UI of)\b", re.IGNORECASE), } PRIVATE_DOMAIN_PATTERN = re.compile(r"(?:https?://|wss?://|ldaps?://|@)[^\s/\"'<>]+\.(?:local|lan|internal)\b", re.IGNORECASE) IPV4_PATTERN = re.compile(r"(? str: return path.relative_to(root).as_posix() def public_files(root: Path): for path in root.rglob("*"): if not path.is_file(): continue relative = path.relative_to(root) if any(part in SKIP_DIRS for part in relative.parts): continue yield path def read_text(path: Path) -> str | None: if path.suffix.lower() not in TEXT_SUFFIXES and path.name not in {"VERSION", ".gitignore", ".dockerignore", ".gitattributes"}: return None try: return path.read_text(encoding="utf-8") except UnicodeDecodeError: return None def version_from_python(path: Path) -> str: tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) for node in tree.body: if isinstance(node, ast.Assign): for target in node.targets: if isinstance(target, ast.Name) and target.id == "APP_VERSION" and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str): return node.value.value return "" def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) parser.add_argument("--denylist", type=Path, help="Optional local file with one private literal per line") args = parser.parse_args() root = args.root.resolve() errors: list[str] = [] local_literals: list[str] = [] if args.denylist and args.denylist.is_file(): local_literals = [ line.strip().casefold() for line in args.denylist.read_text(encoding="utf-8").splitlines() if line.strip() and not line.lstrip().startswith("#") ] for name in FORBIDDEN_FILES: if (root / name).is_file(): errors.append(f"forbidden public file: {name}") for directory in FORBIDDEN_RUNTIME_DIRS: path = root / directory if not path.exists(): continue for item in path.rglob("*"): if item.is_file() and item.name != ALLOWED_RUNTIME_MARKER: errors.append(f"runtime/user data must not be public: {rel(item, root)}") for path in public_files(root): relative = rel(path, root) lower = path.name.lower() if lower.endswith((".db", ".sqlite", ".sqlite3")): errors.append(f"database file must not be public: {relative}") text = read_text(path) if text is None: continue if PRIVATE_KEY_PATTERN.search(text): errors.append(f"private key material found: {relative}") if PRIVATE_DOMAIN_PATTERN.search(text): errors.append(f"private DNS suffix found in {relative}") for candidate in IPV4_PATTERN.findall(text): try: octets = [int(part) for part in candidate.split(".")] except ValueError: continue if any(part > 255 for part in octets): continue is_rfc1918 = ( octets[0] == 10 or (octets[0] == 172 and 16 <= octets[1] <= 31) or (octets[0] == 192 and octets[1] == 168) ) if is_rfc1918: errors.append(f"private IPv4 address found in {relative}") lowered = text.casefold() for literal in local_literals: if literal in lowered: errors.append(f"local denylist value found in {relative}") if relative not in IGNORE_PRODUCT_SCAN: for label, pattern in COMPARISON_PATTERNS.items(): if pattern.search(text): errors.append(f"{label} found in {relative}") license_path = root / "LICENSE.txt" if not license_path.is_file() or "Apache License" not in license_path.read_text(encoding="utf-8", errors="replace") or "Version 2.0, January 2004" not in license_path.read_text(encoding="utf-8", errors="replace"): errors.append("LICENSE.txt is not the complete Apache License 2.0 text") for appinfo_name in ("APPINFO.json", "APPINFO.example.json"): path = root / appinfo_name try: info = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: errors.append(f"cannot read {appinfo_name}: {exc}") continue if info.get("license") != "Apache-2.0": errors.append(f"{appinfo_name} must use SPDX identifier Apache-2.0") try: version = (root / "VERSION").read_text(encoding="utf-8").strip() py_version = version_from_python(root / "app/version.py") readme = (root / "README.md").read_text(encoding="utf-8") if not version or version != py_version: errors.append(f"version mismatch: VERSION={version!r}, app/version.py={py_version!r}") if version and version not in readme: errors.append(f"README.md does not contain current version {version}") except OSError as exc: errors.append(f"version check failed: {exc}") if errors: print("Public release check failed:") for item in sorted(set(errors)): print(f" - {item}") return 1 print(f"Public release check passed for {root}") return 0 if __name__ == "__main__": raise SystemExit(main())