Files
RustProxy/scripts/swagger_assets.py
T
2026-10-06 15:12:59 +02:00

233 lines
10 KiB
Python
Executable File

#!/usr/bin/env python3
"""Prepare pinned Swagger assets for build.rs. No Cargo config or runtime CDN needed.
Only the upstream tag archive (or an operator-supplied local copy of that tag) is
accepted. ZIP paths are never extracted: bounded, exact members are read and
written to fixed output names. No application data or credentials are accessed.
"""
from __future__ import annotations
import argparse
import hashlib
import io
import json
import os
from pathlib import Path
import re
import sys
import tempfile
import time
from urllib.parse import urlsplit
from urllib.request import HTTPRedirectHandler, Request, build_opener, url2pathname
import zipfile
import zlib
VERSION = "5.32.11"
DOWNLOAD_URL = f"https://github.com/swagger-api/swagger-ui/archive/refs/tags/v{VERSION}.zip"
PREFIX = f"swagger-ui-{VERSION}/"
MAX_ARCHIVE = 64 * 1024 * 1024
ASSETS = {
"swagger-ui-bundle.js": ("dist/swagger-ui-bundle.js", 16 * 1024 * 1024),
"swagger-ui.css": ("dist/swagger-ui.css", 2 * 1024 * 1024),
}
LICENSE_MEMBERS = ("LICENSE", "NOTICE", "dist/swagger-ui-bundle.js.LICENSE.txt")
HELP = ("Install Python 3.10+ and allow HTTPS access to github.com/codeload.github.com, "
"or set SWAGGER_UI_DOWNLOAD_URL=file:///absolute/path/swagger-ui-5.32.11.zip "
"to a reviewed copy of the same tag archive. See docs/API_ACCESS.md.")
class AssetError(ValueError):
"""A controlled, actionable source/archive error (never an implicit fallback)."""
def source_value(value: str | None) -> str | Path:
"""Unset/empty means the pin, never the adapter's historical default."""
if value is None or not value.strip():
return DOWNLOAD_URL
if any(c in value for c in "\r\n\0"):
raise AssetError("Swagger source contains invalid control characters.")
value = value.strip()
if value == DOWNLOAD_URL:
return value
try:
parts = urlsplit(value)
if (parts.scheme != "file" or parts.netloc not in ("", "localhost")
or parts.query or parts.fragment):
raise ValueError()
# url2pathname decodes percent escapes (including spaces) on this platform.
path = Path(url2pathname(parts.path))
if not path.is_absolute() or any(c in str(path) for c in "\r\n\0"):
raise ValueError()
return path
except (ValueError, OSError):
raise AssetError("Swagger source must be the pinned HTTPS URL or an absolute file:// URL.") from None
def expected_digest(value: str | None) -> str | None:
if value is None or not value.strip():
return None
value = value.strip().lower()
if not re.fullmatch(r"[0-9a-f]{64}", value):
raise AssetError("SWAGGER_UI_ARCHIVE_SHA256 must contain exactly 64 hexadecimal digits.")
return value
class HttpsRedirects(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
target = urlsplit(newurl)
if (target.scheme != "https" or target.hostname not in ("github.com", "codeload.github.com")
or target.username or target.password or target.port not in (None, 443)):
raise AssetError("Swagger download refused an unexpected redirect.")
return super().redirect_request(req, fp, code, msg, headers, newurl)
def download(destination: Path) -> None:
"""TLS verification uses Python's default trust store; never executes a shell."""
started = time.monotonic()
opener = build_opener(HttpsRedirects())
request = Request(DOWNLOAD_URL, headers={"User-Agent": "RustProxy-build/0.26.6"})
with opener.open(request, timeout=30) as response, destination.open("wb") as output:
length = response.headers.get("Content-Length")
if length and int(length) > MAX_ARCHIVE:
raise AssetError("Swagger archive exceeds the 64 MiB limit.")
received = 0
while True:
block = response.read(128 * 1024)
if not block:
break
received += len(block)
if received > MAX_ARCHIVE or time.monotonic() - started > 180:
raise AssetError("Swagger download exceeded the size or time limit.")
output.write(block)
def read_assets(path: Path, checksum: str | None) -> tuple[dict[str, bytes], str]:
if not path.is_file() or not 0 < path.stat().st_size <= MAX_ARCHIVE:
raise AssetError("Swagger archive must be a nonempty regular file of at most 64 MiB.")
with path.open("rb") as stream:
snapshot = stream.read(MAX_ARCHIVE + 1)
if not 0 < len(snapshot) <= MAX_ARCHIVE:
raise AssetError("Swagger archive exceeds the 64 MiB limit.")
# Parse exactly the same bounded bytes that were hashed, not a reopened file
# that could have changed between verification and ZIP member reads.
digest = hashlib.sha256(snapshot).hexdigest()
if checksum and digest != checksum:
raise AssetError("Swagger archive SHA-256 mismatch; no assets were published.")
with zipfile.ZipFile(io.BytesIO(snapshot)) as archive:
names = archive.namelist()
if len(names) > 20000:
raise AssetError("Swagger archive contains too many entries.")
def member(relative: str, limit: int, *, required: bool = True) -> bytes:
name = PREFIX + relative
count = names.count(name)
if count == 0 and not required:
return b""
if count != 1:
raise AssetError(f"Swagger archive must contain exactly one {name}.")
info = archive.getinfo(name)
mode = (info.external_attr >> 16) & 0o170000
if info.is_dir() or mode not in (0, 0o100000) or not 0 < info.file_size <= limit:
raise AssetError(f"Swagger archive member is not a bounded regular file: {name}.")
# Bounded read as well as metadata validation; CRC checked by ZipFile.
with archive.open(info) as stream:
data = stream.read(limit + 1)
if not 0 < len(data) <= limit or len(data) != info.file_size:
raise AssetError(f"Swagger archive member has an invalid size: {name}.")
data.decode("utf-8")
return data
package = json.loads(member("package.json", 256 * 1024))
if not isinstance(package, dict) or package.get("name") != "swagger-ui" or package.get("version") != VERSION:
raise AssetError(f"Swagger archive must be swagger-ui {VERSION}; older versions are not a fallback.")
assets = {name: member(source, limit) for name, (source, limit) in ASSETS.items()}
licenses = []
for name in LICENSE_MEMBERS:
text = member(name, 512 * 1024, required=name == "LICENSE")
if text:
licenses.append(name.encode() + b"\n" + b"-" * 40 + b"\n" + text)
assets["licenses.txt"] = b"Swagger UI " + VERSION.encode() + b"\n\n" + b"\n\n".join(licenses)
return assets, digest
def atomic_write(path: Path, data: bytes) -> None:
if path.is_file() and path.read_bytes() == data:
return # Preserve mtime for Cargo's rerun-if-changed tracking.
fd, name = tempfile.mkstemp(prefix=".swagger-", dir=path.parent)
temporary = Path(name)
try:
with os.fdopen(fd, "wb") as stream:
stream.write(data)
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
def prepare(out_dir: Path, source: str | Path, checksum: str | None = None,
*, offline: bool = False) -> list[Path]:
out_dir.mkdir(parents=True, exist_ok=True)
cache = out_dir / f"swagger-ui-{VERSION}.zip"
if isinstance(source, Path):
archive = source
assets, digest = read_assets(archive, checksum)
elif source == DOWNLOAD_URL:
archive = cache
if cache.exists():
assets, digest = read_assets(cache, checksum)
else:
if offline:
raise AssetError("Swagger assets are not cached and CARGO_NET_OFFLINE is set. " + HELP)
fd, name = tempfile.mkstemp(prefix=".swagger-download-", suffix=".zip", dir=out_dir)
os.close(fd)
temporary = Path(name)
try:
download(temporary)
assets, digest = read_assets(temporary, checksum)
os.replace(temporary, cache) # Cache only an already-validated archive.
finally:
temporary.unlink(missing_ok=True)
else:
raise AssetError("Unrecognized Swagger source.")
destination = out_dir / "swagger-ui"
destination.mkdir(exist_ok=True)
outputs = []
for name, data in assets.items():
target = destination / name
atomic_write(target, data)
outputs.append(target)
receipt = {
"version": VERSION, "source": str(source), "archive_sha256": digest,
"assets_sha256": {key: hashlib.sha256(data).hexdigest() for key, data in assets.items()},
}
receipt_path = destination / "source.json"
atomic_write(receipt_path, (json.dumps(receipt, indent=2, sort_keys=True) + "\n").encode())
# Watch both a supplied symlink and its resolved target, plus cached/generated files.
inputs = [archive.absolute(), archive.resolve(), *outputs, receipt_path]
return list(dict.fromkeys(inputs))
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--out-dir", required=True, type=Path)
args = parser.parse_args()
if sys.version_info < (3, 10):
parser.error("Python 3.10+ is required to prepare the embedded Swagger UI.")
try:
source = source_value(os.environ.get("SWAGGER_UI_DOWNLOAD_URL"))
checksum = expected_digest(os.environ.get("SWAGGER_UI_ARCHIVE_SHA256"))
offline = os.environ.get("CARGO_NET_OFFLINE", "").lower() in ("1", "true")
paths = prepare(args.out_dir.resolve(), source, checksum, offline=offline)
for path in paths:
# These values are Cargo directives, so never allow extra output lines.
if any(c in str(path) for c in "\r\n\0"):
raise AssetError("Build input/output path contains an invalid control character.")
print(f"cargo:rerun-if-changed={path}")
except (OSError, ValueError, RuntimeError, zipfile.BadZipFile, zlib.error) as error:
print(f"Swagger UI asset preparation failed: {error}\n{HELP}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())