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/finder.py ADDED
@@ -0,0 +1,699 @@
1
+ """
2
+ Discover Ventoy drives and locate ISO files.
3
+
4
+ Detects Ventoy volumes on Windows, macOS and Linux, reads ISO volume IDs
5
+ from the file header, maps IDs to friendly distro names, and discovers
6
+ .iso files under a directory.
7
+ """
8
+
9
+ import contextlib
10
+ import json
11
+ import os
12
+ import re
13
+ import tomllib
14
+ from datetime import UTC, datetime
15
+ from pathlib import Path
16
+
17
+ from rich.markup import escape as _escape
18
+
19
+ from visync.output import console
20
+
21
+ _CONFIG_CACHE: dict | None = None
22
+
23
+
24
+ def reset_config_cache() -> None:
25
+ """Forget the cached config so the next load re-resolves from disk."""
26
+ global _CONFIG_CACHE
27
+ _CONFIG_CACHE = None
28
+
29
+
30
+ def _packaged_config() -> Path:
31
+ """Path of the config shipped with the installed package.
32
+
33
+ Wheels install config.toml as visync/config.toml, beside the modules.
34
+ importlib.resources resolves it so a zip-imported package still works; the
35
+ filesystem path is returned because every caller treats the config as a real
36
+ file (open, rewrite, --config round-trips).
37
+
38
+ An editable install and a bare source checkout instead have config.toml at
39
+ the repository root, so that layout is returned as the fallback rather than
40
+ letting discovery fall through to the current working directory — otherwise
41
+ running from an unrelated directory would silently find no config.
42
+ """
43
+ beside_package = Path(__file__).parent / "config.toml"
44
+ if beside_package.is_file():
45
+ return beside_package
46
+
47
+ try:
48
+ from importlib.resources import files
49
+
50
+ resource = Path(str(files("visync").joinpath("config.toml")))
51
+ if resource.is_file():
52
+ return resource
53
+ except (ImportError, ModuleNotFoundError, FileNotFoundError, TypeError):
54
+ pass
55
+
56
+ # Editable install / source checkout: one level above the package.
57
+ return Path(__file__).parent.parent / "config.toml"
58
+
59
+
60
+ def _config_candidates() -> list[Path]:
61
+ """Config search order: env override, user config, packaged config, cwd last."""
62
+ candidates = []
63
+ env_path = os.environ.get("VISYNC_CONFIG")
64
+ if env_path:
65
+ candidates.append(Path(env_path))
66
+ candidates.append(Path.home() / ".config" / "visync" / "config.toml")
67
+ candidates.append(_packaged_config())
68
+ candidates.append(Path.cwd() / "config.toml")
69
+ return candidates
70
+
71
+
72
+ def load_config(config_path: Path | None = None) -> dict:
73
+ """Load configuration dictionary maps and distro settings from config.toml.
74
+
75
+ With no explicit path, resolves in a fixed order (env, user, packaged,
76
+ cwd-last) and caches the result so distro identification always agrees
77
+ with the config the command loaded — including an explicit --config.
78
+ """
79
+ global _CONFIG_CACHE
80
+ if config_path is None:
81
+ if _CONFIG_CACHE is not None:
82
+ return _CONFIG_CACHE
83
+ for candidate in _config_candidates():
84
+ if candidate.is_file():
85
+ config_path = candidate
86
+ break
87
+ else:
88
+ # No candidate existed. Report where we looked rather than falling
89
+ # through to a bare cwd path, which used to surface as the
90
+ # misleading "Failed to parse config.toml: ... No such file".
91
+ searched = ", ".join(str(c) for c in _config_candidates())
92
+ if os.environ.get("VISYNC_CONFIG"):
93
+ console.print(
94
+ " [yellow]⚠[/yellow] VISYNC_CONFIG is set but the file does "
95
+ "not exist."
96
+ )
97
+ console.print(f" [red]✗[/red] No config.toml found. Searched: {searched}")
98
+ console.print(" [dim]Set VISYNC_CONFIG or pass --config.[/dim]")
99
+ return {}
100
+ try:
101
+ with config_path.open("rb") as f:
102
+ data = tomllib.load(f)
103
+ except FileNotFoundError as e:
104
+ console.print(f" [red]✗[/red] Config not found: {_escape(str(e))}")
105
+ return {}
106
+ except tomllib.TOMLDecodeError as e:
107
+ console.print(
108
+ f" [red]✗[/red] Invalid TOML in {config_path}: {_escape(str(e))}"
109
+ )
110
+ return {}
111
+ except OSError as e:
112
+ console.print(f" [red]✗[/red] Failed to read config.toml: {_escape(str(e))}")
113
+ return {}
114
+ _CONFIG_CACHE = data
115
+ return data
116
+
117
+
118
+ def _mount_device(dev: str, detected: list[Path]) -> None:
119
+ """Try to mount a device temporarily and add its path if successful."""
120
+ import subprocess
121
+ import tempfile
122
+
123
+ mount_dir = Path(tempfile.mkdtemp(prefix="ventoy_"))
124
+ try:
125
+ subprocess.run(
126
+ ["mount", dev, str(mount_dir)],
127
+ capture_output=True,
128
+ timeout=10,
129
+ )
130
+ if mount_dir.is_dir() and any(mount_dir.iterdir()):
131
+ detected.append(mount_dir)
132
+ except Exception: # noqa: S110 - mount without privileges fails; detection continues
133
+ pass
134
+ finally:
135
+ if not detected or not mount_dir.is_dir() or not any(mount_dir.iterdir()):
136
+ import shutil
137
+
138
+ shutil.rmtree(mount_dir, ignore_errors=True)
139
+
140
+
141
+ def _udisksctl_mount(dev: str) -> Path | None:
142
+ """Mount a block device using udisksctl (no root required on most setups)."""
143
+ import subprocess
144
+
145
+ try:
146
+ result = subprocess.run(
147
+ ["udisksctl", "mount", "-b", dev],
148
+ capture_output=True,
149
+ text=True,
150
+ timeout=15,
151
+ )
152
+ if result.returncode == 0:
153
+ # Output: "Mounted /dev/sdX1 at /run/media/$USER/Ventoy"
154
+ for line in result.stdout.strip().splitlines():
155
+ if " at " in line:
156
+ mount_point = line.split(" at ", 1)[-1].strip().rstrip(".")
157
+ return Path(mount_point)
158
+ # Localized/unexpected output: ask the system where it landed
159
+ findmnt = subprocess.run(
160
+ ["findmnt", "-rn", "-o", "TARGET", "--source", dev],
161
+ capture_output=True,
162
+ text=True,
163
+ timeout=10,
164
+ )
165
+ if findmnt.returncode == 0 and findmnt.stdout.strip():
166
+ return Path(findmnt.stdout.strip().splitlines()[0])
167
+ except FileNotFoundError:
168
+ pass # udisksctl not installed
169
+ except Exception: # noqa: S110 - unmountable device is simply not a Ventoy drive
170
+ pass
171
+ return None
172
+
173
+
174
+ def _try_mount_ventoy(partition: dict, detected: list[Path]) -> None:
175
+ """When Ventoy data partition is unmounted, find the dm-exposed device and mount it."""
176
+ for child in partition.get("children", []):
177
+ if child.get("mountpoint"):
178
+ continue
179
+ child_name = child.get("name", "")
180
+ child_type = child.get("type", "")
181
+ if not child_name:
182
+ continue
183
+ # dm devices live under /dev/mapper/<name>
184
+ if child_type == "dm":
185
+ dev_path = f"/dev/mapper/{child_name}"
186
+ else:
187
+ dev_path = f"/dev/{child_name}"
188
+ # Try udisksctl first (no root required)
189
+ mount_point = _udisksctl_mount(dev_path)
190
+ if mount_point:
191
+ detected.append(mount_point)
192
+ return
193
+ # Fallback to manual mount
194
+ _mount_device(dev_path, detected)
195
+ if detected:
196
+ return
197
+
198
+
199
+ def find_ventoy_drives() -> list[Path]:
200
+ """Detect mounted Ventoy drives across Windows, macOS, and Linux.
201
+
202
+ Returns a list of Path objects pointing to the root of each detected
203
+ Ventoy drive. Index [0] for the primary drive.
204
+ """
205
+ import platform
206
+ import subprocess
207
+
208
+ system = platform.system()
209
+ detected_paths = []
210
+
211
+ if system == "Linux":
212
+ import json
213
+
214
+ cmd = ["lsblk", "-o", "NAME,TYPE,LABEL,MOUNTPOINT", "--json"]
215
+ result = subprocess.run(cmd, capture_output=True, text=True)
216
+
217
+ if result.returncode == 0 and result.stdout.strip():
218
+ try:
219
+ data = json.loads(result.stdout)
220
+ for device in data.get("blockdevices", []):
221
+ for partition in device.get("children", []):
222
+ label = partition.get("label")
223
+ mount = partition.get("mountpoint")
224
+ if label in ("Ventoy", "VTOYEFI"):
225
+ if mount:
226
+ detected_paths.append(Path(mount))
227
+ elif label == "Ventoy":
228
+ _try_mount_ventoy(partition, detected_paths)
229
+ except (json.JSONDecodeError, KeyError):
230
+ pass
231
+
232
+ # Fallback: blkid + findmnt (covers systems without lsblk)
233
+ if not detected_paths:
234
+ for lbl in ("Ventoy", "VTOYEFI"):
235
+ try:
236
+ blkid_proc = subprocess.run(
237
+ ["blkid", "-L", lbl], capture_output=True, text=True, timeout=10
238
+ )
239
+ if blkid_proc.returncode != 0 or not blkid_proc.stdout.strip():
240
+ continue
241
+ dev = blkid_proc.stdout.strip()
242
+ mnt_proc = subprocess.run(
243
+ ["findmnt", "-n", "-o", "TARGET", dev],
244
+ capture_output=True,
245
+ text=True,
246
+ timeout=10,
247
+ )
248
+ if mnt_proc.returncode == 0 and mnt_proc.stdout.strip():
249
+ detected_paths.append(Path(mnt_proc.stdout.strip()))
250
+ elif lbl == "Ventoy":
251
+ mount_point = _udisksctl_mount(dev)
252
+ if mount_point:
253
+ detected_paths.append(mount_point)
254
+ else:
255
+ _mount_device(dev, detected_paths)
256
+ except Exception: # noqa: S112 - try the next candidate device
257
+ continue
258
+
259
+ elif system == "Darwin":
260
+ import plistlib
261
+
262
+ cmd = ["diskutil", "list", "-plist"]
263
+ result = subprocess.run(cmd, capture_output=True)
264
+
265
+ if result.returncode != 0:
266
+ return []
267
+
268
+ try:
269
+ data = plistlib.loads(result.stdout)
270
+
271
+ for pool in data.get("AllDisksAndPartitions", []):
272
+ for partition in pool.get("Partitions", [pool]):
273
+ volume_name = partition.get("VolumeName")
274
+ mount_point = partition.get("MountPoint")
275
+
276
+ if volume_name in ["Ventoy", "VTOYEFI"] and mount_point:
277
+ detected_paths.append(Path(mount_point))
278
+ except Exception:
279
+ return []
280
+
281
+ elif system == "Windows":
282
+ import json
283
+
284
+ cmd = "@(Get-Volume | Where-Object {$_.FileSystemLabel -match 'Ventoy|VTOYEFI'} | Select-Object DriveLetter) | ConvertTo-Json"
285
+ result = subprocess.run(
286
+ ["powershell", "-Command", cmd], capture_output=True, text=True
287
+ )
288
+
289
+ if not result.stdout.strip():
290
+ return []
291
+
292
+ detected_paths = []
293
+ try:
294
+ drives_data = json.loads(result.stdout)
295
+ if isinstance(drives_data, dict):
296
+ drives_data = [drives_data]
297
+
298
+ detected_paths = [
299
+ Path(f"{drive['DriveLetter']}:\\")
300
+ for drive in drives_data
301
+ if drive.get("DriveLetter")
302
+ ]
303
+ except json.JSONDecodeError:
304
+ return []
305
+
306
+ else:
307
+ raise NotImplementedError(
308
+ f"Unsupported operating system: {system}\nThis script currently supports Windows, macOS, and Linux.\nTo add support for your OS, please contribute to github.com/hxmbl/visync"
309
+ )
310
+
311
+ return detected_paths
312
+
313
+
314
+ def get_iso_volume_id(iso_path: Path) -> str:
315
+ """Read the unchangeable internal Volume Identifier of an ISO file."""
316
+ try:
317
+ with iso_path.open("rb") as f:
318
+ # Skip directly to the ISO 9660 primary descriptor header
319
+ f.seek(32808)
320
+ volume_id = f.read(32)
321
+ return volume_id.decode("utf-8", errors="ignore").strip("\x00 ")
322
+ except Exception:
323
+ return ""
324
+
325
+
326
+ def _norm_tokens(text: str) -> str:
327
+ """Lowercase and collapse every non-alphanumeric run into single spaces."""
328
+ return re.sub(r"[^a-z0-9]+", " ", text.lower()).strip()
329
+
330
+
331
+ def keyword_hit(keyword: str, text: str) -> bool:
332
+ """Whole-token match of *keyword* inside *text*.
333
+
334
+ 'pop' matches 'pop-os_1.iso' or volume 'POP OS' but not 'popcorn'.
335
+ Multi-part keywords ('nixos-minimal') must appear as consecutive tokens.
336
+ """
337
+ kw = _norm_tokens(keyword)
338
+ if not kw:
339
+ return False
340
+ return re.search(rf"(?<!\w){re.escape(kw)}(?!\w)", _norm_tokens(text)) is not None
341
+
342
+
343
+ def keyword_hit_loose(keyword: str, text: str) -> bool:
344
+ """Whole-token match of *keyword* with tokens allowed to be non-adjacent.
345
+
346
+ Needed where upstream inserts a version between the words: Ubuntu's volume
347
+ ID is ``Ubuntu 26.04.1 LTS amd64`` and its filename
348
+ ``ubuntu-26.04.1-desktop-amd64.iso``, so the keyword ``ubuntu-desktop``
349
+ never appears as consecutive tokens and the strict matcher cannot see it.
350
+ Every token of the keyword must still be present as a whole token, and in
351
+ order, so ``ubuntu-desktop`` does not match a Linux Mint desktop ISO.
352
+ """
353
+ tokens = _norm_tokens(keyword).split()
354
+ if not tokens:
355
+ return False
356
+ haystack = _norm_tokens(text).split()
357
+ position = 0
358
+ for token in tokens:
359
+ while position < len(haystack) and haystack[position] != token:
360
+ position += 1
361
+ if position == len(haystack):
362
+ return False
363
+ position += 1
364
+ return True
365
+
366
+
367
+ def identify_distro(volume_id: str, file_name: str) -> str:
368
+ """Match the OS distribution using a cascading hybrid approach.
369
+
370
+ Checks strict Volume ID matches, contextual filename overrides for forks,
371
+ standalone keyword rules, and finally regex filename parsing as fallback.
372
+ """
373
+ import re
374
+
375
+ vol_lower = volume_id.lower().strip()
376
+ file_lower = file_name.lower().strip()
377
+
378
+ config = load_config()
379
+
380
+ # Check standalone matches first — they're more specific than base distros.
381
+ # Tried most-specific first: the raw TOML order is what decided specificity
382
+ # before, so reordering two lines silently changed which name won.
383
+ standalone_matches = config.get("standalone_matches", {})
384
+ for keyword, clean_name in sorted(
385
+ standalone_matches.items(), key=lambda kv: -len(_norm_tokens(kv[0]))
386
+ ):
387
+ if keyword_hit(keyword, vol_lower) or keyword_hit(keyword, file_lower):
388
+ return clean_name
389
+ # Second pass for keywords upstream splits with a version in the middle
390
+ # ("ubuntu-desktop" vs "ubuntu-26.04.1-desktop-amd64.iso"). Still whole-token
391
+ # and still longest-first, so 'ubuntu-desktop' cannot be beaten by 'pop'.
392
+ for keyword, clean_name in sorted(
393
+ standalone_matches.items(), key=lambda kv: -len(_norm_tokens(kv[0]))
394
+ ):
395
+ if keyword_hit_loose(keyword, vol_lower) or keyword_hit_loose(
396
+ keyword, file_lower
397
+ ):
398
+ return clean_name
399
+
400
+ base_distros = config.get("base_distros", {})
401
+ fork_overrides = config.get("fork_overrides", {})
402
+
403
+ for base_key, base_name in base_distros.items():
404
+ if keyword_hit(base_key, vol_lower):
405
+ for override_key, clean_name in fork_overrides.items():
406
+ parent, _, keyword = override_key.partition(".")
407
+ if parent == base_key and keyword_hit(keyword, file_lower):
408
+ return clean_name
409
+ return base_name
410
+
411
+ if not file_lower.endswith(".iso"):
412
+ return "Unknown OS"
413
+
414
+ name_match = re.match(r"^([a-zA-Z_\-]+?)(?:[-_]v?\d|\.)", file_name)
415
+ if name_match:
416
+ return name_match.group(1).replace("-", " ").replace("_", " ").title().strip()
417
+
418
+ return "Unknown OS"
419
+
420
+
421
+ def find_installed_isos(directory: Path) -> list[Path]:
422
+ """Find all ISO/IMG files under the given directory, ignoring macOS resource forks and .visync metadata."""
423
+ return [
424
+ p
425
+ for p in directory.rglob("*")
426
+ if p.suffix.lower() in (".iso", ".img")
427
+ and not p.name.startswith("._")
428
+ and ".visync" not in p.parts
429
+ ]
430
+
431
+
432
+ # ── .visync metadata engine ──────────────────────────────────────
433
+
434
+
435
+ def ensure_visync_dir(drive_root: Path) -> Path:
436
+ """Create .visync/metadata/ at the drive root if it doesn't exist. Returns the metadata dir."""
437
+ visync_dir = drive_root / ".visync"
438
+ metadata_dir = visync_dir / "metadata"
439
+ metadata_dir.mkdir(parents=True, exist_ok=True)
440
+ return metadata_dir
441
+
442
+
443
+ def read_iso_metadata(drive_root: Path, filename: str) -> dict | None:
444
+ """Read cached JSON metadata for a given ISO filename. Returns None if not found or invalid."""
445
+ metadata_dir = drive_root / ".visync" / "metadata"
446
+ meta_file = metadata_dir / f"{filename}.json"
447
+ try:
448
+ with meta_file.open() as f:
449
+ data = json.load(f)
450
+ except (json.JSONDecodeError, OSError):
451
+ return None
452
+ if not isinstance(data, dict):
453
+ return None
454
+ # Validate required keys exist
455
+ if "variant_stem" in data and "version" in data:
456
+ return data
457
+ return None
458
+
459
+
460
+ def write_iso_metadata(
461
+ drive_root: Path,
462
+ filename: str,
463
+ variant_stem: str,
464
+ version: str,
465
+ sha256: str,
466
+ size: int = 0,
467
+ ) -> None:
468
+ """Write a JSON metadata manifest for a successfully synced ISO."""
469
+ metadata_dir = ensure_visync_dir(drive_root)
470
+ meta_file = metadata_dir / f"{filename}.json"
471
+ manifest = {
472
+ "variant_stem": variant_stem,
473
+ "version": version,
474
+ "sha256": sha256,
475
+ "size": size,
476
+ "sync_timestamp": datetime.now(UTC).isoformat(),
477
+ }
478
+ try:
479
+ tmp_file = meta_file.with_suffix(".json.tmp")
480
+ with tmp_file.open("w") as f:
481
+ json.dump(manifest, f, indent=2)
482
+ tmp_file.replace(meta_file)
483
+ except OSError as e:
484
+ console.print(
485
+ f" [yellow]⚠[/yellow] Could not write metadata for {_escape(filename)}: {_escape(str(e))}"
486
+ )
487
+
488
+
489
+ def remove_iso_metadata(drive_root: Path, filename: str) -> None:
490
+ """Delete the metadata file for a given ISO (called when an ISO is removed)."""
491
+ metadata_dir = drive_root / ".visync" / "metadata"
492
+ meta_file = metadata_dir / f"{filename}.json"
493
+ # The caller treats a failed delete as "metadata stays behind"; the watchdog
494
+ # will retry. Only OSError is swallowed, as before.
495
+ with contextlib.suppress(OSError):
496
+ meta_file.unlink(missing_ok=True)
497
+
498
+
499
+ def load_all_metadata(drive_root: Path) -> dict[str, dict]:
500
+ """Bulk-load all ISO metadata from .visync/metadata/. Returns {filename: manifest}."""
501
+ metadata_dir = drive_root / ".visync" / "metadata"
502
+ if not metadata_dir.is_dir():
503
+ return {}
504
+ result = {}
505
+ for meta_file in metadata_dir.glob("*.json"):
506
+ if meta_file.name.startswith("._"):
507
+ continue
508
+ filename = meta_file.stem
509
+ data = read_iso_metadata(drive_root, filename)
510
+ if data:
511
+ result[filename] = data
512
+ return result
513
+
514
+
515
+ # ── .visync watchdog ─────────────────────────────────────────────
516
+
517
+ VISYNC_SIZE_LIMIT = 1_073_741_824 # 1 GiB
518
+ _WATCHDOG_DIR_NAME = ".visync"
519
+ # An allowlist, not a blocklist of ".iso"/".img": every deletion the watchdog can
520
+ # perform is checked against this, so anything not listed here is refused. That
521
+ # covers .iso and .img without naming them, and also covers a future file type
522
+ # nobody thought to block.
523
+ _WATCHDOG_ALLOWED_EXTENSIONS = {".json"}
524
+
525
+
526
+ def _guard_json_only(path: Path) -> None:
527
+ """Assert that a file path is a permitted extension for metadata cleanup.
528
+
529
+ Raises ValueError immediately if the file is not a .json file.
530
+ This prevents accidental deletion of ISO or IMG files.
531
+ """
532
+ if path.suffix.lower() not in _WATCHDOG_ALLOWED_EXTENSIONS:
533
+ raise ValueError(
534
+ f"SAFETY BLOCK: refusing to delete '{path.name}' — "
535
+ f"only {_WATCHDOG_ALLOWED_EXTENSIONS} files are permitted. "
536
+ f"Blocked extension: {path.suffix}"
537
+ )
538
+
539
+
540
+ def _guard_visync_path(target: Path) -> None:
541
+ """Assert that a directory path is exactly '.visync' and not drive root.
542
+
543
+ Raises ValueError immediately if:
544
+ - The path does not end with '.visync'
545
+ - The path IS the drive root itself
546
+ - The path name doesn't match exactly (prevents '../' traversal)
547
+ """
548
+ if target.name != _WATCHDOG_DIR_NAME:
549
+ raise ValueError(
550
+ f"SAFETY BLOCK: refusing to wipe '{target}' — "
551
+ f"target directory must be named exactly '{_WATCHDOG_DIR_NAME}'. "
552
+ f"Got name: '{target.name}'"
553
+ )
554
+ if not target.is_dir():
555
+ raise ValueError(
556
+ f"SAFETY BLOCK: refusing to wipe '{target}' — target is not a directory."
557
+ )
558
+ # Ensure it's a direct child, not a traversal to root
559
+ if target == target.parent:
560
+ raise ValueError(
561
+ f"SAFETY BLOCK: refusing to wipe '{target}' — "
562
+ f"target resolves to itself (would delete root)."
563
+ )
564
+
565
+
566
+ def _dir_size(path: Path) -> int:
567
+ """Return total size in bytes of a directory tree."""
568
+ total = 0
569
+ try:
570
+ for entry in path.rglob("*"):
571
+ if entry.is_file():
572
+ total += entry.stat().st_size
573
+ except OSError:
574
+ pass
575
+ return total
576
+
577
+
578
+ def visync_watchdog(drive_root: Path, *, allow_wipe: bool = False) -> None:
579
+ """Keep .visync/ under its size ceiling.
580
+
581
+ Stage 1 — deep clean (always): delete metadata whose ISO no longer exists on
582
+ the drive. This only removes ``.json`` orphans and is self-healing, so it
583
+ runs unattended.
584
+
585
+ Stage 2 — full wipe (opt-in, ``allow_wipe=True``): rmtree the directory.
586
+
587
+ Why stage 2 is opt-in: legitimate .visync/ state is installed.json plus one
588
+ ~236-byte manifest per ISO. Even a thousand ISOs is ~230 KiB against a
589
+ 1 GiB ceiling, so being over budget is *never* legitimate and a wipe can
590
+ never repair a real problem — it can only destroy installed.json and every
591
+ registration. The realistic way to get here is a misconfigured
592
+ ``[iso] download_dir`` pointing under .visync/, which stages multi-GB ISOs
593
+ inside the directory. So by default we deep clean and then tell the user
594
+ exactly how to reclaim the space.
595
+
596
+ SAFETY: before any rmtree the target is validated to be exactly '.visync'
597
+ and not the drive root; a ValueError propagates immediately.
598
+ """
599
+ try:
600
+ visync_dir = drive_root / _WATCHDOG_DIR_NAME
601
+ if not visync_dir.is_dir():
602
+ return
603
+
604
+ size = _dir_size(visync_dir)
605
+ if size <= VISYNC_SIZE_LIMIT:
606
+ return
607
+
608
+ console.print(
609
+ f" [yellow]⚠[/yellow] Watchdog: .visync/ is {size / (1024**2):.1f} MiB (limit: 1 GiB). Running deep clean..."
610
+ )
611
+ _deep_clean_metadata(drive_root)
612
+
613
+ size_after = _dir_size(visync_dir)
614
+ if size_after <= VISYNC_SIZE_LIMIT:
615
+ console.print(
616
+ f" [green]✓[/green] Deep clean recovered space. .visync/ now {size_after / (1024**2):.1f} MiB."
617
+ )
618
+ return
619
+
620
+ if not allow_wipe:
621
+ console.print(
622
+ f" [yellow]⚠[/yellow] Watchdog: .visync/ is still {size_after / (1024**2):.1f} MiB after deep clean."
623
+ )
624
+ console.print(
625
+ " [dim]Not wiping automatically — this destroys installed.json and every"
626
+ " registration.[/dim]"
627
+ )
628
+ console.print(
629
+ " [dim]Re-run with --reset-visync to wipe .visync/, or point"
630
+ f" {_escape('[iso]')} download_dir outside .visync/.[/dim]"
631
+ )
632
+ return
633
+
634
+ # GUARDRAIL: Validate target before any recursive deletion
635
+ _guard_visync_path(visync_dir)
636
+
637
+ console.print(
638
+ f" [yellow]⚠[/yellow] Watchdog: wiping .visync/ ({size_after / (1024**2):.1f} MiB) as requested."
639
+ )
640
+ import shutil
641
+
642
+ try:
643
+ shutil.rmtree(visync_dir)
644
+ console.print(
645
+ " [green]✓[/green] .visync/ wiped. Metadata will rebuild on next sync."
646
+ )
647
+ except OSError as e:
648
+ console.print(
649
+ f" [yellow]⚠[/yellow] Could not wipe .visync/: {_escape(str(e))}"
650
+ )
651
+ except ValueError:
652
+ raise
653
+ except Exception as e:
654
+ console.print(f" [yellow]⚠[/yellow] Watchdog error: {_escape(str(e))}")
655
+
656
+
657
+ def _deep_clean_metadata(drive_root: Path) -> None:
658
+ """Remove metadata files whose corresponding ISO no longer exists on the drive.
659
+
660
+ SAFETY: Only files ending with '.json' inside the metadata directory
661
+ are eligible for deletion. A ValueError is raised immediately if any
662
+ non-.json file is encountered.
663
+ """
664
+ try:
665
+ metadata_dir = drive_root / _WATCHDOG_DIR_NAME / "metadata"
666
+ if not metadata_dir.is_dir():
667
+ return
668
+
669
+ existing_isos = {p.name for p in find_installed_isos(drive_root)}
670
+ removed = 0
671
+ skipped_non_json = 0
672
+ for meta_file in metadata_dir.iterdir():
673
+ # GUARDRAIL: Only allow .json files — anything else is left alone
674
+ if not meta_file.is_file():
675
+ continue
676
+ if meta_file.suffix.lower() not in _WATCHDOG_ALLOWED_EXTENSIONS:
677
+ skipped_non_json += 1
678
+ continue
679
+ _guard_json_only(meta_file)
680
+
681
+ iso_name = meta_file.stem # .json filename = ISO filename
682
+ if iso_name not in existing_isos:
683
+ try:
684
+ meta_file.unlink()
685
+ removed += 1
686
+ except OSError as e:
687
+ console.print(
688
+ f" [yellow]⚠[/yellow] Could not remove {_escape(meta_file.name)}: {_escape(str(e))}"
689
+ )
690
+ if removed:
691
+ console.print(f" [dim]Removed {removed} orphaned metadata file(s).[/dim]")
692
+ if skipped_non_json:
693
+ console.print(
694
+ f" [yellow]⚠[/yellow] Skipped {skipped_non_json} non-metadata file(s) in .visync/metadata/."
695
+ )
696
+ except ValueError:
697
+ raise
698
+ except Exception as e:
699
+ console.print(f" [yellow]⚠[/yellow] Deep clean error: {_escape(str(e))}")