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/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
+ )