Add safe ISO download and explicit run/open commands
This commit is contained in:
+164
@@ -0,0 +1,164 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user