"""Private, validated ISO downloads with atomic, non-replacing publication.""" import fcntl import hashlib import http.client import os import re import shutil import stat import tempfile import urllib.error import urllib.parse import urllib.request from pathlib import Path from .runtime import _private_dir class MediaError(ValueError): """An actionable image error without remote query strings or credentials.""" def _checksum(sha256: str | None) -> str | None: if sha256 is not None and (not isinstance(sha256, str) or not re.fullmatch(r'[a-fA-F0-9]{64}', sha256)): raise MediaError('SHA-256 must contain exactly 64 hexadecimal characters') return sha256.lower() if sha256 else None def _url(url: str) -> urllib.parse.SplitResult: try: parts = urllib.parse.urlsplit(url) port = parts.port except ValueError as exc: raise MediaError('Invalid HTTP(S) image URL') from exc if parts.scheme not in ('http', 'https') or not parts.hostname: raise MediaError('Use a direct HTTP(S) ISO URL, or a local ISO path with hydra run') if parts.username is not None or parts.password is not None: raise MediaError('URL credentials are not supported; use a direct image URL without credentials') if any(ord(char) < 32 or ord(char) == 127 for char in url): raise MediaError('Image URL contains control characters') return parts class _SafeRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): _url(newurl) return super().redirect_request(req, fp, code, msg, headers, newurl) def validate_iso(path: Path, *, sha256: str | None = None) -> Path: """Recognize ISO9660 or UDF volume descriptors and optional whole-file hash.""" wanted = _checksum(sha256) path = Path(path) if not path.is_file(): raise MediaError(f'ISO does not exist or is not a file: {path}') found = False with path.open('rb') as stream: stream.seek(16 * 2048) for _ in range(241): sector = stream.read(2048) if len(sector) != 2048: break if ((sector[1:6] == b'CD001' and sector[0] in (0, 1, 2, 3, 255) and sector[6] == 1) or (sector[1:6] in (b'NSR02', b'NSR03') and sector[6] == 1)): found = True break if not found: raise MediaError('Not an ISO9660/UDF image; use a direct ISO download URL rather than an HTML download page') if wanted: stream.seek(0) digest = hashlib.file_digest(stream, 'sha256').hexdigest() if digest != wanted: raise MediaError('ISO SHA-256 mismatch; image was not accepted') return path def _cached(path: Path, sha256: str | None) -> Path | None: try: info = path.lstat() except FileNotFoundError: return None if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid(): raise MediaError('Refusing an unsafe ISO cache entry; choose another cache directory') try: return validate_iso(path, sha256=sha256) except MediaError as exc: raise MediaError('Cached ISO failed validation; move it aside or choose another cache directory') from exc def download_iso(url: str, *, sha256: str | None = None, cache: Path | None = None, progress=None) -> Path: """Download once under a URL lock; publish only complete verified images.""" parts = _url(url) wanted = _checksum(sha256) cache = Path(cache) if cache is not None else Path(os.environ.get('XDG_CACHE_HOME', Path.home() / '.cache')) / 'hydra/iso' cache = cache.expanduser().absolute() _private_dir(cache) basename = re.sub(r'[^A-Za-z0-9._-]', '_', urllib.parse.unquote(Path(parts.path).name))[:96].strip('.') or 'image.iso' if not basename.lower().endswith('.iso'): basename += '.iso' key = hashlib.sha256(url.encode()).hexdigest() destination = cache / f'{key}-{basename}' lock = os.open(cache / (key + '.lock'), os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) try: fcntl.flock(lock, fcntl.LOCK_EX) cached = _cached(destination, wanted) if cached: if progress: size = cached.stat().st_size progress(size, size) return cached temporary = None try: opener = urllib.request.build_opener(_SafeRedirect()) request = urllib.request.Request(url, headers={'User-Agent': 'Hydra-OS/1', 'Accept-Encoding': 'identity'}) with opener.open(request, timeout=30) as response: if response.status != 200: raise MediaError(f'Image server returned HTTP {response.status}; use a direct ISO URL') if response.headers.get_content_type() in ('text/html', 'application/xhtml+xml'): raise MediaError('Server returned an HTML download page; use a direct ISO URL') raw_length = response.headers.get('Content-Length') try: total = int(raw_length) if raw_length is not None else None if total is not None and total <= 0: raise ValueError except ValueError as exc: raise MediaError('Image server supplied an invalid content length') from exc if total is not None and total > shutil.disk_usage(cache)[2]: raise MediaError('Not enough free disk space for the ISO download') fd, name = tempfile.mkstemp(prefix='.' + key + '-', suffix='.part', dir=cache) temporary = Path(name) received = 0 with os.fdopen(fd, 'wb') as output: if progress: progress(0, total) while True: chunk = response.read(1024 * 1024) if not chunk: break if len(chunk) > shutil.disk_usage(cache)[2]: raise MediaError('Not enough free disk space for the ISO download') received += len(chunk) if total is not None and received > total: raise MediaError('Image download exceeds its advertised content length') output.write(chunk) if progress: progress(received, total) output.flush() os.fsync(output.fileno()) if total is not None and received != total: raise MediaError('Image download was truncated; retry the direct ISO URL') validate_iso(temporary, sha256=wanted) # Linking cannot replace a concurrent or unrelated destination. os.link(temporary, destination) return destination except urllib.error.HTTPError as exc: raise MediaError(f'Image server returned HTTP {exc.code}; use a direct ISO URL') from None except (urllib.error.URLError, http.client.HTTPException, TimeoutError) as exc: raise MediaError('ISO download failed or timed out; check networking and retry the direct URL') from None finally: if temporary is not None: temporary.unlink(missing_ok=True) finally: os.close(lock)