Files
rustpad/scripts/update_browser_libs.py
T

366 lines
13 KiB
Python
Executable File

#!/usr/bin/env python3
"""Download and refresh RustPad's third-party browser libraries.
The script uses only the Python standard library. It resolves the current
stable package version from the npm registry, verifies the downloaded tarball,
and atomically replaces the generated directory under static/libs.
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import shutil
import sys
import tarfile
import tempfile
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from typing import Callable, Iterable
USER_AGENT = "RustPad browser-library updater/1.0"
DEFAULT_TIMEOUT = 45
class UpdateError(RuntimeError):
pass
@dataclass(frozen=True)
class Library:
key: str
package: str
destination: str
entrypoint: str
repository_fragment: str
installer: Callable[[tarfile.TarFile, Path], None]
def registry_url(package: str) -> str:
encoded = urllib.parse.quote(package, safe="")
return f"https://registry.npmjs.org/{encoded}/latest"
def request_bytes(url: str, timeout: int) -> bytes:
request = urllib.request.Request(
url,
headers={
"Accept": "application/json, application/octet-stream;q=0.9, */*;q=0.8",
"User-Agent": USER_AGENT,
},
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.read()
except (urllib.error.URLError, TimeoutError, OSError) as error:
raise UpdateError(f"Cannot download {url}: {error}") from error
def package_metadata(package: str, timeout: int) -> dict:
raw = request_bytes(registry_url(package), timeout)
try:
metadata = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise UpdateError(f"Invalid npm metadata for {package}") from error
if not isinstance(metadata, dict):
raise UpdateError(f"Unexpected npm metadata for {package}")
return metadata
def repository_url(metadata: dict) -> str:
repository = metadata.get("repository", "")
if isinstance(repository, dict):
repository = repository.get("url", "")
return str(repository or "")
def verify_tarball(data: bytes, dist: dict) -> None:
integrity = str(dist.get("integrity") or "")
if integrity:
algorithms = {
"sha512": hashlib.sha512,
"sha384": hashlib.sha384,
"sha256": hashlib.sha256,
}
for token in integrity.split():
algorithm, separator, encoded = token.partition("-")
if not separator or algorithm not in algorithms:
continue
expected = base64.b64decode(encoded)
actual = algorithms[algorithm](data).digest()
if actual != expected:
raise UpdateError(f"Tarball integrity verification failed ({algorithm})")
return
shasum = str(dist.get("shasum") or "")
if shasum and hashlib.sha1(data).hexdigest().lower() != shasum.lower():
raise UpdateError("Tarball SHA-1 verification failed")
def safe_relative(member_name: str, prefix: tuple[str, ...]) -> PurePosixPath | None:
path = PurePosixPath(member_name)
parts = path.parts
if len(parts) <= len(prefix) or tuple(parts[: len(prefix)]) != prefix:
return None
relative = PurePosixPath(*parts[len(prefix) :])
if relative.is_absolute() or any(part in {"", ".", ".."} for part in relative.parts):
raise UpdateError(f"Unsafe path in package archive: {member_name}")
return relative
def write_member(archive: tarfile.TarFile, member: tarfile.TarInfo, destination: Path) -> None:
if not member.isfile():
return
source = archive.extractfile(member)
if source is None:
raise UpdateError(f"Cannot read {member.name} from package archive")
destination.parent.mkdir(parents=True, exist_ok=True)
with source, destination.open("wb") as output:
shutil.copyfileobj(source, output)
def copy_license(archive: tarfile.TarFile, destination: Path) -> None:
names = {
"package/LICENSE",
"package/LICENSE.txt",
"package/LICENSE.md",
"package/LICENCE",
"package/LICENCE.txt",
"package/LICENCE.md",
}
member = next((item for item in archive.getmembers() if item.isfile() and item.name in names), None)
if member is None:
raise UpdateError("The package archive does not contain a license file")
write_member(archive, member, destination / "LICENSE.txt")
def install_mermaid(archive: tarfile.TarFile, destination: Path) -> None:
copied = 0
for member in archive.getmembers():
relative = safe_relative(member.name, ("package", "dist"))
if relative is None or not member.isfile():
continue
suffix = relative.suffix.lower()
is_entrypoint = relative == PurePosixPath("mermaid.esm.min.mjs")
is_minified_chunk = relative.parts[:2] == ("chunks", "mermaid.esm.min")
if not (is_entrypoint or is_minified_chunk):
continue
if suffix not in {".mjs", ".wasm", ".css"}:
continue
write_member(archive, member, destination / Path(*relative.parts))
copied += 1
if copied == 0 or not (destination / "mermaid.esm.min.mjs").is_file():
raise UpdateError("Mermaid browser entrypoint was not found in the npm package")
copy_license(archive, destination)
def install_highlight(archive: tarfile.TarFile, destination: Path) -> None:
candidates = {
"package/highlight.min.js",
"package/build/highlight.min.js",
}
member = next((item for item in archive.getmembers() if item.isfile() and item.name in candidates), None)
if member is None:
member = next(
(
item
for item in archive.getmembers()
if item.isfile() and PurePosixPath(item.name).name == "highlight.min.js"
),
None,
)
if member is None:
raise UpdateError("Highlight.js browser build was not found in the npm package")
write_member(archive, member, destination / "highlight.min.js")
copy_license(archive, destination)
LIBRARIES = (
Library(
key="mermaid",
package="mermaid",
destination="mermaid",
entrypoint="mermaid.esm.min.mjs",
repository_fragment="mermaid-js/mermaid",
installer=install_mermaid,
),
Library(
key="highlight",
package="@highlightjs/cdn-assets",
destination="highlight",
entrypoint="highlight.min.js",
repository_fragment="highlightjs/highlight.js",
installer=install_highlight,
),
)
def installed_version(destination: Path) -> str:
version_file = destination / "VERSION"
try:
return version_file.read_text(encoding="utf-8").strip()
except OSError:
return ""
def is_complete(library: Library, destination: Path) -> bool:
return (destination / library.entrypoint).is_file() and (destination / "LICENSE.txt").is_file()
def source_note(library: Library, metadata: dict, version: str) -> str:
repository = repository_url(metadata)
return (
f"Package: {library.package}\n"
f"Version: {version}\n"
f"Registry: {registry_url(library.package)}\n"
f"Repository: {repository}\n"
"Generated by scripts/update_browser_libs.py; do not edit or commit this directory.\n"
)
def install_library(
library: Library,
metadata: dict,
destination: Path,
timeout: int,
) -> None:
version = str(metadata.get("version") or "").strip()
dist = metadata.get("dist")
if not version or not isinstance(dist, dict) or not dist.get("tarball"):
raise UpdateError(f"npm metadata for {library.package} is missing version or tarball data")
repository = repository_url(metadata).lower()
if library.repository_fragment.lower() not in repository:
raise UpdateError(
f"Unexpected repository for {library.package}: {repository or 'not provided'}"
)
tarball = request_bytes(str(dist["tarball"]), timeout)
verify_tarball(tarball, dist)
destination.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix=f".{library.key}-", dir=destination.parent) as temporary:
staging = Path(temporary) / library.destination
staging.mkdir(parents=True)
archive_path = Path(temporary) / "package.tgz"
archive_path.write_bytes(tarball)
try:
with tarfile.open(archive_path, mode="r:gz") as archive:
library.installer(archive, staging)
except (tarfile.TarError, OSError) as error:
raise UpdateError(f"Cannot unpack {library.package}: {error}") from error
(staging / "VERSION").write_text(f"{version}\n", encoding="utf-8")
(staging / "SOURCE.txt").write_text(
source_note(library, metadata, version), encoding="utf-8"
)
if not is_complete(library, staging):
raise UpdateError(f"Generated {library.key} directory is incomplete")
old_destination = destination.with_name(f".{destination.name}.old")
if old_destination.exists():
shutil.rmtree(old_destination)
if destination.exists():
destination.replace(old_destination)
try:
shutil.move(str(staging), str(destination))
except Exception:
if old_destination.exists() and not destination.exists():
old_destination.replace(destination)
raise
finally:
if old_destination.exists():
shutil.rmtree(old_destination)
def selected_libraries(keys: Iterable[str]) -> list[Library]:
requested = set(keys)
if not requested:
return list(LIBRARIES)
return [library for library in LIBRARIES if library.key in requested]
def update_one(
library: Library,
libs_root: Path,
*,
timeout: int,
force: bool,
check: bool,
strict: bool,
) -> bool:
destination = libs_root / library.destination
current = installed_version(destination)
complete = is_complete(library, destination)
try:
metadata = package_metadata(library.package, timeout)
except UpdateError as error:
if complete and not strict:
print(f"warning: {error}; keeping {library.key} {current or 'local copy'}", file=sys.stderr)
return True
raise
latest = str(metadata.get("version") or "").strip()
if not latest:
raise UpdateError(f"npm did not return a version for {library.package}")
if complete and current == latest and not force:
print(f"{library.key}: up to date ({latest})")
return True
state = "missing" if not complete else f"{current or 'unknown'} -> {latest}"
if check:
print(f"{library.key}: update required ({state})")
return False
print(f"{library.key}: downloading {latest} ({state})")
install_library(library, metadata, destination, timeout)
print(f"{library.key}: installed {latest}")
return True
def parse_args() -> argparse.Namespace:
script_root = Path(__file__).resolve().parent.parent
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=script_root, help="RustPad repository root")
parser.add_argument("--library", action="append", choices=[item.key for item in LIBRARIES], default=[])
parser.add_argument("--force", action="store_true", help="download again even when the version is current")
parser.add_argument("--check", action="store_true", help="only report whether an update is required")
parser.add_argument("--strict", action="store_true", help="fail when the registry cannot be reached")
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT, help="network timeout in seconds")
return parser.parse_args()
def main() -> int:
args = parse_args()
root = args.root.resolve()
libs_root = root / "static" / "libs"
if args.timeout < 1:
print("error: --timeout must be at least 1 second", file=sys.stderr)
return 2
success = True
try:
for library in selected_libraries(args.library):
success = update_one(
library,
libs_root,
timeout=args.timeout,
force=args.force,
check=args.check,
strict=args.strict or args.check,
) and success
except UpdateError as error:
print(f"error: {error}", file=sys.stderr)
return 1
return 0 if success else 1
if __name__ == "__main__":
raise SystemExit(main())