151 lines
5.3 KiB
Python
Executable File
151 lines
5.3 KiB
Python
Executable File
#!/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)
|