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/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))}")
|