visync 0.1.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/__init__.py +3 -0
- visync/config.toml +259 -0
- visync/download.py +1505 -0
- visync/finder.py +699 -0
- visync/main.py +1172 -0
- visync/net.py +84 -0
- visync/output.py +126 -0
- visync/pm.py +133 -0
- visync/verify.py +703 -0
- visync-0.1.0.dist-info/METADATA +204 -0
- visync-0.1.0.dist-info/RECORD +14 -0
- visync-0.1.0.dist-info/WHEEL +4 -0
- visync-0.1.0.dist-info/entry_points.txt +2 -0
- visync-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|