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/__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-1.0.0.dist-info/METADATA +204 -0
- visync-1.0.0.dist-info/RECORD +14 -0
- visync-1.0.0.dist-info/WHEEL +4 -0
- visync-1.0.0.dist-info/entry_points.txt +2 -0
- visync-1.0.0.dist-info/licenses/LICENSE +21 -0
visync/verify.py
ADDED
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
"""Verify ISO integrity using checksums.
|
|
2
|
+
|
|
3
|
+
Supports multiple checksum formats:
|
|
4
|
+
- gpg_checksum : GPG-inline-signed files (Fedora, CentOS, etc.)
|
|
5
|
+
- sha256sums : Plain hash + filename files (Ubuntu, Arch, Debian)
|
|
6
|
+
- sha1sums : Same format, SHA1
|
|
7
|
+
- json : Signed JSON payloads (Tails latest.json)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import tempfile
|
|
16
|
+
import urllib.request
|
|
17
|
+
from enum import StrEnum
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from urllib.request import urlopen
|
|
20
|
+
|
|
21
|
+
from visync.finder import (
|
|
22
|
+
find_installed_isos,
|
|
23
|
+
get_iso_volume_id,
|
|
24
|
+
identify_distro,
|
|
25
|
+
keyword_hit,
|
|
26
|
+
load_all_metadata,
|
|
27
|
+
)
|
|
28
|
+
from visync.net import install_safe_opener, require_https
|
|
29
|
+
from visync.output import warn
|
|
30
|
+
|
|
31
|
+
install_safe_opener()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class VerifyStatus(StrEnum):
|
|
35
|
+
"""Outcome of checking one ISO against its published checksums.
|
|
36
|
+
|
|
37
|
+
Replaces a ``True | False | None | "unavailable"`` union whose sentinel was
|
|
38
|
+
a *string*, and therefore truthy: a consumer reaching for ``if result:``
|
|
39
|
+
reported a file whose checksum could not be fetched as verified. Four
|
|
40
|
+
explicit members make that mistake a type error rather than a wrong answer.
|
|
41
|
+
|
|
42
|
+
UNAVAILABLE is the one that matters most. "The mirror was unreachable" and
|
|
43
|
+
"this file is fine" must never collapse into the same branch, for the same
|
|
44
|
+
reason SyncStatus.UNREACHABLE is distinct from SyncStatus.CURRENT.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
VERIFIED = "verified"
|
|
48
|
+
MISMATCH = "mismatch"
|
|
49
|
+
UNAVAILABLE = "unavailable"
|
|
50
|
+
NO_CONFIG = "no-config"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _status(result: bool | None) -> VerifyStatus:
|
|
54
|
+
"""Fold verify_from_config's tri-state bool|None into a VerifyStatus."""
|
|
55
|
+
if result is True:
|
|
56
|
+
return VerifyStatus.VERIFIED
|
|
57
|
+
if result is False:
|
|
58
|
+
return VerifyStatus.MISMATCH
|
|
59
|
+
return VerifyStatus.NO_CONFIG
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ── Version comparison utilities ──────────────────────────────────
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def parse_version(version_str: str) -> tuple | None:
|
|
66
|
+
"""Parse a version string into a comparable tuple.
|
|
67
|
+
|
|
68
|
+
Handles semantic versions (e.g. 24.04.1) and date-based versions (e.g. 2026.06.01).
|
|
69
|
+
Returns a tuple of ints for comparison, or None if parsing fails entirely.
|
|
70
|
+
"""
|
|
71
|
+
parts = version_str.split(".")
|
|
72
|
+
try:
|
|
73
|
+
parsed = tuple(int(p) for p in parts if p.isdigit())
|
|
74
|
+
except (ValueError, TypeError):
|
|
75
|
+
return None
|
|
76
|
+
else:
|
|
77
|
+
return parsed if parsed else None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def compare_versions(remote: str, local: str) -> int:
|
|
81
|
+
"""Compare two version strings. Returns 1 if remote is newer, -1 if local is newer, 0 if equal.
|
|
82
|
+
|
|
83
|
+
Falls back to string comparison with a warning if structured parsing fails.
|
|
84
|
+
"""
|
|
85
|
+
remote_ver = parse_version(remote)
|
|
86
|
+
local_ver = parse_version(local)
|
|
87
|
+
|
|
88
|
+
if remote_ver is not None and local_ver is not None:
|
|
89
|
+
if remote_ver > local_ver:
|
|
90
|
+
return 1
|
|
91
|
+
if remote_ver < local_ver:
|
|
92
|
+
return -1
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
# Structured parsing failed — fall back to lexicographic string comparison
|
|
96
|
+
warn(
|
|
97
|
+
f"Could not parse version strings for comparison "
|
|
98
|
+
f"(remote='{remote}', local='{local}'). Falling back to string comparison."
|
|
99
|
+
)
|
|
100
|
+
if remote > local:
|
|
101
|
+
return 1
|
|
102
|
+
if remote < local:
|
|
103
|
+
return -1
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
_ARCH_TOKEN_RE = re.compile(
|
|
108
|
+
r"x86[_-]?64|amd64|aarch64|arm64|armhfp|i[36]86|riscv64|x86", re.IGNORECASE
|
|
109
|
+
)
|
|
110
|
+
_VERSION_TOKEN_RE = re.compile(r"\d+(?:\.\d+)*")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def extract_version_from_filename(filename: str) -> str:
|
|
114
|
+
"""Extract the version portion from an ISO/IMG filename.
|
|
115
|
+
|
|
116
|
+
Prefers the first standalone numeric segment after architecture tokens are
|
|
117
|
+
removed, so 'Fedora-Workstation-Live-x86_64-42-1.1.iso' yields '42'
|
|
118
|
+
(the release) rather than '1.1' (the build). Falls back to the first
|
|
119
|
+
dotted numeric run for names like 'nixos-minimal-26.05.1947.a037-x86_64-linux.iso'.
|
|
120
|
+
Returns the raw version substring or empty string if no numeric version found.
|
|
121
|
+
"""
|
|
122
|
+
stem = re.sub(r"\.(iso|img)$", "", filename, flags=re.IGNORECASE)
|
|
123
|
+
stem = _ARCH_TOKEN_RE.sub("-", stem)
|
|
124
|
+
for token in re.split(r"[\s_\-]+", stem):
|
|
125
|
+
if _VERSION_TOKEN_RE.fullmatch(token):
|
|
126
|
+
return token
|
|
127
|
+
match = re.search(r"(\d+(?:\.\d+)+)", stem)
|
|
128
|
+
return match.group(1) if match else ""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
HASH_ALGOS = {
|
|
132
|
+
"sha256": hashlib.sha256,
|
|
133
|
+
"sha1": hashlib.sha1,
|
|
134
|
+
"sha512": hashlib.sha512,
|
|
135
|
+
"blake2b": hashlib.blake2b,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ── Hash computation ──────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def compute_iso_hash(iso_path: Path, algo: str = "sha256") -> str:
|
|
143
|
+
"""Compute the hex digest of an ISO file using the given hash algorithm."""
|
|
144
|
+
if algo not in HASH_ALGOS:
|
|
145
|
+
raise ValueError(f"Unsupported hash algorithm: {algo}")
|
|
146
|
+
h = HASH_ALGOS[algo]()
|
|
147
|
+
with iso_path.open("rb") as f:
|
|
148
|
+
while True:
|
|
149
|
+
chunk = f.read(65536)
|
|
150
|
+
if not chunk:
|
|
151
|
+
break
|
|
152
|
+
h.update(chunk)
|
|
153
|
+
return h.hexdigest()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ── Network ────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class ChecksumUnavailable(Exception):
|
|
160
|
+
"""Raised when the expected checksum cannot be obtained or evaluated.
|
|
161
|
+
|
|
162
|
+
Distinct from a hash mismatch: callers must NOT delete downloads when
|
|
163
|
+
this is raised — verification simply could not be performed.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
_UA = {"User-Agent": "Mozilla/5.0"}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _fetch(url: str) -> str:
|
|
171
|
+
require_https(url, "checksum source")
|
|
172
|
+
req = urllib.request.Request(url, headers=_UA)
|
|
173
|
+
# require_https immediately above is the explicit allowlist S310 asks for.
|
|
174
|
+
with urlopen(req, timeout=30) as resp:
|
|
175
|
+
return resp.read().decode("utf-8", errors="replace")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ── GPG verification ──────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _import_key_then_verify(
|
|
182
|
+
signed_path: Path, key_url: str, key_fingerprint: str | list[str] = ""
|
|
183
|
+
) -> bool:
|
|
184
|
+
if shutil.which("gpg") is None:
|
|
185
|
+
raise ChecksumUnavailable(
|
|
186
|
+
"gpg binary not found — cannot verify the GPG signature "
|
|
187
|
+
"(install GnuPG, or it is not on PATH)"
|
|
188
|
+
)
|
|
189
|
+
wanted = _normalize_fingerprints(key_fingerprint)
|
|
190
|
+
key_file = Path(tempfile.mkdtemp(prefix="visync-gpg")) / "signing.key"
|
|
191
|
+
try:
|
|
192
|
+
try:
|
|
193
|
+
# The key itself must be fetched over HTTPS. _fetch enforces this
|
|
194
|
+
# for checksum content, but the signing key was going out in
|
|
195
|
+
# cleartext, which defeats the fingerprint pinning it exists to
|
|
196
|
+
# support.
|
|
197
|
+
require_https(key_url, "signing key")
|
|
198
|
+
req = urllib.request.Request(key_url, headers=_UA)
|
|
199
|
+
with urlopen(req, timeout=30) as resp:
|
|
200
|
+
key_file.write_bytes(resp.read())
|
|
201
|
+
except ValueError as e:
|
|
202
|
+
# A config-supplied http:// signing key is a misconfiguration, not
|
|
203
|
+
# an outage, so surface it as a refusal rather than "unreachable".
|
|
204
|
+
raise ChecksumUnavailable(str(e)) from e
|
|
205
|
+
except Exception as e:
|
|
206
|
+
raise ChecksumUnavailable(
|
|
207
|
+
f"signing key unreachable ({key_url}): {e}"
|
|
208
|
+
) from e
|
|
209
|
+
|
|
210
|
+
tmpdir = str(key_file.parent)
|
|
211
|
+
import_proc = subprocess.run(
|
|
212
|
+
["gpg", "--homedir", tmpdir, "--import", str(key_file)],
|
|
213
|
+
capture_output=True,
|
|
214
|
+
)
|
|
215
|
+
if import_proc.returncode != 0:
|
|
216
|
+
return False
|
|
217
|
+
|
|
218
|
+
verify_proc = subprocess.run(
|
|
219
|
+
[
|
|
220
|
+
"gpg",
|
|
221
|
+
"--homedir",
|
|
222
|
+
tmpdir,
|
|
223
|
+
"--status-fd",
|
|
224
|
+
"1",
|
|
225
|
+
"--verify",
|
|
226
|
+
str(signed_path),
|
|
227
|
+
],
|
|
228
|
+
capture_output=True,
|
|
229
|
+
text=True,
|
|
230
|
+
)
|
|
231
|
+
if verify_proc.returncode != 0:
|
|
232
|
+
return False
|
|
233
|
+
if wanted:
|
|
234
|
+
got = set()
|
|
235
|
+
for line in verify_proc.stdout.splitlines():
|
|
236
|
+
parts = line.split()
|
|
237
|
+
if "VALIDSIG" not in parts:
|
|
238
|
+
continue
|
|
239
|
+
i = parts.index("VALIDSIG")
|
|
240
|
+
fields = parts[i + 1 :]
|
|
241
|
+
if not fields:
|
|
242
|
+
continue
|
|
243
|
+
# field 1 = fingerprint of the signing key (may be a subkey);
|
|
244
|
+
# last field = fingerprint of the primary key
|
|
245
|
+
got.add(fields[0].lower())
|
|
246
|
+
got.add(fields[-1].lower())
|
|
247
|
+
if not any(w in got for w in wanted):
|
|
248
|
+
return False
|
|
249
|
+
elif verify_proc.stdout:
|
|
250
|
+
warn(
|
|
251
|
+
"GPG signature trusted without fingerprint pinning "
|
|
252
|
+
"(set signing_key_fingerprint in config)"
|
|
253
|
+
)
|
|
254
|
+
return True
|
|
255
|
+
finally:
|
|
256
|
+
shutil.rmtree(key_file.parent, ignore_errors=True)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _normalize_fingerprints(value: str | list[str]) -> list[str]:
|
|
260
|
+
"""Accept one fingerprint or a list; strip spaces/case."""
|
|
261
|
+
items = value if isinstance(value, list) else [value]
|
|
262
|
+
return [v.replace(" ", "").replace(":", "").lower() for v in items if v]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ── Checksum-file parsers ─────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
# Digest length in hex characters. A SUMS file may legitimately list the same
|
|
268
|
+
# file under several algorithms (Parrot's signed-hashes.txt has md5, sha256 and
|
|
269
|
+
# sha512 sections), so a parser must select by expected length, not by whichever
|
|
270
|
+
# line appears first. Without this, Parrot's md5 line was compared against a
|
|
271
|
+
# sha256 digest, the comparison always failed, and download_iso deleted a
|
|
272
|
+
# perfectly good 2 GiB download as "corrupt".
|
|
273
|
+
_ALGO_HEX_LEN = {"md5": 32, "sha1": 40, "sha256": 64, "sha512": 128}
|
|
274
|
+
|
|
275
|
+
# Case-insensitive algorithm names as they appear in signed content.
|
|
276
|
+
_ALGO_LABEL_RE = {
|
|
277
|
+
"md5": "MD5",
|
|
278
|
+
"sha1": "SHA1",
|
|
279
|
+
"sha256": "SHA256",
|
|
280
|
+
"sha512": "SHA512",
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _hex_len_for(algo: str) -> int | None:
|
|
285
|
+
"""Expected hex digest length for *algo*, or None if unrecognised."""
|
|
286
|
+
return _ALGO_HEX_LEN.get(algo.strip().lower().replace("-", ""))
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def parse_gpg_checksum(content: str, iso_name: str, algo: str = "sha256") -> str | None:
|
|
290
|
+
"""Parse a GPG-inline-signed CHECKSUM file (e.g. Fedora).
|
|
291
|
+
|
|
292
|
+
Lines look like: SHA256 (Fedora-Workstation-...iso) = <hex>
|
|
293
|
+
A Fedora CHECKSUM file can carry both SHA256 and SHA512 lines for the same
|
|
294
|
+
ISO, so the labelled algorithm must match *algo* rather than being
|
|
295
|
+
pattern-matched loosely.
|
|
296
|
+
"""
|
|
297
|
+
want = _hex_len_for(algo)
|
|
298
|
+
if want is None:
|
|
299
|
+
return None
|
|
300
|
+
label = _ALGO_LABEL_RE.get(algo.strip().lower().replace("-", ""))
|
|
301
|
+
if label is None:
|
|
302
|
+
return None
|
|
303
|
+
pattern = re.compile(
|
|
304
|
+
r"\b"
|
|
305
|
+
+ label
|
|
306
|
+
+ r"\s*\(([^)]*"
|
|
307
|
+
+ re.escape(iso_name)
|
|
308
|
+
+ r"[^)]*)\)\s*=\s*([a-fA-F0-9]+)",
|
|
309
|
+
re.IGNORECASE,
|
|
310
|
+
)
|
|
311
|
+
for line in content.splitlines():
|
|
312
|
+
m = pattern.search(line.strip())
|
|
313
|
+
if m and len(m.group(2)) == want:
|
|
314
|
+
return m.group(2).lower()
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _sums_filename(field: str) -> str:
|
|
319
|
+
"""Normalize a SUMS filename field (strip binary-mode '*' prefix)."""
|
|
320
|
+
return field.strip().removeprefix("*")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def parse_hashsums(content: str, iso_name: str, algo: str = "sha256") -> str | None:
|
|
324
|
+
"""Parse a standard *SUMS file (hash filename), selecting the *algo* digest.
|
|
325
|
+
|
|
326
|
+
Multi-algorithm files (Parrot) put bare section headers such as ``md5`` and
|
|
327
|
+
``sha256`` above each block, so a matching line is additionally required to
|
|
328
|
+
fall inside the requested algorithm's section when such headers are present.
|
|
329
|
+
Digest length is the final backstop.
|
|
330
|
+
"""
|
|
331
|
+
want = _hex_len_for(algo)
|
|
332
|
+
if want is None:
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
section: str | None = None
|
|
336
|
+
sections_present = False
|
|
337
|
+
fallback: str | None = None
|
|
338
|
+
|
|
339
|
+
for raw in content.splitlines():
|
|
340
|
+
line = raw.strip()
|
|
341
|
+
if not line:
|
|
342
|
+
continue
|
|
343
|
+
bare = line.strip("*").lower()
|
|
344
|
+
if bare in _ALGO_HEX_LEN:
|
|
345
|
+
section = bare
|
|
346
|
+
sections_present = True
|
|
347
|
+
continue
|
|
348
|
+
if line.startswith("#"):
|
|
349
|
+
continue
|
|
350
|
+
parts = line.split(None, 1)
|
|
351
|
+
if len(parts) != 2 or _sums_filename(parts[1]) != iso_name:
|
|
352
|
+
continue
|
|
353
|
+
digest = parts[0].strip().lower()
|
|
354
|
+
if len(digest) != want or not re.fullmatch(r"[a-f0-9]+", digest):
|
|
355
|
+
continue
|
|
356
|
+
if sections_present:
|
|
357
|
+
# Headers delimit real sections; only trust a digest in the
|
|
358
|
+
# requested one.
|
|
359
|
+
if section == algo.strip().lower().replace("-", ""):
|
|
360
|
+
return digest
|
|
361
|
+
continue
|
|
362
|
+
# No section headers: a length match is unambiguous.
|
|
363
|
+
return digest
|
|
364
|
+
|
|
365
|
+
return fallback
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def parse_tails_json(
|
|
369
|
+
content: str, _iso_name: str = "", algo: str = "sha256"
|
|
370
|
+
) -> str | None:
|
|
371
|
+
"""Parse Tails latest.json containing a sha256 field.
|
|
372
|
+
|
|
373
|
+
Every parser in FORMAT_PARSERS is called as parser(content, iso_name, algo).
|
|
374
|
+
Tails publishes one digest per platform with no per-file line to match, so
|
|
375
|
+
iso_name has nothing to select on here — the leading underscore records
|
|
376
|
+
that the parameter exists for the shared signature, not for this body.
|
|
377
|
+
"""
|
|
378
|
+
try:
|
|
379
|
+
data = json.loads(content)
|
|
380
|
+
digest = data.get("sha256", "")
|
|
381
|
+
if not isinstance(digest, str):
|
|
382
|
+
return None
|
|
383
|
+
digest = digest.lower()
|
|
384
|
+
want = _hex_len_for(algo)
|
|
385
|
+
if want is not None and len(digest) != want:
|
|
386
|
+
return None
|
|
387
|
+
except (json.JSONDecodeError, AttributeError):
|
|
388
|
+
return None
|
|
389
|
+
else:
|
|
390
|
+
return digest or None
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
FORMAT_PARSERS = {
|
|
394
|
+
"gpg_checksum": parse_gpg_checksum,
|
|
395
|
+
"sha256sums": parse_hashsums,
|
|
396
|
+
"sha1sums": parse_hashsums,
|
|
397
|
+
"json": parse_tails_json,
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# ── Top-level verification ────────────────────────────────────────
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def verify_iso(
|
|
405
|
+
iso_path: Path,
|
|
406
|
+
checksum_url: str,
|
|
407
|
+
algo: str = "sha256",
|
|
408
|
+
checksum_format: str = "sha256sums",
|
|
409
|
+
signing_key_url: str | None = None,
|
|
410
|
+
signing_key_fingerprint: str = "",
|
|
411
|
+
precomputed_hash: str = "",
|
|
412
|
+
) -> bool:
|
|
413
|
+
"""Fetch a checksum, optionally verify its GPG signature, then compare.
|
|
414
|
+
|
|
415
|
+
Returns True if the local ISO hash matches the published checksum.
|
|
416
|
+
Returns False ONLY on a genuine mismatch (or bad signature) — the caller
|
|
417
|
+
may safely delete the file. Raises ChecksumUnavailable when the expected
|
|
418
|
+
hash cannot be obtained or evaluated (network failure, 404, ISO absent
|
|
419
|
+
from the sums file, unknown format); callers must keep the file then.
|
|
420
|
+
If *precomputed_hash* is supplied and the requested algo is sha256, it is
|
|
421
|
+
compared directly instead of re-reading the ISO from disk — avoids the
|
|
422
|
+
duplicate full-file hash right after a download.
|
|
423
|
+
"""
|
|
424
|
+
iso_name = iso_path.name
|
|
425
|
+
|
|
426
|
+
try:
|
|
427
|
+
content = _fetch(checksum_url)
|
|
428
|
+
except Exception as e:
|
|
429
|
+
raise ChecksumUnavailable(f"{checksum_url}: {e}") from e
|
|
430
|
+
|
|
431
|
+
# GPG verification of the fetched content. This is deliberately independent
|
|
432
|
+
# of checksum_format: signature authenticity and digest *parsing* are
|
|
433
|
+
# separate concerns. Parrot's signed-hashes.txt is clearsigned but uses a
|
|
434
|
+
# plain `hash filename` layout with md5/sha256/sha512 sections, so gating
|
|
435
|
+
# the signature check on "gpg_checksum" left it entirely unauthenticated.
|
|
436
|
+
if signing_key_url:
|
|
437
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
438
|
+
signed = Path(tmpdir) / "CHECKSUM.asc"
|
|
439
|
+
signed.write_text(content)
|
|
440
|
+
if not _import_key_then_verify(
|
|
441
|
+
signed, signing_key_url, signing_key_fingerprint
|
|
442
|
+
):
|
|
443
|
+
return False
|
|
444
|
+
|
|
445
|
+
parser = FORMAT_PARSERS.get(checksum_format)
|
|
446
|
+
if not parser:
|
|
447
|
+
raise ChecksumUnavailable(
|
|
448
|
+
f"unknown checksum_format '{checksum_format}' for {iso_name}"
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
expected = parser(content, iso_name, algo)
|
|
452
|
+
if not expected:
|
|
453
|
+
raise ChecksumUnavailable(
|
|
454
|
+
f"no {algo} digest for {iso_name} in {checksum_url} — "
|
|
455
|
+
f"cannot determine expected hash"
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
if precomputed_hash and algo == "sha256":
|
|
459
|
+
local = precomputed_hash
|
|
460
|
+
else:
|
|
461
|
+
local = compute_iso_hash(iso_path, algo)
|
|
462
|
+
return local == expected
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def extract_iso_metadata(iso_name: str) -> dict[str, str]:
|
|
466
|
+
"""Pull version/arch/path tokens from common ISO filenames."""
|
|
467
|
+
meta: dict[str, str] = {
|
|
468
|
+
"version": "",
|
|
469
|
+
"arch": "",
|
|
470
|
+
"variant_dir": "",
|
|
471
|
+
"checksum_stem": "",
|
|
472
|
+
# Full YY.MM channel ("26.05") and full release id
|
|
473
|
+
# ("26.05.11045.774debe7a0d1"). NixOS checksum URLs need both, and
|
|
474
|
+
# extract_version_from_filename only recovers "26.05.11045".
|
|
475
|
+
"channel": "",
|
|
476
|
+
"release": "",
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
fedora = re.match(
|
|
480
|
+
r"^(?P<prefix>Fedora(?:-[A-Za-z]+)+-Live)"
|
|
481
|
+
r"(?:-(?P<infix>x86_64|aarch64|i686|armhfp))?"
|
|
482
|
+
r"-(?P<major>\d+)-(?P<minor>[\d\.]+)"
|
|
483
|
+
r"(?:\.(?:iso)|\.(?P<ext>x86_64|aarch64|i686|armhfp)\.iso)$",
|
|
484
|
+
iso_name,
|
|
485
|
+
re.IGNORECASE,
|
|
486
|
+
)
|
|
487
|
+
if fedora:
|
|
488
|
+
prefix = fedora.group("prefix")
|
|
489
|
+
infix = fedora.group("infix")
|
|
490
|
+
ext = fedora.group("ext")
|
|
491
|
+
major = fedora.group("major")
|
|
492
|
+
minor = fedora.group("minor")
|
|
493
|
+
arch = infix or ext or "x86_64"
|
|
494
|
+
mid = prefix[len("Fedora") : -len("-Live")].lstrip("-")
|
|
495
|
+
if mid.lower().endswith("-desktop"):
|
|
496
|
+
mid = mid[: -len("-Desktop")]
|
|
497
|
+
meta["version"] = major
|
|
498
|
+
meta["arch"] = arch
|
|
499
|
+
meta["variant_dir"] = mid
|
|
500
|
+
if infix:
|
|
501
|
+
meta["checksum_stem"] = f"{prefix}-{infix}-{major}-{minor}"
|
|
502
|
+
else:
|
|
503
|
+
# dl.fedoraproject.org convention, e.g.
|
|
504
|
+
# Fedora-Workstation-43-1.6-x86_64-CHECKSUM
|
|
505
|
+
meta["checksum_stem"] = f"Fedora-{mid}-{major}-{minor}-{arch}"
|
|
506
|
+
return meta
|
|
507
|
+
|
|
508
|
+
ubuntu = re.match(
|
|
509
|
+
r"^ubuntu-(\d+\.\d+(?:\.\d+)?)-live-server-amd64\.iso$", iso_name, re.IGNORECASE
|
|
510
|
+
)
|
|
511
|
+
if ubuntu:
|
|
512
|
+
meta["version"] = ubuntu.group(1)
|
|
513
|
+
return meta
|
|
514
|
+
|
|
515
|
+
arch = re.match(r"^archlinux-(\d+\.\d+\.\d+)-x86_64\.iso$", iso_name, re.IGNORECASE)
|
|
516
|
+
if arch:
|
|
517
|
+
meta["version"] = arch.group(1)
|
|
518
|
+
return meta
|
|
519
|
+
|
|
520
|
+
nixos = re.match(
|
|
521
|
+
r"^nixos-(?:minimal|graphical)-(\d+\.\d+)\.(\d+)\.([a-f0-9]+)"
|
|
522
|
+
r"(?:-[a-z0-9_]+)*\.iso$",
|
|
523
|
+
iso_name,
|
|
524
|
+
re.IGNORECASE,
|
|
525
|
+
)
|
|
526
|
+
if nixos:
|
|
527
|
+
channel = f"{nixos.group(1)}"
|
|
528
|
+
meta["version"] = channel
|
|
529
|
+
meta["channel"] = channel
|
|
530
|
+
meta["release"] = f"{channel}.{nixos.group(2)}.{nixos.group(3)}"
|
|
531
|
+
return meta
|
|
532
|
+
|
|
533
|
+
generic = re.search(r"(\d+\.\d+(?:\.\d+)?)", iso_name)
|
|
534
|
+
if generic:
|
|
535
|
+
meta["version"] = generic.group(1)
|
|
536
|
+
return meta
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def expand_url(
|
|
540
|
+
template: str, iso_name: str, base_url: str = "", release_base_url: str = ""
|
|
541
|
+
) -> str:
|
|
542
|
+
base = base_url.rstrip("/")
|
|
543
|
+
meta = extract_iso_metadata(iso_name)
|
|
544
|
+
expanded = template.replace("{iso_name}", iso_name)
|
|
545
|
+
expanded = expanded.replace("{base_url}/", base + "/")
|
|
546
|
+
expanded = expanded.replace("{base_url}", base + "/")
|
|
547
|
+
if "{release_base_url}" in template:
|
|
548
|
+
# Explicit, so a checksum can live on a different host than base_url
|
|
549
|
+
# (NixOS publishes ISOs under releases.nixos.org while the channel page
|
|
550
|
+
# is on channels.nixos.org).
|
|
551
|
+
release_base = str(release_base_url or base).rstrip("/")
|
|
552
|
+
expanded = expanded.replace("{release_base_url}/", release_base + "/")
|
|
553
|
+
expanded = expanded.replace("{release_base_url}", release_base + "/")
|
|
554
|
+
return (
|
|
555
|
+
expanded.replace("{version}", meta["version"])
|
|
556
|
+
.replace("{arch}", meta["arch"])
|
|
557
|
+
.replace("{variant_dir}", meta["variant_dir"])
|
|
558
|
+
.replace("{checksum_stem}", meta["checksum_stem"])
|
|
559
|
+
.replace("{channel}", meta["channel"])
|
|
560
|
+
.replace("{release}", meta["release"])
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def index_distro_configs(config: dict) -> dict[str, dict]:
|
|
565
|
+
"""Map clean_name → distro settings from [distros.*] tables."""
|
|
566
|
+
indexed: dict[str, dict] = {}
|
|
567
|
+
for _key, settings in config.get("distros", {}).items():
|
|
568
|
+
name = settings.get("clean_name") or _key
|
|
569
|
+
indexed[name] = settings
|
|
570
|
+
return indexed
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def resolve_distro_settings(
|
|
574
|
+
detected_name: str,
|
|
575
|
+
iso_name: str,
|
|
576
|
+
distro_configs: dict[str, dict],
|
|
577
|
+
) -> dict:
|
|
578
|
+
"""Match detected distro to config by name, then filename keyword."""
|
|
579
|
+
if detected_name in distro_configs:
|
|
580
|
+
return distro_configs[detected_name]
|
|
581
|
+
iso_lower = iso_name.lower()
|
|
582
|
+
for settings in distro_configs.values():
|
|
583
|
+
keyword = settings.get("keyword", "")
|
|
584
|
+
if keyword and keyword_hit(keyword, iso_lower):
|
|
585
|
+
return settings
|
|
586
|
+
return {}
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def build_iso_distro_map(iso_dir: Path) -> dict[str, tuple[Path, str]]:
|
|
590
|
+
"""Map ISO path string → (path, detected distro name)."""
|
|
591
|
+
distro_map: dict[str, tuple[Path, str]] = {}
|
|
592
|
+
for iso_path in find_installed_isos(iso_dir):
|
|
593
|
+
volume_id = get_iso_volume_id(iso_path)
|
|
594
|
+
distro_name = identify_distro(volume_id, iso_path.name)
|
|
595
|
+
distro_map[str(iso_path)] = (iso_path, distro_name)
|
|
596
|
+
return distro_map
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _cached_hash_for(iso_path: Path, cached: dict[str, dict]) -> str:
|
|
600
|
+
"""Return the metadata SHA-256 for an ISO only if the on-disk size matches.
|
|
601
|
+
|
|
602
|
+
Lets `visync verify` skip re-reading large ISOs from slow drives while still
|
|
603
|
+
catching files that were replaced or truncated after download. Old metadata
|
|
604
|
+
without a recorded size (or a size mismatch) falls back to hashing the file.
|
|
605
|
+
"""
|
|
606
|
+
meta = cached.get(iso_path.name)
|
|
607
|
+
if not meta:
|
|
608
|
+
return ""
|
|
609
|
+
sha = meta.get("sha256", "")
|
|
610
|
+
if not sha:
|
|
611
|
+
return ""
|
|
612
|
+
try:
|
|
613
|
+
if meta.get("size") == iso_path.stat().st_size:
|
|
614
|
+
return sha
|
|
615
|
+
except OSError:
|
|
616
|
+
pass
|
|
617
|
+
return ""
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def run_directory_verify(
|
|
621
|
+
iso_dir: Path, config: dict
|
|
622
|
+
) -> list[tuple[Path, str, VerifyStatus]]:
|
|
623
|
+
"""Identify ISOs under *iso_dir* and verify each against config.
|
|
624
|
+
|
|
625
|
+
The third element is always a VerifyStatus; verify_from_config's tri-state
|
|
626
|
+
return is folded into it here, which is the one place that knows about both.
|
|
627
|
+
"""
|
|
628
|
+
distro_map = build_iso_distro_map(iso_dir)
|
|
629
|
+
distro_configs = index_distro_configs(config)
|
|
630
|
+
checksums_config = config.get("checksums", {})
|
|
631
|
+
cached = load_all_metadata(iso_dir)
|
|
632
|
+
results: list[tuple[Path, str, VerifyStatus]] = []
|
|
633
|
+
for iso_path, distro_name in distro_map.values():
|
|
634
|
+
settings = resolve_distro_settings(distro_name, iso_path.name, distro_configs)
|
|
635
|
+
try:
|
|
636
|
+
result = _status(
|
|
637
|
+
verify_from_config(
|
|
638
|
+
iso_path,
|
|
639
|
+
settings,
|
|
640
|
+
checksums_config,
|
|
641
|
+
precomputed_hash=_cached_hash_for(iso_path, cached),
|
|
642
|
+
)
|
|
643
|
+
)
|
|
644
|
+
except ChecksumUnavailable as e:
|
|
645
|
+
warn(f"{iso_path.name} — {e}")
|
|
646
|
+
result = VerifyStatus.UNAVAILABLE
|
|
647
|
+
results.append((iso_path, distro_name, result))
|
|
648
|
+
return results
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def verify_from_config(
|
|
652
|
+
iso_path: Path,
|
|
653
|
+
distro_config: dict,
|
|
654
|
+
checksums_config: dict,
|
|
655
|
+
precomputed_hash: str = "",
|
|
656
|
+
) -> bool | None:
|
|
657
|
+
"""Verify a single ISO using its distro's checksum configuration.
|
|
658
|
+
|
|
659
|
+
Returns True/False on success, None if no checksum config is available.
|
|
660
|
+
If *precomputed_hash* is provided, skip re-hashing the file.
|
|
661
|
+
|
|
662
|
+
There is deliberately no distro-name parameter: the caller has already
|
|
663
|
+
resolved the name into *distro_config*, and download_iso passed an empty
|
|
664
|
+
string here, so nothing ever read it.
|
|
665
|
+
"""
|
|
666
|
+
if not checksums_config.get("enabled", True):
|
|
667
|
+
return None
|
|
668
|
+
|
|
669
|
+
# Fast path: if the scraper already resolved a checksum (e.g. NixOS channel
|
|
670
|
+
# page embeds hashes inline), compare directly without fetching a URL.
|
|
671
|
+
resolved = distro_config.get("resolved_checksum")
|
|
672
|
+
if resolved:
|
|
673
|
+
algo = distro_config.get("checksum_algo", "sha256")
|
|
674
|
+
local = precomputed_hash or compute_iso_hash(iso_path, algo)
|
|
675
|
+
return local == resolved
|
|
676
|
+
|
|
677
|
+
checksum_url = distro_config.get("checksum_url")
|
|
678
|
+
if not checksum_url:
|
|
679
|
+
return None
|
|
680
|
+
|
|
681
|
+
iso_name = iso_path.name
|
|
682
|
+
base_url = distro_config.get("base_url", "")
|
|
683
|
+
checksum_url = expand_url(
|
|
684
|
+
checksum_url,
|
|
685
|
+
iso_name,
|
|
686
|
+
base_url,
|
|
687
|
+
str(distro_config.get("releases_base_url") or ""),
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
algo = distro_config.get("checksum_algo", "sha256")
|
|
691
|
+
fmt = distro_config.get("checksum_format", "sha256sums")
|
|
692
|
+
key_url = distro_config.get("signing_key_url")
|
|
693
|
+
key_fingerprint = distro_config.get("signing_key_fingerprint", "")
|
|
694
|
+
|
|
695
|
+
return verify_iso(
|
|
696
|
+
iso_path=iso_path,
|
|
697
|
+
checksum_url=checksum_url,
|
|
698
|
+
algo=algo,
|
|
699
|
+
checksum_format=fmt,
|
|
700
|
+
signing_key_url=key_url,
|
|
701
|
+
signing_key_fingerprint=key_fingerprint,
|
|
702
|
+
precomputed_hash=precomputed_hash,
|
|
703
|
+
)
|