Files

206 lines
9.2 KiB
Python

"""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)