v0.36.6
This commit is contained in:
1 parent
0cde2c8a18
commit
3d3e0ea5b8
12 files changed
+474
-7
No files matched your search
Executable → Regular
+108
-1
@@ -14,6 +14,7 @@ import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
@@ -25,6 +26,7 @@ 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),
|
||||
@@ -82,11 +84,29 @@ class HttpsRedirects(HTTPRedirectHandler):
|
||||
|
||||
|
||||
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=30) as response, destination.open("wb") as output:
|
||||
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.")
|
||||
@@ -162,6 +182,75 @@ def atomic_write(path: Path, data: bytes) -> None:
|
||||
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)
|
||||
@@ -169,10 +258,27 @@ def prepare(out_dir: Path, source: str | Path, checksum: str | None = None,
|
||||
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)
|
||||
@@ -185,6 +291,7 @@ def prepare(out_dir: Path, source: str | Path, checksum: str | None = None,
|
||||
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"
|
||||
|
||||
Reference in new issue
Block a user