Files
rustpad/scripts/update_browser_libs.py

651 lines
23 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. By default it resolves the
current stable package versions from npm. Reproducible builds can use a
committed lock file generated with ``--update-lock`` and consumed with
``--locked``.
"""
from __future__ import annotations
import argparse
import base64
import binascii
import hashlib
import json
import os
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/0.1"
DEFAULT_TIMEOUT = 45
LOCK_SCHEMA = 1
DEFAULT_LOCK_FILE = Path("scripts/browser-libs.lock.json")
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, version: str = "latest") -> str:
encoded_package = urllib.parse.quote(package, safe="")
encoded_version = urllib.parse.quote(version, safe="")
return f"https://registry.npmjs.org/{encoded_package}/{encoded_version}"
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, version: str = "latest") -> dict:
raw = request_bytes(registry_url(package, version), timeout)
try:
metadata = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise UpdateError(f"Invalid npm metadata for {package}@{version}") from error
if not isinstance(metadata, dict):
raise UpdateError(f"Unexpected npm metadata for {package}@{version}")
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 package_dist(metadata: dict) -> dict:
dist = metadata.get("dist")
if not isinstance(dist, dict):
return {}
return dist
def validate_tarball_url(url: str, package: str) -> None:
parsed = urllib.parse.urlparse(url)
if parsed.scheme != "https" or not parsed.netloc:
raise UpdateError(f"Invalid HTTPS tarball URL for {package}: {url or 'not provided'}")
def validate_metadata(
library: Library,
metadata: dict,
*,
require_integrity: bool = True,
) -> tuple[str, dict, str]:
version = str(metadata.get("version") or "").strip()
dist = package_dist(metadata)
tarball = str(dist.get("tarball") or "").strip()
integrity = str(dist.get("integrity") or "").strip()
shasum = str(dist.get("shasum") or "").strip()
repository = repository_url(metadata).strip()
if not version or not tarball:
raise UpdateError(
f"npm metadata for {library.package} is missing version or tarball data"
)
validate_tarball_url(tarball, library.package)
if require_integrity and not integrity and not shasum:
raise UpdateError(f"npm metadata for {library.package}@{version} has no integrity hash")
if library.repository_fragment.lower() not in repository.lower():
raise UpdateError(
f"Unexpected repository for {library.package}: {repository or 'not provided'}"
)
return version, dist, repository
def verify_tarball(data: bytes, dist: dict) -> None:
integrity = str(dist.get("integrity") or "").strip()
algorithms = {
"sha512": hashlib.sha512,
"sha384": hashlib.sha384,
"sha256": hashlib.sha256,
}
if integrity:
recognized = False
for token in integrity.split():
algorithm, separator, encoded = token.partition("-")
if not separator or algorithm not in algorithms:
continue
recognized = True
try:
expected = base64.b64decode(encoded, validate=True)
except (binascii.Error, ValueError) as error:
raise UpdateError(f"Invalid {algorithm} integrity value") from error
actual = algorithms[algorithm](data).digest()
if actual != expected:
raise UpdateError(f"Tarball integrity verification failed ({algorithm})")
return
if not recognized:
raise UpdateError("Tarball integrity uses an unsupported hash algorithm")
shasum = str(dist.get("shasum") or "").strip()
if shasum:
if hashlib.sha1(data).hexdigest().lower() != shasum.lower():
raise UpdateError("Tarball SHA-1 verification failed")
return
raise UpdateError("Tarball metadata does not contain a supported integrity hash")
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,
),
)
LIBRARIES_BY_KEY = {library.key: library for library in LIBRARIES}
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:
dist = package_dist(metadata)
repository = repository_url(metadata)
return (
f"Package: {library.package}\n"
f"Version: {version}\n"
f"Registry: {registry_url(library.package, version)}\n"
f"Tarball: {dist.get('tarball', '')}\n"
f"Integrity: {dist.get('integrity') or dist.get('shasum') or ''}\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, dist, _ = validate_metadata(library, metadata)
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 resolve_path(root: Path, value: Path) -> Path:
return value.resolve() if value.is_absolute() else (root / value).resolve()
def load_lock_file(path: Path, *, required: bool) -> dict:
if not path.is_file():
if required:
raise UpdateError(
f"Lock file does not exist: {path}. Generate it with --update-lock first."
)
return {"schema": LOCK_SCHEMA, "libraries": {}}
try:
document = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
raise UpdateError(f"Cannot read lock file {path}: {error}") from error
if not isinstance(document, dict) or document.get("schema") != LOCK_SCHEMA:
raise UpdateError(f"Unsupported or missing lock-file schema in {path}")
entries = document.get("libraries")
if not isinstance(entries, dict):
raise UpdateError(f"Lock file {path} has no libraries object")
return document
def metadata_from_lock(library: Library, document: dict) -> dict:
entries = document["libraries"]
entry = entries.get(library.key)
if not isinstance(entry, dict):
raise UpdateError(f"Lock file has no entry for {library.key}")
package = str(entry.get("package") or "").strip()
version = str(entry.get("version") or "").strip()
tarball = str(entry.get("tarball") or "").strip()
integrity = str(entry.get("integrity") or "").strip()
shasum = str(entry.get("shasum") or "").strip()
repository = str(entry.get("repository") or "").strip()
if package != library.package:
raise UpdateError(
f"Lock entry {library.key} points to {package or 'no package'}, expected {library.package}"
)
metadata = {
"name": package,
"version": version,
"repository": repository,
"dist": {
"tarball": tarball,
"integrity": integrity,
"shasum": shasum,
},
}
validate_metadata(library, metadata)
return metadata
def lock_entry_from_metadata(library: Library, metadata: dict) -> dict:
version, dist, repository = validate_metadata(library, metadata)
return {
"package": library.package,
"version": version,
"tarball": str(dist.get("tarball") or ""),
"integrity": str(dist.get("integrity") or ""),
"shasum": str(dist.get("shasum") or ""),
"repository": repository,
}
def write_lock_file(path: Path, document: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
payload = json.dumps(document, indent=2, sort_keys=True, ensure_ascii=True) + "\n"
descriptor, temporary_name = tempfile.mkstemp(
prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
)
temporary_path = Path(temporary_name)
try:
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as output:
output.write(payload)
output.flush()
os.fsync(output.fileno())
os.replace(temporary_path, path)
except Exception:
temporary_path.unlink(missing_ok=True)
raise
def parse_version_overrides(values: Iterable[str]) -> dict[str, str]:
overrides: dict[str, str] = {}
for value in values:
key, separator, version = value.partition("=")
key = key.strip()
version = version.strip()
if not separator or key not in LIBRARIES_BY_KEY or not version:
choices = ", ".join(sorted(LIBRARIES_BY_KEY))
raise UpdateError(
f"Invalid --version value {value!r}; expected LIBRARY=VERSION ({choices})"
)
if key in overrides:
raise UpdateError(f"Duplicate --version value for {key}")
overrides[key] = version
return overrides
def update_one(
library: Library,
metadata: dict,
libs_root: Path,
*,
timeout: int,
force: bool,
check: bool,
) -> bool:
target, _, _ = validate_metadata(library, metadata)
destination = libs_root / library.destination
current = installed_version(destination)
complete = is_complete(library, destination)
if complete and current == target and not force:
print(f"{library.key}: up to date ({target})")
return True
state = "missing" if not complete else f"{current or 'unknown'} -> {target}"
if check:
print(f"{library.key}: update required ({state})")
return False
print(f"{library.key}: downloading {target} ({state})")
install_library(library, metadata, destination, timeout)
print(f"{library.key}: installed {target}")
return True
def check_updates(
libraries: list[Library],
libs_root: Path,
lock_path: Path,
timeout: int,
) -> bool:
document = load_lock_file(lock_path, required=False)
entries = document["libraries"]
success = True
for library in libraries:
baseline = ""
source = "installed copy"
if library.key in entries:
baseline = str(metadata_from_lock(library, document).get("version") or "")
source = "lock file"
if not baseline:
baseline = installed_version(libs_root / library.destination)
latest_metadata = package_metadata(library.package, timeout)
latest, _, _ = validate_metadata(library, latest_metadata)
if not baseline:
print(f"{library.key}: no locked or installed version; latest is {latest}")
success = False
elif baseline == latest:
print(f"{library.key}: {source} is current ({latest})")
else:
print(f"{library.key}: update available ({baseline} -> {latest}, based on {source})")
success = False
return success
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=[],
help="limit the operation to one library; may be repeated",
)
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 verify that installed assets match the target versions")
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")
parser.add_argument(
"--lock-file",
type=Path,
default=DEFAULT_LOCK_FILE,
help=f"lock-file path relative to --root (default: {DEFAULT_LOCK_FILE})",
)
modes = parser.add_mutually_exclusive_group()
modes.add_argument(
"--locked",
action="store_true",
help="install exact versions and tarballs from the lock file without querying npm metadata",
)
modes.add_argument(
"--update-lock",
action="store_true",
help="resolve target versions, install them, and atomically update the lock file",
)
modes.add_argument(
"--check-updates",
action="store_true",
help="compare locked or installed versions with the current npm latest versions",
)
parser.add_argument(
"--version",
action="append",
default=[],
metavar="LIBRARY=VERSION",
help="resolve an exact npm version instead of latest; may be repeated",
)
return parser.parse_args()
def validate_arguments(args: argparse.Namespace, overrides: dict[str, str]) -> None:
if args.timeout < 1:
raise UpdateError("--timeout must be at least 1 second")
if args.locked and overrides:
raise UpdateError("--version cannot be combined with --locked")
if args.check_updates and (args.check or args.force or overrides):
raise UpdateError("--check-updates cannot be combined with --check, --force, or --version")
if args.update_lock and args.check:
raise UpdateError("--update-lock cannot be combined with --check")
selected = set(args.library)
if selected:
outside_selection = sorted(set(overrides) - selected)
if outside_selection:
raise UpdateError(
"--version was provided for an unselected library: "
+ ", ".join(outside_selection)
)
def main() -> int:
args = parse_args()
try:
overrides = parse_version_overrides(args.version)
validate_arguments(args, overrides)
root = args.root.resolve()
libs_root = root / "static" / "libs"
lock_path = resolve_path(root, args.lock_file)
libraries = selected_libraries(args.library)
if args.check_updates:
return 0 if check_updates(libraries, libs_root, lock_path, args.timeout) else 1
if args.locked or args.update_lock:
lock_document = load_lock_file(lock_path, required=args.locked)
else:
lock_document = {"schema": LOCK_SCHEMA, "libraries": {}}
targets: dict[str, dict] = {}
skipped: set[str] = set()
effective_strict = args.strict or args.check or args.update_lock
for library in libraries:
if args.locked:
targets[library.key] = metadata_from_lock(library, lock_document)
continue
requested_version = overrides.get(library.key, "latest")
try:
targets[library.key] = package_metadata(
library.package,
args.timeout,
requested_version,
)
validate_metadata(library, targets[library.key])
except UpdateError as error:
destination = libs_root / library.destination
complete = is_complete(library, destination)
current = installed_version(destination)
if complete and not effective_strict:
print(
f"warning: {error}; keeping {library.key} {current or 'local copy'}",
file=sys.stderr,
)
skipped.add(library.key)
continue
raise
success = True
for library in libraries:
if library.key in skipped:
continue
success = update_one(
library,
targets[library.key],
libs_root,
timeout=args.timeout,
force=args.force,
check=args.check,
) and success
if args.update_lock and success:
entries = dict(lock_document["libraries"])
for library in libraries:
entries[library.key] = lock_entry_from_metadata(library, targets[library.key])
updated_document = {
"schema": LOCK_SCHEMA,
"libraries": entries,
}
write_lock_file(lock_path, updated_document)
print(f"lock: updated {lock_path}")
return 0 if success else 1
except UpdateError as error:
print(f"error: {error}", file=sys.stderr)
return 1
except OSError as error:
print(f"error: {error}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())