"""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 _both_endian(field: bytes) -> int: half = len(field) // 2 little = int.from_bytes(field[:half], 'little') if little != int.from_bytes(field[half:], 'big'): raise MediaError('Invalid ISO9660 descriptor: inconsistent byte-order fields') return little def _volume_extent(stream) -> None: """Check ECMA-119 6.7.1 and PVD fields 8.4.8/8.4.12 against the file.""" size = os.fstat(stream.fileno()).st_size extent = None udf = False terminated = False stream.seek(16 * 2048) for index in range(16, 257): sector = stream.read(2048) if len(sector) != 2048: break if sector[1:6] in (b'NSR02', b'NSR03') and sector[6] == 1: udf = True if sector[1:6] != b'CD001': continue if sector[0] == 1: if sector[6] != 1: raise MediaError('Unsupported ISO9660 primary volume descriptor version') blocks = _both_endian(sector[80:88]) block_size = _both_endian(sector[128:132]) # Raw ISO files use 2048-byte sectors. ECMA-119 6.2.2 permits # power-of-two logical blocks from 512 bytes up to sector size. if not blocks or block_size not in (512, 1024, 2048): raise MediaError('Invalid ISO9660 volume extent or logical block size') declared = blocks * block_size if declared > size: raise MediaError(f'ISO is truncated: declared volume extent is {declared} bytes, ' f'but the image contains {size}; retry the direct ISO URL') if extent is not None and extent != declared: raise MediaError('Invalid ISO9660 descriptors: inconsistent volume extents') extent = declared elif sector[0] == 255 and sector[6] == 1: terminated = True if extent is not None and extent < (index + 1) * 2048: raise MediaError('Invalid ISO9660 volume extent: descriptor set lies outside the volume') break if extent is None: if udf: raise MediaError('Pure UDF image completeness is unsupported; use an ISO9660 or ISO9660/UDF hybrid ISO') raise MediaError('Not a supported ISO9660 image; use a direct ISO download URL rather than an HTML download page') if not terminated: raise MediaError('ISO9660 descriptor set is incomplete or unsupported; retry a complete ISO image') def validate_iso(path: Path, *, sha256: str | None = None) -> Path: """Validate ISO9660 extent (including hybrids) 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}') with path.open('rb') as stream: _volume_extent(stream) 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)