visync 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of visync might be problematic. Click here for more details.

visync/download.py ADDED
@@ -0,0 +1,1505 @@
1
+ """Scrape and synchronize distributions configured in config.toml."""
2
+
3
+ import concurrent.futures
4
+ import hashlib
5
+ import os
6
+ import re
7
+ import shutil
8
+ import socket
9
+ import sys
10
+ import time
11
+ import urllib.error
12
+ import urllib.request
13
+ from collections.abc import Callable
14
+ from enum import StrEnum
15
+ from pathlib import Path
16
+ from typing import NamedTuple
17
+ from urllib.parse import urlparse
18
+
19
+ from rich.markup import escape as _esc
20
+
21
+ from visync.finder import (
22
+ find_installed_isos,
23
+ find_ventoy_drives,
24
+ get_iso_volume_id,
25
+ identify_distro,
26
+ load_config,
27
+ remove_iso_metadata,
28
+ visync_watchdog,
29
+ write_iso_metadata,
30
+ )
31
+ from visync.net import install_safe_opener, require_https
32
+
33
+ install_safe_opener()
34
+ from visync.output import (
35
+ console,
36
+ error,
37
+ info,
38
+ make_download_progress,
39
+ removed,
40
+ spin_start,
41
+ spin_stop,
42
+ spin_update,
43
+ success,
44
+ warn,
45
+ )
46
+ from visync.verify import compare_versions, extract_version_from_filename, parse_version
47
+
48
+ DEBUG = os.environ.get("VISYNC_DEBUG", "0") == "1"
49
+
50
+
51
+ def _debug(msg: str) -> None:
52
+ """Print a debug message when VISYNC_DEBUG=1."""
53
+ if DEBUG:
54
+ print(f" [debug] {msg}", file=sys.stderr)
55
+
56
+
57
+ MIRROR_CONNECT_TIMEOUT = 5
58
+ MIRROR_HTTP_TIMEOUT = 10
59
+ SCRAPE_DEADLINE = 120
60
+ DEFAULT_STAGING_DIR = Path.home() / ".cache" / "visync" / "staging"
61
+
62
+
63
+ class SyncStatus(StrEnum):
64
+ """Outcome of checking one distro against its upstream mirror.
65
+
66
+ UNREACHABLE is deliberately distinct from CURRENT: "the mirror is down or
67
+ its page shape changed" must never be reported as "nothing to do", or a
68
+ broken scraper silently stops updating forever.
69
+ """
70
+
71
+ CURRENT = "current"
72
+ STALE = "stale"
73
+ UNREACHABLE = "unreachable"
74
+
75
+
76
+ class DistroCheck(NamedTuple):
77
+ """Result of scraping and version-comparing one configured distro."""
78
+
79
+ entry_id: str
80
+ clean_name: str
81
+ latest_filename: str
82
+ status: SyncStatus
83
+ download_url: str | None
84
+ reason: str = ""
85
+
86
+
87
+ def _unreachable(entry_id: str, clean_name: str, reason: str) -> DistroCheck:
88
+ """Build an UNREACHABLE result carrying an actionable reason."""
89
+ return DistroCheck(
90
+ entry_id,
91
+ clean_name,
92
+ "",
93
+ SyncStatus.UNREACHABLE,
94
+ None,
95
+ reason.strip() or "could not resolve an ISO from the mirror",
96
+ )
97
+
98
+
99
+ def ping_mirror(url: str) -> bool:
100
+ """Pre-flight TCP connectivity check. Returns True if host is reachable."""
101
+ _debug(f"Pinging {url}")
102
+ try:
103
+ parsed = urlparse(url)
104
+ host = parsed.hostname
105
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
106
+ with socket.create_connection((host, port), timeout=MIRROR_CONNECT_TIMEOUT):
107
+ _debug(f"Ping OK: {host}:{port}")
108
+ return True
109
+ except (TimeoutError, OSError) as e:
110
+ _debug(f"Ping failed: {e}")
111
+ return False
112
+
113
+
114
+ def fetch_html(url: str) -> str:
115
+ """Download HTML source from a mirror index page."""
116
+ require_https(url, "mirror index")
117
+ _debug(f"Fetching {url}")
118
+ try:
119
+ req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
120
+ with urllib.request.urlopen( # require_https above is the allowlist
121
+ req, timeout=MIRROR_HTTP_TIMEOUT
122
+ ) as response:
123
+ html = response.read().decode("utf-8", errors="ignore")
124
+ # Detect bot-protected pages (e.g. Anubis proof-of-work)
125
+ if "Anubis" in html[:1000]:
126
+ warn(
127
+ "Mirror protected by bot challenge (Anubis). Cannot scrape automatically."
128
+ )
129
+ warn("Visit the URL in a browser, complete the challenge, then re-run.")
130
+ return ""
131
+ return html
132
+ except urllib.error.URLError as e:
133
+ err_str = str(e).lower()
134
+ if "ssl" in err_str or "certificate" in err_str or "cert" in err_str:
135
+ error(f"SSL certificate verification failed for {url}")
136
+ info(f"Details: {e}")
137
+ return ""
138
+ error(f"Network error: {url}: {e}")
139
+ return ""
140
+ except Exception as e:
141
+ error(f"Network error: {url}: {e}")
142
+ return ""
143
+
144
+
145
+ def _safe_filename(name: str) -> str:
146
+ """Normalize an API-provided filename to a bare, traversal-safe segment."""
147
+ name = name.split("?", 1)[0].split("#", 1)[0]
148
+ name = name.replace("\\", "/").rsplit("/", 1)[-1].strip()
149
+ if name in ("", ".", ".."):
150
+ return ""
151
+ return name
152
+
153
+
154
+ def _safe_version_segment(version: str) -> str:
155
+ """Keep a scraped version directory usable as exactly one URL segment.
156
+
157
+ Release-directory scrapers interpolate the matched version straight into a
158
+ URL. A listing that yielded "26.04/../../pool" would send the request
159
+ somewhere the operator never asked for, so only a plain single path segment
160
+ is accepted; anything with a separator or a dot-prefixed name is dropped.
161
+ """
162
+ version = version.strip().strip("/")
163
+ if not version or version.startswith("."):
164
+ return ""
165
+ if "/" in version or "\\" in version or "?" in version or "#" in version:
166
+ return ""
167
+ return version
168
+
169
+
170
+ NIXOS_RELEASES_S3 = "https://nix-releases.s3.amazonaws.com/"
171
+ # nixos/<YY.MM>/ are numbered stable releases. Suffixes mark non-stable or
172
+ # non-default trees: -small is the minimal installer, -aarch64 the ARM build.
173
+ # "unstable" is not a YY.MM channel and is deliberately excluded by this pattern.
174
+ _NIXOS_CHANNEL_RE = re.compile(r"nixos/(\d{2}\.\d{2})/")
175
+
176
+ # Plausibility ceiling for a NixOS channel year. NixOS releases twice a year and
177
+ # 26.05 was newest when this was written, so anything past year 49 is a parsing
178
+ # artefact or a hostile listing rather than a real channel. Guards against
179
+ # pointing a download at an attacker-chosen path.
180
+ _NIXOS_CHANNEL_MAX_YEAR = 49
181
+
182
+
183
+ def _nixos_stable_channel(settings: dict) -> tuple[str, str]:
184
+ """Resolve the current stable NixOS release, e.g. ``("26.05", "26.05")``.
185
+
186
+ NixOS publishes no ``nixos-stable`` alias (404), so the current stable is
187
+ derived from the release bucket: the highest ``nixos/<YY.MM>/`` prefix,
188
+ excluding ``-small`` and ``-aarch64`` trees. An in-progress next release has
189
+ no YY.MM channel directory yet, so the newest one is always the current
190
+ stable — this tracks 26.11, 27.05 and later without a config edit.
191
+
192
+ Returns ``(channel, reason)``; *reason* is non-empty on failure.
193
+ """
194
+ listing_url = str(settings.get("releases_index_url") or NIXOS_RELEASES_S3)
195
+ prefix = str(settings.get("releases_index_prefix") or "nixos/")
196
+ if not prefix.endswith("/"):
197
+ prefix += "/"
198
+
199
+ listing = fetch_html(f"{listing_url}?delimiter=/&prefix={prefix}")
200
+ if not listing:
201
+ return "", f"could not fetch NixOS release listing {listing_url}"
202
+
203
+ channels = {m.group(1) for m in _NIXOS_CHANNEL_RE.finditer(listing)}
204
+ if not channels:
205
+ return "", f"no nixos/<YY.MM>/ channels found in the {listing_url} listing"
206
+
207
+ def _key(channel: str) -> tuple[int, int]:
208
+ major, _, minor = channel.partition(".")
209
+ return int(major), int(minor)
210
+
211
+ newest = max(channels, key=_key)
212
+ if _key(newest)[0] > _NIXOS_CHANNEL_MAX_YEAR:
213
+ return "", (
214
+ f"NixOS listing contains an implausible channel ({newest}); "
215
+ "refusing to guess"
216
+ )
217
+
218
+ # Prefer an explicit channel pin when it is still a real channel, so a
219
+ # deliberate config choice is respected until upstream supersedes it.
220
+ pinned = str(settings.get("channel") or "")
221
+ if pinned and pinned in channels:
222
+ return pinned, ""
223
+
224
+ return max(channels, key=_key), ""
225
+
226
+
227
+ def process_scraping_strategy(name: str, settings: dict) -> tuple[str, str]:
228
+ """Resolve specific folder parsing pipelines based on the configured strategy.
229
+
230
+ On failure returns ``("", "")`` and records an actionable, one-line reason in
231
+ ``settings["resolve_error"]``. Callers surface that reason to the user instead
232
+ of a generic "unable to reach mirror", so an upstream page-shape change is
233
+ distinguishable from a transient network fault.
234
+ """
235
+ strategy = settings.get("strategy")
236
+ base_url = str(settings.get("base_url") or "")
237
+ iso_regex = str(settings.get("iso_regex") or "")
238
+ version_regex = str(settings.get("version_regex") or "")
239
+ settings["resolve_error"] = ""
240
+
241
+ def fail(reason: str) -> tuple[str, str]:
242
+ settings["resolve_error"] = reason
243
+ return "", ""
244
+
245
+ # Pre-flight connectivity check — skip dead mirrors instantly
246
+ if base_url and not ping_mirror(base_url):
247
+ warn(f"Mirror unreachable (ping failed): {base_url}")
248
+ return fail(f"no route to mirror {base_url}")
249
+
250
+ # Strategy A: Direct Index File Tracking (e.g. Arch Linux)
251
+ if strategy == "direct_match":
252
+ if not base_url or not iso_regex:
253
+ warn(f"{name} — direct_match requires base_url and iso_regex")
254
+ return fail("config incomplete: needs base_url + iso_regex")
255
+ html = fetch_html(base_url)
256
+ if not html:
257
+ return fail(f"could not fetch index page {base_url}")
258
+ match = re.search(iso_regex, html)
259
+ if match:
260
+ filename = _safe_filename(match.group(1))
261
+ if not filename:
262
+ return fail(f"index page {base_url} matched an unsafe filename")
263
+ return filename, f"{base_url.rstrip('/')}/{filename}"
264
+ return fail(f"iso_regex matched nothing on {base_url}")
265
+
266
+ # Strategy B: Two-Tier Version Directory Traversal for Fedora
267
+ if strategy == "fedora_nested":
268
+ if not base_url or not iso_regex or not version_regex:
269
+ warn(f"{name} — fedora_nested requires base_url, iso_regex, version_regex")
270
+ return fail("config incomplete: needs base_url + iso_regex + version_regex")
271
+ root_html = fetch_html(base_url)
272
+ if not root_html:
273
+ return fail(f"could not fetch releases index {base_url}")
274
+ versions = [
275
+ v
276
+ for v in (
277
+ _safe_version_segment(v) for v in re.findall(version_regex, root_html)
278
+ )
279
+ if v
280
+ ]
281
+ if not versions:
282
+ return fail(f"no version directories matched at {base_url}")
283
+
284
+ versions.sort(key=lambda x: parse_version(x) or ())
285
+ latest_version = versions[-1].rstrip("/")
286
+
287
+ variant_path = str(settings.get("variant_path") or "Workstation/x86_64/iso")
288
+ iso_dir_url = f"{base_url}{latest_version}/{variant_path}/"
289
+ iso_html = fetch_html(iso_dir_url)
290
+ if not iso_html:
291
+ return fail(f"could not fetch ISO directory {iso_dir_url}")
292
+
293
+ match = re.search(iso_regex, iso_html)
294
+ if match:
295
+ filename = _safe_filename(match.group(1))
296
+ if not filename:
297
+ warn(f"{name} — index page matched an unsafe filename")
298
+ return fail(f"{iso_dir_url} matched an unsafe filename")
299
+ return filename, f"{iso_dir_url}{filename}"
300
+ return fail(f"iso_regex matched nothing in {iso_dir_url}")
301
+
302
+ # Strategy C: Directory Sub-paths for Ubuntu Ecosystem Releases
303
+ if strategy == "ubuntu_nested":
304
+ if not base_url or not iso_regex or not version_regex:
305
+ warn(f"{name} — ubuntu_nested requires base_url, iso_regex, version_regex")
306
+ return fail("config incomplete: needs base_url + iso_regex + version_regex")
307
+ root_html = fetch_html(base_url)
308
+ if not root_html:
309
+ return fail(f"could not fetch releases index {base_url}")
310
+ versions = [
311
+ v
312
+ for v in (
313
+ _safe_version_segment(v) for v in re.findall(version_regex, root_html)
314
+ )
315
+ if v
316
+ ]
317
+ if not versions:
318
+ return fail(f"no version directories matched at {base_url}")
319
+
320
+ # Optional release-family filter, applied before sorting so we pick the
321
+ # newest *matching* release rather than the newest overall.
322
+ filter_regex = str(settings.get("version_filter") or "")
323
+ if filter_regex:
324
+ versions = [v for v in versions if re.fullmatch(filter_regex, v)]
325
+ if not versions:
326
+ return fail(
327
+ f"no version directories matched version_filter "
328
+ f"{filter_regex!r} at {base_url}"
329
+ )
330
+
331
+ versions.sort(key=lambda x: parse_version(x) or ())
332
+ latest_version = versions[-1].rstrip("/")
333
+
334
+ iso_dir_url = f"{base_url}{latest_version}/"
335
+ iso_html = fetch_html(iso_dir_url)
336
+ if not iso_html:
337
+ return fail(f"could not fetch release directory {iso_dir_url}")
338
+
339
+ match = re.search(iso_regex, iso_html)
340
+ if match:
341
+ filename = _safe_filename(match.group(1))
342
+ if not filename:
343
+ warn(f"{name} — release page matched an unsafe filename")
344
+ return fail(f"{iso_dir_url} matched an unsafe filename")
345
+ return filename, f"{iso_dir_url}{filename}"
346
+ return fail(f"iso_regex matched nothing in {iso_dir_url} (upstream layout?)")
347
+
348
+ # Strategy D: NixOS channel page — parse version, construct ISO URL
349
+ if strategy == "nixos_channel":
350
+ # NixOS publishes no nixos-stable alias, so derive the current stable
351
+ # channel from the release bucket before touching the channel page.
352
+ channel, channel_err = _nixos_stable_channel(settings)
353
+ if channel_err:
354
+ return fail(channel_err)
355
+ channel_url = f"{base_url.rstrip('/')}-{channel}" if base_url else ""
356
+ if not channel_url:
357
+ return fail("config incomplete: needs base_url")
358
+ html = fetch_html(channel_url)
359
+ if not html:
360
+ return fail(f"could not fetch channel page {channel_url}")
361
+
362
+ # The channel page contains text like "nixos-26.05 release nixos-26.05.1947.a0374025a863"
363
+ version_match = re.search(
364
+ r"nixos-[\d\.]+\s+release\s+(nixos-[\d\.]+\.[a-f0-9]+)", html
365
+ )
366
+ if not version_match:
367
+ warn(f"{name} — could not parse NixOS version from channel page")
368
+ return fail(f"could not parse release id from channel page {channel_url}")
369
+
370
+ full_version = version_match.group(1) # e.g. "nixos-26.05.1947.a0374025a863"
371
+ # Strip the "nixos-" prefix for constructing URLs
372
+ version_id = full_version.replace(
373
+ "nixos-", "", 1
374
+ ) # e.g. "26.05.1947.a0374025a863"
375
+ # Extract the short version (e.g. "26.05") from the full version
376
+ short_version_match = re.search(r"nixos-([\d]+\.[\d]+)", full_version)
377
+ if not short_version_match:
378
+ return fail(f"could not parse short version from {full_version!r}")
379
+ short_version = short_version_match.group(1) # e.g. "26.05"
380
+ if short_version != channel:
381
+ return fail(
382
+ f"channel {channel} resolved to release {short_version} — "
383
+ "mismatch, refusing to guess"
384
+ )
385
+
386
+ variant = settings.get("variant", "minimal") # "minimal" or "graphical"
387
+ iso_filename = f"nixos-{variant}-{version_id}-x86_64-linux.iso"
388
+ releases_base = str(
389
+ settings.get("releases_base_url") or "https://releases.nixos.org"
390
+ )
391
+ iso_url = f"{releases_base.rstrip('/')}/nixos/{short_version}/{full_version}/{iso_filename}"
392
+
393
+ # Parse SHA-256 checksum from the channel page HTML table.
394
+ # The page has rows: <td><a href='...'>FILENAME</a></td><td>SIZE</td><td><tt>HASH</tt></td>
395
+ checksum_match = re.search(
396
+ r"href=['\"][^'\"]*"
397
+ + re.escape(iso_filename)
398
+ + r"['\"]>"
399
+ + re.escape(iso_filename)
400
+ + r"</a></td><td[^>]*>\d+</td><td><tt>([a-f0-9]{64})</tt>",
401
+ html,
402
+ )
403
+ if checksum_match:
404
+ settings["resolved_checksum"] = checksum_match.group(1)
405
+
406
+ # Verify the URL is reachable
407
+ try:
408
+ require_https(iso_url, "ISO download")
409
+ req = urllib.request.Request(
410
+ iso_url, method="HEAD", headers={"User-Agent": "Mozilla/5.0"}
411
+ )
412
+ with urllib.request.urlopen( # require_https above is the allowlist
413
+ req, timeout=MIRROR_HTTP_TIMEOUT
414
+ ) as resp:
415
+ if resp.status == 200:
416
+ return iso_filename, iso_url
417
+ return fail(f"HEAD probe returned HTTP {resp.status} for {iso_url}")
418
+ except ValueError as e:
419
+ return fail(str(e))
420
+ except Exception as e:
421
+ return fail(f"HEAD probe failed for {iso_url}: {e}")
422
+
423
+ if strategy == "content_disposition":
424
+ requirements = ["base_url", "iso_regex"]
425
+ if not base_url or not iso_regex:
426
+ warn(f"{name} — {strategy} requires {requirements}")
427
+ return fail(f"config incomplete: needs {requirements}")
428
+
429
+ # Get the filename without downloading the ISO.
430
+ try:
431
+ require_https(base_url, "mirror base URL")
432
+ req = urllib.request.Request(
433
+ base_url, method="HEAD", headers={"User-Agent": "Mozilla/5.0"}
434
+ )
435
+ with urllib.request.urlopen(req, timeout=MIRROR_HTTP_TIMEOUT) as resp:
436
+ if resp.status != 200:
437
+ return fail(
438
+ f"HEAD probe returned HTTP {resp.status} for {base_url}"
439
+ )
440
+
441
+ content_disposition = resp.headers.get("Content-Disposition", "")
442
+ match = re.search(r'filename="?([^";]+)"?', content_disposition)
443
+
444
+ if not match:
445
+ return fail("Content-Disposition has no filename")
446
+
447
+ raw_filename = match.group(1).strip()
448
+
449
+ if "/" in raw_filename or "\\" in raw_filename:
450
+ return fail("Content-Disposition contains an unsafe filename")
451
+
452
+ filename = _safe_filename(raw_filename)
453
+
454
+ if not filename or not re.fullmatch(iso_regex, filename):
455
+ return fail("Content-Disposition filename does not match iso_regex")
456
+
457
+ download_url = f"{base_url.rstrip('/')}/{filename}"
458
+ return filename, download_url
459
+
460
+ except ValueError as e:
461
+ return fail(str(e))
462
+ except Exception as e:
463
+ return fail(f"HEAD probe failed for {base_url}: {e}")
464
+
465
+ # Strategy E: Pop!_OS JSON API — fetch latest build info
466
+ elif strategy == "popos_api":
467
+ import json as _json
468
+
469
+ api_url = settings.get("api_url", "https://api.pop-os.org/builds")
470
+ variant = settings.get("variant", "generic")
471
+ release = settings.get("release", "24.04")
472
+
473
+ url = f"{api_url}/{release}/{variant}"
474
+ html = fetch_html(url)
475
+ if not html:
476
+ return fail(f"could not fetch {url}")
477
+
478
+ try:
479
+ data = _json.loads(html)
480
+ except _json.JSONDecodeError:
481
+ warn(f"{name} — could not parse Pop!_OS API response")
482
+ return fail(f"{url} did not return JSON")
483
+
484
+ iso_url = data.get("url", "") if isinstance(data, dict) else ""
485
+ if not iso_url:
486
+ return fail(f"{url} returned no ISO url")
487
+ iso_filename = _safe_filename(iso_url.rsplit("/", 1)[-1])
488
+ if not iso_filename:
489
+ warn(f"{name} — API returned unsafe filename")
490
+ return fail(f"{url} returned an unusable filename")
491
+ # Pop!_OS publishes the digest as "sha_sum"; older docs said "sha256".
492
+ checksum = ""
493
+ if isinstance(data, dict):
494
+ for key in ("sha256", "sha_sum"):
495
+ value = data.get(key, "")
496
+ if isinstance(value, str) and re.fullmatch(r"[a-fA-F0-9]{64}", value):
497
+ checksum = value.lower()
498
+ break
499
+ if checksum:
500
+ settings["resolved_checksum"] = checksum
501
+ return iso_filename, iso_url
502
+
503
+ # Strategy F: Tails JSON API — fetch latest version from releases.json
504
+ elif strategy == "tails_api":
505
+ import json as _json
506
+
507
+ api_url = settings.get(
508
+ "api_url", "https://tails.net/install/v2/Tails/amd64/stable/latest.json"
509
+ )
510
+ file_type = settings.get("file_type", "img") # "iso" or "img"
511
+
512
+ html = fetch_html(api_url)
513
+ if not html:
514
+ return fail(f"could not fetch {api_url}")
515
+
516
+ try:
517
+ data = _json.loads(html)
518
+ installations = data.get("installations", [])
519
+ except (_json.JSONDecodeError, AttributeError):
520
+ warn(f"{name} — could not parse Tails API response")
521
+ return fail(f"{api_url} did not return the expected JSON")
522
+ if not installations:
523
+ return fail(f"{api_url} listed no installations")
524
+
525
+ latest = installations[0]
526
+ for installation in installations:
527
+ if installation.get("version", "") > latest.get("version", ""):
528
+ latest = installation
529
+
530
+ for path in latest.get("installation-paths", []):
531
+ if path.get("type") != file_type:
532
+ continue
533
+ for target in path.get("target-files", []):
534
+ url = target.get("url", "")
535
+ if not url:
536
+ continue
537
+ iso_filename = _safe_filename(url.rsplit("/", 1)[-1])
538
+ if not iso_filename:
539
+ warn(f"{name} — API returned unsafe filename")
540
+ return fail(f"{api_url} returned an unusable filename")
541
+ checksum = target.get("sha256", "")
542
+ if isinstance(checksum, str) and re.fullmatch(
543
+ r"[a-fA-F0-9]{64}", checksum
544
+ ):
545
+ settings["resolved_checksum"] = checksum.lower()
546
+ return iso_filename, url
547
+ return fail(f"{api_url} has no {file_type} artifact for the latest release")
548
+
549
+ return fail(f"unknown or unhandled strategy {strategy!r}")
550
+
551
+
552
+ MIN_CHUNK_SIZE = 4 * 1024 * 1024 # 4 MiB minimum per chunk
553
+ MAX_DOWNLOAD_THREADS = 16
554
+
555
+
556
+ def _download_threads() -> int:
557
+ """Thread count from VISYNC_DOWNLOAD_THREADS; invalid values fall back to 4."""
558
+ raw = os.environ.get("VISYNC_DOWNLOAD_THREADS", "4")
559
+ try:
560
+ n = int(raw)
561
+ except ValueError:
562
+ _debug(f"Invalid VISYNC_DOWNLOAD_THREADS={raw!r}, using 4")
563
+ return 4
564
+ return max(1, min(n, MAX_DOWNLOAD_THREADS))
565
+
566
+
567
+ def _seek_write(fd: int, data: bytes, offset: int) -> int:
568
+ """Write *data* at *offset* via a positioned descriptor.
569
+
570
+ The fallback for platforms without os.pwrite, where each thread owns a
571
+ descriptor and must move its own cursor before writing.
572
+ """
573
+ os.lseek(fd, offset, os.SEEK_SET)
574
+ return os.write(fd, data)
575
+
576
+
577
+ def _download_chunked(
578
+ url: str,
579
+ part_path: Path,
580
+ total: int,
581
+ num_threads: int,
582
+ filename: str,
583
+ ) -> bool:
584
+ """Download a file using HTTP Range requests in parallel threads.
585
+
586
+ Writes directly to part_path at the correct offsets, using os.pwrite where
587
+ available and per-thread lseek/write on platforms that lack it (Windows).
588
+ Returns True on success, False on failure.
589
+ """
590
+ import threading
591
+
592
+ require_https(url, "ISO download")
593
+
594
+ pwrite_available = callable(getattr(os, "pwrite", None))
595
+
596
+ chunk_size = max(MIN_CHUNK_SIZE, total // num_threads)
597
+ # Build (start, end) ranges
598
+ ranges: list[tuple[int, int]] = []
599
+ start = 0
600
+ while start < total:
601
+ end = min(start + chunk_size - 1, total - 1)
602
+ ranges.append((start, end))
603
+ start = end + 1
604
+
605
+ actual_threads = len(ranges)
606
+ _debug(
607
+ f"Chunked download: {total} bytes in {actual_threads} chunks of ~{chunk_size} bytes"
608
+ )
609
+
610
+ # Pre-allocate the file. O_BINARY is required on Windows — without it the
611
+ # CRT opens in text mode and os.write() inflates every 0x0A into 0x0D 0x0A,
612
+ # silently corrupting ISO bytes. It is a no-op constant on POSIX.
613
+ _BINARY = getattr(os, "O_BINARY", 0)
614
+ fd = os.open(str(part_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC | _BINARY)
615
+ try:
616
+ os.ftruncate(fd, total)
617
+ except OSError:
618
+ os.close(fd)
619
+ return False
620
+
621
+ downloaded = [0] * actual_threads
622
+ lock = threading.Lock()
623
+ errors: list[str] = []
624
+
625
+ def _download_chunk(idx: int, chunk_start: int, chunk_end: int) -> None:
626
+ nonlocal downloaded
627
+ range_header = f"bytes={chunk_start}-{chunk_end}"
628
+
629
+ # Bind the write strategy once per chunk thread instead of branching on
630
+ # pwrite_available at every write. os.pwrite writes at an offset without
631
+ # moving the shared file cursor, which is what makes parallel chunk
632
+ # writes safe; platforms without it get a private descriptor plus an
633
+ # explicit seek. Binding it here also removes the `thread_fd is None`
634
+ # assertion that used to guard the second branch: an assert cannot do
635
+ # that job under `python -O`, where it is stripped and the fallback
636
+ # would call os.lseek(None, ...).
637
+ thread_fd: int | None = None
638
+ if pwrite_available:
639
+ write_at: Callable[[bytes, int], int] = lambda data, off: os.pwrite(
640
+ fd, data, off
641
+ )
642
+ else:
643
+ thread_fd = os.open(str(part_path), os.O_WRONLY | _BINARY)
644
+ write_at = lambda data, off, _f=thread_fd: _seek_write(_f, data, off)
645
+ try:
646
+ req = urllib.request.Request(
647
+ url,
648
+ headers={"User-Agent": "Mozilla/5.0", "Range": range_header},
649
+ )
650
+ with urllib.request.urlopen( # require_https above is the allowlist
651
+ req, timeout=60
652
+ ) as resp:
653
+ status = getattr(resp, "status", 206)
654
+ if status != 206:
655
+ with lock:
656
+ errors.append(
657
+ f"Chunk {idx}: server ignored Range request "
658
+ f"(HTTP {status}, expected 206)"
659
+ )
660
+ return
661
+ # A 206 alone does not prove the body is the range we asked for.
662
+ # Without this, a server answering every Range with the same
663
+ # 206 slice would assemble a full-size file out of duplicated
664
+ # chunks and pass every byte-count check. Where a checksum
665
+ # exists that catches it; where none is configured it would be
666
+ # silent corruption.
667
+ content_range = resp.headers.get("Content-Range", "")
668
+ served = re.fullmatch(
669
+ r"bytes (\d+)-(\d+)/(?:(\d+)|\*)", content_range.strip()
670
+ )
671
+ if (
672
+ not served
673
+ or int(served.group(1)) != chunk_start
674
+ or int(served.group(2)) != chunk_end
675
+ ):
676
+ with lock:
677
+ errors.append(
678
+ f"Chunk {idx}: Content-Range {content_range!r} does not "
679
+ f"match the requested bytes {chunk_start}-{chunk_end}"
680
+ )
681
+ return
682
+ offset = chunk_start
683
+ while True:
684
+ try:
685
+ data = resp.read(128000)
686
+ except TimeoutError:
687
+ with lock:
688
+ errors.append(f"Chunk {idx} stalled")
689
+ return
690
+ if not data:
691
+ break
692
+ room = chunk_end - offset + 1
693
+ if len(data) > room:
694
+ with lock:
695
+ errors.append(
696
+ f"Chunk {idx}: response exceeds requested range "
697
+ f"by {len(data) - room} bytes"
698
+ )
699
+ return
700
+ written = write_at(data, offset)
701
+ offset += written
702
+ with lock:
703
+ downloaded[idx] = offset - chunk_start
704
+ if written < len(data):
705
+ with lock:
706
+ errors.append(
707
+ f"Chunk {idx}: short write ({written} of {len(data)})"
708
+ )
709
+ return
710
+ except Exception as e:
711
+ with lock:
712
+ errors.append(f"Chunk {idx}: {e}")
713
+ finally:
714
+ if thread_fd is not None:
715
+ os.close(thread_fd)
716
+
717
+ try:
718
+ with make_download_progress() as progress:
719
+ task = progress.add_task(
720
+ "download", filename=_esc(filename), total=total or None
721
+ )
722
+ with concurrent.futures.ThreadPoolExecutor(
723
+ max_workers=actual_threads
724
+ ) as pool:
725
+ futures = [
726
+ pool.submit(_download_chunk, i, s, e)
727
+ for i, (s, e) in enumerate(ranges)
728
+ ]
729
+ while not all(f.done() for f in futures):
730
+ time.sleep(0.25)
731
+ with lock:
732
+ total_done = sum(downloaded)
733
+ progress.update(task, completed=total_done)
734
+ concurrent.futures.wait(futures)
735
+
736
+ expected_sizes = [e - s + 1 for s, e in ranges]
737
+ with lock:
738
+ short = [
739
+ f"Chunk {i}: got {downloaded[i]} of {expected_sizes[i]} bytes"
740
+ for i in range(actual_threads)
741
+ if downloaded[i] != expected_sizes[i]
742
+ ]
743
+ if short:
744
+ errors.extend(short)
745
+
746
+ if errors:
747
+ error(f"Chunked download failed: {'; '.join(errors[:3])}")
748
+ return False
749
+
750
+ return True
751
+ finally:
752
+ os.close(fd)
753
+
754
+
755
+ def _download_single_stream(
756
+ url: str,
757
+ part_path: Path,
758
+ total: int,
759
+ filename: str,
760
+ ) -> bool:
761
+ """Download a file in a single stream with progress reporting."""
762
+ require_https(url, "ISO download")
763
+ CHUNK_SIZE = 128000
764
+ READ_TIMEOUT = 30
765
+
766
+ try:
767
+ req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
768
+ with urllib.request.urlopen( # require_https above is the allowlist
769
+ req, timeout=30
770
+ ) as resp:
771
+ downloaded = 0
772
+ with make_download_progress() as progress:
773
+ task = progress.add_task(
774
+ "download",
775
+ filename=_esc(filename),
776
+ total=total or None,
777
+ )
778
+ with part_path.open("wb", buffering=1048576) as f:
779
+ while True:
780
+ try:
781
+ chunk = resp.read(CHUNK_SIZE)
782
+ except TimeoutError:
783
+ error(f"Download stalled — no data for {READ_TIMEOUT}s")
784
+ part_path.unlink(missing_ok=True)
785
+ return False
786
+ if not chunk:
787
+ break
788
+ f.write(chunk)
789
+ downloaded += len(chunk)
790
+ progress.update(task, completed=downloaded)
791
+ except OSError as e:
792
+ error(f"Write/disk error during download: {e}")
793
+ part_path.unlink(missing_ok=True)
794
+ return False
795
+ except Exception as e:
796
+ error(f"Network error during download: {e}")
797
+ part_path.unlink(missing_ok=True)
798
+ return False
799
+
800
+ return True
801
+
802
+
803
+ def download_iso(
804
+ url: str,
805
+ dest_path: Path,
806
+ drive_root: Path | None = None,
807
+ distro_config: dict | None = None,
808
+ checksums_config: dict | None = None,
809
+ no_verify: bool = False,
810
+ ) -> bool:
811
+ """Download an ISO file with streaming progress and optional metadata persistence.
812
+
813
+ Returns True on success, False on failure.
814
+ """
815
+ _debug(f"Starting download: {url} -> {dest_path}")
816
+ if drive_root and dest_path.parent != drive_root:
817
+ console.print(
818
+ f" [cyan]↓[/cyan] Downloading to staging: [bold]{_esc(dest_path.name)}[/bold]"
819
+ )
820
+ else:
821
+ console.print(
822
+ f" [cyan]↓[/cyan] Downloading: [bold]{_esc(dest_path.name)}[/bold]"
823
+ )
824
+
825
+ require_https(url, "ISO download")
826
+
827
+ # HEAD request: get size, check range support
828
+ expected = 0
829
+ ranges_supported = False
830
+ try:
831
+ req = urllib.request.Request(
832
+ url, method="HEAD", headers={"User-Agent": "Mozilla/5.0"}
833
+ )
834
+ with urllib.request.urlopen( # require_https above is the allowlist
835
+ req, timeout=10
836
+ ) as resp:
837
+ expected = int(resp.headers.get("Content-Length", 0))
838
+ accept_ranges = resp.headers.get("Accept-Ranges", "")
839
+ ranges_supported = accept_ranges == "bytes"
840
+ except Exception: # noqa: S110 - HEAD is advisory; unknown size still downloads
841
+ pass
842
+
843
+ # Disk space check
844
+ try:
845
+ usage = shutil.disk_usage(dest_path.parent)
846
+ available = usage.free
847
+ if expected > 0:
848
+ needed = int(expected * 1.05)
849
+ info(
850
+ f"Expected: {expected / (1024**3):.2f} GiB | Available: {available / (1024**3):.2f} GiB"
851
+ )
852
+ if available < needed:
853
+ error(
854
+ f"Insufficient disk space — need {needed / (1024**3):.2f} GiB, "
855
+ f"have {available / (1024**3):.2f} GiB"
856
+ )
857
+ return False
858
+ else:
859
+ info(f"Available disk space: {available / (1024**3):.2f} GiB")
860
+ warn("Content-Length unknown — disk space cannot be verified.")
861
+ except Exception: # noqa: S110 - disk_usage is best-effort on exotic filesystems
862
+ pass
863
+
864
+ part_path = dest_path.with_suffix(dest_path.suffix + ".part")
865
+
866
+ # Choose chunked or single-stream
867
+ _threads = _download_threads()
868
+ if ranges_supported and expected > MIN_CHUNK_SIZE * _threads:
869
+ _debug(f"Using chunked download ({_threads} threads)")
870
+ ok = _download_chunked(url, part_path, expected, _threads, dest_path.name)
871
+ if not ok:
872
+ part_path.unlink(missing_ok=True)
873
+ return False
874
+ else:
875
+ if not ranges_supported:
876
+ _debug("Server does not support Range requests — using single stream")
877
+ else:
878
+ _debug("File too small for chunked download — using single stream")
879
+ ok = _download_single_stream(url, part_path, expected, dest_path.name)
880
+ if not ok:
881
+ return False
882
+
883
+ # Verify file is not empty or obviously truncated
884
+ if part_path.stat().st_size == 0:
885
+ error(f"Download produced empty file: {dest_path.name}")
886
+ part_path.unlink(missing_ok=True)
887
+ return False
888
+
889
+ if expected > 0 and part_path.stat().st_size < expected:
890
+ error(
891
+ f"Download truncated — got {part_path.stat().st_size / (1024**3):.2f} GiB "
892
+ f"of expected {expected / (1024**3):.2f} GiB"
893
+ )
894
+ part_path.unlink(missing_ok=True)
895
+ return False
896
+
897
+ part_path.replace(dest_path)
898
+ if drive_root and dest_path.parent != drive_root:
899
+ success(f"Downloaded to staging: {dest_path.name}")
900
+ else:
901
+ success(f"Downloaded: {dest_path.name}")
902
+
903
+ # Compute SHA-256 once — used for both verification and metadata
904
+ sha256_hex = ""
905
+ spin_start(f"Hashing {dest_path.name}...")
906
+ try:
907
+ try:
908
+ h = hashlib.sha256()
909
+ with dest_path.open("rb") as f:
910
+ while True:
911
+ chunk = f.read(65536)
912
+ if not chunk:
913
+ break
914
+ h.update(chunk)
915
+ sha256_hex = h.hexdigest()
916
+ except OSError:
917
+ pass
918
+
919
+ # Auto-verify checksum if config is available
920
+ if not no_verify and distro_config and checksums_config is not None:
921
+ from visync.verify import ChecksumUnavailable, verify_from_config
922
+
923
+ spin_update(f"Verifying checksum for {dest_path.name}...")
924
+ try:
925
+ result = verify_from_config(
926
+ dest_path,
927
+ distro_config,
928
+ checksums_config,
929
+ precomputed_hash=sha256_hex,
930
+ )
931
+ except ChecksumUnavailable as e:
932
+ warn(f"Could not verify {dest_path.name} — {e}")
933
+ warn("Keeping downloaded file UNVERIFIED.")
934
+ result = None
935
+ if result is False:
936
+ error(f"Checksum verification failed for {dest_path.name} — deleting")
937
+ dest_path.unlink(missing_ok=True)
938
+ return False
939
+ if result is True:
940
+ success(f"Checksum verified: {dest_path.name}")
941
+ else:
942
+ warn(f"No checksum config for {dest_path.name} — installed UNVERIFIED")
943
+ finally:
944
+ spin_stop()
945
+
946
+ _cleanup_old_versions(dest_path, drive_root)
947
+
948
+ if drive_root and sha256_hex:
949
+ volume_id = get_iso_volume_id(dest_path)
950
+ version = ""
951
+ if volume_id:
952
+ version = extract_version_from_filename(dest_path.name)
953
+ variant_stem = _variant_stem(volume_id) if volume_id else ""
954
+ try:
955
+ iso_size = dest_path.stat().st_size
956
+ except OSError:
957
+ iso_size = 0
958
+ write_iso_metadata(
959
+ drive_root=drive_root,
960
+ filename=dest_path.name,
961
+ variant_stem=variant_stem,
962
+ version=version,
963
+ sha256=sha256_hex,
964
+ size=iso_size,
965
+ )
966
+ _debug(f"Metadata written for {dest_path.name}")
967
+
968
+ return True
969
+
970
+
971
+ def _cleanup_old_versions(new_iso: Path, drive_root: Path | None = None) -> None:
972
+ """Scan the target directory and delete older ISOs of the same distribution variant.
973
+
974
+ Uses volume ID for the new file only, then uses filename-based matching
975
+ to find candidates. Only reads volume IDs for filename-matched candidates
976
+ to confirm they are the same distro+variant before deletion.
977
+ """
978
+ _debug(f"Cleanup check for {new_iso.name}")
979
+ try:
980
+ new_vid = get_iso_volume_id(new_iso)
981
+ if new_vid:
982
+ new_distro = identify_distro(new_vid, new_iso.name)
983
+ new_stem = _variant_stem(new_vid)
984
+ else:
985
+ new_distro = identify_distro("", new_iso.name)
986
+ new_stem = _filename_variant_key(new_iso.name)
987
+
988
+ if new_distro in ("Unknown OS", ""):
989
+ return
990
+
991
+ target_dir = new_iso.parent
992
+
993
+ for iso_path in find_installed_isos(target_dir):
994
+ if iso_path == new_iso:
995
+ continue
996
+
997
+ try:
998
+ # Cheap pre-filter on the filename key, so we only pay for a
999
+ # volume-ID read on plausible candidates.
1000
+ if not same_variant_prefix(
1001
+ new_stem, _filename_variant_key(iso_path.name)
1002
+ ):
1003
+ continue
1004
+
1005
+ # Confirm match by reading volume ID only for candidates
1006
+ old_vid = get_iso_volume_id(iso_path)
1007
+ if old_vid:
1008
+ old_distro = identify_distro(old_vid, iso_path.name)
1009
+ old_stem = _variant_stem(old_vid)
1010
+ else:
1011
+ old_distro = identify_distro("", iso_path.name)
1012
+ old_stem = _filename_variant_key(iso_path.name)
1013
+
1014
+ if old_distro == new_distro and old_stem == new_stem:
1015
+ removed(f"Removing deprecated image: {iso_path.name}")
1016
+ iso_path.unlink(missing_ok=True)
1017
+ if drive_root:
1018
+ remove_iso_metadata(drive_root, iso_path.name)
1019
+ except OSError:
1020
+ warn(f"Could not remove stale file: {iso_path.name}")
1021
+ except Exception: # noqa: S110 - never let cleanup abort a sync
1022
+ pass
1023
+ except Exception: # noqa: S110 - cleanup is best-effort
1024
+ pass
1025
+
1026
+
1027
+ def _sweep_old_versions(drive_root: Path, clean: bool = False) -> None:
1028
+ """Scan all ISOs on the drive and remove older versions of the same distro+variant.
1029
+
1030
+ Groups ISOs by (distro, variant_stem), sorts each group by version, and
1031
+ removes all but the newest in each group.
1032
+ With clean=False (default), only reports what would be deleted.
1033
+ """
1034
+ from collections import defaultdict
1035
+
1036
+ _debug("Running sweep for stale ISOs")
1037
+ all_isos = find_installed_isos(drive_root)
1038
+ groups: dict[tuple[str, str], list[tuple[str, Path]]] = defaultdict(list)
1039
+
1040
+ for iso_path in all_isos:
1041
+ vid = get_iso_volume_id(iso_path)
1042
+ if vid:
1043
+ distro = identify_distro(vid, iso_path.name)
1044
+ stem = _variant_stem(vid)
1045
+ else:
1046
+ distro = identify_distro("", iso_path.name)
1047
+ stem = _filename_variant_key(iso_path.name)
1048
+ version = extract_version_from_filename(iso_path.name) or "0"
1049
+ if distro and distro != "Unknown OS":
1050
+ groups[(distro, stem)].append((version, iso_path))
1051
+
1052
+ for (distro, _stem), versions in groups.items():
1053
+ if len(versions) <= 1:
1054
+ continue
1055
+ versions.sort(key=lambda x: parse_version(x[0]) or ())
1056
+ _newest_version, _newest_path = versions[-1]
1057
+ for version, iso_path in versions[:-1]:
1058
+ if clean:
1059
+ try:
1060
+ removed(f"Removing old {distro} {version}: {iso_path.name}")
1061
+ iso_path.unlink(missing_ok=True)
1062
+ remove_iso_metadata(drive_root, iso_path.name)
1063
+ except OSError as e:
1064
+ warn(f"Could not remove {iso_path.name}: {e}")
1065
+ else:
1066
+ info(f"Would remove old {distro} {version}: {iso_path.name}")
1067
+
1068
+
1069
+ # Tokens that identify the architecture rather than the distro variant. They are
1070
+ # dropped from variant keys so a rebuild for a different arch is treated as the
1071
+ # same variant. Mirrors _ARCH_TOKEN_RE in verify.py.
1072
+ #
1073
+ # The arch name is bracketed by separator classes rather than \b because
1074
+ # filenames glue it to underscores ("pop-os_24.04_amd64_generic_24.iso"), where a
1075
+ # trailing underscore counts as a word character and \bamd64\b would not match.
1076
+ _ARCH_IN_KEY_RE = re.compile(
1077
+ r"(?:(?<=^)|(?<=[\s_\-.]))(?:x86[_-]?64|amd64|aarch64|arm64|armhfp"
1078
+ r"|i[36]86|riscv64|x86)(?=[\s_\-.]|$)",
1079
+ re.IGNORECASE,
1080
+ )
1081
+ # Version tokens: pure numbers, optionally dotted, tolerating a trailing
1082
+ # separator left behind by a stripped arch token ("live-1.7.").
1083
+ _VERSION_IN_KEY_RE = re.compile(r"^\d+(?:\.\d+)*\.?$")
1084
+ # Release-type words that appear in volume IDs but never distinguish a variant.
1085
+ _RELEASE_WORD_RE = re.compile(
1086
+ r"^(?:lts|esd|point|pre|rc|beta|alpha|rc\d*)$", re.IGNORECASE
1087
+ )
1088
+
1089
+
1090
+ def variant_key(text: str) -> str:
1091
+ """Derive a stable distro-variant identity from arbitrary identifying text.
1092
+
1093
+ Accepts either an ISO filename or an ISO 9660 volume ID and reduces both to
1094
+ the same distro+variant key, so two versions of one variant match while
1095
+ distinct variants (desktop vs server, KDE vs Workstation) never do.
1096
+
1097
+ Architecture tokens, version tokens, and release-type words are removed, and
1098
+ all separator styles collapse to single hyphens. Unifying the two former
1099
+ implementations mattered because they disagreed: the volume-ID path kept
1100
+ ``amd64`` (its "protect the underscore" step is a no-op for every arch
1101
+ except x86_64) while the filename path stripped it, producing keys such as
1102
+ ``pop_os-amd64`` versus ``pop-os-generic``. That mismatch made the
1103
+ cleanup's filename prefix filter reject every Pop!_OS candidate, so old
1104
+ builds were never removed.
1105
+
1106
+ Examples:
1107
+ 'Pop_OS 24.04 amd64' -> 'pop-os'
1108
+ 'pop-os_24.04_amd64_generic_24.iso' -> 'pop-os-generic'
1109
+ 'Ubuntu-Server 26.04.1 LTS amd64' -> 'ubuntu-server'
1110
+ 'ubuntu-24.04.1-live-server-amd64.iso' -> 'ubuntu-live-server'
1111
+ """
1112
+ stem = re.sub(r"\.(iso|img)$", "", text, flags=re.IGNORECASE)
1113
+ # Remove arch tokens with their surrounding separator so "…-amd64" does not
1114
+ # leave a dangling hyphen, then drop standalone version and release words.
1115
+ stem = _ARCH_IN_KEY_RE.sub(" ", stem)
1116
+ tokens = []
1117
+ for token in re.split(r"[\s_\-]+", stem.lower()):
1118
+ if not token:
1119
+ continue
1120
+ if _VERSION_IN_KEY_RE.match(token):
1121
+ continue
1122
+ if _RELEASE_WORD_RE.fullmatch(token):
1123
+ continue
1124
+ tokens.append(token)
1125
+ return "-".join(tokens)
1126
+
1127
+
1128
+ def _variant_stem(volume_id: str) -> str:
1129
+ """Variant key for an ISO 9660 volume ID. Thin alias of variant_key()."""
1130
+ return variant_key(volume_id)
1131
+
1132
+
1133
+ def _filename_variant_key(filename: str) -> str:
1134
+ """Variant key for a filename. Thin alias of variant_key()."""
1135
+ return variant_key(filename)
1136
+
1137
+
1138
+ def same_variant_prefix(key_a: str, key_b: str) -> bool:
1139
+ """Cheap pre-filter: could two keys plausibly be the same variant?
1140
+
1141
+ Used to avoid reading a volume ID from every ISO on the drive. Both sides
1142
+ are normalised keys, and the check keeps the original "filename starts with
1143
+ the first token" semantics: one leading token being a prefix of the other is
1144
+ enough to proceed. Exact equality alone would be too strict — the volume ID
1145
+ of an Arch ISO is ``ARCH_202610`` (key ``arch``) while its filename key is
1146
+ ``archlinux``, and requiring equality would silently stop cleanup.
1147
+
1148
+ Normalising both sides is the fix: the previous raw comparison rejected
1149
+ ``pop_os`` versus ``pop-os`` because of the separator mismatch.
1150
+ """
1151
+ if not key_a or not key_b:
1152
+ return True
1153
+ first_a = key_a.split("-", 1)[0]
1154
+ first_b = key_b.split("-", 1)[0]
1155
+ return first_a.startswith(first_b) or first_b.startswith(first_a)
1156
+
1157
+
1158
+ def _check_distro(
1159
+ entry_id: str, settings: dict, ventoy_root: Path, force: bool = False
1160
+ ) -> DistroCheck:
1161
+ """Scrape and version-check a single distro. Returns metadata for download decisions.
1162
+
1163
+ A mirror we could not read yields UNREACHABLE (never CURRENT), so callers can
1164
+ report it and exit non-zero rather than pretending the distro is up to date.
1165
+ """
1166
+ clean_name = settings.get("clean_name", entry_id)
1167
+ _debug(f"Checking {clean_name} (force={force})")
1168
+ spin_update(clean_name)
1169
+
1170
+ latest_filename, download_url = process_scraping_strategy(clean_name, settings)
1171
+ if not latest_filename:
1172
+ # Terse here; the full reason is collected and tabulated by the caller.
1173
+ warn(f"{clean_name} — unreachable (details below)")
1174
+ return _unreachable(
1175
+ entry_id, clean_name, str(settings.get("resolve_error") or "")
1176
+ )
1177
+
1178
+ local_ventoy_files = find_installed_isos(ventoy_root)
1179
+
1180
+ # Exact filename match — already up to date (skip check if --force).
1181
+ # Case-insensitive: Ventoy drives are typically FAT/exFAT (case-insensitive),
1182
+ # so 'Arch' and 'arch' are the same file there.
1183
+ if not force and any(
1184
+ f.name.lower() == latest_filename.lower() for f in local_ventoy_files
1185
+ ):
1186
+ success(f"{clean_name} is up to date")
1187
+ return DistroCheck(
1188
+ entry_id, clean_name, latest_filename, SyncStatus.CURRENT, None
1189
+ )
1190
+
1191
+ # Version-based comparison: find best local candidate and compare
1192
+ remote_version = extract_version_from_filename(latest_filename)
1193
+ if not remote_version:
1194
+ reason = f"no version in upstream filename {latest_filename!r}"
1195
+ warn(f"{clean_name} — {reason}")
1196
+ return _unreachable(entry_id, clean_name, reason)
1197
+
1198
+ if not force:
1199
+ remote_key = _filename_variant_key(latest_filename)
1200
+ same_distro = [
1201
+ f
1202
+ for f in local_ventoy_files
1203
+ if extract_version_from_filename(f.name)
1204
+ and _filename_variant_key(f.name) == remote_key
1205
+ ]
1206
+ if same_distro:
1207
+ best_local = max(
1208
+ same_distro,
1209
+ key=lambda f: (
1210
+ parse_version(extract_version_from_filename(f.name)) or (0,)
1211
+ ),
1212
+ )
1213
+ local_version = extract_version_from_filename(best_local.name)
1214
+ comparison = compare_versions(remote_version, local_version)
1215
+ if comparison <= 0:
1216
+ success(
1217
+ f"{clean_name} is up to date (local {local_version}, upstream {remote_version})"
1218
+ )
1219
+ return DistroCheck(
1220
+ entry_id, clean_name, latest_filename, SyncStatus.CURRENT, None
1221
+ )
1222
+
1223
+ if force:
1224
+ warn(f"{clean_name} — force re-download")
1225
+
1226
+ return DistroCheck(
1227
+ entry_id, clean_name, latest_filename, SyncStatus.STALE, download_url
1228
+ )
1229
+
1230
+
1231
+ def _copy_with_progress(src: Path, dst: Path, filename: str) -> None:
1232
+ """Copy a file with a live progress bar.
1233
+
1234
+ Used when moving an ISO from the staging buffer onto the Ventoy drive
1235
+ (different filesystems), where shutil.move would silently copy the whole
1236
+ file and look like a hard freeze.
1237
+ """
1238
+ total = src.stat().st_size
1239
+ with make_download_progress() as progress:
1240
+ task = progress.add_task(
1241
+ "copy to drive", filename=_esc(filename), total=total or None
1242
+ )
1243
+ copied = 0
1244
+ with src.open("rb") as rf, dst.open("wb") as wf:
1245
+ while True:
1246
+ chunk = rf.read(1024 * 1024)
1247
+ if not chunk:
1248
+ break
1249
+ wf.write(chunk)
1250
+ copied += len(chunk)
1251
+ progress.update(task, completed=copied)
1252
+
1253
+
1254
+ def _discard_partial(part_file: Path, label: str) -> None:
1255
+ """Remove one in-flight .part file, reporting rather than raising on failure.
1256
+
1257
+ Called on the cancellation path, so it must never turn a clean Ctrl-C into a
1258
+ second error.
1259
+
1260
+ This replaced a blanket rglob sweep of every ``*.iso.part`` in the download
1261
+ directory and the drive. That sweep only ever ran from download.py's
1262
+ __main__ block, which the CLI does not execute, and it was the wrong tool
1263
+ anyway: it could delete a partial another visync run had in flight. Downloads
1264
+ are sequential, so there is exactly one partial to clean up.
1265
+ """
1266
+ try:
1267
+ if not part_file.exists():
1268
+ return
1269
+ part_file.unlink()
1270
+ except OSError as e:
1271
+ warn(f"Could not remove partial download {label}: {e}")
1272
+ return
1273
+ info(f"Discarded partial download: {_esc(label)}")
1274
+
1275
+
1276
+ def sync_all_configured_distros(
1277
+ dry_run: bool = False,
1278
+ force: bool = False,
1279
+ clean: bool = False,
1280
+ config_path: Path | None = None,
1281
+ only: list[str] | None = None,
1282
+ drive_override: Path | None = None,
1283
+ use_buffer: bool = True,
1284
+ no_verify: bool = False,
1285
+ reset_visync: bool = False,
1286
+ ) -> tuple[Path | None, list[str], list[tuple[str, str]]]:
1287
+ """Iterate through user-defined scrapers to pull updates down safely.
1288
+
1289
+ If *only* is provided, only sync those entry_ids.
1290
+ If *drive_override* is provided, use that as the Ventoy root.
1291
+ Set *use_buffer* to False to download directly to the Ventoy drive.
1292
+ Set *reset_visync* to allow the watchdog to wipe an over-budget .visync/;
1293
+ without it the watchdog only deep-cleans. The watchdog never runs on a
1294
+ dry run, which must not modify the drive at all.
1295
+
1296
+ Returns ``(download_dir, downloaded_filenames, unreachable)`` where
1297
+ *unreachable* is a list of ``(clean_name, reason)`` for every distro whose
1298
+ upstream could not be read. This function never raises for a failed scrape;
1299
+ the caller decides whether to exit non-zero.
1300
+ """
1301
+ _debug(
1302
+ f"sync_all_configured_distros(dry_run={dry_run}, force={force}, clean={clean}, only={only})"
1303
+ )
1304
+ config = load_config(config_path)
1305
+ distro_scrapers = config.get("distros", {})
1306
+ iso_settings = config.get("iso", {})
1307
+ unreachable: list[tuple[str, str]] = []
1308
+
1309
+ if not distro_scrapers:
1310
+ error("No distribution definitions configured inside [distros] block.")
1311
+ return None, [], [("config.toml", "no [distros] block is defined")]
1312
+
1313
+ if drive_override:
1314
+ ventoy_root = drive_override
1315
+ else:
1316
+ drives = find_ventoy_drives()
1317
+ if not drives:
1318
+ error("No Ventoy drives found.")
1319
+ return None, [], [("(drive)", "no Ventoy drives found")]
1320
+ ventoy_root = drives[0]
1321
+
1322
+ # The watchdog deep-cleans and can wipe .visync/, so it must never run on a
1323
+ # dry run — that command promises to leave the drive untouched.
1324
+ if not dry_run:
1325
+ visync_watchdog(ventoy_root, allow_wipe=reset_visync)
1326
+ _sweep_old_versions(ventoy_root, clean=clean and not dry_run)
1327
+
1328
+ config_download_dir = iso_settings.get("download_dir", "").strip()
1329
+ if use_buffer:
1330
+ download_target_dir = (
1331
+ Path(config_download_dir) if config_download_dir else DEFAULT_STAGING_DIR
1332
+ )
1333
+ if not dry_run:
1334
+ download_target_dir.mkdir(parents=True, exist_ok=True)
1335
+ info(f"Buffer staging → {download_target_dir}")
1336
+ else:
1337
+ download_target_dir = ventoy_root
1338
+ info(f"Direct volume mode → {download_target_dir}")
1339
+
1340
+ pending_downloads: list[tuple[str, str, str]] = []
1341
+
1342
+ if only:
1343
+ configured = set(distro_scrapers)
1344
+ unknown = sorted(set(only) - configured)
1345
+ distro_scrapers = {k: v for k, v in distro_scrapers.items() if k in only}
1346
+ if unknown:
1347
+ # Usually an entry_id left in installed.json by a removed distro.
1348
+ warn(
1349
+ "Installed but no longer configured: "
1350
+ f"{', '.join(unknown)} — run 'visync search' for the current list"
1351
+ )
1352
+ unreachable.extend(
1353
+ (eid, "no longer configured in config.toml") for eid in unknown
1354
+ )
1355
+ if not distro_scrapers:
1356
+ if not unreachable:
1357
+ warn("None of the specified distros are configured.")
1358
+ unreachable.append(("(none)", "no requested distros are configured"))
1359
+ return None, [], unreachable
1360
+
1361
+ spin_start("Syncing ISOs...")
1362
+ executor = concurrent.futures.ThreadPoolExecutor(max_workers=len(distro_scrapers))
1363
+ try:
1364
+ future_map = {
1365
+ executor.submit(
1366
+ _check_distro, entry_id, settings, ventoy_root, force
1367
+ ): entry_id
1368
+ for entry_id, settings in distro_scrapers.items()
1369
+ }
1370
+ deadline = time.monotonic() + SCRAPE_DEADLINE
1371
+ pending = set(future_map)
1372
+ while pending:
1373
+ remaining = deadline - time.monotonic()
1374
+ if remaining <= 0:
1375
+ for f in pending:
1376
+ f.cancel()
1377
+ timed_out_id = future_map[f]
1378
+ error(f"{timed_out_id} timed out")
1379
+ unreachable.append(
1380
+ (timed_out_id, f"scrape exceeded {SCRAPE_DEADLINE}s deadline")
1381
+ )
1382
+ break
1383
+ done, pending = concurrent.futures.wait(
1384
+ pending, timeout=min(remaining, 0.5)
1385
+ )
1386
+ for future in done:
1387
+ try:
1388
+ check = future.result()
1389
+ except (TimeoutError, ConnectionResetError, OSError) as e:
1390
+ failed_id = future_map[future]
1391
+ error(f"{failed_id}: {e}")
1392
+ unreachable.append((failed_id, str(e)))
1393
+ continue
1394
+ except Exception as e:
1395
+ failed_id = future_map[future]
1396
+ error(f"{failed_id}: {e}")
1397
+ unreachable.append((failed_id, f"{type(e).__name__}: {e}"))
1398
+ continue
1399
+ if check.status is SyncStatus.UNREACHABLE:
1400
+ unreachable.append((check.clean_name, check.reason))
1401
+ continue
1402
+ if check.status is not SyncStatus.STALE or not check.download_url:
1403
+ continue
1404
+ pending_downloads.append(
1405
+ (check.download_url, check.latest_filename, check.entry_id)
1406
+ )
1407
+ finally:
1408
+ executor.shutdown(wait=False, cancel_futures=True)
1409
+ spin_stop()
1410
+
1411
+ checksums_config = config.get("checksums", {})
1412
+
1413
+ downloaded: list[str] = []
1414
+
1415
+ if dry_run:
1416
+ if pending_downloads:
1417
+ console.print()
1418
+ info(f"Would download {len(pending_downloads)} file(s):")
1419
+ for _url, filename, _ in pending_downloads:
1420
+ console.print(f" [cyan]→[/cyan] {_esc(filename)}")
1421
+ elif unreachable:
1422
+ info("Nothing to download — but some distros could not be checked.")
1423
+ else:
1424
+ info("All ISOs are current — nothing to download.")
1425
+ else:
1426
+ for download_url, latest_filename, entry_id in pending_downloads:
1427
+ dest = download_target_dir / latest_filename
1428
+ part_file = dest.with_suffix(dest.suffix + ".part")
1429
+ distro_cfg = distro_scrapers.get(entry_id, {})
1430
+ try:
1431
+ ok = download_iso(
1432
+ download_url,
1433
+ dest,
1434
+ drive_root=ventoy_root,
1435
+ distro_config=distro_cfg,
1436
+ checksums_config=checksums_config,
1437
+ no_verify=no_verify,
1438
+ )
1439
+ except KeyboardInterrupt:
1440
+ # Ctrl-C leaves the partial file behind otherwise. Every other
1441
+ # failure path above unlinks it, and an interrupted download is
1442
+ # no different: the bytes are worthless, and on a Ventoy drive
1443
+ # or in the staging buffer they silently eat space that the next
1444
+ # ISO needs. The user asked to stop, so stop and tidy up.
1445
+ _discard_partial(part_file, latest_filename)
1446
+ raise
1447
+ except ValueError as e:
1448
+ error(f"Skipping {latest_filename}: {e}")
1449
+ part_file.unlink(missing_ok=True)
1450
+ unreachable.append((latest_filename, f"rejected: {e}"))
1451
+ continue
1452
+ except (TimeoutError, ConnectionResetError, OSError) as e:
1453
+ error(f"Failed syncing {latest_filename}: {e}")
1454
+ part_file.unlink(missing_ok=True)
1455
+ unreachable.append((latest_filename, f"download failed: {e}"))
1456
+ continue
1457
+ if not ok:
1458
+ part_file.unlink(missing_ok=True)
1459
+ unreachable.append((latest_filename, "download or verification failed"))
1460
+ continue
1461
+ downloaded.append(latest_filename)
1462
+ if dest.parent != ventoy_root:
1463
+ drive_dest = ventoy_root / latest_filename
1464
+ try:
1465
+ try:
1466
+ dest.rename(drive_dest)
1467
+ success(f"Moved to Ventoy drive: {latest_filename}")
1468
+ except OSError:
1469
+ info(f"Copying to Ventoy drive: {latest_filename}")
1470
+ _copy_with_progress(dest, drive_dest, latest_filename)
1471
+ if drive_dest.stat().st_size != dest.stat().st_size:
1472
+ error(
1473
+ f"Copy verification failed for {latest_filename} — "
1474
+ f"source {dest.stat().st_size}, dest {drive_dest.stat().st_size}"
1475
+ )
1476
+ drive_dest.unlink(missing_ok=True)
1477
+ unreachable.append(
1478
+ (latest_filename, "copy to drive failed size check")
1479
+ )
1480
+ continue
1481
+ success(f"Copied to Ventoy drive: {latest_filename}")
1482
+ except OSError as e:
1483
+ error(f"Failed placing {latest_filename} on drive: {e}")
1484
+ try:
1485
+ drive_dest.unlink(missing_ok=True)
1486
+ except OSError as unlink_err:
1487
+ if unlink_err.errno == 30:
1488
+ warn(
1489
+ f"Drive became read-only (unplugged?), skipping cleanup of {drive_dest}"
1490
+ )
1491
+ else:
1492
+ warn(
1493
+ f"Could not remove partial file {drive_dest}: {unlink_err}"
1494
+ )
1495
+ unreachable.append(
1496
+ (latest_filename, f"could not place on drive: {e}")
1497
+ )
1498
+ continue
1499
+ try:
1500
+ dest.unlink(missing_ok=True)
1501
+ except OSError as e:
1502
+ warn(f"Could not remove staging copy {dest.name}: {e}")
1503
+ _cleanup_old_versions(drive_dest, ventoy_root)
1504
+
1505
+ return download_target_dir, downloaded, unreachable