#!/usr/bin/env python3 from __future__ import annotations import hashlib import os import re import shlex import sys from pathlib import Path from urllib.parse import unquote, urlsplit ROOT = Path(__file__).resolve().parents[1] EXPECTED_PACKAGES = ( "adwyra", "anidesk", "balena-etcher", "chatbox", "clash-verge-rev", "claude", "claude-alt", "codex", "fisher", "github-desktop", "happ", "netbird", "nivora-cli", "opencode", "parsec", "pineconemc", "tailscale", "telegram-desktop", "ventoy", "vintner", "vual", "yandex-browser-stable", ) REQUIRED_ROOT_FILES = { Path("README.md"), Path("CHANGELOG.md"), Path("CONTRIBUTING.md"), Path("SECURITY.md"), Path("LICENSE"), Path("stapler-repo.toml"), Path("docs/maintenance.md"), Path("docs/security-model.md"), Path("docs/packages/claude.md"), Path("docs/packages/claude-alt.md"), Path("docs/packages/codex.md"), Path("docs/packages/github-desktop.md"), Path("docs/packages/opencode.md"), Path("docs/packages/nivora-cli.md"), Path("docs/packages/ventoy.md"), } CHECKSUM_RE = re.compile(r"(?:sha256:)?[0-9a-f]{64}\Z") MARKDOWN_LINK_RE = re.compile(r"!?\[[^\]]*\]\(([^)\s]+)(?:\s+[^)]*)?\)") HTML_LINK_RE = re.compile(r"(?:src|href)=[\"']([^\"']+)[\"']") SECRET_PATTERNS = ( re.compile(r"-----BEGIN (?:RSA |OPENSSH |EC )?PRIVATE KEY-----"), re.compile(r"\bgh[pousr]_[A-Za-z0-9_]{20,}\b"), re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,}\b"), re.compile(r"\bAKIA[0-9A-Z]{16}\b"), ) def scalar(text: str, field: str) -> str | None: match = re.search( rf"^{re.escape(field)}=(?:'([^']*)'|\"([^\"]*)\"|([^#\n]+))", text, re.MULTILINE, ) if not match: return None return next(value.strip() for value in match.groups() if value is not None) def array(text: str, field: str) -> list[str] | None: match = re.search( rf"^{re.escape(field)}=\((.*?)\)", text, re.MULTILINE | re.DOTALL ) if not match: return None try: return shlex.split(match.group(1), comments=True, posix=True) except ValueError: return None def source_arrays(text: str) -> dict[str, list[str]]: result: dict[str, list[str]] = {} for match in re.finditer(r"^(sources(?:_[a-z0-9_]+)?)=\(", text, re.MULTILINE): name = match.group(1) values = array(text, name) if values is not None: result[name] = values return result def sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as stream: for chunk in iter(lambda: stream.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def local_source_name(source: str) -> str | None: if not source.startswith("local:///"): return None value = unquote(source.removeprefix("local:///").split("?", 1)[0]) path = Path(value) if not value or path.is_absolute() or ".." in path.parts: return "" return value def markdown_targets(text: str) -> set[str]: return set(MARKDOWN_LINK_RE.findall(text)) | set(HTML_LINK_RE.findall(text)) def validate_links(path: Path, errors: list[str]) -> None: text = path.read_text(encoding="utf-8") for target in markdown_targets(text): parsed = urlsplit(target) if parsed.scheme or target.startswith(("mailto:", "#")): continue local = unquote(parsed.path) if not local: continue if local.startswith("/"): errors.append(f"{path.relative_to(ROOT)}: unsafe local link: {target}") continue resolved = (path.parent / local).resolve() try: resolved.relative_to(ROOT) except ValueError: errors.append(f"{path.relative_to(ROOT)}: link escapes repository: {target}") continue if not resolved.exists(): errors.append(f"{path.relative_to(ROOT)}: missing link target: {target}") def validate_package(package: str, errors: list[str]) -> dict[str, object]: directory = ROOT / package staplerfile = directory / "Staplerfile" text = staplerfile.read_text(encoding="utf-8") name = scalar(text, "name") version = scalar(text, "version") release = scalar(text, "release") architectures = array(text, "architectures") provides = array(text, "provides") replaces = array(text, "replaces") conflicts = array(text, "conflicts") if name != package: errors.append(f"{package}: directory and name differ: {name!r}") if not version: errors.append(f"{package}: version is missing") if not release or not release.isdigit() or int(release) < 1: errors.append(f"{package}: release must be a positive integer") if not architectures: errors.append(f"{package}: architectures are missing") elif any(item not in {"amd64", "arm64", "all"} for item in architectures): errors.append(f"{package}: unsupported architecture value: {architectures}") if provides != [] or conflicts != []: errors.append(f"{package}: provides/conflicts must not contain binary aliases") expected_replaces = [package] if package == "claude": expected_replaces.append("claude-desktop") if replaces != expected_replaces: errors.append( f"{package}: replaces must be {expected_replaces}, got {replaces}" ) if "package()" not in text or "files()" not in text: errors.append(f"{package}: package() or files() is missing") arrays = source_arrays(text) if not arrays: errors.append(f"{package}: sources are missing") for source_field, sources in arrays.items(): checksum_field = source_field.replace("sources", "checksums", 1) checksums = array(text, checksum_field) if checksums is None: errors.append(f"{package}: {checksum_field} is missing") continue if len(sources) != len(checksums): errors.append( f"{package}: {source_field}/{checksum_field} lengths differ " f"({len(sources)} != {len(checksums)})" ) continue for source, checksum in zip(sources, checksums, strict=True): if checksum == "SKIP" or not CHECKSUM_RE.fullmatch(checksum): errors.append(f"{package}: invalid checksum for {source}: {checksum}") continue if source.startswith("http://"): errors.append(f"{package}: insecure source URL: {source}") if source.startswith("git+") and "#" not in source: errors.append(f"{package}: unpinned Git source: {source}") local_name = local_source_name(source) if local_name is None: continue if local_name == "": errors.append(f"{package}: unsafe local source: {source}") continue local_path = directory / local_name if not local_path.is_file(): errors.append(f"{package}: missing local source: {local_name}") continue expected = checksum.removeprefix("sha256:") actual = sha256(local_path) if actual != expected: errors.append( f"{package}: checksum mismatch for {local_name}: {actual} != {expected}" ) for hook in re.findall(r"\['[^']+'\]='([^']+)'", text): hook_path = directory / hook if not hook_path.is_file(): errors.append(f"{package}: missing lifecycle script: {hook}") elif not os.access(hook_path, os.X_OK): errors.append(f"{package}: lifecycle script is not executable: {hook}") update_check = directory / ".stapler/update-check" if not update_check.is_file() or not os.access(update_check, os.X_OK): errors.append(f"{package}: executable .stapler/update-check is required") return { "name": name, "version": version, "architectures": architectures or [], } def validate_readme(metadata: dict[str, dict[str, object]], errors: list[str]) -> None: path = ROOT / "README.md" text = path.read_text(encoding="utf-8") count_match = re.search(r"\s*\*\*(\d+) пакет", text) if not count_match or int(count_match.group(1)) != len(EXPECTED_PACKAGES): errors.append("README.md: package counter is stale") if text.count("### ") != 6: errors.append("README.md: catalog must contain exactly six categories") for package, values in metadata.items(): command = f"`stplr install nivora/{package}`" catalog_rows = [ line for line in text.splitlines() if line.startswith("|") and command in line ] if len(catalog_rows) != 1: errors.append(f"README.md: expected one catalog command for {package}") version = str(values["version"]) if f"`{version}`" not in text: errors.append(f"README.md: version {version} is missing for {package}") def validate_repository_text(errors: list[str]) -> None: for path in ROOT.rglob("*"): if not path.is_file() or ".git" in path.parts: continue relative = path.relative_to(ROOT) if path.suffix.lower() in {".png", ".ico", ".zip", ".gz"}: continue try: text = path.read_text(encoding="utf-8") except UnicodeDecodeError: continue scans_validator_source = relative == Path("tools/validate_repo.py") if not scans_validator_source and ( "/home/cheviiot" in text or "/.codex/attachments/" in text ): errors.append(f"{relative}: personal path is forbidden") if not scans_validator_source and "chmod 777" in text: errors.append(f"{relative}: chmod 777 is forbidden") if not scans_validator_source and re.search(r"\brm\s+-rf\b", text): allowed_purge = relative in { Path("tailscale/tailscale-purge-data"), Path("netbird/netbird-purge-data"), } if not allowed_purge or "--yes" not in text: errors.append(f"{relative}: unsafe rm -rf") for pattern in SECRET_PATTERNS: if pattern.search(text): errors.append(f"{relative}: possible secret detected") if text.startswith("#!") and not os.access(path, os.X_OK): errors.append(f"{relative}: script is not executable") def main() -> int: errors: list[str] = [] for required in sorted(REQUIRED_ROOT_FILES): if not (ROOT / required).is_file(): errors.append(f"missing required file: {required}") package_dirs = tuple( sorted(path.name for path in ROOT.iterdir() if (path / "Staplerfile").is_file()) ) if package_dirs != EXPECTED_PACKAGES: errors.append( "package list mismatch: " f"expected {', '.join(EXPECTED_PACKAGES)}; got {', '.join(package_dirs)}" ) metadata: dict[str, dict[str, object]] = {} for package in package_dirs: metadata[package] = validate_package(package, errors) validate_readme(metadata, errors) for path in sorted([*ROOT.glob("*.md"), *ROOT.glob("docs/**/*.md")]): validate_links(path, errors) validate_repository_text(errors) if errors: print("Nivora validation failed:", file=sys.stderr) for error in errors: print(f"- {error}", file=sys.stderr) return 1 print(f"OK: validated {len(package_dirs)} Nivora packages") return 0 if __name__ == "__main__": raise SystemExit(main())