206 lines
9.2 KiB
Python
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)
|