Files
Nivora/tools/validate_repo.py
T

326 lines
11 KiB
Python
Executable File

#!/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-alt",
"claude-desktop",
"codex",
"fisher",
"github-desktop",
"happ",
"netbird",
"nivora-stplr",
"opencode",
"parsec",
"pineconemc",
"tailscale",
"ventoy",
"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-desktop.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-stplr.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")
if replaces != [package]:
errors.append(f"{package}: replaces must contain only its own base package name")
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*package-count\s*-->\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())