blob: baf45108062e955f7b58662def1ad5861cd95a57 [file]
#!/usr/bin/env python3
"""Downloads files with retries, mirrors, and SHA-256 checks.
Usage:
verified_download.py \\
--urls https://primary.example.com/tool,https://mirror.example.com/tool \\
--sha256 <hex-digest> \\
--executable \\
--output /path/to/tool
Input format for `--urls`:
Comma- or newline-delimited list of candidate URLs tried in order per attempt.
"""
from __future__ import annotations
import argparse
import asyncio
import hashlib
import io
import stat
import sys
import tarfile
import urllib.request
import zipfile
from collections.abc import Sequence
from pathlib import Path
# Allow up to 120s per HTTP request before treating the connection as stalled.
DEFAULT_TIMEOUT_SECONDS = 120.0
# Retry up to 5 times (2s, 4s, 8s, 16s) to ride out transient 5xx/reset flakes.
DEFAULT_MAX_ATTEMPTS = 5
DEFAULT_INITIAL_DELAY_SECONDS = 2.0
_USER_AGENT = "rules_python-verified-download/1.0"
_EXEC_BITS = stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
def _parse_args(argv: list[str]) -> argparse.Namespace:
"""Parses CLI flags for single-file verified downloads."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--urls",
required=True,
help=(
"Comma- or newline-delimited list of candidate URLs to download, "
"tried in order."
),
)
parser.add_argument(
"--sha256",
required=True,
help="Expected lowercase or uppercase hex SHA-256 digest.",
)
parser.add_argument(
"--archive-member",
default="",
help=(
"Optional archive member path to extract when the downloaded "
"payload is a zip or tar archive."
),
)
parser.add_argument(
"--output",
type=Path,
required=True,
help="Destination file path.",
)
parser.add_argument(
"--executable",
action="store_true",
help="Set user/group/other executable permission bits (+x) on output.",
)
return parser.parse_args(argv)
def parse_url_list(raw_urls: str) -> list[str]:
"""Extracts ordered non-empty URLs from comma- or newline-delimited text."""
urls: list[str] = []
for line in raw_urls.splitlines():
for part in line.split(","):
url = part.strip()
if url:
urls.append(url)
if not urls:
raise ValueError("At least one URL must be provided")
return urls
def sha256_bytes(data: bytes) -> str:
"""Returns the lowercase hex SHA-256 digest of `data`."""
return hashlib.sha256(data).hexdigest().lower()
def verify_sha256(data: bytes, expected_sha256: str) -> bool:
"""Checks `data` against `expected_sha256`, logging a CI warning on mismatch.
Args:
data: Raw payload bytes to hash.
expected_sha256: Expected hex SHA-256 digest.
Returns:
True when the digest matches, False otherwise.
"""
actual = sha256_bytes(data)
expected = expected_sha256.lower()
if actual != expected:
print(
f"::warning::SHA-256 mismatch: expected {expected}, got {actual}",
flush=True,
)
return False
return True
def _set_executable(path: Path) -> None:
"""Sets `+x` permission bits (`0o111`) on `path` while keeping existing bits."""
path.chmod(path.stat().st_mode | _EXEC_BITS)
def _extract_archive_member(data: bytes, url: str, archive_member: str) -> bytes | None:
"""Extracts `archive_member` from a zip or tar archive payload."""
if zipfile.is_zipfile(io.BytesIO(data)):
with zipfile.ZipFile(io.BytesIO(data)) as zf:
if archive_member not in zf.namelist():
print(
f"::warning::Member {archive_member!r} not found in {url}",
flush=True,
)
return None
return zf.read(archive_member)
with tarfile.open(fileobj=io.BytesIO(data), mode="r:*") as tar:
member = tar.extractfile(archive_member)
if member is None:
print(
f"::warning::Member {archive_member!r} not found in {url}",
flush=True,
)
return None
return member.read()
def _read_source_bytes(source: tuple[str, str, str], timeout: float) -> bytes | None:
"""Blocking worker that fetches `url` and extracts `archive_member`."""
url, _, archive_member = source
req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
with urllib.request.urlopen(req, timeout=timeout) as response:
data = response.read()
if archive_member:
return _extract_archive_member(data, url, archive_member)
return data
async def _fetch_source(source: tuple[str, str, str], timeout: float) -> bytes | None:
"""Fetches `source` and extracts its optional archive member.
Args:
source: `(url, sha256, archive_member)` tuple.
timeout: Per-request deadline in seconds.
Returns:
Payload bytes on success, or `None` if fetching or extraction fails.
"""
url = source[0]
try:
return await asyncio.wait_for(
asyncio.to_thread(_read_source_bytes, source, timeout),
timeout=timeout,
)
except Exception as exc: # Retry transient network, HTTP, and archive errors.
print(f"::warning::Failed to fetch {url}: {exc}", flush=True)
return None
async def download_verified_bytes(
sources: Sequence[tuple[str, str, str]],
*,
timeout: float = DEFAULT_TIMEOUT_SECONDS,
max_attempts: int = DEFAULT_MAX_ATTEMPTS,
initial_delay: float = DEFAULT_INITIAL_DELAY_SECONDS,
) -> bytes:
"""Downloads and SHA-256-verifies bytes with retries.
Args:
sources: Ordered `(url, sha256, archive_member)` tuples tried on each
attempt until one succeeds and matches its SHA-256 digest. Pass `""`
for `archive_member` when `url` serves the raw file directly.
timeout: Per-request deadline in seconds.
max_attempts: Maximum outer retry attempts across `sources`.
initial_delay: Initial backoff delay in seconds (doubled each retry).
Returns:
Verified payload bytes from the first matching source.
Raises:
ValueError: If `sources` is empty.
DownloadError: If all retry attempts fail to produce verified bytes.
"""
if not sources:
raise ValueError("sources must not be empty")
delay = initial_delay
for attempt in range(1, max_attempts + 1):
for source in sources:
_, expected_sha256, _ = source
data = await _fetch_source(source, timeout)
if data is not None and verify_sha256(data, expected_sha256):
return data
if attempt < max_attempts:
print(
f"::warning::Download failed (attempt {attempt}/{max_attempts}); "
f"retrying in {delay:g}s...",
flush=True,
)
await asyncio.sleep(delay)
delay *= 2
raise DownloadError(f"Failed to download after {max_attempts} attempts")
def _reuse_cached_file(dest: Path, expected_shas: set[str], executable: bool) -> bool:
"""Returns True if `dest` exists and matches any digest in `expected_shas`."""
if not dest.is_file():
return False
if sha256_bytes(dest.read_bytes()) not in expected_shas:
return False
if executable:
_set_executable(dest)
return True
def _write_output_file(dest: Path, data: bytes, executable: bool) -> None:
"""Writes verified `data` to `dest` and optionally marks it executable."""
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(data)
if executable:
_set_executable(dest)
async def download_verified_file(
dest: Path,
sources: Sequence[tuple[str, str, str]],
*,
executable: bool = False,
timeout: float = DEFAULT_TIMEOUT_SECONDS,
max_attempts: int = DEFAULT_MAX_ATTEMPTS,
initial_delay: float = DEFAULT_INITIAL_DELAY_SECONDS,
) -> None:
"""Ensures `dest` contains SHA-256-verified bytes.
Reuses `dest` without network requests if `dest` already exists and its
SHA-256 matches any entry in `sources`.
Args:
dest: Destination file path to verify or write.
sources: Ordered `(url, sha256, archive_member)` tuples. Pass `""` for
`archive_member` when `url` serves the raw file directly.
executable: Whether to mark `dest` executable (`+x`).
timeout: Per-request deadline in seconds.
max_attempts: Maximum outer retry attempts across `sources`.
initial_delay: Initial backoff delay in seconds (doubled each retry).
"""
expected_shas = {sha256.lower() for _, sha256, _ in sources}
if await asyncio.to_thread(_reuse_cached_file, dest, expected_shas, executable):
return
data = await download_verified_bytes(
sources,
timeout=timeout,
max_attempts=max_attempts,
initial_delay=initial_delay,
)
await asyncio.to_thread(_write_output_file, dest, data, executable)
class DownloadError(Exception):
"""Raised when all candidate sources and retry attempts fail verification."""
async def _async_main(argv: list[str]) -> int:
"""Async CLI entry point."""
args = _parse_args(argv)
urls = parse_url_list(args.urls)
sources = [(url, args.sha256, args.archive_member) for url in urls]
await download_verified_file(
args.output,
sources,
executable=args.executable,
)
print(f"Downloaded and verified {args.output}")
return 0
def main(argv: list[str]) -> int:
"""Synchronous CLI entry point."""
return asyncio.run(_async_main(argv))
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))