341 lines
15 KiB
Python
341 lines
15 KiB
Python
#!/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 shutil
|
|
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.33.1"
|
|
DOWNLOAD_URL = f"https://github.com/swagger-api/swagger-ui/archive/refs/tags/v{VERSION}.zip"
|
|
PREFIX = f"swagger-ui-{VERSION}/"
|
|
VENDORED = Path(__file__).resolve().parents[2] / "third_party" / 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.33.1.zip "
|
|
"to a reviewed copy of the same tag archive. See docs/INSTALL.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:
|
|
"""Download with a few retries; github.com occasionally times out or returns a gateway error."""
|
|
last: Exception | None = None
|
|
for attempt in range(4):
|
|
try:
|
|
_download_once(destination)
|
|
return
|
|
except AssetError:
|
|
raise
|
|
except (OSError, ValueError) as error: # URLError/HTTPError/timeouts are OSError subclasses
|
|
last = error
|
|
if attempt < 3:
|
|
delay = (3, 6, 12)[attempt]
|
|
print(f"Swagger download failed ({error}); retrying in {delay}s ({attempt + 1}/3).", file=sys.stderr)
|
|
time.sleep(delay)
|
|
raise AssetError(f"Swagger download failed after 4 attempts: {last}")
|
|
|
|
|
|
def _download_once(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"})
|
|
with opener.open(request, timeout=60) 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.")
|
|
|
|
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 cached_archives(out_dir: Path, cache: Path) -> list[Path]:
|
|
"""Previously downloaded copies of the pinned archive.
|
|
|
|
Cargo gives every package version its own OUT_DIR, so a version bump would download the same
|
|
pinned archive again. Reuse an archive from the shared cache or from another build directory,
|
|
after the usual validation below.
|
|
"""
|
|
candidates: list[Path] = []
|
|
override = os.environ.get("SWAGGER_UI_CACHE_DIR", "").strip()
|
|
if override:
|
|
candidates.append(Path(override) / cache.name)
|
|
parents = out_dir.parents
|
|
if len(parents) > 3 and parents[1].name == "build":
|
|
candidates.append(parents[3] / "swagger-ui-cache" / cache.name)
|
|
try:
|
|
siblings = sorted(parents[1].glob("*/out/" + cache.name), key=lambda item: item.stat().st_mtime, reverse=True)
|
|
except OSError:
|
|
siblings = []
|
|
candidates.extend(siblings)
|
|
return [item for item in dict.fromkeys(candidates) if item != cache and item.is_file()]
|
|
|
|
|
|
def remember_archive(out_dir: Path, cache: Path) -> None:
|
|
"""Best-effort copy of a validated archive into the shared cache directory."""
|
|
parents = out_dir.parents
|
|
if len(parents) > 3 and parents[1].name == "build":
|
|
shared = parents[3] / "swagger-ui-cache"
|
|
try:
|
|
shared.mkdir(parents=True, exist_ok=True)
|
|
if not (shared / cache.name).exists():
|
|
shutil.copyfile(cache, shared / cache.name)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def read_vendored() -> tuple[dict[str, bytes], str, Path] | None:
|
|
"""Assets vendored in third_party/ (official swagger-ui-dist files); verified against SHA256SUMS.
|
|
|
|
Returns None when the directory is absent so the pinned download still works for older trees.
|
|
A present but inconsistent directory is an error, never a silent fallback.
|
|
"""
|
|
manifest = VENDORED / "SHA256SUMS"
|
|
if not manifest.is_file():
|
|
return None
|
|
expected: dict[str, str] = {}
|
|
for line in manifest.read_text(encoding="utf-8").splitlines():
|
|
if not line.strip():
|
|
continue
|
|
digest, _, name = line.partition(" ")
|
|
if not re.fullmatch(r"[0-9a-f]{64}", digest) or not name or "/" in name or name.startswith("."):
|
|
raise AssetError("Vendored Swagger SHA256SUMS is malformed.")
|
|
expected[name] = digest
|
|
files: dict[str, bytes] = {}
|
|
for name in ("swagger-ui-bundle.js", "swagger-ui.css", "LICENSE", "NOTICE", "swagger-ui-bundle.js.LICENSE.txt"):
|
|
path = VENDORED / name
|
|
if name not in expected or not path.is_file() or path.is_symlink():
|
|
raise AssetError(f"Vendored Swagger UI is incomplete: {name} is missing or not listed in SHA256SUMS.")
|
|
data = path.read_bytes()
|
|
if not 0 < len(data) <= 16 * 1024 * 1024 or hashlib.sha256(data).hexdigest() != expected[name]:
|
|
raise AssetError(f"Vendored Swagger UI file failed its SHA-256 check: {name}.")
|
|
data.decode("utf-8")
|
|
files[name] = data
|
|
package = {name: files[name] for name in ASSETS}
|
|
licenses = [label.encode() + b"\n" + b"-" * 40 + b"\n" + files[name] for label, name in (("LICENSE", "LICENSE"), ("NOTICE", "NOTICE"), ("dist/swagger-ui-bundle.js.LICENSE.txt", "swagger-ui-bundle.js.LICENSE.txt"))]
|
|
assets = {"swagger-ui-bundle.js": package["swagger-ui-bundle.js"], "swagger-ui.css": package["swagger-ui.css"],
|
|
"licenses.txt": b"Swagger UI " + VERSION.encode() + b"\n\n" + b"\n\n".join(licenses)}
|
|
return assets, hashlib.sha256(manifest.read_bytes()).hexdigest(), manifest
|
|
|
|
|
|
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 and checksum is None and (vendored := read_vendored()) is not None:
|
|
assets, digest, archive = vendored
|
|
print(f"Using the vendored Swagger UI {VERSION} from {VENDORED}.", file=sys.stderr)
|
|
elif source == DOWNLOAD_URL:
|
|
archive = cache
|
|
reused = None
|
|
if cache.exists():
|
|
assets, digest = read_assets(cache, checksum)
|
|
else:
|
|
for candidate in cached_archives(out_dir, cache):
|
|
try:
|
|
reused = (read_assets(candidate, checksum), candidate)
|
|
break
|
|
except (OSError, ValueError, KeyError, RuntimeError, zipfile.BadZipFile, zlib.error):
|
|
continue
|
|
if reused is not None:
|
|
(assets, digest), candidate = reused
|
|
shutil.copyfile(candidate, cache)
|
|
print(f"Reusing the cached Swagger UI archive from {candidate}.", file=sys.stderr)
|
|
elif cache.exists():
|
|
pass
|
|
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)
|
|
remember_archive(out_dir, cache)
|
|
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)
|
|
|
|
import secrets
|
|
atomic_write(args.out_dir.resolve() / "asset-build-id.txt", secrets.token_hex(8).encode("ascii"))
|
|
for path in paths:
|
|
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())
|