#!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.11" # dependencies = [] # /// import argparse import hashlib import re import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent APPS_DIR = ROOT / "apps" ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$") PATH_PATTERN = re.compile(r"^[A-Za-z0-9._/-]+$") DESCRIPTION_PATTERN = re.compile(r"^--\s*DESCRIPTION:\s*(.+?)\s*$") MAX_APPS = 64 MAX_FILES = 64 MAX_FILE_BYTES = 8 * 1024 * 1024 MAX_APP_BYTES = 16 * 1024 * 1024 MAX_MANIFEST_BYTES = 16384 MAX_CATALOG_BYTES = 32768 def sha256(data: bytes) -> str: return hashlib.sha256(data).hexdigest() def payload_files(app_dir: Path): for path in sorted(app_dir.rglob("*")): if path.is_symlink(): raise ValueError(f"symlinks are not supported: {path.relative_to(ROOT)}") if path.is_file() and path.relative_to(app_dir).as_posix() not in {"manifest.txt", "README.md"}: yield path def description(app_dir: Path) -> str: with (app_dir / "main.lua").open(encoding="utf-8") as source: for line in source: match = DESCRIPTION_PATTERN.match(line) if match: value = match.group(1) if "|" in value: raise ValueError(f"description contains '|': {app_dir.name}") return value raise ValueError(f"missing DESCRIPTION in {app_dir.name}/main.lua") def encode_lines(lines: list[str]) -> bytes: for line in lines: if len(line.encode()) > 192: raise ValueError(f"generated line exceeds 192 bytes: {line[:40]}") return ("\n".join(lines) + "\n").encode() def build_manifest(app_dir: Path) -> bytes: lines = ["XTEINK-MANIFEST|1"] seen_files = set() seen_directories = set() total = 0 for path in payload_files(app_dir): relative = path.relative_to(app_dir).as_posix() lower = relative.lower() if (not PATH_PATTERN.fullmatch(relative) or lower == ".manifest.txt" or any(part in ("", ".", "..") for part in relative.split("/"))): raise ValueError(f"unsupported app path: {relative}") parent = "" for component in lower.split("/"): if parent: if parent in seen_files: raise ValueError(f"path collision: {relative}") seen_directories.add(parent) parent = component if not parent else f"{parent}/{component}" if lower in seen_files or lower in seen_directories: raise ValueError(f"path collision: {relative}") data = path.read_bytes() if not data or len(data) > MAX_FILE_BYTES: raise ValueError(f"unsupported app file size: {relative}") total += len(data) if len(lines) > MAX_FILES or total > MAX_APP_BYTES: raise ValueError(f"app exceeds package limits: {app_dir.name}") seen_files.add(lower) lines.append(f"{relative}|{len(data)}|{sha256(data)}") manifest = encode_lines(lines) if len(manifest) > MAX_MANIFEST_BYTES: raise ValueError(f"manifest exceeds {MAX_MANIFEST_BYTES} bytes: {app_dir.name}") return manifest def generated_files(): apps = [path for path in APPS_DIR.iterdir() if path.is_dir() and (path / "main.lua").is_file()] apps.sort(key=lambda path: path.name.casefold()) if len(apps) > MAX_APPS: raise ValueError(f"catalog exceeds {MAX_APPS} apps") manifests = {} catalog = ["XTEINK-CATALOG|1"] seen_ids = set() for app_dir in apps: app_id = app_dir.name lower_id = app_id.lower() if not ID_PATTERN.fullmatch(app_id) or len(app_id) > 48 or lower_id in seen_ids: raise ValueError(f"invalid or duplicate app id: {app_id}") seen_ids.add(lower_id) app_description = description(app_dir) if len(app_description.encode()) > 80: raise ValueError(f"description exceeds 80 bytes: {app_id}") manifest = build_manifest(app_dir) manifest_path = f"apps/{app_id}/manifest.txt" manifests[ROOT / manifest_path] = manifest catalog.append( f"{app_id}|{app_description}|{manifest_path}|{len(manifest)}|{sha256(manifest)}" ) catalog_data = encode_lines(catalog) if len(catalog_data) > MAX_CATALOG_BYTES: raise ValueError(f"catalog exceeds {MAX_CATALOG_BYTES} bytes") manifests[ROOT / "catalog.txt"] = catalog_data return manifests def main() -> int: parser = argparse.ArgumentParser(description="Regenerate app manifests and catalog.txt") parser.add_argument("--check", action="store_true", help="fail instead of updating stale files") args = parser.parse_args() stale = [] for path, expected in generated_files().items(): if not path.exists() or path.read_bytes() != expected: stale.append(path) if not args.check: temporary = path.with_suffix(path.suffix + ".tmp") temporary.write_bytes(expected) temporary.replace(path) print(f"updated {path.relative_to(ROOT)}") if args.check and stale: for path in stale: print(f"stale: {path.relative_to(ROOT)}", file=sys.stderr) return 1 return 0 if __name__ == "__main__": try: raise SystemExit(main()) except ValueError as error: print(error, file=sys.stderr) raise SystemExit(1)